Solid Principles in Swift for Kids

How to explain Solid Principles to a kid.

·

12 min read

Play this article

"Solid" refers to the five principles of object-oriented design: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. These principles help developers to create software that is more maintainable, extensible, and robust.

Here is a brief explanation of each principle:

  1. Single Responsibility: A class should have only one reason to change. In other words, a class should have only one responsibility or task that it performs. This makes it easier to understand and modify the code, as well as to test and debug it.

  2. Open/Closed: A class should be open for extension but closed for modification. This means that new functionality should be added through new code, rather than by modifying existing code. This principle encourages the use of interfaces and abstract classes, which can be extended without changing the underlying implementation.

  3. Liskov Substitution: Subtypes must be substitutable for their base types. This principle ensures that objects of a derived class can be used in place of objects of the base class without causing errors or unexpected behavior.

  4. Interface Segregation: Clients should not be forced to depend on methods they do not use. This principle suggests that interfaces should be kept small and focused, rather than including a large number of unrelated methods.

  5. Dependency Inversion: Depend upon abstractions, not concretions. This principle encourages the use of interfaces and abstract classes to reduce dependencies between classes, making the code more modular and easier to maintain.

By following these principles, we as developers can create software that is easier to understand, modify, and maintain, and that is more resistant to errors and unexpected behavior.

How to explain Solid in swift for a Kid?

Hey there! So imagine you are building a big castle out of Legos. When you build your castle, you want to make sure it is strong and can withstand anything that comes its way.

So when we talk about "Solid" in Swift and iOS, it means we are using five special rules to build our castle (or software) to make it strong and long-lasting.

The first rule is called "Single Responsibility," which means each part of our castle (or code) should only have one job to do. This way, it's easier to understand and fix if something goes wrong.

The second rule is called "Open/Closed," which means we can add new things to our castle (or code) without changing what we've already built. This helps us make sure everything works together correctly.

The third rule is called "Liskov Substitution," which means we can switch out some of the parts of our castle (or code) without causing any problems.

The fourth rule is called "Interface Segregation," which means we only use the parts of our castle (or code) that we need and don't have any extra parts that we don't use.

The fifth and final rule is "Dependency Inversion," which means we use particular parts of our castle (or code) that make building and changing things easier without breaking everything else.

So just like building a strong and sturdy Lego castle, following these five Solid rules helps us build strong and long-lasting software.

Now I think you got the main point but what about some examples?

Examples in Swift

  1. Single Responsibility Principle: Let's say we have a Person class that has multiple responsibilities like handling personal information and sending notifications. We can split these responsibilities into two separate classes - PersonInfo and NotificationManager.
class PersonInfo {
    var name: String
    var age: Int

    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }

    // Methods related to personal information
}

class NotificationManager {
    // Methods related to sending notifications
}
  1. Open/Closed Principle: Let's say we have a Shape class that has a method to calculate its area. Now we want to add a new shape - Triangle - that has a different formula to calculate its area. We can make the Shape class closed to modification by creating an abstract class Shape and then creating separate classes for each shape.
class Shape {
    func calculateArea() -> Double {
        // Calculate area for different shapes
    }
}

class Rectangle: Shape {
    // Implement calculateArea method for rectangle
}

class Circle: Shape {
    // Implement calculateArea method for circle
}

// A new shape - Triangle
class Triangle: Shape {
    // Implement calculateArea method for triangle
}
  1. Liskov Substitution Principle: Let's say we have a Vehicle class with a method to calculate its speed. Now we want to add a new subclass - Car - that has a different way of calculating its speed. We can make sure that the Car class can be used in place of the Vehicle class without any issues.
class Vehicle {
    func calculateSpeed() -> Double {
        // Calculate speed for different vehicles
    }
}

class Car: Vehicle {
    override func calculateSpeed() -> Double {
        // Calculate speed for cars
    }
}
  1. Interface Segregation Principle: Let's say we have a Printer protocol with multiple methods, and we want to use it in a class that only needs to use one of those methods. We can create a new protocol SimplePrinter that only includes the necessary method and have the class conform to that instead.
protocol Printer {
    func print()
    func scan()
    func copy()
}

protocol SimplePrinter {
    func print()
}

class BasicPrinter: SimplePrinter {
    func print() {
        // Implementation for printing
    }
}
  1. Dependency Inversion Principle: Let's say we have a NotificationService class that sends notifications, but we want to make it easy to switch to a different notification service. We can create a protocol NotificationProvider and have the NotificationService class depend on that instead of a concrete implementation.
