SOLID Principles

SOLID is an acronym for five principles of object-oriented design that help to create clean and maintainable code. In this blog post, we will explore each principle and see how to apply them in Swift.

  • Single responsibility principle (SRP): A class should have only one responsibility.
  • Open/closed principle (OCP): Classes should be open for extension but closed for modification.
  • Liskov substitution principle (LSP): Derived classes should be substitutable for their base classes.
  • Interface segregation principle (ISP): Clients should not be forced to depend on methods they do not use.
  • Dependency inversion principle (DIP): A class should depend on abstractions, not on concrete implementations.

How the SOLID principles can be applied in Swift?

Single Responsibility Principle (SRP)

A class that represents a user should only be responsible for storing and retrieving user data. It should not also be responsible for validating user input or sending emails. For example, a User class could have properties for the user’s name, email address, and password. It could also have methods for getting and setting these properties. However, it should not have methods for validating user input or sending emails. These tasks could be delegated to other classes, such as a Validator class or an Emailer class.

// A User class that follows the SRP
class User {
    var name: String
    var email: String
    var password: String
    
    init(name: String, email: String, password: String) {
        self.name = name
        self.email = email
        self.password = password
    }
}

// A Validator class that is responsible for validating user input
class Validator {
    func validateEmail(_ email: String) -> Bool {
        // some logic to check if the email is valid
    }
    
    func validatePassword(_ password: String) -> Bool {
        // some logic to check if the password is strong enough
    }
}

// An Emailer class that is responsible for sending emails
class Emailer {
    func sendEmail(to recipient: String, subject: String, body: String) {
        // some logic to send an email
    }
}

By following the SRP, we can make our classes more cohesive and less coupled. This makes them easier to understand, test, and reuse.

Open/Closed Principle (OCP)

A class that represents a car should be open to new features, such as a sunroof or a navigation system. However, it should be closed to changes in its core functionality, such as the ability to start and stop the car. For example, a Car class could have a property for the car’s make and model. It could also have methods for starting and stopping the car, as well as for turning on the headlights and the radio. However, it should not be modified to add new features, such as a sunroof or a navigation system. These features could be added to the Car class by subclassing it and adding the new features to the subclass.

// A Car class that follows the OCP
class Car {
    var make: String
    var model: String
    
    init(make: String, model: String) {
        self.make = make
        self.model = model
    }
    
    func start() {
        // some logic to start the car
    }
    
    func stop() {
        // some logic to stop the car
    }
    
    func turnOnHeadlights() {
        // some logic to turn on the headlights
    }
    
    func turnOnRadio() {
        // some logic to turn on the radio
    }
}

// A SportsCar subclass that extends the Car class with new features
class SportsCar: Car {
    var sunroof: Bool
    
    init(make: String, model: String, sunroof: Bool) {
        self.sunroof = sunroof
        super.init(make: make, model: model)
    }
    
    func openSunroof() {
        // some logic to open the sunroof
    }
    
    func closeSunroof() {
        // some logic to close the sunroof
    }
}

// A NavigationSystem protocol that defines a new feature
protocol NavigationSystem {
    func navigate(to destination: String)
}

// A NavigationCar subclass that conforms to the NavigationSystem protocol and adds a new feature to the Car class
class NavigationCar: Car, NavigationSystem {
    func navigate(to destination: String) {
        // some logic to navigate to the destination
    }
}

By following the OCP, we can make our classes more flexible and adaptable. This allows us to add new features without breaking or changing the existing ones.

Liskov Substitution Principle (LSP)

A subclass of a Car class should be able to be used anywhere a Car object is expected. For example, a SportsCar subclass should be able to be used in a CarPool class. This means that a CarPool class should be able to accept a SportsCar object as a parameter, and it should be able to call the same methods on the SportsCar object as it would call on a Car object.

// A CarPool class that expects a Car object as a parameter
class CarPool {
    var car: Car
    
    init(car: Car) {
        self.car = car
    }
    
    func driveToWork() {
        car.start()
        car.navigate(to: "Work")
        car.stop()
    }
}

