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.