protocol NotificationProvider {
    func sendNotification()
}

class EmailNotificationProvider: NotificationProvider {
    func sendNotification() {
        // Implementation for sending email notifications
    }
}

class SMSNotificationProvider: NotificationProvider {
    func sendNotification() {
        // Implementation for sending SMS notifications
    }
}

class NotificationService {
    let notificationProvider: NotificationProvider

    init(notificationProvider: NotificationProvider) {
        self.notificationProvider = notificationProvider
    }

    func sendNotification() {
        notificationProvider.sendNotification()
    }
}

For now, I think you should get the idea but if you still feel that you need more clearance Please check the following examples:

Deeper Examples

Example 1: Single Responsibility Principle

Before:

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
    }

    func save() {
        // Save the user to the database
    }

    func sendWelcomeEmail() {
        // Send a welcome email to the user
    }
}

Sure, I can provide some before and after examples that demonstrate how applying SOLID principles can improve the design of the code.

Example 1: Single Responsibility Principle

Before:

 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
    }

    func save() {
        // Save the user to the database
    }

    func sendWelcomeEmail() {
        // Send a welcome email to the user
    }
}

In this example, the User class has two responsibilities - saving the user to the database and sending a welcome email. This violates the Single Responsibility Principle. If we want to change the way we save users to the database, we would also need to change the code that sends the welcome email.

After:

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
    }
}

class UserRepository {
    func save(user: User) {
        // Save the user to the database
    }
}

class EmailService {
    func sendWelcomeEmail(to user: User) {
        // Send a welcome email to the user
    }
}

In this updated version, we've split the responsibilities of saving the user to the database and sending the welcome email into two separate classes - UserRepository and EmailService. This way, if we want to change the way we save users to the database, we only need to change the UserRepository class. Similarly, if we want to change the way we send emails, we only need to change the EmailService class.

Example2: Open/Closed Principle

Before:

class Book {
    var title: String
    var author: String
    var price: Double

    init(title: String, author: String, price: Double) {
        self.title = title
        self.author = author
        self.price = price
    }
}

class ShoppingCart {
    var items: [Book] = []

    func add(book: Book) {
        items.append(book)
    }

    func calculateTotalPrice() -> Double {
        var totalPrice = 0.0
        for item in items {
            totalPrice += item.price
        }
        return totalPrice
    }
}

In this example, we have a Book class and a ShoppingCart class. The ShoppingCart class has an items array that holds Book objects. The calculateTotalPrice() method calculates the total price of all items in the cart. However, this design violates the Open/Closed Principle because if we want to add a new type of item to the cart (e.g., a movie or a game), we need to modify the ShoppingCart class.

After:

protocol Item {
    var price: Double { get }
}

class Book: Item {
    var title: String
    var author: String
    var price: Double

    init(title: String, author: String, price: Double) {
        self.title = title
        self.author = author
        self.price = price
    }
}

class ShoppingCart {
    var items: [Item] = []

    func add(item: Item) {
        items.append(item)
    }

    func calculateTotalPrice() -> Double {
        var totalPrice = 0.0
        for item in items {
            totalPrice += item.price
        }
        return totalPrice
    }
}

In this updated version, we've applied the Open/Closed Principle by creating an Item protocol. The Book class now conforms to this protocol, which defines the price property. The ShoppingCart class has an items array that holds objects that conform to the Item protocol. If we want to add a new type of item to the cart, we can create a new class that conforms to the Item protocol without modifying any existing code. This makes our code more open for extension but closed for modification.

Example3: Liskov Substitution Principle

Before:

class Rectangle {
    var width: Double
    var height: Double

    init(width: Double, height: Double) {
        self.width = width
        self.height = height
    }

    func area() -> Double {
        return width * height
    }
}

class Square: Rectangle {
    override init(width: Double, height: Double) {
        super.init(width: width, height: height)
        if width != height {
            fatalError("A square must have equal width and height")
        }
    }

    override var width: Double {
        didSet {
            if width != height {
                fatalError("A square must have equal width and height")
            }
        }
    }

    override var height: Double {
        didSet {
            if height != width {
                fatalError("A square must have equal width and height")
            }
        }
    }
}

In this example, we have a Rectangle class and a Square class that inherits from Rectangle. However, this design violates the Liskov Substitution Principle because a Square object cannot be substituted for a Rectangle object without causing problems. For example, if we have a function that expects a Rectangle object and we pass it a Square object, the function may not behave as expected.

After:

