SOLID Principles in Swift - Liskov Substitution Principle

Background

In this series of posts we are going to be covering the SOLID principles of software development. These are a set of principles / guidelines, that when followed when developing a software system, make it more likely that the system will be easier to extend and maintain over time. Let’s take a look at the problems that they seek to solve:

  • Fragility: A change may break unexpected parts, it is very difficult to detect if you don’t have a good test coverage
  • Immobility: A component is difficult to reuse in another project or in multiple places of the same project because it has too many coupled dependencies
  • Rigidity: A change requires a lot of effort because it affects several parts of the project

So what are the SOLID principles?

  • Single Responsibility Principle - A class should have only a single responsibility / have only one reason to change
  • Open-Closed Principle - Software should be open for extension but closed for modification
  • Liskov Substitution Principle - Objects in a program should be replaceable with instances of their sub types without altering the correctness of the program
  • Interface Segregation Principle - Many client-specific interfaces are better than one general-purpose interface
  • Dependency Inversion Principle - High level modules should not depend on low level modules. Both should depend on abstractions

In this article we will focus on the Liskov Substitution Principle.

What does it mean?

So the Liskov Substitution Principle states:

Derived classes must be substitutable for their base classes

What exactly does this mean? In a basic sense it for example if you have a function that accepts a type of class which is a parent of other classes, any class that subclasses the parent class should be able to be passed in without it breaking the program.

See a summary of the main points of the principle below:

  1. Contra variance of method parameter types in the sub type.
  2. Covariance of method return types in the sub type.
  3. New exceptions cannot be thrown by the methods in the sub type, except if they are sub types of exceptions thrown by the methods of the super type.
  4. Don’t implement stricter validation rules on input parameters than those implemented by the parent class.
  5. Apply at the least the same rules to all output parameters as applied by the parent class.

Let’s take a look at what these different rules mean for subclasses.

The Parent Class

First of all, let’s define our parent class or base class that contains some functionality. Let’s use a vehicle class as an example, this vehicle has a throttle which can be set at any value between 0 and 100.

// 1
enum VehicleError: Error {
    case outOfBounds
}

// 2
class Vehicle {
    private var throttle: Int = 0
    
    // 3
    func setThrottle(throttle: Int) throws {
        guard (0...100).contains(throttle) else {
            throw VehicleError.outOfBounds
        }
        self.throttle = throttle
    }
}

Let’s step through it:

  1. First of all we define a custom error to throw if the throttle is not within bounds
  2. Here we define our vehicle class that has a throttle variable to store the value being set
  3. We have a function to set the throttle value, there is a guard statement to check whether the value being set is in the appropriate range. If it is not, we throw an error, if it is we set the value

Validation rules on input parameters

Now let’s create a subclass that breaks the principle. We will make a lorry class that inherits from the super class but adds its own restrictions to the throttle function, only allowing the throttle to be set between 0 and 60 for example.

class Lorry: Vehicle {
    override func setThrottle(throttle: Int) throws {
        guard (0...60).contains(throttle) else {
            throw VehicleError.outOfBounds
        }
        
        try super.setThrottle(throttle: throttle)
    }
}

So what is happening here? We have subclassed the Vehicle class and overriden the setThrottle method. Now what we have done here is we have added a guard statement to check if the throttle is between 0 and 60. We throw an error saying out of bounds if outside of that, if it is within bounds we call the super class method.

Why is this a problem? Well imagine we are building a system / class that interacts with the Vehicle class. Now based on the Vehicle class you would expect to be able to set the throttle to anything between 0 and a 100. However now, if someone chooses to pass a Lorry subclass to your system / class, you will not be able to set the throttle above 60. Depending on how this other class or system is built this may have unintended side effects as you can’t set the values that you are expecting without getting an error.

This example breaks the rule:

Don’t implement stricter validation rules on input parameters than those implemented by the parent class.

Errors in the liskov principle

Let’s modify our example to see how we could break the principle by throwing different errors. Let’s modify the Lorry subclass:

enum LorryError: Error {
	case outOfBounds
}

class Lorry: Vehicle {
    override func setThrottle(throttle: Int) throws {
        guard (0...60).contains(throttle) else {
            throw LorryError.outOfBounds
        }
        
        try super.setThrottle(throttle: throttle)
    }
}

So what is happening here:

  • We have added a new Error type called LorryError
  • When we have our bounds exception we are throwing this new error type instead of the one provided by the super class

Why does that cause a problem? To find out let’s take a look at the error handling code:

// 1
let vehicle: Vehicle = Vehicle()

do {
	// 2
    try vehicle.setThrottle(throttle: 110)
} catch VehicleError.outOfBounds {
	// 3
    print("System shutdown")
} catch {
	// 4
    print("Show generic error")
}

Let’s step through this code:

  1. We are creating a Vehicle super class object.
  2. We are calling our function with a value considered out of bounds
  3. We catch the outOfBounds exception and print a system shutdown message
  4. We have a generic catch for other errors where we show a generic error message

Now if we run this code we see the below message in the console as expected:

System shutdown

So what happens if we replace our Lorry subclass with its new error and put it in place of the Vehicle super class? If we change line one to read:

let vehicle: Vehicle = Lorry()

If we run the code above we will now see a different error:

Show generic error

The error handling code is not aware of subclass specific errors so is no longer able to handle them accordingly. Imagine a mission critical system that needs to shut down if an out of bounds happens, in this case the error would be missed as it would require the error handling class to have knowledge of all possible sub types in order to handle all the errors appropriate. Defeating the point of using the super class and thus breaking the principle:

New exceptions cannot be thrown by the methods in the sub type, except if they are sub types of exceptions thrown by the methods of the super type.

Contra variance and Covariance of parameters and return types

In the list of rules you may recall seeing two items talking about contra variance and covariance of parameters and return types. What does that mean exactly?

  1. Contra variance of method parameter types in the sub type.
  2. Covariance of method return types in the sub type.

Contra variance of method parameter types in the sub type

Contra variance means that we can use change the type of method parameter to a super class of the type but not a subclass. This rules works basically in combination with the rule below:

Don’t implement stricter validation rules on input parameters than those implemented by the parent class.

What it means is, we can use a super class of a parameter, thus ‘weakening’ the restrictions of the method, but not a subclass of that type which would tighten the restrictions of the method.

Covariance of method return types in the sub type

Covariance means that the type being used can be a sub type of the class provided by the super class function return type. Similarly, this works in the same way as the 5th rule:

Apply at the least the same rules to all output parameters as applied by the parent class.

Now both of these rules aren’t possible to be broken as part of Swift. It’s not possible to overload functions providing alternative type specifications, at least while still referring to the super class type. We can override methods and provide different parameter types and return types but this requires the calling class to know the type of the subclass. When referring to the super class, the super class implementation is always called regardless of subclass functions with different params.