Skip to main content

Solid Principles in Swift


There are 5 SOLID principles in programming:
  • A Single-responsibility principle
  • Open-closed principle
  • Liskov Substitution principle
  • Interface segregation principle
  • Dependency inversion principle
Here’s a quick overview of each principle with the small code snippet:

SINGLE-RESPONSIBILITY PRINCIPLE

Single-responsibility states that each object, module or component should have just that, a single responsibility. The code snippet below demonstrates the application of a single-responsibility principle to SomeManager class:
final class SomeManager {
    func handle() {
        /// Logic to call API
        /// then logic to storge to DB...
        /// ... then logic to update the UI
    }
}
The result of a single-responsibility principle should follow that instead of putting logic for API call, storing and updating the UI in a single function of a single object, all different responsibilities should be split amongst themselves into other objects that should be responsible for just a single task.
final class SomeManager {
    
    let api: ApiClass
    let dbController: DbController
    let delegate: SomeManagerDelegate
    
    required init(api: ApiClass, dbController: DbController, delegate: SomeManagerDelegate) {
        self.api = api
        self.dbController = dbController
        self.delegate = delegate
    }
    
    func handle() {
        self.api.execute { result in
            dbController.store { storeResult in
                self.delegate.updateUI(with: storeResult)
            }
        }
    }
}

OPEN-CLOSED PRINCIPLE

Next in line is the open-closed principle, which states that something should be opened for extending or inheriting, but closed for modifying.

For example, here are 2 different structs that have the same data and the same method:
struct Bike {
    var modelNo: Int
    var color: UIColor
    
    func printDetails() {
        debugPrint("Bike: \(modelNo), color: \(color)")
    }
}

struct Motorcycle {
    var modelNo: Int
    var color: UIColor
    
    func printDetails() {
        debugPrint("Motorcycle: \(modelNo), color: \(color)")
    }
}
If there is, for example, a class that contains all of the products, like motorcycles and bikes, and there is a requirement to print all of the data, the implementation should look like this:
final class Printer {
    func printAll() {
        let bikes = [
            Bike(modelNo: 1, color: .red),
            Bike(modelNo: 2, color: .blue)
        ]
        
        let motorcycles = [
            Motorcycle(modelNo: 22, color: .yellow),
            Motorcycle(modelNo: 33, color: .brown)
        ]
        
        bikes.forEach {
            $0.printDetails()
        }
        
        motorcycles.forEach {
            $0.printDetails()
        }
    }
}
By adapting to the open-closed principle, this solves the “problem”, by conforming the two structs to the same interface, or protocol 🙂

Code improvement for open-closed principle:
protocol Product {
    var modelNo: Int { get set }
    var color: UIColor { get set }
    
    func printDetails()
}
Next, conformation to the Product protocol is added to the 2 structs:
struct Bike: Product {
...

struct Motorcycle: Product {
...
And by doing this, Printer class now can have a single array of [Product] types, which can use the same method to print the details:
final class Printer {
    func printAll() {
        let allProducts: [Product] = [
            Bike(modelNo: 1, color: .red),
            Bike(modelNo: 2, color: .blue),
            Motorcycle(modelNo: 22, color: .yellow),
            Motorcycle(modelNo: 33, color: .brown)
        ]
        
        allProducts.forEach {
            $0.printDetails()
        }
    }
}

LISKOV SUBSTITUTION PRINCIPLE

Liksov substitution principle is created by Barbara Liskov. She is an amazing woman, and one of the first women in the US to achieve doctorate in the field of computer science.

This principle is described by:
Substitutability is a principle in object-oriented programming stating that, in a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S (i.e. an object of type T may be substituted with any object of a subtype S) without altering any of the desirable properties of the program (correctness, task performed, etc.).

Barbara Liskov
Here is an example code snippet:
protocol Food {}
extension Food {
    func describe() -> String {
        "Yep, it food..."
    }
}

protocol Apple: Food {}
extension Apple {
    func describe() -> String {
        "Apple.... Jobs...."
    }
}
To recap this, there are 2 protocols. Food contains default implementation of a method describe(). Apple protocol inherits the Food protocol, but it’s default implementation have a different logic inside of a describe() method. So, by creating 2 structs and calling describe() method, the output will look like this:
/// This is a food
struct ApplePie: Food {}

/// This is an Apple product
struct iPhone: Apple {}

let pie = ApplePie()
pie.describe()

let iPhone5s = iPhone()
iPhone5s.describe()

"Yep, it food..."
"Apple.... Jobs...."
Next, here is the application of the principle:
let classicFood = ApplePie()
let iPhone13 = iPhone()
let strangeFood: Apple = iPhone()

func setFood(_ food: Food) {
    print("food loaded")
    print(food.describe())
}

setFood(classicFood)
setFood(iPhone13)
setFood(strangeFood)
All this is a type-safe code, but the output will look like this:
food loaded
Yep, it food...
food loaded
Yep, it food...
food loaded
Yep, it food...
Wait, whaaaat?! How is an iPhone13 a food now?! How is a strangeFood constant, that is specifically said it’s an Apple, also a Food?!

It is because the method is set to use Food protocol as an argument, but the Food protocol has default implementation of a method describe(), so every thing is a food.

Typecast:
func setFood(_ food: Food) {
    print("food loaded")
    if let food = (food as? Apple) {
        debugPrint(food.describe())
    } else {
        debugPrint(food.describe())
    }
}
Now, the value of a child protocol given in the output instead. More detailed info about the Liskov substitution principle: https://holyswift.app/the-liskov-substitution-principle-and-swift/

INTERFACE SEGREGATION PRINCIPLE (ISP)

Classes, Structs, Enums, etc… should not implement protocols that they do not need, and protocols (interfaces) should be broken down, so an interface could inherit another interface in order to de-couple the logic. The next example is protocol that have 3 methods:
protocol GestureProtocol {
    func didTap()
    func didDoubleTap()
    func didLongPress()
}
Requirement is to create some button class that inherit these methods:
final class Button: GestureProtocol {
    func didTap() {
        // logic...
    }
    
