Deep Dive Into Liskov Substitution Principle
History With Barbara Liskov
Liskov Substitution Principle (LSP), introduced by Barbara Liskov in a 1987 conference keynote, is the third principle in the SOLID acronym, which represents five principles of object-oriented programming and design. The principle's formal statement is:
"If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program."
In simpler words, derived classes must be substitutable for their base classes. That is, a program that uses a base class must be able to substitute a subclass without affecting the correctness of the program.
Application In OOP
Robert C Martin, better known as Uncle Bob, is a significant figure in the software engineering world. While he didn't invent the Liskov Substitution Principle (that was Barbara Liskov), he's perhaps best known for popularizing the SOLID principles, including Liskov's principle, in the early 2000s.
The Principle
To illustrate the Liskov Substitution Principle (LSP) more effectively in line with the statement "If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program," let's consider an example involving a class hierarchy for geometric shapes.
Suppose we have a base class Shape
with a method calculateArea
. We'll create
two subclasses: Rectangle
and Square
. According to LSP, we should be able to
substitute instances of Square
for Rectangle
without affecting the program's
ability to function correctly.
abstract class Shape {
abstract calculateArea(): number;
}
class Rectangle extends Shape {
constructor(public width: number, public height: number) {
super();
}
public calculateArea(): number {
return this.width * this.height;
}
}
class Square extends Shape {
constructor(public side: number) {
super();
}
public calculateArea(): number {
return this.side * this.side;
}
}
// ====== Client Code
function area(shape: Shape) {
return shape.calculateArea();
}
let rectangle = new Rectangle(10, 12);
let square = new Square(8);
area(rectangle); // 120
area(square); // 64
In this example, Square
and Rectangle
are both subtypes of Shape
. They
each implement the calculateArea
method differently, according to their
specific geometric properties. The function area
is designed to work
with any Shape
. It uses the calculateArea
method and specific setters to
modify the shape's dimensions.
This design adheres to LSP because a Square
can be substituted for
a Rectangle
(or any other Shape
) in the area
function without
altering the correctness of the program. Each subclass correctly implements
the calculateArea
method, ensuring that the area
function behaves as
expected regardless of the specific Shape
subtype it is working with.
This example demonstrates how LSP encourages the creation of a system where subclasses can be interchanged without impacting the program's functionality, leading to more robust and maintainable code.
Real World Use Case
Let's consider a real world example - say, an online payment processing system where you have multiple methods to process payments like CreditCard, DebitCard, and PayPal.
First, we'll establish a base class, PaymentProcessor
, and a method that any
form of payment needs to implement:
abstract class PaymentProcessor {
abstract processPayment(amount: number): void;
}
Now, we create the specific payment method classes:
class CreditCardProcessor extends PaymentProcessor {
processPayment(amount: number): void {
console.log(`Processing credit card payment of $${amount}`);
// implementation details for processing credit card payment...
}
}
class DebitCardProcessor extends PaymentProcessor {
processPayment(amount: number): void {
console.log(`Processing debit card payment of $${amount}`);
// implementation details for processing debit card payment...
}
}
class PayPalProcessor extends PaymentProcessor {
processPayment(amount: number): void {
console.log(`Processing PayPal payment of $${amount}`);
// implementation details for processing PayPal payment...
}
}
In this scenario, every payment processor class is a subtype
of PaymentProcessor
and each of them implements the processPayment
method.
Now, let's create a function that takes an instance of PaymentProcessor
and
uses it to process a payment. Because of the Liskov Substitution Principle, this
function can use any subtype of PaymentProcessor
:
function executePayment(paymentProcessor: PaymentProcessor, amount: number) {
paymentProcessor.processPayment(amount);
}
// Now, we can process payments using any of the payment methods:
const creditCardProcessor = new CreditCardProcessor();
executePayment(creditCardProcessor, 100);
const debitCardProcessor = new DebitCardProcessor();
executePayment(debitCardProcessor, 200);
const payPalProcessor = new PayPalProcessor();
executePayment(payPalProcessor, 300);
This design respects the Liskov Substitution Principle, as
any PaymentProcessor
subtype can be used in the executePayment
function
without causing issues. Each processor has its own specific implementation
of processPayment
, but the way they're used doesn't need to change based on
the specific subtype.
What Can You Do Next 🙏😊
If you liked the article, consider subscribing to Cloudaffle, my YouTube Channel, where I keep posting in-depth tutorials and all edutainment stuff for software developers.