The Liskov Substitution Principle (LSP) in simple terms.

Sameera De Silva
7 min readDec 13, 2024

--

Liskov Substitution Principle (LSP)

Definition: The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.

Why Is It Needed? LSP is important because it ensures that subclasses behave in a way that aligns with the expectations set by their parent class. This guarantees that your system remains predictable, reliable, and easy to maintain. It helps avoid bugs or unexpected behavior when extending or replacing classes.

When LSP is Violated: LSP is violated when a subclass introduces behavior that is inconsistent with what the superclass promises. For example, if a subclass overrides a method in such a way that it breaks the functionality or the assumptions the superclass established, the principle is violated. This can lead to errors when polymorphism is used, because the subclass object behaves unexpectedly when treated as an instance of the superclass.

Polymorphism and LSP:
The replacement in subclasses refers to the ability to replace a superclass object with a subclass object without breaking the program’s behavior.
This is closely tied to polymorphism, as polymorphism allows the subclass to be treated as the superclass type. LSP ensures that the subclass doesn’t violate the expected behavior of the superclass when invoked through polymorphic calls.

Definition in Layman’s Terms (with Kiwi and Duck Example):

Imagine you have a general type of bird, let’s say a Bird (this is your superclass). This Bird class may have a behavior, like the ability to fly. Now, you create more specific types of birds, like a Kiwi and an Duck (these are your subclasses).

According to LSP, if your program expects a Bird object, you should be able to replace it with a Kiwi or an Duck without causing any problems.

Here’s how it works:

Duck: An Duck is a flying bird, so it makes sense that an Duck can fly. If your program expects a Bird and you pass an Duck, the program will still work as expected because the Duck behaves like a bird, including the flying ability.

Kiwi: A Kiwi, on the other hand, is a flightless bird. If you pass a Kiwi where a Bird is expected, your program should still work. The Kiwi won’t fly, but it will still perform the actions a Bird should, like eating and walking.

The key here is that both the Kiwi and the Duck are Birds, and the program should be able to handle them correctly, even though they have different behaviors. If you try to make a Kiwi fly (when it can’t), that would break the principle because it goes against the expectations set by the Bird superclass.

Here is an example where it violates the principle.

// Superclass
class Bird {
void fly() {
System.out.println("Flying");
}

void eat() {
System.out.println("Eating");
}
}

// Subclass Duck
class Duck extends Bird {
@Override
void fly() {
System.out.println("Flying like a duck");
}

@Override
void eat() {
System.out.println("Eating like a duck");
}
}

// Subclass Kiwi (violates LSP)
class Kiwi extends Bird {
@Override
void eat() {
System.out.println("Eating like a kiwi");
}

// The Kiwi cannot fly, but we are forced to override the fly method.(or inherid from Parent class.)
@Override
void fly() {
throw new UnsupportedOperationException("Kiwi cannot fly");
}
}

public class Main {
public static void main(String[] args) {
Bird duck = new Duck();
duck.fly(); // Flying like a duck
duck.eat(); // Eating like a duck

Bird kiwi = new Kiwi();
kiwi.fly(); // Throws exception, violates LSP
kiwi.eat(); // Eating like a kiwi
}
}

Output-

Exception in thread "main" java.lang.UnsupportedOperationException: Kiwi cannot fly
at com.sam.oop.Kiwi.fly(Kiwi.java:13)
at com.sam.oop.Main.main(Main.java:10)
Flying like a duck
Eating like a duck

There are two ways to solve this.

Using Abstract Classes to Adhere to LSP
Interface-Based Approach:

Here is the example of using Abstract class approach.

// Base class for all birds
abstract class Bird {
// All birds can eat, so this is implemented here
void eat() {
System.out.println("Eating like a bird");
}

// Abstract method for move, which will be implemented by subclasses
abstract void move();
}

// Subclass for Birds that can fly
class FlyingBird extends Bird {
@Override
void move() {
fly(); // Flying birds can fly
}

// Flying behavior for flying birds
void fly() {
System.out.println("Flying like a bird");
}
}

// Subclass for Flightless Birds (Birds that cannot fly)
class FlightlessBird extends Bird {
@Override
void move() {
// Flightless birds cannot fly, so no fly method here
System.out.println("Cannot fly, just walk");
}
}

// Duck class (flying bird)
class Duck extends FlyingBird {
@Override
void move() {
System.out.println("Duck moves:");
fly();
}
}

// Kiwi class (flightless bird)
class Kiwi extends FlightlessBird {
@Override
void move() {
System.out.println("Kiwi moves:");
// Kiwi cannot fly, so just walks (or does nothing related to movement)
}
}

