Почему у многих возникают проблемы с этим принципом? Если взять не «заумное», а более простое определение, то оно звучит так:
Наследующий класс должен дополнять, а не замещать поведение базового класса.
Звучит понятно и вполне логично, расходимся. но блин, как этого добиться? Почему-то многие просто пропускают информацию про предусловия и постусловия, которые как раз отлично объясняют что нужно делать.
В данной статье мы НЕ будем рассматривать общие примеры данного принципа, о которых уже есть много материалов (пример с квадратом и прямоугольником или управления термостатами). Здесь мы немного подробнее остановимся на таких понятиях как «Предусловия», «Постусловия», рассмотрим что такое ковариантность, контравариантность и инвариантность, а также что такое «исторические ограничения» или «правило истории».
Предусловия не могут быть усилены в подклассе
️Другими словами дочерние классы не должны создавать больше предусловий, чем это определено в базовом классе, для выполнения некоторого бизнес-поведения. Вот пример:
<?php
class Customer
{
protected float $account = 0;
public function putMoneyIntoAccount(int|float $sum): void
{
if ($sum < 1) {
throw new Exception('Вы не можете положить на счёт меньше 1$');
}
$this->account += $sum;
}
}
class MicroCustomer extends Customer
{
public function putMoneyIntoAccount(int|float $sum): void
{
if ($sum < 1) {
throw new Exception('Вы не можете положить на счёт меньше 1$');
}
// Усиление предусловий
if ($sum > 100) {
throw new Exception('Вы не можете положить на больше 100$');
}
$this->account += $sum;
}
}
Добавление второго условия как раз является усилением. Так делать не надо!
К предусловиям также следует отнести «Контравариантность», она касается параметров функции, которые может ожидать подкласс.
Подкласс может увеличить свой диапазон параметров, но он должен принять все параметры, которые принимает родительский.
Этот пример показывает, как расширение допускается, потому что метод Bar->process()
принимает все типы параметров, которые принимает метод в родительском классе.
<?php
class Foo
{
public function process(int|float $value)
{
// some code
}
}
class Bar extends Foo
{
public function process(int|float|string $value)
{
// some code
}
}
Пример ниже показывает, как дочерний класс VIPCustomer
может принимать в аргумент переопределяемого метода putMoneyIntoAccount
более широкий (более абстрактный) объект Money
, чем в его родительском методе (принимает Dollars
).
<?php
class Money {}
class Dollars extends Money {}
class Customer
{
protected Money $account;
public function putMoneyIntoAccount(Dollars $sum): void
{
$this->account = $sum;
}
}
class VIPCustomer extends Customer
{
public function putMoneyIntoAccount(Money $sum): void
{
$this->account = $sum;
}
}
Таким образом, мы не добавляем дополнительных проверок, не делаем условия жестче и наш дочерний класс уже ведёт себя более предсказуемо.
Постусловия не могут быть ослаблены в подклассе
️То есть подклассы должны выполнять все постусловия, которые определены в базовом классе. Постусловия проверяют состояние возвращаемого объекта на выходе из функции.
<?php
class Customer
{
protected Dollars $account;
public function chargeMoney(Dollars $sum): float
{
$result = $this->account - $sum->getAmount();
if ($result < 0) { // Постусловие
throw new Exception();
}
return $result;
}
}
class VIPCustomer extends Customer
{
public function chargeMoney(Dollars $sum): float
{
$result = $this->account - $sum->getAmount();
if ($sum < 1000) { // Добавлено новое поведение
$result -= 5;
}
// Пропущено постусловие базового класса
return $result;
}
}
Условное выражение проверяющее результат является постусловием в базовом классе, а в наследнике его уже нет. Не делай так!
Сюда-же можно отнести и «Ковариантность», которая позволяет объявлять в методе дочернего класса типом возвращаемого значения подтип того типа (ШО?!), который возвращает родительский метод.
На примере будет проще. Здесь в методе render()
дочернего класса, JpgImage
объявлен типом возвращаемого значения, который в свою очередь является подтипом Image
, который возвращает метод родительского класса Renderer
.
<?php
class Image {}
class JpgImage extends Image {}
class Renderer
{
public function render(): Image
{
}
}
class PhotoRenderer extends Renderer
{
public function render(): JpgImage
{
}
}
️Таким образом в дочернем классе мы сузили возвращаемое значение. Не ослабили. Усилили :)
Инвариантность
Здесь должно быть чуть проще.
Все условия базового класса - также должны быть сохранены и в подклассе.
Инварианты — это некоторые условия, которые остаются истинными на протяжении всей жизни объекта. Как правило, инварианты передают внутреннее состояние объекта.
Например типы свойств базового класса не должны изменяться в дочернем.
<?php
class Wallet
{
protected float $amount;
// тип данного свойства не должен изменяться в подклассе
}
Здесь также стоит упомянуть исторические ограничения («правило истории»):
Подкласс не должен создавать новых мутаторов свойств базового класса.
Если базовый класс не предусматривал методов для изменения определенных в нем свойств, подтип этого класса так же не должен создавать таких методов. Иными словами, неизменяемые данные базового класса не должны быть изменяемыми в подклассе.
<?php
class Deposit
{
protected float $account = 0;
public function __construct(float $sum)
{
if ($sum < 0) {
throw new Exception('Сумма вклада не может быть меньше нуля');
}
$this->account += $sum;
}
}
class VipDeposit extends Deposit
{
public function getMoney(float $sum)
{
$this->account -= $sum;
}
}
С точки зрения класса Deposit
поле не может быть меньше нуля. А вот производный класс VipDeposit
, добавляет метод для изменения свойства account
, поэтому инвариант класса Deposit
нарушается. Такого поведения следует избегать.
В таком случае стоит рассмотреть добавление мутатора в базовый класс.
Выводы
Даже не вникая в общие сложные абстрктные примеры самого принципа подстановки Барбары Лисков, а пользуясь этими, на мой взгляд, более простыми и более конкретными правилами, вы уже добьётесь более предсказуемого поведения дочерних классов.
Стоит упомянуть, что нужно страться избавляться от пред/пост условий. В идеале они должны быть определенны как входные/выходные параметры метода (например передачей в сигнатуру готовых value objects и возвращением конкретного валидного объекта на выход).
Надеюсь, было полезно.