The principle of strong behavioural subtyping, first described by Barbara Liskov, has been putting the ‘L’ in SOLID since 1987. Beyond being a job interview answer, it represents the essence of what inheritance or interface implementation are really supposed to mean.
This comes up in languages that support covariant and/or contravariant typehinting. Here we briefly recap what the LSP is, then explore how it relates to co/contravariance.
Liskov Substitution Principle Reminder
Essentially, the LSP means that when we extend a type, we should be doing exactly that: extending it. What works in the parent class should also work in the child, but it’s ok if the latter does other things as well.
More formally:
$$ { S \leq T \to (\forall x{:}T.\phi (x)\to \forall y{:}S.\phi (y))} $$
Or put another way:
If S
is a subtype of T
, for any property 𝜙
of T
must hold for S
. You can use an S
as a T
, literally by
substituting, without changing system behaviour.
Consider the following code example, using everybody’s favourite schoolbook classnames:
class Animal
{
public function eat(Food $food): void
{
// ...
}
}
class Dog extends Animal
{
public function eat(Food $food): void
{
// ...
}
public function threatenPeopleInThePark(): void
{
// ...
}
}
Here we see the Liskov Substitution Principle in action. Find any Animal
in the system and use a Dog
instead.
eat()
called? No problem: Dog
shows strong behavioural subtyping, by doing whatever Animal
does, plus other things.
Contravariance
Introducing Parametric Contravariance to the System
What happens if we amend Dog
slightly, to do the following?
class Dog extends Animal
{
public function eat(FoodOrTheCushions $food): void
{
// ...
}
}
Now as we move down the type chain (Animal
(\geq) Dog
) we find the parameter moving up from its original type to a
supertype: Food
(\leq) FoodOrTheCushions
. This is what we mean by contravariance. ‘Vary’ from a supertype down to a
subtype, and the parameters vary the other way.
Tip
Contravariance: As we look down the inheritance chain, contravariant parameters ‘vary’ in the opposite direction
Parametric Contravariance & the LSP
Is our example of parametric contravariance compliant with the LSP? Yes: a Dog
can still be used as an Animal
. It
can still eat food. Dog::eat()
accepts food, amongst other things, so it won’t trip up if you use it where a more
generic Animal
is expected.
Covariance
Covariant Return Typehinting
Let’s take a look at an example of covariant return types in action:
class Animal
{
public function makeNoise(): AnimalNoise
}
class Dog extends Animal
{
public function makeNoise(): BarkingNoise
}
With the covariant return types, as we move down the inheritance chain, the return types get more specific.
Tip
Covariance: As we move down the inheritance chain to more specific types, covariant return types ‘vary’ the same way
Covariance & the LSP
Can we still use a Dog
as an Animal
here? What if code calling Animal::makeNoise()
really needs an AnimalNoise
,
and nothing else? It may seem that this should break the assumptions of the Liskov Substitution Principle.
However, consider: why would client code need an AnimalNoise
specifically? What could it be doing with one that
couldn’t be done with a BarkingNoise
?
The fact is, provided the return types are also LSP-compliant, there is no issue here. Calling makeNoise()
gets
you something that can be used as an AnimalNoise
in both cases. Client code shouldn’t need to care whether it receives
an AnimalNoise
or one of its subtypes, they’re interchangeable.
The LSP is preserved when we use covariant return typehinting.
A Note on Programming Languages
Not every OOP language works the same way with regards to covariance and contravariance. There are differences, and languages whose type systems guide the developer towards covariant parameter types, for example. However, PHP is an example of a language whose type system works in what we might conclude is the ‘right’ way.