SOLID: The First 5 Principles of Object Oriented Design | DigitalOcean #weblinks

Dependency Inversion Principle

Entities must depend on abstractions, not on concretions. It states that the high-level module must not depend on the low-level module, but they should depend on abstractions.

  • The Dependency Inversion Principle is a software design principle that states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. This allows for greater flexibility and maintainability of the code.

  • In this example, the DataAccess protocol defines an interface for fetching data from some source. The DatabaseDataAccess and FileSystemDataAccess structs both conform to the DataAccess protocol and provide their own implementation of the fetchData() function. The DataAnalysis class depends on the DataAccess protocol, rather than any specific implementation, to fetch data for analysis.

  • By following the Dependency Inversion Principle, the DataAnalysis class is not directly dependent on the low-level DatabaseDataAccess and FileSystemDataAccess structs. This allows for greater flexibility because you can easily swap in a different implementation of the DataAccess protocol without changing the DataAnalysis class. It also makes the code easier to maintain because changes to the low-level implementations will not affect the high-level DataAnalysis class.

  • Here’s an example of how you might apply the Dependency Inversion Principle in Swift:

protocol DataAccess {
  func fetchData() -> String
}

struct DatabaseDataAccess: DataAccess {
  func fetchData() -> String {
    // implementation details for fetching data from a database
  }
}

struct FileSystemDataAccess: DataAccess {
  func fetchData() -> String {
    // implementation details for fetching data from the file system
  }
}

class DataAnalysis {
  private let dataAccess: DataAccess
  
  init(dataAccess: DataAccess) {
    self.dataAccess = dataAccess
  }
  
  func analyze() {
    let data = dataAccess.fetchData()
    // implementation details for analyzing the data
  }
}

Interface Segregation Principle

  • A client should never be forced to implement an interface that it doesn’t use, or clients shouldn’t be forced to depend on methods they do not use.

  • The Interface Segregation Principle is a software design principle that states that clients (i.e., objects that use other objects) should not be forced to depend on interfaces they do not use. In other words, a class should not be required to implement interfaces that it does not need.

  • Here’s an example of how you might apply the Interface Segregation Principle in Swift:

protocol Swimming {
  func swim() -> String
}

protocol Flying {
  func fly() -> String
}

protocol Walking {
  func walk() -> String
}

struct Duck: Swimming, Flying {
  func swim() -> String {
    return "I'm swimming!"
  }
  
  func fly() -> String {
    return "I'm flying!"
  }
}

struct Penguin: Swimming {
  func swim() -> String {
    return "I'm swimming!"
  }
}

struct Ostrich: Walking {
  func walk() -> String {
    return "I'm walking!"
  }
}
  • In this example, the Swimming, Flying, and Walking protocols define interfaces for different types of movement. The Duck struct implements both the Swimming and Flying protocols, while the Penguin struct only implements the Swimming protocol and the Ostrich struct only implements the Walking protocol.

  • By separating the interfaces into smaller, more specific protocols, we avoid forcing classes to implement unnecessary methods. For example, the Ostrich class does not need to implement the swim() or fly() methods because it cannot swim or fly. This leads to a more maintainable and scalable design.

Liskov Substitution Principle

Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.

  • The Liskov Substitution Principle is a software design principle that states that if a program is using a base type, then it should be able to use a subtype of that base type without knowing it. In other words, a subclass should be able to be used as a substitute for its superclass without affecting the correctness of the program.

  • In this example, the Animal protocol defines an interface for making a noise. The Dog and Cat structs both conform to the Animal protocol and provide their own implementation of the makeNoise() function. The AnimalShelter class has an array of Animals and a function that makes all the animals in the array make a noise.

  • Because the Dog and Cat structs both conform to the Animal protocol, they can be used as substitutes for each other in the AnimalShelter class without affecting the correctness of the program. This allows for greater flexibility and reuse of code.

protocol Animal {
  func makeNoise() -> String
}

struct Dog: Animal {
  func makeNoise() -> String {
    return "Woof!"
  }
}

struct Cat: Animal {
  func makeNoise() -> String {
    return "Meow!"
  }
}

class AnimalShelter {
  private var animals: [Animal] = []
  
  func add(_ animal: Animal) {
    animals.append(animal)
  }
  
  func makeNoises() {
    for animal in animals {
      print(animal.makeNoise())
    }
  }
}

let shelter = AnimalShelter()
shelter.add(Dog())
shelter.add(Cat())
shelter.makeNoises()

Open Closed Principle

  • Objects or entities should be open for extension but closed for modification.

  • The Open-Closed Principle is a software design principle that states that software entities (such as classes, modules, functions, etc.) should be open for extension but closed for modification. In other words, you should be able to add new functionality to a software entity without changing its existing code.

  • In this example, the Shape protocol defines an interface for calculating the area of a shape. The Circle and Rectangle structs both conform to the Shape protocol and provide their own implementation of the area() function. The ShapeCalculator class has a function that calculates the total area of an array of shapes by iterating over the array and summing the areas of each shape.

  • If you want to add a new type of shape to the program, you can simply define a new struct that conforms to the Shape protocol and provide an implementation of the area() function. You don’t need to modify the existing ShapeCalculator class in any way. This allows you to add new functionality to the program without changing existing code, which makes the program more flexible and maintainable.

  • Here’s an example of how you might apply the Open-Closed Principle in Swift:

protocol Shape {
  func area() -> Double
}

struct Circle: Shape {
  let radius: Double
  
  func area() -> Double {
    return .pi * radius * radius
  }
}

struct Rectangle: Shape {
  let width: Double
  let height: Double
  
  func area() -> Double {
    return width * height
  }
}

class ShapeCalculator {
  func totalArea(shapes: [Shape]) -> Double {
    return shapes.reduce(0) { $0 + $1.area() }
  }
}

Single-responsibility Principle (SRP)

  • A class should have one and only one reason to change, meaning that a class should have only one job.

  • The Single Responsibility Principle is a software design principle that states that every module or class should have responsibility over a single part of the functionality provided by the software, and that responsibility should be entirely encapsulated by the class. All its services should be narrowly aligned with that responsibility.

  • Here’s an example of how you might apply the Single Responsibility Principle in Swift:

  • In this example, the UserPersistence protocol and its implementation UserPersistenceImpl have the single responsibility of persisting a User object to and from disk. The UserService class has the responsibility of interacting with the UserPersistence implementation to register and retrieve users, but it does not have any responsibility for the actual persistence of the data. This separation of responsibilities allows for better maintainability and testability of the code.

struct User {
  let name: String
  let email: String
}

protocol UserPersistence {
  func save(_ user: User)
  func load() -> User?
}

struct UserPersistenceImpl: UserPersistence {
  private let fileManager: FileManager
  
  init(fileManager: FileManager) {
    self.fileManager = fileManager
  }
  
  func save(_ user: User) {
    // implementation details for saving the user to disk
  }
  
  func load() -> User? {
    // implementation details for loading the user from disk
  }
}

class UserService {
  private let userPersistence: UserPersistence
  
  init(userPersistence: UserPersistence) {
    self.userPersistence = userPersistence
  }
  
  func register(name: String, email: String) {
    let user = User(name: name, email: email)
    userPersistence.save(user)
  }
  
  func getUser() -> User? {
    return userPersistence.load()
  }
}