Published on

The Liskov Substitution Principle

Authors
liskov substitution principle featured image

Welcome, dear readers! We're thrilled to have you join us once again as we delve deeper into the fascinating world of programming and software design principles. We know you've been following our series on the Solid Design Principles with keen interest, and we appreciate your dedication and curiosity.

In this blog post, we're turning our attention to a principle that may initially seem a little elusive, yet underpins a lot of what we do as programmers. Yes, we're talking about the Liskov Substitution Principle (LSP), one of the five pillars of the Solid Design Principles.

What is the Liskov Substitution Principle, you might ask? Well, in essence, it's a principle that enforces a certain level of consistency in your software. It guides how we structure our classes and objects, ensuring that a subclass can step into the shoes of its superclass without causing any hiccups. We're going to dive into the depths of LSP, and by the end of this post, you'll understand its practical implications and why it is so critical to clean, efficient, and maintainable code.

Our mission here, as always, is to empower you with knowledge that's not just theoretical, but directly applicable to your real-world coding challenges. Our examples will be in JavaScript, a language familiar to many, and we trust this will help you in grasping these concepts.

Before we delve in, we'd like to remind you to subscribe to our blog if you haven't already. This way, you won't miss out on the wealth of resources we publish regularly. We're continually expanding on a variety of topics, all geared toward enriching your journey in the ever-evolving tech landscape.

Understanding the Liskov Substitution Principle

liskov substitution principle featured image

Now, let's take a closer look at the Liskov Substitution Principle. This principle was introduced by Barbara Liskov in a 1987 paper and is a fundamental principle in the world of object-oriented programming.

The formal definition of the Liskov Substitution Principle is quite technical. It states: "If a program is using a base class pointer to a derived class object, then the derived class object must be substitutable for the base class object without altering any of the desirable properties of that program."

Don't worry if that sounds complex. In simple terms, the Liskov Substitution Principle implies that any subclass should be able to replace its superclass in a program without causing any errors or unexpected results. When we substitute a subclass for its superclass, the behavior of the program should remain correct and consistent.

Let's illustrate this principle using JavaScript and an example most of us can relate to: Animals and Dogs. Consider "Animal" as a parent class and "Dog" as a subclass. In JavaScript, we might define these classes as follows:

class Animal {
  move() {
    // code to move
  }
}

class Dog extends Animal {
  bark() {
    // code to bark
  }
}

Here, Dog is a subclass of Animal, and it inherits the move method from Animal. According to the Liskov Substitution Principle, wherever our program expects an Animal, we should be able to use a Dog object without the program blowing up or behaving unexpectedly. This concept is integral to maintaining the stability and reliability of object-oriented systems.

As we progress through this post, we'll examine more complex examples that will further illuminate this principle. But remember, the essence of the Liskov Subtraction Principle is that subclasses must be substitutable for their base classes without causing any hiccups in the software. It's all about ensuring a seamless interchangeability of objects.


Practical Example 1: Vehicles and Electric Cars

liskov substitution principle featured image

Traditionally, we consider an Electric Car to be a type of Vehicle. This is because it essentially performs the primary function of a vehicle: transportation. Based on this, one might design a class hierarchy where ElectricCar is a subclass of Vehicle. Here's how it might be structured in JavaScript:

class Vehicle {
  constructor(fuel) {
    this.fuel = fuel // Fuel in liters or kilowatt-hours
  }

  refuel(amount) {
    this.fuel += amount
  }

  consumeFuel(distance) {
    // Consume fuel based on distance
  }

  drive(distance) {
    this.consumeFuel(distance)
  }
}

class ElectricCar extends Vehicle {
  constructor(charge) {
    super(charge) // Charge in kilowatt-hours
  }

  consumeFuel(distance) {
    // Electricity consumption logic, which is different from traditional vehicles
  }

  fastCharge() {
    this.fuel += 50 // Add 50 kWh
  }
}

In this structure, ElectricCar inherits methods from Vehicle. But notice the issue with the refuel method. Traditional vehicles refuel with gasoline or diesel, while electric cars recharge with electricity. The method might not make sense for an electric car and could lead to confusion.

Imagine a scenario where we treat both types of vehicles uniformly:

let car1 = new Vehicle(20) // 20 liters of gasoline
let car2 = new ElectricCar(100) // 100 kWh of charge

car1.refuel(10)
car2.refuel(30)

console.log(car1.fuel) // Expected: 30
console.log(car2.fuel) // Expected: 130, but does this make sense for an electric car?

The behavior of the refuel method for the ElectricCar instance isn't intuitive. This is a violation of the Liskov Substitution Principle. An ElectricCar doesn't behave as we'd expect when treated as a generic Vehicle.

