Как стать автором
Обновить

Принцип подстановки Барбары Лисков (предусловия и постусловия)

Время на прочтение4 мин
Количество просмотров20K

​Почему у многих возникают проблемы с этим принципом? Если взять не «заумное», а более простое определение, то оно звучит так:

Наследующий класс должен дополнять, а не замещать поведение базового класса.

Звучит понятно и вполне логично, расходимся. но блин, как этого добиться? Почему-то многие просто пропускают информацию про предусловия и постусловия, которые как раз отлично объясняют что нужно делать.

В данной статье мы НЕ будем рассматривать общие примеры данного принципа, о которых уже есть много материалов (пример с квадратом и прямоугольником или управления термостатами). Здесь мы немного подробнее остановимся на таких понятиях как «Предусловия», «Постусловия», рассмотрим что такое ковариантность, контравариантность и инвариантность, а также что такое «исторические ограничения» или «правило истории».

Предусловия не могут быть усилены в подклассе

​️Другими словами дочерние классы не должны создавать больше предусловий, чем это определено в базовом классе, для выполнения некоторого бизнес-поведения. Вот пример:

<?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 и возвращением конкретного валидного объекта на выход).

Надеюсь, было полезно.

Источники

  1. Вики - Принцип подстановки Барбары Лисков

  2. Metanit

  3. PHP.watch

  4. Telegram канал, с короткими заметками

Теги:
Хабы:
Всего голосов 14: ↑12 и ↓2+18
Комментарии20

Публикации

Истории

Работа

PHP программист
106 вакансий

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань