Friday, September 20, 2024

SOLID Principles: Liskov Substitution Principle



In this series, I will highlight some areas where you can improve the quality of your code during the development process. In the first article, I explained why code quality is paramount. Then I introduced briefly some ideas that would help you improve the quality of your code. I will provide more details on these points in future articles. Some concepts will be illustrated with examples in Java and Python. I will begin this journey with you by introducing the SOLID principles in this article. They are some of the concepts that will help you improve the quality of your code and boost your abstract thinking in the object-oriented paradigm. After introducing the Single Responsibility Principle and Open/Closed Principle in previous articles, I will elucidate the Liskov Substitution Principle in this article, with examples in Java and Python.


1. Liskov Substitution Principle

This principle states that objects in a program should be replaceable by instances of their sub-types without modifying the accuracy of this program. In other words, if S is a sub-type of T, then instances of type T may be replaced with objects of type S.


Consider an example of a program in which ‘Car’, ‘Truck’, and ‘Van’ are sub-classes of the ‘Vehicle’ class. This program adheres to LSP when each occurrence of an object of the ‘Vehicle’ class could be replaced by one of its three sub-classes without any impact on the correctness of the program.


This principle ensures that any one of the sub-classes extends its super-class without altering its behavior. That means you should only insert in the parent class properties shared by all sub-classes.


On the other hand, this principle helps you keep your software class hierarchies consistent with the Open/Closed principle. We will confirm this later with the help of an example.


We can summarize the benefits of adhering to the LSP as follows:

- It helps prevent unexpected behavior and avoid opening closed classes to make changes.

- It allows for more predictable behavior of the model hierarchy, which will be easier to extend.

- It leads to better maintenance and testability.

- It helps ensure better loose coupling between certain system components.


2. Examples of LSP in Java

To illustrate the LSP, here we take two different examples in Java language. One adheres to LSP and the second violates it.

2.1. Example 1

Let’s take a simple example of a class ‘Bird’ and suggest the following code for it:


public class Bird {


    Double weight;

    public Bird(Double weight) {

        this.weight = weight;

    }

    public void eat() {

        System.out.println("I can eat");

    }

    public void fly() {

        System.out.println("I can fly");

    }

}


An eagle is a bird and it can extend the ‘Bird’ class correctly as follows:

public class Eagle extends Bird {

    public Eagle(Double weight) {

        super(weight);

    }

    @Override

    public void eat() {

        super.eat();

        System.out.println("I am carnivore");

    }

}


But a chicken is also a bird. So, the ‘Chicken’ class can extend the ‘Bird’ class as follows:

public class Chicken extends Bird {

    public Chicken(Double weight) {

        super(weight);

    }

    @Override

    public void eat() {

        super.eat();

        System.out.println("I am omnivore");

    }

    @Override

    public void fly() {

        throw new UnsupportedOperationException("Unsupported Operation");

    }

}


But we have a problem here! The chicken cannot fly and therefore cannot support the ‘fly’ method. In this case, you need to change the behavior of the super-class, like throwing an exception, which violates the Liskov Substitution Principle.
The principal cause of this problem is that flying is not a shared property by all birds. In other words, objects of the ‘Chicken’ sub-class cannot substitute objects of the ‘Bird’ super-class. We illustrate this issue using an example. 
In the ‘BirdDemo’ class, we define ‘birdFly’ as a method that takes a list of instances of the ‘Bird’ class. It iterates over the list and calls each bird’s ‘fly’ method. In the main method of the ‘BirdDemo’ class, we create two birds as instances of the ‘Eagle’ and ‘Chicken’ classes, respectively. Then we add these two instances to a list and call the ‘birdFly’ method with this list as an argument.

import java.util.List;


public class BirdDemo {

    public static void main(String[] args) {

        Bird bird = new Eagle(10.0);

        Bird chicken = new Chicken(5.5);

        List<Bird> birdsList = List.of(bird, chicken);

        birdFly(birdsList);

    }

    public static void birdFly(List<Bird> birdsList) {

        for (Bird bird : birdsList){

            bird.fly();

        }

    }

}


Running this demonstration gives us the following result:

I can fly

Exception in thread "main" java.lang.UnsupportedOperationException: Unsupported Operation
at lsp.app.violate.Chicken.fly(Chicken.java:17)
at lsp.app.violate.BirdDemo.birdFly(BirdDemo.java:17)
at lsp.app.violate.BirdDemo.main(BirdDemo.java:12)


To fix such an issue, you need to reopen the class (violating the Open/Closed Principle) and make changes. You should account for this behavior early in the requirements phase. We can suggest a better implementation that adheres to the Liskov Substitution Principle for these classes as follows:

public class Bird {

    Double weight;

    public Bird(Double weight) {

        this.weight = weight;

    }

    public void eat() {

        System.out.println("I can eat");

    }

}


public class FlyingBird extends Bird {

    public FlyingBird(Double weight) {

        super(weight);

    }

    public void fly() {

        System.out.println("I can fly");

    }

}


public class Eagle extends FlyingBird {

    public Eagle(Double weight) {

        super(weight);

    }

    @Override

    public void eat() {

        super.eat();

        System.out.println("I am carnivore");

    }

}


public class Chicken extends Bird {

    public Chicken(Double weight) {

        super(weight);

    }

    @Override

    public void eat() {

        super.eat();

        System.out.println("I am omnivore");

    }

}


2.2. Example 2

Let’s take the famous example of ‘Shape’. Any object of the class ‘Shape’ has an area and a perimeter. For that, we suggest two methods ‘getArea()’ and ‘getPerimeter()’ to implement such behavior. However, we return something like ‘0’ in these methods because we don’t have any information that helps us perform such calculations. These details can only be available if we know what shape it is. We should create new sub-classes like ‘Circle’ and ‘Triangle’ to achieve such a goal.