Problem-solving: How to Respect the Liskov Principle

To address this, we need to reconsider our class structure. One approach is to have separate methods for electric cars that better represent their characteristics:

class Vehicle {
  // ... (rest remains unchanged)
}

class ElectricCar {
  constructor(charge) {
    this.charge = charge // Charge in kilowatt-hours
  }

  recharge(kWh) {
    this.charge += kWh
  }

  consumeCharge(distance) {
    // Electricity consumption logic
  }

  drive(distance) {
    this.consumeCharge(distance)
  }

  fastCharge() {
    this.charge += 50 // Add 50 kWh
  }
}

Now, the methods are clear and specific to each type:

let car1 = new Vehicle(20)
let eCar = new ElectricCar(100)

car1.refuel(10)
eCar.recharge(30)

console.log(car1.fuel) // Expected: 30
console.log(eCar.charge) // Expected: 130

This design ensures that each class behaves as expected, adhering to the Liskov Substitution Principle.


Practical Example 2: Bird, Duck, and Penguin classes

liskov substitution principle featured image

Let's continue our exploration of the Liskov Substitution Principle with another interesting example. This time we'll take a look at classes representing Birds, Ducks, and Penguins.

In nature, most birds can fly, and ducks, being birds, can both fly and quack. However, penguins are a type of bird that can't fly but can swim. If we were to model this in JavaScript, we might start with a Bird class, then create Duck and Penguin classes as subclasses of Bird. Here's a simple implementation:

class Bird {
  fly() {
    // code to fly
  }
}

class Duck extends Bird {
  quack() {
    // code to quack
  }
}

class Penguin extends Bird {
  swim() {
    // code to swim
  }

  fly() {
    throw new Error("Penguins can't fly!")
  }
}

In the Bird class, we have the fly method, which we assume all birds can do. The Duck class extends Bird, adding the ability to quack. The Penguin class also extends Bird, but it adds the ability to swim and overrides the fly method because penguins, as we know, can't fly.

Now, let's imagine we have a function in our program that makes birds fly:

function makeBirdFly(bird) {
  bird.fly() //When you see the line bird.fly() in the makeBirdFly function, bird is a parameter (an instance of some class) passed to the function. It's not the Bird class itself. So, bird.fly() is attempting to call the fly method on whatever instance is passed to the function.
}

let duck = new Duck()
let penguin = new Penguin()

makeBirdFly(duck) // This works fine
makeBirdFly(penguin) // Error: Penguins can't fly!

In this scenario, making a Duck fly is fine because ducks can fly. However, trying to make a Penguin fly results in an error because we've overwritten the fly method in the Penguin class to throw an error. Therefore, substituting a Bird with a Penguin has caused our program to fail, and we've once again violated the Liskov Substitution Principle.

This case might seem a bit tricky. After all, we correctly modeled the fact that penguins can't fly, right? The catch is, our assumption that all birds can fly is incorrect. In the context of our program, we've incorrectly modeled the classes which led to the LSP violation.

In the next section, we will discuss how to correct this violation while still accurately representing our bird species' abilities.

Solution for the Second Violation

Having identified the Liskov Substitution Principle violation in our bird example, it's time to propose a solution. The key to addressing this violation is understanding that our initial class hierarchy was not properly modeling the behaviors of our birds. Not all birds can fly, and some birds can swim, so our class hierarchy needs to reflect this diversity of behaviors.

One way to solve this is by introducing two new subclasses: FlyingBird and SwimmingBird. FlyingBird would contain the fly method, and SwimmingBird would contain the swim method. Duck would then inherit from FlyingBird (and also get the quack method), while Penguin would inherit from SwimmingBird. Here's how this might look in JavaScript:

class Bird {
  // Base class with common bird properties and methods
}

class FlyingBird extends Bird {
  fly() {
    // code to fly
  }
}

class SwimmingBird extends Bird {
  swim() {
    // code to swim
  }
}

class Duck extends FlyingBird {
  quack() {
    // code to quack
  }
}

class Penguin extends SwimmingBird {
  // Penguins only swim and do not have any additional methods here
}

Now, if we recreate our makeBirdFly function and try to make a Duck and a Penguin fly, we see that our function only accepts instances of FlyingBird:

function makeBirdFly(bird) {
  if (bird instanceof FlyingBird) {
    bird.fly()
  } else {
    console.log("This bird can't fly!")
  }
}

let duck = new Duck()
let penguin = new Penguin()

makeBirdFly(duck) // This works fine
makeBirdFly(penguin) // Console: "This bird can't fly!"

