At this point, we are halfway through our discussion of the SOLID principles. Today, we'll review the L, which stands for Liskov Substitution, Liskov being the creator's name (Barbara Liskov). Let's see the mathematical definition of this principle. LSP math definition You might be confused after seeing this above. I, certainly, was the first time I come across this principle. Instead, let's break all of that down to something a lot more consumable. Think of it like this: Every time you have a subclass, that subclass should be substitutable in every place where the base class was accepted or to tweak that definition just a bit, any implementation of an abstraction or an interface (protocol in Swift) should be substitutable anywhere that the abstraction is accepted or we can just say: Derived classes must be substitutable for their base classes. LSP simplified definition

LSP anti-pattern

To illustrate that, let's suppose that we have VideoPlayer class with a play method.

enum FileType {
  case mp4
  case mkv
  case mov
}


class VideoPlayer {
  
  func play(file: FileType) {
      // play the video
  }
  
}

But now let's imagine that if we are playing, for example .mp4 videos, that are a little bit different, maybe we'll override the play method. However, this time we'll do something a little bit different; maybe we check first that the file type is an .mp4 before we continue the program; otherwise, we stop the execution with a fatalError. We'll have some like this:

enum FileType {
  case mp4
  case mkv
  case mov
}


class VideoPlayer {
  
  func play(file: FileType) {
      // play the video
  }
  
}

class MP4Player: VideoPlayer {
  
  override func play(file: FileType) {
    guard file == .mp4 else {
      fatalError("file type should be of type .mp4")
    }
    // Play the .mp4 file
  }
  
}

At first glance, this code seems acceptable, right? But this is the perfect example of a LSP violation. Wait, what? How does it violates it, you might ask ๐Ÿค”? Well, one of the rules of the LSP is that preconditions for the subclass can't be greater. So in our example, we no longer can't substitute anywhere else because the output could be potentially different. The LSP states that we can normally substitute MP4Player anywhere the VideoPlayer class is accepted. Unfortunately, that's not the case because if we were to use the MP4Player and the file extension doesn't match the .mp4 file type, the program will crash with a fatalError, and that creates the violation. So as you can hopefully see, this principle helps protect us against those situations where some descendant exposes a behavior that's quite different from the original parent class or the abstraction. Now, if you don't mind, let's stick with this idea just a little bit more. What we demonstrated here is an illustration of the preconditions being too detailed. However, that's not the only thing you need to worry about. So far, if you read the two previous articles on the Single Responsibility Principle and the Open Close Principle, you should be pretty comfortable with the idea of coding according to a contract (protocol). However, there's just one problem: a contract validates the input, but it says nothing about the output. Let me give you a concrete example on an app I am currently building. It's an app that logs the gibberish your kid is saying while learning to speak. I use the repository pattern to fetch the words or "charabias". Here is what it looks like:

protocol CharabiaRepository {
  func fetchAllCharabias() -> Array<NSObject>
}

I know in practice, we'll never do something like this, but it's just to illustrate my point here; therefore, please be indulgent ๐Ÿ™ Now let's implement this protocol in two different classes, one for fetching from a file on disk and another one from a SQL database backed by Core Data. Let's first add some classes that inherit from NSObject (actually, so many UIKit and AppKit classes have NSObject as parent class)


class Charabia: NSObject {
  private(set) var gibberish: String
  private(set) var meaning: String
  
  init(gibberish: String, meaning: String) {
    self.gibberish = gibberish
    self.meaning = meaning
    super.init()
  }
}

class OtherObject: NSObject {
  private(set) var foo: String
  private(set) var bar: String
  
  init(foo: String, bar: String) {
    self.foo = foo
    self.bar = bar
    super.init()
  }
}

Then weโ€™ll have the implementation of the CharabiaRepository protocol like so:

class FileCharabiaRepository: CharabiaRepository {
  func fetchAllCharabias() -> Array<NSObject> {
    return [
      Charabia(gibberish: "dayi", meaning: "shoes"),
      Charabia(gibberish: "nana", meaning: "drink"),
    ]
  }
}

