Skip to main content

Solid Principles

Liskov’s substitution principle

L — LSP — Liskov’s substitution principle

Definitions

Derived classes must be substitutable for their base classesA program that uses an interface must not be confused by an implementation of that interface.If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program.

Liskov’s Substitution Principle is a foundational principle in object-oriented languages, emphasizing the ability to seamlessly substitute instances of a subclass for instances of its parent class. In essence, it ensures that any derived class can be used interchangeably with its base class without compromising the program’s correctness. This principle enhances the reliability of contracts within the codebase, assuring that objects identified as a certain type will consistently exhibit the expected behavior of that type. This confidence in substitutability fosters robust and predictable software design.

Let’s look at some of the inheritance’s which violates this principle.

1. Vehicle Rental System:

  • Imagine a system with a Vehicle base class and subclasses like CarTruck, and Motorcycle.
  • LSP dictates that a client expecting a Vehicle should be able to work correctly with any of its subclasses without issues.
  • Violation: If Motorcycle doesn't have a doors() method like other vehicles, replacing a Car with a Motorcycle could lead to errors.

2. Rectangle and Square:

  • If Square is a subclass of Rectangle, it should inherit its properties and behaviors.
  • Violation: If you set a Rectangle's width and height independently, but a Square's width always equals its height, replacing a Rectangle with a Square in some contexts could break functionality.

3. Payment Gateway:

  • Consider a PaymentGateway interface for processing payments, with concrete implementations like CreditCardGateway and PayPalGateway.
  • Violation: If PayPalGateway throws an exception for expired cards, while other gateways don't, replacing a gateway could introduce unexpected errors.

4. Coffee Machine:

  • A basic coffee machine might only make filter coffee.
  • A premium model might also make espresso and cappuccino.
  • Violation: If the premium model can’t make filter coffee (due to a missing brewFilterCoffee() method), it violates LSP and can't fully substitute for the basic model.

5. Bird and Ostrich:

  • In a biological model, Ostrich might be a subclass of Bird.
  • Violation: If Bird has a fly() method, but Ostrich cannot fly, using an Ostrich where a flying Bird is expected would lead to incorrect behaviour.
These examples clearly demonstrate that real-world entities with “is-a” relationships do not necessarily imply a corresponding “is-a” relationship in their code representations or the classes that represent these entities.

Let’s go through a code example.

/***
Bad code!
Violations:
  + Square violates LSP because it breaks the expected behavior of Rectangle's setWidth and setHeight methods.
  + Clients expecting a Rectangle might not anticipate the linked behavior of width and height in Square, leading to unexpected results.
*/

class Rectangle {
    private int width, height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width); // Enforce width = height for a square
    }

    @Override
    public void setHeight(int height) {
        super.setWidth(height); // Enforce width = height for a square
        super.setHeight(height);
    }
}
/***
Fix Liskov's substition principle.
Improvements:
  + Shape interface captures the shared behavior of calculating area.
  + Rectangle and Square implement Shape independently, ensuring their consistency with the contract.
  + Clients can now work with Shape objects without worrying about specific implementation details, promoting flexibility and adherence to LSP.
*/
interface Shape {
    int getArea();
}

class Rectangle implements Shape {
    // ... as before
}

class Square implements Shape {
    private int side;

    public void setSide(int side) {
        this.side = side;
    }

    @Override
    public int getArea() {
        return side * side;
    }
}

Java Collections is another good example which follows LSP principle

Concept of Java Collections
Interfaces: The framework defines interfaces like ListSet, and Map to establish contracts for collection behavior. Implementations: Concrete classes like ArrayListHashSet, and HashMap implement these interfaces, providing specific implementations.

  • Substitutability: Any instance of a subclass (e.g., ArrayList) can be used wherever a superclass (e.g., List) is expected, without breaking client code.
  • Pre- and Postconditions: Implementations uphold the contracts defined in interfaces, ensuring consistent behavior for methods like add()remove(), and contains().
  • Sorting: You can sort a List using Collections.sort(), regardless of whether it's an ArrayListLinkedList, or custom implementation, as they all adhere to the List contract.
  • Iterating: You can iterate over elements of a Set using a for loop, even if it's a HashSetTreeSet, or custom implementation, as they all provide the iterator() method.