protocol Shape {
    func area() -> Double
}

class Rectangle: Shape {
    var width: Double
    var height: Double

    init(width: Double, height: Double) {
        self.width = width
        self.height = height
    }

    func area() -> Double {
        return width * height
    }
}

class Square: Shape {
    var side: Double

    init(side: Double) {
        self.side = side
    }

    func area() -> Double {
        return side * side
    }
}

In this updated version, we've applied the Liskov Substitution Principle by creating a Shape protocol that defines the area() method. Both the Rectangle class and the Square class now conform to this protocol. We've also removed the inheritance relationship between Rectangle and Square. This makes it possible to treat a Rectangle object and a Square object interchangeably when they are both used as Shape objects. For example, we can pass a Rectangle object or a Square object to a function that expects a Shape object without causing any problems.

Example 4: Interface Segregation Principle

Before:

protocol Worker {
    func work()
    func takeBreak()
    func attendMeeting()
}

class Programmer: Worker {
    func work() {
        // Implementation
    }

    func takeBreak() {
        // Implementation
    }

    func attendMeeting() {
        // Implementation
    }
}

class Manager: Worker {
    func work() {
        // Implementation
    }

    func takeBreak() {
        // Implementation
    }

    func attendMeeting() {
        // Implementation
    }
}

In this example, we have a Worker protocol that defines three methods: work(), takeBreak(), and attendMeeting(). We also have two classes, Programmer and Manager, that both conform to this protocol. However, this design violates the Interface Segregation Principle because not all workers need to attend meetings or take breaks.

After:

protocol Worker {
    func work()
}

protocol BreakTaker {
    func takeBreak()
}

protocol MeetingAttender {
    func attendMeeting()
}

class Programmer: Worker, BreakTaker {
    func work() {
        // Implementation
    }

    func takeBreak() {
        // Implementation
    }
}

class Manager: Worker, BreakTaker, MeetingAttender {
    func work() {
        // Implementation
    }

    func takeBreak() {
        // Implementation
    }

    func attendMeeting() {
        // Implementation
    }
}

In this updated version, we've applied the Interface Segregation Principle by creating three separate protocols: Worker, BreakTaker, and MeetingAttender. Each protocol defines only the methods that are necessary for the corresponding role. We've also updated the Programmer and Manager classes to conform to the appropriate protocols. This makes it possible to use each protocol independently, which provides greater flexibility and better separation of concerns. For example, we can now create a TeamLeader class that conforms to Worker and MeetingAttender, but not BreakTaker.

Example 5: Dependency Inversion Principle

Before:

class ProductService {
    func getAllProducts() -> [Product] {
        let api = ProductAPI()
        let products = api.getAllProducts()
        return products
    }
}

class ProductAPI {
    func getAllProducts() -> [Product] {
        // Implementation
    }
}

In this example, the ProductService class depends on the ProductAPI class. This means that the ProductService class has a direct dependency on the ProductAPI class, and it cannot function without it. This design violates the Dependency Inversion Principle because the higher-level ProductService class depends on the lower-level ProductAPI class, which makes it difficult to reuse or replace the ProductAPI class in other parts of the application.

After:

protocol ProductDataProvider {
    func getAllProducts() -> [Product]
}

class ProductService {
    let productDataProvider: ProductDataProvider

    init(productDataProvider: ProductDataProvider) {
        self.productDataProvider = productDataProvider
    }

    func getAllProducts() -> [Product] {
        let products = productDataProvider.getAllProducts()
        return products
    }
}

class ProductAPI: ProductDataProvider {
    func getAllProducts() -> [Product] {
        // Implementation
    }
}

In this updated version, we've applied the Dependency Inversion Principle by introducing an abstraction layer through the ProductDataProvider protocol. The ProductService class now depends on the abstraction layer, rather than the ProductAPI class directly. This makes it possible to use different data providers that conform to the ProductDataProvider protocol without modifying the ProductService class. For example, we can now create a ProductDatabase class that conforms to the ProductDataProvider protocol, and pass it to the ProductService class in the initializer, without modifying the ProductService class itself. This provides greater flexibility and modularity in the application design.

In conclusion, the SOLID principles in Swift are like a good set of building blocks for your code. Just like how a tower made of poorly fitting blocks will fall over, a poorly designed application that violates SOLID principles will eventually come crashing down. But with SOLID, your code will be strong, sturdy, and dependable - like a Lego castle that can withstand the most enthusiastic playtime. So let's embrace SOLID principles and build some amazing, robust applications with Swift!