// A SportsCar subclass that can be used in a CarPool class without affecting its behavior
let sportsCar = SportsCar(make: "Ferrari", model: "F40", sunroof: true)
let carPool = CarPool(car: sportsCar)
carPool.driveToWork()

// A BrokenCar subclass that violates the LSP by changing the behavior of its superclass methods
class BrokenCar: Car {
    override func start() {
        // some logic to make weird noises and smoke
    }
    
    override func stop() {
        // some logic to crash into something
    }
}

// A BrokenCar object cannot be used in a CarPool class without affecting its behavior and causing errors
let brokenCar = BrokenCar(make: "Ford", model: "Pinto")
let carPool2 = CarPool(car: brokenCar)
carPool2.driveToWork() // this will cause problems!

By following the LSP, we can make our subclasses more consistent and compatible with their superclasses. This ensures that our code is more reliable and robust.

Interface Segregation Principle (ISP)

An Animal protocol should not require all animals to be able to swim. Instead, there should be a separate Swimable protocol that animals that can swim can conform to. For example, an Animal protocol could have methods for eating, sleeping, and walking. However, it should not have a method for swimming. This is because not all animals can swim. Instead, there could be a separate Swimable protocol that has a method for swimming. Animals that can swim could conform to the Swimable protocol, while animals that cannot swim could conform to the Animal protocol.

// An Animal protocol that contains only methods that are common to all animals
protocol Animal {
    func eat()
    func sleep()
    func walk()
}

// A Swimable protocol that contains only methods that are specific to swimming animals
protocol Swimable {
    func swim()
}

// A Dog class that conforms to both Animal and Swimable protocols
class Dog: Animal, Swimable {
    func eat() {
        // some logic to eat dog food
    }
    
    func sleep() {
        // some logic to sleep on a couch
    }
    
    func walk() {
        // some logic to walk on four legs
    }
    
    func swim() {
        // some logic to swim in water
    }
}

// A Cat class that conforms only to Animal protocol and does not need to implement swim method
class Cat: Animal {
    func eat() {
            // some logic to eat cat food
    }
        
    func sleep() {
            // some logic to sleep on a bed
    }
        
    func walk() {
            // some logic to walk on four legs
    }
}

By following the ISP, we can make our interfaces or protocols more focused and modular. This reduces unnecessary dependencies and makes our code more maintainable.

Dependency Inversion Principle (DIP)

A Car class should not depend on a specific type of engine. Instead, it should depend on an Engine protocol. This way, the Car class can be used with any type of engine, such as a gasoline engine or an electric engine.

// An Engine protocol that defines an abstraction for different types of engines

protocol Engine {
func start()
func stop()
}

// A GasolineEngine class that conforms to Engine protocol and provides an implementation for gasoline engines

class GasolineEngine: Engine {
    func start() {
    // some logic to start a gasoline engine
    }
    func stop() {
    // some logic to stop a gasoline engine
    }
}

// An ElectricEngine class that conforms to Engine protocol and provides an implementation for electric engines

class ElectricEngine: Engine {
    func start() {
    // some logic to start an electric engine
    }

    func stop() {
    // some logic to stop an electric engine
    }
}

// A Car class that depends on Engine protocol instead of a specific type of engine

class Car {
    var engine: Engine

    init(engine: Engine) {
    self.engine = engine
    }

    func start() {
    engine.start()
    }

    func stop() {
    engine.stop()
    }
}

// A GasolineCar object that uses GasolineEngine as its engine
let gasolineEngine = GasolineEngine()
let gasolineCar = Car(engine: gasolineEngine)
gasolineCar.start()
gasolineCar.stop()

// An ElectricCar object that uses ElectricEngine as its engine
let electricEngine = ElectricEngine()
let electricCar = Car(engine: electricEngine)
electricCar.start()
electricCar.stop()

By following the DIP, we can make our classes more loosely coupled and interchangeable. This allows us to change or replace implementations without affecting the high-level modules.

Conclusion

SOLID principles are useful guidelines for designing object-oriented code in Swift. By applying them, we can create code that is more readable, testable, reusable, extensible, and maintainable.

Thanks for reading!