How to follow LSP in functional programming.

While LSP often aligns with object-oriented principles, its core concepts of substitutability and behavioral contracts apply equally to functional programming. Higher-order functions, which take other functions as arguments or return them as values, provide a powerful mechanism for upholding LSP in functional contexts.

Here’s an example demonstrating this idea:

// Functions as "types" with clear contracts:
const isEven = (x: number): boolean => x % 2 === 0;
const isOdd = (x: number): boolean => x % 2 === 1;

// Higher-order function enforcing a contract:
const printFiltered = (arr: number[], filterFunc: (int) => boolean) => {
    arr.forEach((item) => {
        if (filterFunc(item)) {
            console.log(item);
        }
    });
};

// Substitutability in action:
printFiltered([1, 2, 3, 4, 5], isEven);  // Prints even numbers
printFiltered([1, 2, 3, 4, 5], isOdd);  // Prints odd numbers

// Any function following the contract can be used:
const isGreaterThan3 = (x: number): boolean => x > 3;
printFiltered([1, 2, 3, 4, 5], isGreaterThan3);  // Prints numbers greater than 3

Important points to note here

  • Functions as Types: Function signatures define contracts, ensuring substitutability.
  • Higher-Order Functions: Promote flexibility and code reuse while maintaining LSP.
  • Referential Transparency: Pure functions aid in reasoning about code behaviour and LSP compliance.
  • Testing: Crucial for verifying contract adherence and preventing unintended violations.

By embracing these principles, functional programmers can create robust, adaptable, and reliable code that adheres to LSP, even in the absence of traditional object-oriented constructs.

Guidelines for implementing Liskov’s Substitution Principle (LSP):

1. Model Relationships Carefully:

  • True “is-a” Relationships: Ensure subclasses represent true specializations of their base classes, sharing essential properties and behaviors.
  • Favor Composition over Inheritance: If a relationship isn’t strictly “is-a”, consider composition or interfaces to avoid violating LSP.

2. Respect Pre- and Postconditions:

  • Strengthen, Don’t Weaken: Subclasses can strengthen preconditions (tighter constraints on input) but shouldn’t weaken them.
  • Maintain or Strengthen Postconditions: Subclasses must maintain or strengthen postconditions (guarantees about output), ensuring consistency with base class behavior.

3. Avoid Overriding Methods Unexpectedly:

  • Consistency in Behavior: Subclasses should implement inherited methods in a way that aligns with their expected behavior in the base class.
  • Document Deviations: If overriding leads to different behavior, clearly document it for clarity and maintainability.

4. Design for Invariants:

  • Identify Class Invariants: Define constraints that must always hold true for a class and its subclasses.
  • Maintain Invariants in Subclasses: Ensure subclasses uphold these invariants to prevent unexpected behavior.

5. Use Design Patterns Wisely:

  • Template Method Pattern: Facilitates adherence to LSP by defining a skeleton algorithm in the base class with placeholders for subclasses to implement specific steps.
  • Strategy Pattern: Encapsulates algorithms in interchangeable classes, allowing for flexible selection at runtime without violating LSP.

6. Test Thoroughly:

  • Include Subclasses in Tests: Write tests that exercise both base classes and their subclasses to uncover potential LSP violations early.
  • Focus on Behavior: Test for expected behavior rather than implementation details to ensure consistency across the hierarchy.

7. Refactor When Necessary:

  • Recognize Violations: Be alert to signs of LSP violations, such as unexpected errors, inconsistencies, or testing challenges.
  • Restructure Classes: If needed, refactor class hierarchies or consider alternative approaches like composition to restore LSP compliance.