Принцип підстановки Барбари Лісков. Про передумови, постумови та інваріанти
Чому у багатьох виникають проблеми з цим принципом? Якщо взяти не якесь там «заумне» визначення, а щось простіше, то звучить воно так:
Клас-спадкоємець має доповнювати, а не заміщати поведінку базового класу.
Звучить зрозуміло і цілком логічно, правда? Ну все, розходимось. Але блін, як цього досягти? 😅 Чомусь багато хто просто пропускає інфу про передумови і постумови, які якраз класно пояснюють що саме треба і не треба робити.
Але спочатку — навіщо воно взагалі?
Уявіть ситуацію: ви написали код, який працює з базовим класом PaymentProcessor. Все чудово, тести зелені, код на проді. А потім приходить колега і каже: «О, я тут створив PremiumPaymentProcessor, він наслідується від твого, давай використаємо його для VIP-клієнтів».
Ви підставляєте новий клас, і... все падає 💥 Чому? Бо новий клас поводиться не так, як ви очікували від PaymentProcessor. Ось саме це LSP і намагається попередити.
Головна ідея: якщо ваш код працює з батьківським класом, він має працювати з будь-яким його нащадком без жодних змін і сюрпризів.
У цій статті ми трошки докладніше зупинимось на поняттях «передумови», «постумови», розглянемо, що таке коваріантність, контраваріантність і інваріантність, а також «історичні обмеження» або «правило історії». Усі ці правила про передумови та постумови прийшли з концепції «Design by Contract».
Передумови не можуть бути посилені в підкласі
️Іншими словами, дочірні класи не мають створювати більше передумов, ніж це визначено в базовому класі.
Тобто якщо батьківський клас каже «дай мені будь-яке позитивне число», то дочірній не може казати «ні, давай тільки парні позитивні числа». Це буде посилення вимог.
От приклад:
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;
}
}
Додавання другої умови — це якраз і є посилення.
Але чому це проблема? Уявіть код, який використовує ваші класи:
function processDeposit(Customer $customer) {
$customer->putMoneyIntoAccount(500); // Все ок для Customer
}
// Але якщо передати MicroCustomer - буде exception
processDeposit(new MicroCustomer());
Контраваріантність параметрів
До передумов також варто віднести «контраваріантність» — складне слово, але просте правило:
Підклас може приймати ширший діапазон параметрів, але він має приймати ВСІ параметри, які приймає батьківський.
З PHP 7.4 навіть підтримується на рівні мови:
// Батьківський клас очікує конкретний тип
class PaymentGateway {
public function process(CreditCard $payment) { }
}
// Дочірній може прийняти ширший тип
class FlexibleGateway extends PaymentGateway {
public function process(PaymentMethod $payment) { } // CreditCard є PaymentMethod
}
Постумови не можуть бути послаблені в підкласі
️Тобто підкласи мають виконувати всі постумови, які визначені в базовому класі. Постумови перевіряють стан об’єкта, що повертається на виході з функції.
Різниця з передумовами:
- Передумови — що ми ПЕРЕВІРЯЄМО на вході (параметри, які нам дали).
- Постумови — що ми ГАРАНТУЄМО на виході (результат, який повертаємо).
Простіше кажучи: передумови — це вимоги до клієнта, постумови — це наші обіцянки клієнту.
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();
// Пропущена постумова базового класу - результат може бути від'ємним
return $result; // Порушили обіцянку - можемо повернути від'ємне число
}
}
Умовний вираз, що перевіряє результат, є постумовою в базовому класі, а в наслідника її вже нема.
Чому це критично? Клієнтський код покладається на гарантії:
$balance = $customer->chargeMoney($amount); // Клієнт впевнений що $balance >= 0 (бо це гарантія методу) $this->displayBalance($balance); // Може впасти якщо від'ємне
Сюди ж можна віднести і «коваріантність», яка дозволяє оголошувати в методі дочірнього класу типом значення, що повертається, підтип того типу (шо?! 😂), який повертає батьківський метод.
На прикладі буде простіше. Тут в методі render() дочірнього класу, JpgImage оголошений типом значення, що повертається, який в свою чергу є підтипом Image, який повертає метод батьківського класу Renderer:
class Image {}
class JpgImage extends Image {}
class Renderer
{
public function render(): Image
{
}
}
class PhotoRenderer extends Renderer
{
public function render(): JpgImage
{
}
}
️Таким чином в дочірньому класі ми звузили значення, що повертається. Не послабили. Посилили :)
Це безпечно, бо JpgImage — це все ще Image, просто з додатковими можливостями. Клієнтський код, який очікує Image, отримає його (просто більш специфічний).
Інваріантність
Тут має бути трошки простіше.
Всі умови базового класу — також мають бути збережені і в підкласі.
Інваріанти — це деякі умови, які залишаються істинними протягом всього життя об’єкта. Як правило, інваріанти передають внутрішній стан об’єкта.
Важлива різниця:
- Передумови/постумови — про конкретні методи.
- Інваріанти — про стан об’єкта, незалежно від методів.
Наприклад, бізнес-правила базового класу не повинні змінюватись в дочірньому:
class EmailList
{
private array $emails = [];
// ІНВАРІАНТ: список містить тільки валідні email
public function addEmail(string $email): void
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException();
}
$this->emails[] = $email;
}
public function getEmails(): array
{
return $this->emails; // Завжди повертає валідні emails
}
}
// Якщо дочірній клас додасть метод, який дозволить
// додати невалідний email - він порушить інваріант
Тут також варто згадати історичні обмеження («правило історії»):
Підклас не повинен створювати нових мутаторів властивостей базового класу.
Якщо базовий клас не передбачав методів для зміни визначених в ньому властивостей, підтип цього класу також не повинен створювати таких методів. Іншими словами, якщо в батьківському класі щось було незмінним (immutable), не робіть це змінним (mutable) в дочірньому.
class Order
{
private float $totalAmount;
private string $status = 'pending';
public function __construct(float $amount)
{
$this->totalAmount = $amount;
}
public function getTotal(): float
{
return $this->totalAmount;
}
// Зверніть увагу - немає методу setTotal()
// Сума замовлення незмінна після створення
}
class EditableOrder extends Order
{
public function setTotal(float $newAmount): void
{
$this->totalAmount = $newAmount; // порушення
}
}
В такому випадку варто розглянути додавання мутатора в базовий клас або використання композиції замість наслідування.
Exceptions — це теж частина контракту
Про це часто забувають, але LSP стосується і виключень. Підклас не може кидати ширші виключення:
class PaymentProcessor
{
public function charge(float $amount): void
{
if ($amount > $this->limit) {
throw new PaymentLimitExceededException(); // Специфічний
}
}
}
class CustomPaymentProcessor extends PaymentProcessor
{
public function charge(float $amount): void
{
if ($amount > $this->limit) {
throw new \Exception('Limit exceeded'); // ПОРУШЕННЯ! Ширший exception
}
}
}
// Клієнтський код
try {
$processor->charge(1000);
} catch (PaymentLimitExceededException $e) {
// Не зловить загальний Exception від CustomPaymentProcessor!
}
Клієнтський код, який ловить PaymentLimitExceededException, не зловить загальний Exception.
Tell, Don’t Ask
Ще одне важливе правило — замість перевірки типу об’єкта, дайте йому самому вирішити, що робити:
// Погано - питаємо про тип (порушення LSP)
if ($account instanceof PremiumAccount) {
$account->applyPremiumBenefits();
}
// Добре - кажемо що робити (LSP дотримано)
$account->applyBenefits(); // кожен тип сам знає свої benefits
Як це працює в реальному коді?
Замість того щоб робити глибоку ієрархію наслідування і потім боротись з порушеннями LSP, часто краще використовувати композицію.
Проблема з наслідуванням полягає в тому, що ви змушені взяти всю поведінку батьківського класу, навіть ту, яка вам не підходить.
За допомогою композиції ви створюєте новий клас, який використовує потрібну функціональність через делегування:
// Замість наслідування з обмеженнями
interface AccountInterface {
public function deposit(float $amount): void;
public function getBalance(): float;
}
class StandardAccount implements AccountInterface {
// стандартна реалізація
}
class LimitedAccount implements AccountInterface {
private AccountInterface $account;
private float $maxDeposit = 100;
public function __construct(AccountInterface $account) {
$this->account = $account;
}
public function deposit(float $amount): void {
if ($amount > $this->maxDeposit) {
throw new LimitExceededException();
}
$this->account->deposit($amount); // делегуємо
}
// ...
}
// Клієнтський код працює однаково з обома
function processPayment(AccountInterface $account, float $amount) {
$account->deposit($amount);
echo "Депозит прийнято";
}
В чому перевага?
- LimitedAccount не порушує жодних правил StandardAccount.
- Ми не змушені перевизначати всі методи.
- Можемо комбінувати різну поведінку.
Висновки
LSP — це не про складні правила типів. Це про передбачувану поведінку. Коли дочірній клас поводиться «як батьківський, тільки краще» — все добре. Коли він починає додавати обмеження або міняти очікувану поведінку — маєте проблему.
Пам’ятайте:
- Передумови — можна послабити (приймати більше), не можна посилити.
- Постумови — можна посилити (гарантувати більше), не можна послабити.
- Інваріанти — не можна змінювати взагалі.
- Поведінка — має залишатись передбачуваною.
І головне — якщо сумніваєтесь, використовуйте композицію замість наслідування. Це вирішує 90% проблем з LSP 😊
30 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів