DEV Community

Thomas Heniart
Thomas Heniart

Posted on

The Liskov Substitution Principle Demystified

The Liskov Substitution Principle (LSP) is a fundamental concept in object-oriented programming (OOP) introduced by
Barbara Liskov in 1987. You probably know it as the L in the five SOLID
principles.


According to the LSP, objects of a superclass should seamlessly replace objects of its subclasses without affecting the
program's correctness. In simpler terms, if S is a subtype of T, then objects of type T can be substituted with objects
of type S without altering the program's desirable properties.

Practically, violating the Liskov Substitution Principle can result in unexpected behavior, increased code complexity,
and difficulties in future maintenance and extension. Therefore, it's crucial for developers to meticulously design
their class hierarchies, ensuring that subtypes are genuine extensions of their supertypes and can be effortlessly
substituted wherever the supertype is employed.


Consider an example using a traditional interpretation of ducks in a program and the introduction of a robot duck that
violates the Liskov Substitution Principle.

Suppose we have a class hierarchy for ducks:

abstract class Duck {
    abstract quack(): void;

    abstract fly(): void;
}

class MallardDuck extends Duck {
    quack(): void {
        console.log("Quack")
    }

    fly(): void {
        console.log("To Infinity, And Beyond!")
    }
}

class RobotDuck extends Duck {
    quack(): void {
        console.log("Beep beep")
    }

    fly(): void {
        throw new MethodShouldNotBeImplementedException("Robot ducks can't fly")
    }
}
Enter fullscreen mode Exit fullscreen mode

In this scenario, the Liskov Substitution Principle is violated with the RobotDuck class. Although it extends the Duck
class and looks like a duck, it doesn't behave like one. According to LSP, objects of a subclass should be substitutable
for objects of the superclass without altering the program's correctness.

The issue arises when trying to use a RobotDuck object in a context where a Duck object is expected, for example:

const makeDuckFly = (duck: Duck) => {
    duck.fly()
}

const mallard = new MallardDuck()
const robotDuck = new RobotDuck()

makeDuckFly(mallard)  // This works fine
makeDuckFly(robotDuck) // Throws MethodShouldNotBeImplementedException
Enter fullscreen mode Exit fullscreen mode

On a daily basis, it's crucial to be aware that whenever you override a method in your superclass (or interface) to
throw an exception because this method doesn't make sense for your current type, take a step back and analyze your
codebase to identify problems in your design.

Concerning the previous example, you can redesign the hierarchy and introduce interfaces, allowing MallardDuck and
RobotDuck to share their common trait through a Quackable interface. MallardDuck can then implement a Flying interface
for its flying ability.

interface Quackable {
    quack(): void;
}

interface Flying {
    fly(): void;
}

class MallardDuck implements Quackable, Flying {
    quack(): void {
        console.log("Quack")
    }

    fly(): void {
        console.log("To Infinity, And Beyond!")
    }
}

class RobotDuck implements Quackable {
    quack(): void {
        console.log("Beep beep")
    }
}
Enter fullscreen mode Exit fullscreen mode

By doing so, you can easily add a Goose that shares common properties with a MallardDuck without being a Duck, which was
not possible in the previous hierarchy solely focused on Ducks.

class Goose implements Quackable, Flying {
    quack(): void {
        console.log("Quaaaack")
    }

    fly(): void {
        console.log("To Infinity, And Beyond!")
    }
}
Enter fullscreen mode Exit fullscreen mode

Stay tuned for more insights! Free to follow me on this platform
and LinkedIn. I share insights every week about software
design, OOP practices, and some personal project discoveries! 💻🏄

Top comments (0)