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 likeCar
,Truck
, andMotorcycle
. - 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 adoors()
method like other vehicles, replacing aCar
with aMotorcycle
could lead to errors.
2. Rectangle and Square:
- If
Square
is a subclass ofRectangle
, it should inherit its properties and behaviors. - Violation: If you set a
Rectangle's
width and height independently, but aSquare's
width always equals its height, replacing aRectangle
with aSquare
in some contexts could break functionality.
3. Payment Gateway:
- Consider a
PaymentGateway
interface for processing payments, with concrete implementations likeCreditCardGateway
andPayPalGateway
. - 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 ofBird
. - Violation: If
Bird
has afly()
method, butOstrich
cannot fly, using anOstrich
where a flyingBird
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 List
, Set
, and Map
to establish contracts for collection behavior. Implementations: Concrete classes like ArrayList
, HashSet
, 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()
, andcontains()
. - Sorting: You can sort a
List
usingCollections.sort()
, regardless of whether it's anArrayList
,LinkedList
, or custom implementation, as they all adhere to theList
contract. - Iterating: You can iterate over elements of a
Set
using afor
loop, even if it's aHashSet
,TreeSet
, or custom implementation, as they all provide theiterator()
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.