Understanding and Implementing the Liskov Substitution Principle in Object-Oriented Programming

The Liskov Substitution Principle (LSP) is a principle in object-oriented programming that states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program. This principle is a part of the SOLID principles of object-oriented design.

Here are three examples of LSP in action:

  1. A basic example of LSP is a shape hierarchy, where a shape class is the superclass and rectangle, square, and circle classes are subclasses. The shape class has a method called “area” that calculates the area of the shape. According to LSP, a rectangle object should be able to replace a shape object without causing any errors, since a rectangle is a type of shape and has an area that can be calculated.
  2. Another example of LSP is in a program that manages a collection of vehicles. The superclass is “Vehicle” and the subclasses are “Car”, “Truck”, and “Boat”. The superclass has a method called “move()” which is used to move the vehicle. According to LSP, a “Car” object should be able to replace a “Vehicle” object without causing any errors, since a car is a type of vehicle and can move.
  3. Another example of LSP is a class hierarchy for a media player. The superclass is “MediaPlayer” and the subclasses are “Mp3Player” and “VideoPlayer”. The superclass has a method called “play()” which is used to play a media. According to LSP, “Mp3Player” object should be able to replace a “MediaPlayer” object without causing any errors, since Mp3Player is a type of MediaPlayer and can play audio files.

It’s important to note that LSP is based on the idea that the subclasses should be “substitutable” for the superclass, meaning that the subclass should not add any new functionality or constraints that the superclass doesn’t have. If a subclass adds new functionality or constraints, it may cause errors if an object of that subclass is used in place of an object of the superclass.

Basic Example

A basic example of LSP is a shape hierarchy, where a shape class is the superclass and rectangle, square, and circle classes are subclasses.

class Shape {
    public double area(){
        //returns area of the shape
    }
}

class Rectangle extends Shape {
    private double width;
    private double height;
    public double area(){
        return width*height;
    }
}

class Square extends Shape {
    private double side;
    public double area(){
        return side*side;
    }
}

According to LSP, a rectangle object should be able to replace a shape object without causing any errors, since a rectangle is a type of shape and has an area that can be calculated. By overriding the area method of the super class and returning the correct area of the rectangle, we can say that this implementation follows LSP.

Looking from another way,

Another example of LSP is in a program that manages a collection of vehicles. The superclass is “Vehicle” and the subclasses are “Car”, “Truck”, and “Boat”.

class Vehicle {
    public void move() {
        //moves the vehicle
    }
}

class Car extends Vehicle {
    public void move() {
        //moves the car
    }
}

class Truck extends Vehicle {
    public void move() {
        //moves the truck
    }
}

According to LSP, a “Car” object should be able to replace a “Vehicle” object without causing any errors, since a car is a type of vehicle and can move. The method move() is overridden in all subclasses and thus the LSP is followed.

Yet another example!

Another example of LSP is a class hierarchy for a media player. The superclass is “MediaPlayer” and the subclasses are “Mp3Player” and “VideoPlayer”.

class MediaPlayer {
    public void play() {
        //plays the media
    }
}

class Mp3Player extends MediaPlayer {
    public void play() {
        //plays the mp3
    }
}

class VideoPlayer extends MediaPlayer {
    public void play() {
        //plays the video
    }
}

According to LSP, “Mp3Player” object should be able to replace a “MediaPlayer” object without causing any errors, since Mp3Player is a type of MediaPlayer and can play audio files. Here in this example, since the subclasses Mp3Player and VideoPlayer overrides the play method and thus the LSP is followed.

What’s it all about?

It’s important to note that LSP is not only about overloading or overriding methods, but also about the overall behavior of the subclasses. LSP requires that any client that uses a superclass should be able to use any of its subclasses without any loss of functionality. Thus, LSP is not just about the methods, but also about the behavior and the class invariants of the subclasses. For example, if a subclass changes the state of the object in a way that’s not compatible with the superclass, that would break LSP.

Additionally, if a subclass adds new methods or changes the signature of existing methods in a way that’s not compatible with the superclass, that would also break LSP. Therefore, it is important to ensure that the subclasses adhere to the same contracts and constraints as the superclass while providing additional or specialized functionality. LSP is an important principle to follow in order to maintain the flexibility and extensibility of the codebase and to avoid potential errors or bugs.

Violation example

An example of code that violates the Liskov Substitution Principle is a class hierarchy for a bank account, where the superclass is “Account” and the subclasses are “CheckingAccount” and “SavingsAccount”.

class Account {
    private double balance;
    public void deposit(double amount) {
        balance += amount;
    }
    public void withdraw(double amount) {
        balance -= amount;
    }
}

class CheckingAccount extends Account {
    private double overdraftLimit;
    public void withdraw(double amount) {
        if (balance + overdraftLimit >= amount) {
            balance -= amount;
        }
    }
}

class SavingsAccount extends Account {
    public void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }
}

This code violates the Liskov Substitution Principle because the withdraw method in the CheckingAccount class has different behavior than the withdraw method in the superclass. The CheckingAccount class allows for an overdraft limit, which allows the account to have a negative balance, whereas the Account class does not allow for a negative balance. This means that if we have a variable of type Account and assign it an instance of CheckingAccount, we could withdraw more money than the balance of the account.

