raiyan
raiyan

Reputation: 51

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.

Upvotes: 4

Views: 3551

Answers (2)

plalx
plalx

Reputation: 43728

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.

Upvotes: 6

Totò
Totò

Reputation: 1854

When you extend a class or an interface, the new class is still of the type that it extended. The easiest way to reason about this (IMO) is thinking of a subclass as a specialised type of the superclass. So it's still an instance of the superclass, with some additional behaviors.

For example, your VehicleWithDoor is still a Vehicle, but it also have doors. A Scooter is a vehicle too, but it doesn't have doors. If you have a method to open a vehicle's door, the vehicle must have doors (hence the compile time error when you pass a scooter to it). The same is for a method that takes an object of a certain class, you can pass an object that is instance of its subclass and the method will still work.

In terms of implementation, you can safely cast any object to one of its supertype (e.g. Car and Scooter to Vehicle, Car to VehicleWithDoors), but not the other way around (you can safely do so if you do some checks and cast it explicitly).

Upvotes: 1

Related Questions