public class Main {
public static void main(String[] args) {
Bird duck = new Duck();
duck.move(); // Duck moves: Flying like a bird
duck.eat(); // Eating like a bird

Bird kiwi = new Kiwi();
kiwi.move(); // Kiwi moves: Cannot fly, just walk
kiwi.eat(); // Eating like a bird
}
}

Output

Duck moves:
Flying like a bird
Eating like a bird
Kiwi moves:
Walks like a kiwi
Eating like a bird

Code explanation-

I rename fly() to move() to unify the movement behavior across all birds (flying or not). This ensures:

Polymorphism: The move() method can be used for all birds, whether they fly or walk.
LSP Adherence: Subtypes (like Kiwi) can implement move() without being forced to override a fly() method that doesn’t apply.
Simplified Code: You can call move() on any Bird, and the correct movement behavior (flying or walking) will occur.
This approach ensures flexibility and maintains consistency across all bird subclasses.

Separate classes for FlightlessBird and FlyingBird are needed to:

Model Real-World Behavior: Reflects the actual differences between flying and non-flying birds.
Adhere to LSP: Avoids forcing non-flying birds (like Kiwi) to implement irrelevant flying behavior.
Simplify Code and Extension: Makes it easier to manage and extend behavior (e.g., adding new bird types) without unnecessary overrides.
Avoid Additional Checkers: Without separate classes, we’d need to add checks to determine if a bird can fly or not, cluttering the code and reducing clarity.

Explanation:

Bird (Abstract Class): This is the base class that contains the eat() method, which is inherited by all subclasses.

The move() method remains abstract, forcing each subclass to implement its own version of how the bird moves (e.g., flying or walking).

FlyingBird: This class extends Bird and adds the fly() method, which is used to represent the flying behavior of birds that can fly.

The move() method in FlyingBird simply invokes fly().

FlightlessBird: This class extends Bird and implements the move() method to represent the movement of flightless birds (in this case, walking).

It doesn’t have the fly() method since flightless birds cannot fly.
Duck: This class extends FlyingBird and provides a specific implementation of the move() method, which invokes fly(), as ducks can fly.

Kiwi: This class extends FlightlessBird and implements the move() method to indicate that a kiwi can only walk, as it is a flightless bird.

Now lets look at the interface based approach.

The Interface-Based Approach involves creating interfaces to represent shared behaviors and letting each bird type implement the behaviors it supports. This approach uses interfaces instead of class inheritance to define common actions like move() or eat(), making the design more flexible and decoupled.

Steps:
Create an Interface for Movable Behavior: Define an interface Movable for the move() method.
Create Interfaces for Specific Behaviors: Define an interface Flyable for birds that can fly.
Create Concrete Classes for Each Bird Type: Let each bird implement only the interfaces that are relevant to its behaviour.

Code-

// Interface for common bird behavior
interface Movable {
void move(); // All birds will have a move behavior
}

// Interface for birds that can fly
interface Flyable {
void fly(); // Flying behavior
}

// Abstract Bird class with common behaviors
// Since all birds can move, the Movable interface is implemented here.
abstract class Bird implements Movable {
abstract void eat(); // Abstract method to define eating behavior for all birds
}

// FlyingBird class implementing both Movable and Flyable interfaces
// Since these birds can both move (by flying) and perform flying actions.
class FlyingBird extends Bird implements Flyable {
@Override
public void move() {
fly(); // Flying birds move by flying
}

@Override
public void fly() {
System.out.println("Flying high in the sky!");
}

@Override
void eat() {
System.out.println("Flying bird is eating!");
}
}

// Flightless bird class implementing Movable interface
class FlightlessBird extends Bird {
@Override
public void move() {
System.out.println("Walking on the ground.");
}

@Override
void eat() {
System.out.println("Flightless bird is eating!");
}
}

// Duck class extends FlyingBird and inherits move behavior
class Duck extends FlyingBird {
@Override
public void move() {
System.out.println("Duck is flying and swimming!");
}
}

// Kiwi class extends FlightlessBird and has its own movement behavior
class Kiwi extends FlightlessBird {
@Override
public void move() {
System.out.println("Kiwi is walking!");
}
}

public class Main {
public static void main(String[] args) {
Bird duck = new Duck();
Bird kiwi = new Kiwi();

// Polymorphic behavior: both are of type Bird but have different move implementations
duck.move(); // Output: Duck is flying and swimming!
kiwi.move(); // Output: Kiwi is walking!

duck.eat(); // Output: Flying bird is eating!
kiwi.eat(); // Output: Flightless bird is eating!
}
}

Output

Duck is flying and swimming!
Kiwi is walking!
Flying bird is eating!
Flightless bird is eating!

--

--

No responses yet