How to make it follow the principle?

To make this code follow the Liskov Substitution Principle, we can add a new method withdrawWithOverdraft to the CheckingAccount class and use it to withdraw money with overdraft limit.

class Account {
    private double balance;
    public void deposit(double amount) {
        balance += amount;
    }
    public void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }
}

class CheckingAccount extends Account {
    private double overdraftLimit;
    public void withdrawWithOverdraft(double amount) {
        if (balance + overdraftLimit >= amount) {
            balance -= amount;
        }
    }
}

class SavingsAccount extends Account {
    public void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }
}

In this modification, the behavior of the withdraw method in the CheckingAccount class is consistent with the superclass. Now, a variable of type Account can only be used to withdraw money if the balance is sufficient and any withdrawal with overdraft limit will have to be done through a different method. This modification follows the Liskov Substitution Principle by ensuring that objects of the subclasses can be used in place of objects of the superclass without causing any errors or unexpected behavior.

Some Complex Example, May be?

An example of a more complex code that violates the Liskov Substitution Principle is a class hierarchy for a computer system, where the superclass is “Computer” and the subclasses are “Desktop” and “Laptop”.

class Computer {
    private int ram;
    private int hdd;
    public void setRam(int ram) {
        this.ram = ram;
    }
    public void setHdd(int hdd) {
        this.hdd = hdd;
    }
    public void turnOn() {
        //code to turn on the computer
    }
    public void turnOff() {
        //code to turn off the computer
    }
}

class Desktop extends Computer {
    private boolean hasCDDrive;
    public void setHasCDDrive(boolean hasCDDrive) {
        this.hasCDDrive = hasCDDrive;
    }
    public void turnOn() {
        //code to turn on the computer and check if CD drive is present
    }
}

class Laptop extends Computer {
    private boolean hasBattery;
    public void setHasBattery(boolean hasBattery) {
        this.hasBattery = hasBattery;
    }
    public void turnOn() {
        //code to turn on the computer and check if battery is present
    }
    public void turnOff() {
        //code to turn off the computer and check if battery is present
    }
}

This code violates the Liskov Substitution Principle because the turnOn() and turnOff() methods in the Desktop and Laptop classes have different behavior than the turnOn() and turnOff() methods in the superclass. The Desktop class checks for the presence of a CD drive before turning on, whereas the superclass does not. The Laptop class checks for the presence of a battery before turning on and turning off, whereas the superclass does not. This means that if we have a variable of type Computer and assign it an instance of Desktop or Laptop, we could turn on the computer without checking if the CD drive or battery is present.

Now make it follow liskov principle

To make this code follow the Liskov Substitution Principle, we can move the CD drive and battery checking logic to factory methods, where the factory methods will decide which class to instantiate based on the provided information and return the appropriate object.

class Computer {
  private int ram;
  private int hdd;
  public void setRam(int ram) {
    this.ram = ram;
  }
  public void setHdd(int hdd) {
    this.hdd = hdd;
  }
  public void turnOn() {
    //code to turn on the computer
  }
  public void turnOff() {
    //code to turn off the computer
  }
  public static Computer createComputer(int ram, int hdd, boolean hasCDDrive, boolean hasBattery) {
    if (hasCDDrive) {
      return new Desktop(ram, hdd, hasCDDrive);
    } else if (hasBattery) {
      return new Laptop(ram, hdd, hasBattery);
    } else {
      return new Computer(ram, hdd);
    }
  }
}

class Desktop extends Computer {
  private boolean hasCDDrive;
  public Desktop(int ram, int hdd, boolean hasCDDrive) {
    super(ram, hdd);
    this.hasCDDrive = hasCDDrive;
  }
  public void turnOn() {
    //code to turn on the computer
  }
}

class Laptop extends Computer {
  private boolean hasBattery;
  public Laptop(int ram, int hdd, boolean hasBattery) {
    super(ram, hdd);
    this.hasBattery = hasBattery;
  }
  public void turnOn() {
    //code to turn on the computer
  }
  public void turnOff() {
    //code to turn off the computer
  }
}

This modification follows the Liskov Substitution Principle by ensuring that objects of the subclasses can be used in place of objects of the superclass without causing any errors or unexpected behavior. The factory method `createComputer` decide which class to instantiate based on the provided information and return the appropriate object, it will ensure that the CD drive and battery checking logic is handled in the factory method, rather than in the subclasses, thus the behavior of the subclasses are consistent with the superclass.

Also, the factory method will take care of the initialization of the subclasses with the appropriate parameters, making the code more maintainable and readable. In summary, the modification here is to move the CD drive and battery checking logic to factory methods, where the factory methods will decide which class to instantiate based on the provided information and return the appropriate object. This ensures that the behavior of the subclasses are consistent with the superclass and therefore adheres to the Liskov Substitution Principle.

For more post like this; you may also follow this profile – https://dev.to/asifbuetcse

Leave a Comment