The oldest of the principles, the Liskov Substitution Principle, is, in my opinion, also the most important. In fact I think most other principles can be derived either directly or indirectly from a desire to uphold this principle. Yet, unlike for the OCP, I will not go into its history, for two reasons: first of all, the wording is less outdated and secondly, the principle is more mathematical in nature; it just applies to OOP so perfectly.
The principle, more properly called strong behavioural subtyping (but SOSID or SOBID is not an english word, so we use the last name of its original author instead), is stated as:
Let φ(x) be a property provable about objects x of type T. Then φ(y) should be true for objects y of type S where S is a subtype of T. [1]
For those of you that don't read math-speak, shame on you! But OK, I'll translate it a bit.
Any class should be allowed to be replaced with an instance of a subtype of that class without altering the correctness of the program
Which is an imperfect translation, but somewhat easier to remember. If you read it this way, you might be tempted to think it merely states that the following must be allowed
<?php
class Paper
{
// ...
}
class Cardboard extends Paper
{
// ...
}
class Origami
{
/**
* Magically transforms a piece of paper into a jumbo jet
*
* @param Paper $paper
* @return JumboJet
*/
public function makeAirplane(Paper $paper)
{
// ...
}
}
(new Origami)->makeAirplane(new Cardboard);
And it does. But that is not all. Remember, the SOLID principles are based not on code requirements, but on behavioural requirements. This means the behaviour of the child class must be such that it can replace the parent's behaviour without the program breaking. Allowing the code shown above is obviously a neccessity for such a requirement, but ot the only one. For example, consider the following example:
<?php
class Paper
{
/** @var float */
public $area;
/**
* Folds the paper in two
*/
public function fold()
{
$this->area = $this->area / 2;
}
}
class Cardboard extends Paper
{
/**
* Folds the cardboard in two, which is disallowed, because cardboard
* breaks if you try to fold it
*
* @throws CanNotFoldException
*/
public function fold()
{
throw new CanNotFoldException("I break when I fold");
}
}
class Origami
{
/**
* Makes an airplane by folding a piece of $paper seven times and doing
* some magic, resulting in a fully functional JumboJet!
*
* @param Paper $paper
* @return JumboJet
*/
public function makeAirplane(Paper $paper)
{
// ...
for ($it = 0; $it < 7; $it++) {
$paper->fold();
}
// ...
}
}
In this example, the behaviour of Paper
that Origami
uses, can be described as "paper can fold". It is implied by the existence of the fold
method in the Paper
class. Cardboard
however breaks this expectation: it is paper that can not fold. Therefore it breaks LSP; we have a property that can be proved to work on Paper
, that does not work on Cardboard
(remember the math-speak? It said something about provability).
Weirdly enough, this means that cardboard is not always paper, although in some applications it could be. This is where normal and computer logic sort of diverge. The default example for explaining the LSP even shows that squares are not always rectangles!
We could say the LSP places a couple of behavioural constraints on the child class. Various sources make different lists (which makes sense: behaviour is seldom obvious), but I personally like the following list the best:
Each of which will be handled below.
I'll start with the one that the example used above is doing wrong, which is that Paper
's invariants are not preserved. In this case, I refer to the invariant — a fancy word for something that can be relied upon to be true during execution of the code— that Paper
can be folded without the method throwing an exception.
Solving this issue can be done in a lot of ways, and I'm not going to use the one I consider best here, as it uses the Interface Segregation Principle. Don't worry, Paper
and Cardboard
will return with a nicer solution. The solution I am going to use is changing the expected behaviour of Paper
to allow for CanNotFoldException
s to be thrown:
<?php
class Paper
{
/** @var float */
public $area;
/**
* Folds the paper in two
*
* @throws CanNotFoldException
*/
public function fold()
{
$this->area = $this->area / 2;
}
}
I have not changed the code itself, but I did change its behaviour by removing the "all paper can fold" invariant: the supertype now allows for paper that is not foldable. Some behavioural constraint lists include this type of case separately, claiming that an extension can never throw exceptions that the supertype does not throw (unless they are subtypes of exceptions that are thrown). But it fits in the invariant constraint as well, or even in the weaker postconditions constraint, for that matter. Like I said, behaviour is seldom obvious.
Another way of breaking the LSP is by weakening postconditions. Postconditions are conditions that hold after an object's behaviour has been enacted. The meaning of weaker and stronger is dependent on the situation, but should usually become clear when considering the context. For example, requiring Cardboard
as a method parameter is a stronger precondition than requiring Paper
; after all, all cardboard is paper, but not all paper is cardboard. Using that example, consider the following:
<?php
class Paper { /* ... */ }
class Cardboard extends Paper { /* ... */ }
class CardboardFactory
{
/** @return Cardboard */
public function build() { /* ... */ }
}
class PaperFactory extends CardboardFactory
{
/** @return Paper */
public function build() { /* ... */ }
}
For arguments sake, lets assume the above happened because developer A made a CardboardFactory
class with a lot of useful functionality that developer B was too lazy to re-implement when he needed a PaperFactory
.
In this case, one of the postcondition of the supertype CardboardFactory
was "I return a Cardboard
object", while the extending type's postcondition was "I return a Paper
object", which is weaker. Because of this, all code that assumes that every call CardboardFactory::build
will return a Cardboard
will fail, as some of them do not.
For similar reasons, preconditions can not be stronger; in the following example, not all PaperBurner
s can be trusted to be able to burn paper, as CardboardBurner
only burns cardboard.
<?php
class Paper { /* ... */ }
class Cardboard extends Paper { /* ... */ }
class PaperBurner
{
/**
* Burn $paper, burn!
*
* @param Paper $paper
*/
public function burn(Paper $paper) { /* ... */ }
}
class CardboardBurner extends PaperBurner
{
/**
* Cardboard may not be able to fold, but it can burn!
*
* @param Cardboard $cardboard
*/
public function burn(Cardboard $cardboard) { /* ... */ }
}
(this example will raise an E_WARN error, and for good reason!)
Not all pre- and postconditions are this obvious. For example, a postcondition could be "I have opened a database connection". Weakening this by not opening a database connection at all can obviously break your code in all sorts of ways. Strengthening a precondition to suddenly require an open database connection is a bad idea for similar reasons.
The history constraint rules that an extending class must not expose methods that modify fields in an object if the parent class does not expose such methods (for those fields). Eg:
<?php
class Paper
{
/** @var int */
protected $weight;
/**
* Paper constructor.
*
* @param int $weight
*/
public function __construct(int $weight)
{
$this->weight = $weight;
}
}
class Cardboard extends Paper
{
/**
* Use this method if you wish to break the cardboard in half
*
* @return Cardboard (the other half)
*/
public function breakInHalf()
{
$this->weight = $this->weight / 2;
return new Cardboard($this->weight);
}
}
In the example, Paper
does not allow the changing of the weight that was passed in the constructor, but Cardboard
can break, thus changing its weight. Code that assumes that paper will always be as heavy as when it started will not work for Cardboard
. In this case, we can use the private
keyword on the Paper::weight
instead to prevent this issue (which can be circumvented using reflection, but you should never, ever do this, unless you're me).
Honestly, the history constraint is the one I personally have the most problems with; every time I read the definition, I get the feeling that it is just a specific case of the invariance rule. So if you can think of an example that does not change the invariances, but breaks the history constraint, please let me know!
LSP basically says that expected behaviour of superclasses must always be reflected in their subclasses. And this is always a good thing, so I don't see any real pitfalls. However, the predestination pitfall from the SRP holds here as well: don't spend too much time determining whether something is or is not an invariant of your system. What the invariants are is, after all, more important to the coder that extends your class than it is for you.
However, also be aware that determining the exact behaviour of library code can be very difficult (eg: "who knew that string method A of library B did not only do C, but simultaneously also replaced UTF8 characters with their HTML entities?", which is an unexpected postcondition), so making your invariants obvious in library code is actually important. In the end, the LSP stands or falls with the quality of documentation.
So, using badly documented libraries is actually a pitfall for the LSP; not in and of itself, but if you decide to extend the library, things get ugly fast.
Breaking the LSP results in unmet expectations, which can lead to various unexpected bugs. The real problem is that the code itself is in almost all cases perfectly well written, and works, but it becomes less reusable, because reusing it will introduce bugs in your application.
All in all I'd like to reiterate that the extends
keyword can put you in a world of trouble; make sure you use it correctly if you do! A simple trick is using composition instead of inheritance; often equally powerful, but less error prone.