Skip to main content

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 increaseArea 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 increaseArea function without altering the correctness of the program. Each subclass correctly implements the calculateArea method, ensuring that the increaseArea 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.

YouTube @cloudaffle