public class Shape {

    protected String name;

    public Shape(String name) {

        this.name = name;

    }

    public double getArea() {

        return 0.0;

    }

    public double getPerimeter(){

        return 0.0;

    }

}


public class Shape {

    protected String name;

    public Shape(String name) {

        this.name = name;

    }

    public double getArea() {

        return 0.0;

    }

    public double getPerimeter(){

        return 0.0;

    }

}


A ‘Circle’ class inherits from the ‘Shape’ class and overrides the ‘getArea()’ and ‘getPerimeter()’ methods because we have the formula to calculate the area and perimeter of a circle. 


public class Circle extends Shape {

    private double radius;

    public Circle(String name, double radius) {

        super(name);

        this.radius = radius;

    }

    @Override

    public double getArea() {

        return Math.PI * radius * radius;

    }

    @Override

    public double getPerimeter(){

        return 2*Math.PI*radius;

    }

}


On the other hand, the ‘Triangle’ class also inherits from the ‘Shape’ class. It overrides the ‘getArea()’ and ‘getPerimeter()’ methods to calculate properly the area and the perimeter of a triangle. 



public class Triangle extends Shape {

    private double line1;

    private double line2;

    private double line3;

    public Triangle(String name, double line1, double line2, double line3) {

        super(name);

        this.line1 = line1;

        this.line2 = line2;

        this.line3 = line3;

    }

    @Override

    public double getArea() {

        double s = (line1 + line2 + line3) / 2;

        return Math.sqrt(s * (s - line1) * (s - line2) * (s - line3));

    }

    @Override

    public double getPerimeter() {

        return line1 + line2 + line3;

    }

}


The code of the ‘Shape’ class wraps the common behavior shared by all shapes. The ‘Circle’ and ‘Triangle’ classes inherit this behavior because they have an area and a perimeter. We can say that the ‘Circle’ and ‘Triangle’ instances behave exactly like an instance of the ‘Shape’ super-class. Therefore, they can be substitutable for the ‘Shape’ super-class objects. Let’s illustrate this in an example of a demo class:


public class LSPDemo {

    public static void main(String[] args) {

        Shape circle = new Circle("Circle", 16.5);

        Shape triangle = new Triangle("Triangle", 10.0, 14.0, 18.0);

        List<Shape> shapesList = List.of(circle, triangle);

        double shapesTotalArea = getShapesTotalArea(shapesList);

        System.out.println("Shapes total area: " + shapesTotalArea);

    }


    public static double getShapesTotalArea(List<Shape> shapesList) {

        double shapesTotalArea = 

shapesList.stream().map(s->s.getArea()).reduce(Double::sum).get();

        return shapesTotalArea;

    }

}


In this example, we create two shapes as instances of the ‘Circle’ and ‘Triangle’ classes.  We put these two instances in the ‘shapesList’ list which does not distinguish between circles and triangles. 
We define a function ‘getShapesTotalArea’ which takes a list of objects of the ‘Shape’ class and calculates the total area of all shapes in the list. It uses the Java Stream API to map each ‘Shape’ object to its area using its ‘getArea’ method. Then, it sums them up and returns the total area.
This implementation adheres to the Liskov Substitution Principle. Objects of the ‘Circle’ and ‘Triangle’ sub-classes have successfully replaced objects of the ‘Shape’ super-class in the ‘getShapesTotalArea’ function. Executing the main function of the ‘LSPDemo’ class gives us the following result:


Shapes total area: 924.9477205372846


3. Example of LSP in Python

Suppose we have a class hierarchy that represents different species of animals. We have a base class ‘Animal’ and two sub-classes ‘Dog’ and ‘Fish’. To adhere to LSP, instances of ‘Dog’ and ‘Fish’ should be able to replace instances of ‘Animal’ without affecting the correctness of the program. Let’s inspect if this is the case in our example.


The base class ‘Animal’ has a ‘walk’ method supported by its sub-class ‘Dog’ but not by its sub-class ‘Fish’. In our example, we threw an exception to indicate that this operation is unsupported in the ‘Fish’ sub-class. In this case, an instance of the ‘Fish’ sub-class cannot substitute an object of the ‘Animal’ super-class. Thus, this example violates the Liskov Substitution Principle. 


To illustrate that, we define a function ‘animal_walking’ that takes an animal object as input and calls its ‘walk’ method. As fishes are animals but cannot walk, then the ‘walk’ method should not belong to the ‘Animal’ class but to a sub-class in the hierarchy to regroup only animals that have this feature.


class Animal:

    def __init__(self, weight):

        self.weight = weight

    def eat(self):

        print("I can eat")

    def walk(self):

        print("I can walk")


class Dog(Animal):

    def __init__(self, weight, no_legs):

        super().__init__(weight)

        self.no_legs = no_legs

    def bark(self):

        print("I can bark")


class Fish(Animal):

    def walk(self):

        raise Exception("Sorry, unsupported operation")

    def swim(self):

        print("I can swim")


def animal_walking(animal):

    animal.walk()



# Examples of using these classes

dog = Dog(10, 4)

animal_walking(dog)

fish = Fish(2)



# Violation of Liskov Substitution Principle

animal_walking(fish)


Running the above example gives us the following output:


I can walk

Traceback (most recent call last):

...

    raise Exception("Sorry, unsupported operation")

Exception: Sorry, unsupported operation

No comments:

Post a Comment

Blog Posts

Enhancing Performance of Java-Web Applications

Applications built with a Java back-end, a relational database (such as Oracle or MySQL), and a JavaScript-based front-end form a common and...