SOLID Principles: Liskov Substitution Principle.
Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
What Does LSP Try To Solve?
Essentially this is all about ensuring that the “child” behaves as the “parent” would expect.
This principle is about making sure that replacing a parent class with a subclass won’t break your system.
Here are the main problems that can arise when LSP is violated:
Breaking Code Expectation: If a subclass doesn’t behave as expected from the parent class, replacing a parent with a subclass in the system could lead to unexpected behaviour or even outright failures.
Harder to Understand Code: Developers might expect subclasses to work in the same way as the parent class, but this assumption becomes invalid, leading to confusion.
The Definition
So, what exactly is the Liskov Substitution Principle?
“Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.”
Sounds complicated but it’s actually quite simple—Let’s define what a parent class and a subclass is first.
Parent Class.
class Vehicle {
public function startEngine(): string {
return "The engine is starting...";
}
public function stopEngine(): string {
return "The engine is stopping...";
}
}
Subclass
class Car extends Vehicle {
public function honk(): string {
return "Car is honking...";
}
}
The Car
class is a subclass of Vehicle
. It inherits the startEngine()
and stopEngine()
methods from Vehicle
but adds a new behaviour: honk()
.
The Car
can now use all the features of Vehicle
, and it also adds something new.
With LSP, the idea is that you should be able to substitute any instance of a parent class with an instance of a subclass, and your code should still work as expected.
Some Examples
If we did something like:
class Vehicle {
public function startEngine(): string {
return "The engine is starting...";
}
public function stopEngine(): string {
return "The engine is stopping...";
}
}
class Bird extends Vehicle {
public function fly(): string {
return "I'm flying...";
}
}
This breaks the Liskov Substitution Principle (LSP) because Bird
is a subclass of Vehicle
, but it introduces behaviour that is incompatible with the parent class. The Vehicle
class expects all its subclasses to be vehicles with the ability to start and stop an engine. However, a Bird
doesn't logically have an engine to start or stop, so substituting a Bird
for a Vehicle
would cause confusion or unexpected behavior in the system. The subclass Bird
violates the LSP by not adhering to the expected behaviour of its parent class, leading to a broken inheritance chain where a Bird
can't replace a Vehicle
without breaking the system’s functionality.
Here’s a few more examples:
Inconsistent Method Signatures: When a subclass changes the method signature of an inherited method (for example, by changing the type of parameters or return type), it can break LSP. The subclass should have a method signature that matches the parent class or be compatible with it.
class Vehicle {
public function startEngine(): string {
return "Engine started.";
}
}
class Car extends Vehicle {
public function startEngine(): void { // Return type changed
echo "Car engine started.";
}
}
Weakening Preconditions: If a subclass introduces stronger preconditions (requirements) than the parent class, this can break LSP. The parent class may have a method that can work under broad conditions, but the subclass might require more specific conditions, making it impossible to use the subclass as a replacement.
class User {
public function saveToDatabase(): void {
// Saves user data to the database
}
}
class PremiumUser extends User {
public function saveToDatabase(): void {
if ($this->isPremium()) { // A new precondition is introduced
parent::saveToDatabase();
} else {
throw new Exception("Premium users only.");
}
}
}
In this case, PremiumUser
introduces a new precondition that User
doesn’t have, meaning you can’t substitute PremiumUser
for User
without running into an exception. This weakens the contract set by the parent class and violates LSP.
Strengthening Postconditions: Postconditions define the guarantees made by a method after it executes. If a subclass strengthens postconditions (i.e., adds more strict expectations of what happens after a method is called), it can lead to violations of LSP.
class Document {
public function print(): string {
return "Document printed.";
}
}
class ColorDocument extends Document {
public function print(): string {
$result = parent::print();
return $result . " with colour."; // Stronger postcondition
}
}
In this example, ColorDocument
strengthens the postcondition by modifying the return value. While this might seem like a harmless change, it could break code that relies on the parent class Document
returning exactly the string "Document printed."
and not modifying it.
Throwing New Exceptions: If a subclass introduces new exceptions that aren’t part of the parent class’s contract, substituting a subclass for a parent class can cause unexpected failures.
class Database {
public function connect(): void {
// Connect to a database
}
}
class MySQLDatabase extends Database {
public function connect(): void {
throw new Exception("Disabled");
}
}
In this example, MySQLDatabase
introduces a new exception (Exception("Disabled")
) that wasn’t present in the parent class Database
. If a method in the system expects no exception from a Database
, substituting MySQLDatabase
could break the program due to the new exception.
Changing Behaviour of Inherited Methods: If a subclass overrides a parent method and changes its core functionality in an unexpected or inconsistent way, it will violate LSP. The subclass should not significantly
class Shape {
public function area(): float {
return 0;
}
}
class Circle extends Shape {
public function __construct(protected float $radius) {
}
public function area(): string {
return "Area of circle: " . (pi() * $this->radius * $this->radius); // Changing return type to string
}
}
Final Thoughts
By ensuring that subclasses can be used in place of their parent classes without altering the expected behaviour, LSP encourages consistency in how objects are treated, regardless of their specific type, which leads to more robust and reliable code.
However, in many real-world scenarios, especially when dealing with legacy systems, completely changing the hierarchy structure to make it adhere strictly to the Liskov Substitution Principle (LSP) might not always be practical or feasible.
Refactoring a large or complex system can introduce significant risks and might not provide immediate benefits. In such cases, throwing an exception can serve as a pragmatic solution to signal that a particular operation is not valid for a specific subclass, without requiring a full overhaul of the inheritance structure.
This allows you to preserve the existing hierarchy and communicate invalid operations without the need for a large-scale refactor. It’s an intentional way to prevent unexpected behaviour, ensuring the system remains stable and understandable without a complete overhaul.
Thanks for reading!