The Liskov Substitution Principle (Software Design)
The Liskov Substitution Principle (LSP) governs design rules for object oriented languages and states that "subtypes must be substitutable for their base types." [📖ASD, p. 111]:
If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T." Barbara Liskov, Data Abstraction and Hierarchy
The Liskov Substitution Principle is one of the SOLID-principles.
Example
In languages that conform to the LSP, the effects of this principle can easily be reproduced:
class A
{
public function itsName()
{
echo static::class;
}
}
class B extends A{}
class C extends B {}
class Client
{
public function process(A $a): B
{
$a->itsName();
return new B();
}
}
Here, the process
-function of Client digests an instance of A
, calls a method on it and returns B
.
$client = new Client();
$client->process(new A()); // A
According to the LSP, the code must also work if we pass any subtype of A
.
$client = new Client();
$client->process(new A()); // A
$client->process(new B()); // B
$client->process(new C()); // C
Once we create a specification of Client, let's say ConcreteClient, we must be careful when overwriting process()
:
class ConcreteClient extends Client
{
public function process(A $a): B {}
}
Here, the argument type and the return type do not change. Any ConcreteClient can be used as a substitute for
Client. However, if we narrow the type of the argument down to B
, PHP quits with an error message befoe running the
script:
class ConcreteClient extends Client
{
public function process(B $a): B {}
}
Fatal error: Declaration of ConcreteClient::process(B $a): B must be compatible with Client::process(A $a): B
Implications
The implications are as follows:
Argument-Types may be widened, and must not be narrowed down (Contravariance)
Contravariance:
B < A (B subtype A), A:T, B:T argument types; if A:T < B:T, then T is Contravariant
At this point, we are not allowed to narrow the argument-type passed to
process()
down. This is because the parent implementationClient::process()
sets up an interface all subtypes have to conform to: It seems logical at first to be allowed to pass anB
for anA
, sinceB
is anA
, but any program doing so would break as soon asConcreteClient::process()
accesses a field only known toB
, and anA
is passed instead. Thus, narrowing down is not allowed with the given example.
Return-Types may be narrowed down, and must not be widened (Covariance)
Covariance:
B < A (B subtype A), B:T, A:T return types; if B:T < A:T, then T is Covariant
Conversely, return types must not be widened. Given our example, any program querying
Client::process()
expects anB
. If that return type would be widened toA
inConcreteClient::process()
, any program that tries to access a field specific toB
would break ifConcreteClient::process()
instead returns anA
- which is in fact unaware ofB
.