Liskov Substitution Principle in PHP
PHP did not start as an Object Oriented Programming language, but over the years, PHP has improved with classes, namespaces, interfaces, traits, abstract classes, and other improvements that help developers write SOLID code.
A popular misconception taught is that OOP is about reusing code. A simple and seemingly harmless example is those classic diagrams where you inherit from a parent class, and work your way down to declare everything from boats to ships to bicycles to trucks.
One perceived advantage of OOP is to reuse the code. One class can inherit from another class, which makes the properties and functions of the parent class accessible in the child class, and this child class in turn can be inherited by another grandchild class, and it goes on. A bad smell in OOP is that a child class needs tricks to override a parent class:
class views_object {}
class views_handler extends views_object {}
class views_handler_area extends views_handler {}
class views_handler_area_text extends views_handler_area{}
class views_handler_area_text_custom extends views_handler_area_text {
// ...
public function options_submit(&$form, &$form_state) {
// Empty, so we don't inherit options_submit from the parent.
}
}
This is from the Views module for Drupal 7. The views_handler_area_text_custom
inherits from four parents, and at this stage down the inheritance chain, this class has to override the parent class just to make it work.
This is a simple example on how overuse of inheritance can cause problems and messy code at the end. The five SOLID principles help write better OOP code, and the Liskov Substitution Principle is an important one:
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. -- Barbara Liskov - 1987 - Keynote on data abstractions and hierarchies
Wut?
Let's rephrase this.
Behavioral subtyping
Sub-classes should satisfy the expectations of callers accessing subclass objects through references of superclass. The Liskov Substitution Principle makes sure the callers can expect the sub-classes to behave and interact in the same way the super class does. This means one can substitute an object with an object of a sub-class, and expect it to behave the same way and fulfill its contract.
A properly structured OOP code would not just be syntactically correct, but also correct in its meaning. Making sure that the sub-classes would not result in "method not found" errors is not enough: it has to be semantically the same.
Further, classes abstract a task or a piece of information to provide semantics. This can vary from abstracting the task of sending an email, or providing a unified way to access the author and number of pages in a book.
However, the abstraction has gone too far when a sub-class of the parent email class is turned into a push notification client, or when a simple book information class can fetch information about movies.
Abstraction and semantics
The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise. -- Edsger W. Dijkstra
A properly abstracted class gives a meaning to the task. It makes it meaningful to create sub-classes to handle the same task, but in various forms.
Your email class can be abstracted to receive the recipient email address, subject, and the body, and each sub-class can take care of the implementation details, whether it is using an SMTP server, or sending it over an HTTP API. For the caller, it does not matter which transportation is used, as long as the caller can provide the information, and get feedback whether the email was sent or not.
$email = new PlainTextEmail('foo@example.com', 'Subject', 'Hi Ayesh, ...');
$smtp = new SMTPTransport('smtp.example.com', 465);
$emailer = new Emailer();
$emailer->setTransport($smtp);
$emailer->send($email);
The setTransport()
method will accept any transport method. We use SMTP in here, but it can be any transportation method, as long as it fulfills its contract. You can replace the SMTPTransport
class with MailGunTransport
, and it is supposed to work.
Furthermore, you can replace PlainTextEmail
with a sub-type that might contain HTML emails, or DKIM signed emails, but the transporter (be it SMTPTransport
or MailGunTransport
) can still work with that email.
Behavioral subtyping in PHP
PHP's way of enforcing this is via interfaces, abstract classes, and inheritance. Every-time a class implements an interface, or inherits from a class or an abstract class, PHP checks if the child class or implementation still fulfills its contract:
Covariance
Covariance allows a child class method to declare a return type that is a sub-type of the parent methods return type.
Consider this example:
class Image {}
class JpgImage extends Image {}
class Renderer {
public function render(): Image;
}
class PhotoRenderer {
public function render(): JpgImage;
}
The PhotoRenderer
class fulfills the contract of Renderer
class because it still returns a JpgImage
object, which is a sub-type of Image
. Any code that knows how to work with the Renderer
class will continue to work because the return value is still instanceof
the expected Image
class.
PHP 8.0's Union Types can show an example of this in an easier way:
class Foo {
public function process(): string|int;
}
class Bar extends Foo {
public function process(): int;
}
The Bar::process()
return type further narrows down the return types of its parent class, but it does not violate its contract because any caller who can handle Foo
knows that int
is one of the expected return types.
Contravariance
Contravariance is about the function parameters a sub-class can expect. A sub-class may increase its range of parameters, but it must accept all parameters the parent accepts.
class Foo {
public function process(int|float $value);
}
class Bar extends Foo{
public function process(int|float|string $value);
}
This contravariance is allowed, because Bar::process
method accepts all parameter types the parent method accepts. For class objects, this means the sub-class can "widen" the scope of parameters it accepts, either by extending its Union Types, or by accepting a parent class.
Invariance
Invariance simply says that property types (in PHP 7.4+) cannot be further narrowed down, or widened.
The Big Picture
- Return types can be made narrower: Sub-classes can return sub-types or a smaller Union Types in the return type.
- Parameters can be widened: Sub-classes must accept and handle all parameter types the parent method handles. But it can be widened to accept more types or parent types.
- Property types cannot be changed.
Further Reading
- How to Use Objects: Code and Concepts (book by Holger Gast)
- Data Abstraction and Hierarchy
- Programs, life cycles, and laws of software evolution
- Covariance and Contravariance - PHP Manual