This approach respects the Liskov Substitution Principle. The makeBirdFly function works as expected with Duck instances and doesn't throw an error with Penguin instances. The function simply checks whether the bird it's given can fly, and if it can't, it doesn't attempt to make it fly.

However, this solution has a limitation: a Duck can both fly and swim in real life, but in our program, it can only inherit from one class due to JavaScript's single inheritance model. This limitation highlights the complexities and challenges of accurately modeling real-world scenarios in object-oriented programming.

This might lead us to a discussion about other programming concepts, such as multiple inheritance (not supported in JavaScript), interfaces, or composition. But those are topics for another day. For now, let's celebrate that we've found a way to respect the Liskov Substitution Principle in another real-world example!


Practical Example 3: Payment Methods

liskov substitution principle featured image

Imagine an online store that supports multiple payment methods like Credit Card, PayPal, and Bitcoin. For our application's simplicity, we treat all payment methods the same, with a pay method to process the payment. Here's how we might model this:

class PaymentMethod {
  pay(amount) {
    // Generic payment process
  }
}

class CreditCard extends PaymentMethod {
  pay(amount) {
    // Specific logic to pay using a credit card
  }
}

class PayPal extends PaymentMethod {
  pay(amount) {
    // Specific logic to pay using PayPal
  }
}

class Bitcoin extends PaymentMethod {
  pay(amount) {
    // Specific logic to pay using Bitcoin
  }

  validateWallet() {
    // Bitcoin-specific validation logic
  }
}

Let's say we have a function in our application that processes a payment:

function processPayment(paymentMethod, amount) {
  paymentMethod.pay(amount)
}

let cc = new CreditCard()
let paypal = new PayPal()
let btc = new Bitcoin()

processPayment(cc, 100) // Works fine
processPayment(paypal, 100) // Works fine
processPayment(btc, 100) // This might fail if the wallet is not validated!

The problem here is the Bitcoin payment method. While CreditCard and PayPal methods might process payments directly, the Bitcoin method may require a wallet validation (via validateWallet) before payment. If this step is missed, the payment might fail. Using a Bitcoin payment in place of a generic payment method leads to unexpected behavior, violating the Liskov Substitution Principle.

Problem-solving: Respecting the LSP

To ensure that we respect the Liskov Substitution Principle, we might need to restructure our classes or ensure that all payment methods can be processed uniformly:

class PaymentMethod {
  validate() {
    // Generic validation if needed
  }

  pay(amount) {
    // Generic payment process
  }
}

class Bitcoin extends PaymentMethod {
  validate() {
    // Bitcoin-specific validation logic
  }

  pay(amount) {
    this.validate()
    // Specific logic to pay using Bitcoin
  }
}

In the revised structure, we added a validate method in the PaymentMethod class, which can be overridden by subclasses if needed. For the Bitcoin method, we ensure validation occurs before payment.


Practical Example 4: Books and E-Books

liskov substitution principle featured image

In the world of literature, there are physical books and e-books. At first glance, they both seem to serve the same purpose: providing content to readers. However, their methods of interaction are different.

Initially, our classes might look like this:

class Book {
  turnPage() {
    // Flip to the next page
  }
}

class EBook extends Book {
  clickNextButton() {
    // Click the next button to go to the next page
  }

  turnPage() {
    throw new Error("E-Books don't have physical pages to turn!")
  }
}

Now, let's say our program has a function to read the next page:

function readNextPage(book) {
  book.turnPage()
}

let physicalBook = new Book()
let ebook = new EBook()

readNextPage(physicalBook) // Works fine
readNextPage(ebook) // Error: E-Books don't have physical pages to turn!

Here, the readNextPage function expects a book instance and tries to turn its page. This works for physical books but fails for e-books, leading to a violation of the Liskov Substitution Principle.

Problem-solving: How to Adhere to LSP

To address this, we can redefine our classes to ensure that both books and e-books support the turnPage method, but the underlying implementation might differ:

class Book {
  turnPage() {
    // Flip to the next page
  }
}

class EBook extends Book {
  turnPage() {
    this.clickNextButton()
  }

  clickNextButton() {
    // Logic to go to the next page in an e-book
  }
}

With this design, both physical books and e-books support the turnPage method, and substituting one for the other won't lead to unexpected behaviors, ensuring compliance with the Liskov Substitution Principle.


A New Approach: Composition

liskov substitution principle featured image

While inheritance is a powerful tool in object-oriented programming, we've seen that it can also be complex and lead to issues such as those highlighted by the Liskov Substitution Principle. These challenges often arise due to the rigid structure of inheritance hierarchies, which may not accurately reflect the flexibility and variation we see in real-world entities.

