3. Liskov Substitution Principle (LSP)

What is Liskov Substitution Principle?

In simpler terms, this principle advocates that objects of a superclass should be replaceable with objects of its subclass without affecting the correctness of the program.

It states that: Objects of a superclass should be replaceable with objects of its subclasses without altering the correctness of the program.

This means that a subclass should behave in such a way that it can seamlessly substitute its parent class without breaking the program's functionality. LSP ensures that subclasses remain consistent with the expectations set by their parent class.

In other words, a subclass should be able to be substituted for its parent class without causing any errors or unexpected behavior.

Key Concepts

  1. Behavioral Compatibility: A subclass must exhibit the behavior expected by the parent class. It should not introduce unexpected behavior.
  2. Contract Preservation: Subclasses must adhere to the "contract" defined by the parent class (method definitions, preconditions, postconditions, invariants).
  3. No Side Effects: Substitution should not lead to program errors, misbehavior, or inconsistencies.

Understanding with example:

Violation of LSP

Consider an example where Rectangle is a parent class, and Square is a subclass:

Example: LSP Violation

class Rectangle {
protected:
    int width;
    int height;
public:
    void setWidth(int w) { width = w; }
    void setHeight(int h) { height = h; }
    int getArea() { return width * height; }
};

class Square : public Rectangle {
public:
    void setWidth(int w) override { 
        width = w; 
        height = w; // Ensure square's sides are equal
    }
    void setHeight(int h) override { 
        width = h; 
        height = h; // Ensure square's sides are equal
    }
};

// Client code
void printArea(Rectangle& rect) {
    rect.setWidth(5);
    rect.setHeight(10);
    cout << "Area: " << rect.getArea() << endl; // Expect 50
}

int main() {
    Rectangle rect;
    printArea(rect); // Works as expected

    Square sq;
    printArea(sq); // Output is incorrect because setWidth affects height
    return 0;
}

Why This Violates LSP:

  1. Behavioral Mismatch: The client code expects the area to be computed as width * height. However, Square overrides these methods to maintain equal sides, which disrupts the expected behavior.
  2. Substitution Breaks: Replacing Rectangle with Square causes logical errors in the program.

Adhering to LSP

To adhere to LSP, we can redesign the hierarchy to avoid such conflicts. One way is to avoid inheriting Square from Rectangle and instead use composition.

Correct Design: Composition Instead of Inheritance

class Shape {
public:
    virtual int getArea() const = 0;
    virtual ~Shape() = default;
};

class Rectangle : public Shape {
protected:
    int width;
    int height;
public:
    Rectangle(int w, int h) : width(w), height(h) {}
    void setWidth(int w) { width = w; }
    void setHeight(int h) { height = h; }
    int getArea() const override { return width * height; }
};

class Square : public Shape {
protected:
    int side;
public:
    Square(int s) : side(s) {}
    void setSide(int s) { side = s; }
    int getArea() const override { return side * side; }
};

// Client code
void printArea(Shape& shape) {
    cout << "Area: " << shape.getArea() << endl;
}

int main() {
    Rectangle rect(5, 10);
    printArea(rect); // Output: Area: 50

    Square sq(5);
    printArea(sq); // Output: Area: 25
    return 0;
}

Why This Works:

  1. No Inheritance Conflicts: Rectangle and Square are independent implementations of the Shape interface.
  2. Behavioral Compatibility: Each class maintains its unique behavior without violating client expectations.

Example

Let's consider an example of a Bird class hierarchy that includes different types of birds, such as Eagle, Penguin, and Ostrich. Each bird has a different ability to fly.

class Bird {
public:
  virtual void fly() = 0;
};

class Eagle : public Bird {
public:
  void fly() override;
};

class Penguin : public Bird {
public:
  void fly() override;
};

class Ostrich : public Bird {
public:
  void fly() override;
};

Now, let's suppose that we have a function that takes a Bird object and makes it fly.

void makeBirdFly(Bird& bird) {
  bird.fly();
}

According to the LSP, we should be able to pass any Bird subclass object to this function without affecting the correctness of the program. For example, we should be able to pass an Eagle object, a Penguin object, or an Ostrich object to this function.

Eagle eagle;
Penguin penguin;
Ostrich ostrich;

makeBirdFly(eagle); // okay, eagle can fly
makeBirdFly(penguin); // error, penguin can't fly
makeBirdFly(ostrich); // error, ostrich can't fly

As we can see, passing a Penguin or an Ostrich object to this function would result in an error because they cannot fly. Therefore, these classes do not adhere to the same behavior as their parent class, Bird, and violate the LSP.

Example:

Imagine we have a set of geometric shapes: Rectangle, Shape, and Circle. All these shapes have a area calculation method:

#include <iostream>

class Shape {
public:
    virtual double area() const = 0;
};

class Rectangle : public Shape {
private:
    double width, height;
public:
    Rectangle(double width, double height){
        this->width = width;
        this->height = height;
    }
    double area() const override {
        return width * height;
    }
};

class Square : public Shape {
private:
    double side;
public:
    Square(double side){
        this->side = side;
    }
    double area() const override {
        return side * side;
    }
};

class Circle : public Shape {
private:
    double radius;
public:
    Circle(double radius){
        this->radius = radius;
    }
    double area() const override {
        return 3.14159 * radius * radius;
    }
};

Here, Rectangle, Square, and Circle all inherit from Shape and implement the area() method. This adheres to LSP because each shape can be substituted for another in contexts where the Shape base class is expected. For example:

void printArea(const Shape& shape) {
    std::cout << "Area: " << shape.area() << std::endl;
}

int main() {
    Rectangle rect{4, 5};
    Square square{4};
    Circle circle{3};

    printArea(rect);   // Output: Area: 20
    printArea(square); // Output: Area: 16
    printArea(circle); // Output: Area: 28.27431

    return 0;
}

In this example, we can see that Rectangle, Square, and Circle objects are seamlessly substituting Shape objects in the printArea() function without affecting the correctness of the program, thus satisfying the LSP.