Search code examples
javasolid-principles

Trying to understand Liskov substitution principle


I'm trying to understand the Liskov substitution principle, and I have the following code:

class Vehicle {
}

class VehicleWithDoors extends Vehicle {
    public void openDoor () {
        System.out.println("Doors opened.");
    }
}

class Car extends VehicleWithDoors {
}

class Scooter extends Vehicle {
}

class Liskov {
    public static void function(VehicleWithDoors vehicle) {
        vehicle.openDoor();
    }

    public static void main(String[] args) {
        Car car = new Car();
        function(car);
        Scooter scooter = new Scooter();
        //function(scooter);  --> compile error
    }
}

I'm not sure if this violates it or not. The principle says that if you have an object of class S, then you can substitute it with another object of class T, where S is a subclass of T. However, what if I wrote

Vehicle vehicle = new Vehicle();
function(vehicle);

This of course gives compile error, because the Vehicle class doesn't have an openDoor() method. But this means I can't substitute VehicleWithDoors objects with their parent class, Vehicle, which seems to violate the principle. So does this code violate it or not? I need a good explanation because I can't seem to understand it.


Solution

  • You got that backwards. The principle states that "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".

    Basically, VehicleWithDoors should work where Vehicle works. That obviously doesn't mean Vehicule should work where VehiculeWithDoors work. Yet in other words, you should be able to substitute a generalization by a specialization without affecting the program's correctness.

    A sample violation would be an ImmutableList extending a List that defines an add operation, where the immutable implementation throws an exception.

    class List {
      constructor() {
        this._items = [];
      }
      
      add(item) {
        this._items.push(item);
      }
      
      itemAt(index) {
        return this._items[index];
      }
    }
    
    class ImmutableList extends List {
      constructor() {
        super();
      }
      
      add(item) {
        throw new Error("Can't add items to an immutable list.");
      }
    }

    The Interface Segregation Principle (ISP) can be used to avoid the violation here, where you'd declare ReadableList and WritableList interfaces.

    Another way to communicate that adding an item may not be supported could be to add a canAddItem(item): boolean method. The design may not be as elegant, but it makes it clear not all implementation supports the operation.

    I actually prefer this definition of the LSP: "LSP says that every subclass must obey the same contracts as the superclass". The "contract" may be defined not only in code (better when it does IMO), but also through documentation, etc.