This brings us to a different approach: composition. Composition allows objects to be built by combining simpler objects, instead of defining rigid class hierarchies. This way, we can create complex behaviors by mixing and matching different functionalities as needed. In our bird example, we could use composition to create a Duck object that can both fly and swim, without needing to worry about the limitations of single inheritance.

Here's an example of how you might apply composition in JavaScript:

const canFly = {
  fly: function () {
    console.log('Flying high!')
  },
}

const canSwim = {
  swim: function () {
    console.log('Swimming like a fish!')
  },
}

function Duck() {
  // Ducks can both fly and swim
  Object.assign(this, canFly, canSwim)
}

const duck = new Duck()
duck.fly() // Outputs: "Flying high!"
duck.swim() // Outputs: "Swimming like a fish!"

In this example, we define canFly and canSwim as objects with respective behaviors. Then, when we create a Duck, we use Object.assign to combine these behaviors into the Duck object. This way, our Duck can both fly and swim.

Composition can offer more flexibility and modularity than inheritance, allowing for more robust and maintainable code structures. If you're interested in exploring this topic further, let us know in the comments. We'd love to delve into a comprehensive comparison of inheritance vs. composition in a future blog post!


Keywords and terms disussed in the blog

KeywordDescription
Liskov Substitution PrincipleA principle that enforces substitutability of subtypes for their base types without causing errors or unexpected behavior.
ConsistencyEnsuring a certain level of uniformity and coherence in software by adhering to the Liskov Substitution Principle.
Subclass and superclassA relationship where a subclass inherits properties and behaviors from its superclass.
InheritanceThe mechanism by which one class inherits properties and behaviors from another class.
Base class and derived classAnother term for superclass and subclass, respectively.
SubstitutabilityThe ability to replace objects of a superclass with objects of its subclasses without altering program behavior.
ViolationAn instance where the Liskov Substitution Principle is not upheld, resulting in errors or unexpected behavior.
CompositionAn alternative to inheritance, where objects are built by combining simpler objects, allowing for more flexibility and modularity.
Single inheritanceThe restriction in some programming languages (like JavaScript) where a class can only inherit from one superclass.
Multiple inheritance (mentioned)A programming language feature that allows a class to inherit from multiple superclasses.
InterfacesLanguage constructs that define a contract for the methods and properties that a class must implement.
Design challengesThe difficulties and complexities that arise when designing class hierarchies and managing the behavior of subclasses.
FlexibilityThe ability to adapt and modify software designs to accommodate variations in behavior and requirements.
ModularityThe concept of dividing a software system into smaller, self-contained modules that can be developed and maintained independently.
RobustnessThe quality of software that is resilient, reliable, and can handle unexpected situations or inputs without failing or causing errors.
BehaviorThe actions, operations, or responses exhibited by objects or classes in a software system.
Real-world scenariosPractical situations or use cases that involve modeling entities and their behaviors in software systems.
Inheritance vs. compositionA comparison of two approaches in object-oriented programming for structuring and organizing classes and objects. Inheritance focuses on hierarchy and specialization, while composition emphasizes flexibility and modularity.

This table highlights the specific keywords from the text that are directly relevant to the Liskov Substitution Principle. It provides a concise reference for understanding the core ideas and principles associated with LSP and its practical implications in software design.

Conclusion and next steps

In this blog post, we've taken a deep dive into the Liskov Substitution Principle, one of the key principles in the SOLID guidelines for object-oriented design. Although the principle might seem complex at first, its essence is simple: if a program is using a base class, it should be able to use any of its subclasses without the program knowing or behaving incorrectly.

We've explored this principle through real-world examples and highlighted how violating this principle can lead to issues in your code. Additionally, we've shown how careful design of your class hierarchies or applying different programming techniques like composition can help you respect the Liskov Substitution Principle.

The Liskov Substitution Principle, like all the SOLID principles, is a valuable guide in designing robust, maintainable, and easily understandable code. We encourage you to take these principles to heart and apply them in your coding journey.

We hope you've found this post enlightening and helpful. Don't forget to subscribe and stay updated with our latest posts. Feel free to share your thoughts, questions, or any other feedback in the comments section below.

Thank you for reading!

Sources for Further Reading

The following sources were referenced in the creation of this blog post and provide additional information on the Liskov Substitution Principle

  1. Wikipedia: Liskov Substitution Principle
  1. Stack Overflow: What is an example of the Liskov Substitution Principle?
  1. Stackify: SOLID Design Principles Explained: The Liskov Substitution Principle with Code Examples