Photo by Fotis Fotopoulos on Unsplash
Solid Principles in Swift for Kids
How to explain Solid Principles to a kid.
"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:
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.
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.
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.
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.
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
- 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
andNotificationManager
.
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
}
- 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 theShape
class closed to modification by creating an abstract classShape
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
}
- 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 theCar
class can be used in place of theVehicle
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
}
}
- 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 protocolSimplePrinter
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
}
}
- 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 protocolNotificationProvider
and have theNotificationService
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!