class CoreDataCharabiaRepository: CharabiaRepository {
  func fetchAllCharabias() -> Array<NSObject> {
    return [
      OtherObject(foo: "foo1", bar: "bar1"),
      OtherObject(foo: "foo2", bar: "bar2"),
    ]
  }
}

Although this looks okay, since we don't have any compilation error, this is another form of a LSP violation because the consumer of either of these implementations won't work identically. In one case, we return an array of Charabia and in another one an array of OtherObject. You might ask where is the pitfall when doing this, right? (I saw you coming ๐Ÿ˜…). Imagine we have a function called mySuperUsefulFunction that accepts the CharabiaRepository as a parameter in order to do its job:

func mySuperUsefulFunction(repository: CharabiaRepository) {
  // implementation here
} 

Because we type-hinted a protocol (CharabiaRepository) rather than a concrete class, we freed ourselves in many ways, now, as long as whatever we passed in this function conforms to that contract or behaves according to the terms of that contract, we can use it. Now though continuing on, imagine that we want to process the charabias returned by the repository.

func mySuperUsefulFunction(repository: CharabiaRepository) {
  let charabias = repository.fetchAllCharabias()
} 

The real problem arises when you want to deal with the charabias array because at this point, we're not sure it is an Array of Charabia or an array of OtherObject, and we'll have to type-check this like that, and that should be a smell that we're breaking the LSP principle somehow:

func mySuperUsefulFunction(repository: CharabiaRepository) {
  let charabias = repository.fetchAllCharabias()
  for charabia in charabias {
    if charabia is Charabia {
      // yay it's of type Charabia, do something here ๐Ÿค—
    }
    if charabia is OtherObject {
      // humm, that's not a Charabia boy, deal with it ๐Ÿ˜”
    }
  }
}	

Like we learned with the Open-closed Principle, whenever you find yourself doing type-checking like using the is keyword in Swift, that should be a smell that you are probably breaking one of these principles, or, because the teachings from these principles are linked in many ways if you are breaking one principle, then chances are in fact, you are also breaking the other principle(s). As a result, if you ever find yourself doing checks like what we have done above, then you are likely to break not only the OCP but the SRP as well.

A better abstraction

Now let's see how we can enhance the code above to respect the LSP. It's quite "easy" since the Swift compiler does the heavy lifting for us. What we have to do is to be explicit about the return type like this:

import Combine
import Foundation

struct Charabia {
}

struct Kid {
}

enum LocalCRUDError: LocalizedError {
}

protocol CharabiaRepository {
  func fetchAllCharabias(for kid: Kid) -> AnyPublisher<[Charabia], LocalCRUDError>
}

class FileCharabiaRepository: CharabiaRepository {
  func fetchAllCharabias(for kid: Kid) -> AnyPublisher<[Charabia], LocalCRUDError> {
    let charabias = ... // get charabias from file
    Just(charabias)
      .setFailureType(to: LocalCRUDError.self)
      .eraseToAnyPublisher()
  }
}

class CoreDataCharabiaRepository: CharabiaRepository {
  func fetchAllCharabias(for kid: Kid) -> AnyPublisher<[Charabia], LocalCRUDError> {
    let charabias = ... // get charabias from Core Data
    Just(charabias)
      .setFailureType(to: LocalCRUDError.self)
      .eraseToAnyPublisher()
  }
}		

Here by assuming that we want AnyPublisher<[Charabia], LocalCRUDError> in the CharabiaRepository protocol, weโ€™re making sure that all conformances of the latter should return that type, thus, allowing us to replace FileCharabiaRepository by CoreDataCharabiaRepository or vise-versa without any issue and our program should work as intended, thank to the statically nature of the language (Go Swift ๐Ÿ˜Ž).

Final Thoughts

Hope you find this article useful. But before letting you go, here is a quick list of ways to adhere of the LSP:

  1. Signature must match
  2. Preconditions can't greater
  3. Post conditions at least equal to
  4. Exception types must match

Thanks for reading. Talk to you on the next one. Peace โœŒ๏ธ (I sounded like MKBHD, I know ๐Ÿ˜‚)