    func didDoubleTap() {
        // logic...
    }
    
    func didLongPress() {
        // logic...
    }
}
All this is fine, right? Well, yeah, probably. But, what if another requirement comes and and requires some specific button class that reacts only to double taps and should contain the logic only for didDoubleTap() method? If that button class inherit GestureProtocol now, it will be required to implement 2 methods that it doesn’t need, as well.

So, how does this principle help in solving this?

By splitting protocol into smaller protocols (interfaces) and each of them is responsible for a single purpose:
protocol TapProtocol { 
    func didTap()
}

protocol DoubleTapProtocol {
    func didDoubleTap()
}

protocol LongPressProtocol {
    func didLongPress()
}
The specific case for a button that reacts only for double taps is covered easily, by just conforming only to DoubleTapProtocol, without the need of an empty-conformance for other unneeded methods. And other button classes that require all or some of these protocols, can inherit them if needed, so the code is much more cleaner and concise.

DEPENDENCY INVERSION PRINCIPLE

Higher-level modules, classes, structs, etc should not depend on lover-level ones. Both should depend on abstraction.
Consider the example:
final class LocalLogger {
    func log(method: String, stackTrace: [String]) {
        // Create a .txt file and store
    }
}

final class Logger {
    private let localLogger = LocalLogger()
    
    func log(method: String, stackTrace: [String]) {
        localLogger.log(method: method, stackTrace: stackTrace)
    }
}
We have LocalLogger class, which is a low-level module, reusable and contains the logic to log something locally.

Logger is a high-level module (object) that is not reusable, called directly and depends on a specific logger object to log something. What if the logic requires that the Logger should log something both locally and on a cloud?

Everything can be solved by using protocols 🙂

If, for example, a Loggable protocol is created:
protocol Loggable {
    func log(method: String, stackTrace: [String])
}
Then, the higher-level abstraction can utilise both logics: Local and cloud. So, the different lower-level modules can be used to achieve different logging requirements:
final class LocalLogger: Loggable {
    func log(method: String, stackTrace: [String]) {
        // Create a .txt file and store
    }
}

final class CloudLogger: Loggable {
    func log(method: String, stackTrace: [String]) {
        // Send an API request with these 2 parameters
    }
}
And the high-level module can have this instead:
final class Logger {
    private let logger: Loggable
    
    init(logger: Loggable) {
        self.logger = logger
    }
    
    func log(method: String, stackTrace: [String]) {
        logger.log(method: method, stackTrace: stackTrace)
    }
}
By doing this, the high-level object Logger does not depend on a LocalLogger module, it now had become agnostic of the nature of an object. He can now be created to be used to log both locally or on the cloud.
This has been an overview for each of the following SOLID programming principles, with a small Swift code examples

Must read:

Comments