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

Продолжая серию «ООП — это просто», на этот раз я попытаюсь рассказать о принципе подстановки Барбары Лисков (Liskov substitution principle, далее LSP). Поскольку я считаю этот принцип венцом SOLID, то чтобы читать эту статью, нужно ясно понимать, что такое уровни абстракции и DIP. Попутно я расскажу в меру своего понимания о принципе открытости/закрытости (open/closed principle, далее OCP).

Если потратить некоторое количество времени и положить на алтарь науки пару десятков нервных клеток, можно найти такие самые простые определения данного принципа:
— «Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом».

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

Очень понятно, правда? Особенно на счет того, как же это применять. Итак, не вдаваясь в дальнейшие подробности трехэтажных определений, переходим к делу.

Как не надо

Разжевывать суть принципа будем на примере вот отсюда (C++). Поскольку моим основным языком программирования всё ещё является PHP (haters gonna hate), то и пример будет адаптирован под стилистику этого языка. Наберитесь терпения, пример длинный. В нём мы нарушим принцип подстановки Барбары Лисков и посмотрим, к чему это приведёт.

Задача следующая: есть два похожих термостата (нагревателя) от разных производителей: BrandA и BrandB. Это очень простые приборы. Всё, что они умеют — это греть воду по команде и измерять текущую температуру воды. Никакими другими супер-способностями создатели их не наградили.

Нас просят запрограммировать единый пульт управления для 100500 бойлеров, оснащенных данными термостатами.

Не долго думая, пишем нечто по следующему клише:

abstract class Boiler {
   private $desirableTemperature;
   public function setDesirableTemperature($temp) {
       $this->desirableTemperature = $temp;
   }
   public function getDesirableTemperature() {
       return $this->desirableTemperature;
   }
   abstract function initializeDevice();
   abstract function getWaterTemperature();
   abstract function heatWater();
}

class BrandABoiler extends Boiler {
   function initializeDevice() { /*use API BrandA*/ }
   function getWaterTemperature() { /*use API BrandA*/ }
   function heatWater() { /*use API BrandA*/ }
}

Класс для BrandB пишем по аналогии с BrandA, с той лишь разницей, что ласкаем его по API BrandB.

Когда всё готово, быстренько ваяем клиентский код (если кто не знает — это тот, что является клиентом по отношению к нашим классам, т.е. использует их). Например, вот так:

$myBoiler = SomeFactory::getNextBoiler();
$myBoiler->setDesirableTemperature(37);
$myBoiler->initializeDevice();
while($myBoiler->getWaterTemperature() < $myBoiler->getDesirableTemperature()) {
   $myBoiler->heatWater();
}

Утрированно получаем workflow:

Работа сделана. Радуемся, считаем деньги.

Спустя какое-то время, приходит наш заказчик и с порога выдыхает: «Воооть!»

Перед нами напичканный электроникой бойлер BrandC, который получает указания о желаемой температуре воды один раз и сам её поддерживает.

Обреченно вздыхаем, лезем в код, ваяем класс нового бойлера:

class BrandCBoiler extends Boiler {
   function setDesirableTemperature($temp) {
       /*устанавливаем желаемую температуру воды сразу в бойлере BrandC*/
   }
   function getDesirableTemperature() {
       /*получаем желаемую температуру воды напрямую из бойлера BrandC*/
   }
   function initializeDevice() { /*use API BrandC*/ }
   function getWaterTemperature() { /*use API BrandC*/ }
   function heatWater() { /*empty*/ }
}

Воспользуемся комиксом, чтобы продемонстрировать плоды нашего труда:

В лучшем случае вода будет нагрета до какого-то непонятного дефолтного значения. Что же произошло, почему оно так работает, ведь всё было хорошо?

Произошла подмена поведения. Вот этого:

abstract class Boiler {
   private $desirableTemperature;
   public function setDesirableTemperature($temp) {
       $this->desirableTemperature = $temp;
   }
   public function getDesirableTemperature() {
       return $this->desirableTemperature;
   }
   //bla-bla...
}

На вот это поведение:

class BrandCBoiler extends Boiler {
   function setDesirableTemperature($temp) {
       /*устанавливаем желаемую температуру воды сразу в бойлере BrandC*/
   }
   function getDesirableTemperature() {
       /*получаем желаемую температуру воды напрямую из бойлера BrandC*/
   }
   //bla-bla...
}

Суперкласс Boiler проектировался с таким расчетом, что нужная температура воды инкапсулируется внутри его свойства $desirableTemperature, и затем это значение может быть извлечено и использовано. Мы же, наплевав на эту задумку, пытаемся «срезать путь», подменив в субклассе методы, содержащие фундаментальное проектное поведение. То есть теперь setDesirableTemperature() перестал записывать температуру в контейнер $desirableTemperature, а getDesirableTemperature() перестал оттуда читать.

Но класс BrandCBoiler всё равно остался потомком Boiler, являясь его частным случаем. И продолжает работать по заложенным в Boiler принципам. Часть из которых уже сломана.

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

Другими словами, если бы у нас был автомобиль, созданный для поездок, то:

Если развить мысль и предположить, что наш автомобиль установлен в клозете какого-то крафтового кафе и задумывался как «крутое дизайнерское решение интерьера уборной», то замена двигателя автомобиля на унитаз — принципа подстановки Барбары Лисков не нарушит.

Как надо

Так что же нужно было сделать, чтобы корректно использовать бойлер BrandC в рамках существующей архитектуры? Самые находчивые проектировщики повскакивали с мест и перебили: «А не надо так использовать наши классы! Надо исправить клиентский код. Чего вы его так пишете! Вот можно в нём поменять местами строки с вызовом setDesirableTemperature() и InitializeDevice() - и всё заработает».

Но разве кому-то из нас непривычна ситуация, что код классов пишет один программист, а клиентский код к ним — совершенно другой (и в другое время). Где гарантии, что ваши классы будут использоваться только так, как вы того хотите? Надо проектировать с расчетом, что клиентский код к ним будет ваять идиот — не промахнётесь. К слову, тут может быть отсылка к Инкапсуляции (en capsula: взятие в капсулу) — нужно надёжно защищать свои механизмы от внешних воздействий (в том числе от кривых рук).

А кто-то может предложить, например, такое: «Давайте перепишем initializeDevice() в абстракции Boiler, чтобы при инициализации субкласса сразу задавать требуемую температуру». — «Конечно-конечно, — ответим мы. — Только ты забыл про принцип открытости/закрытости», — Open/closed principle, далее OCP.

Определение звучит так: «Классы должны быть открыты для расширения, но закрыты для изменения».

Что это значит: не трогайте стабильно работающие классы, не переписывайте их, не дописывайте. Субклассируйте, и уже там вносите новое поведение. Почему так? Потому что старый код надёжен, он двести раз протестирован и проверен в боевых условиях — он работает, и не надо его ломать (100500 бойлеров BrandA/B стабильно работают). Плюс субклассировать и переписывать — две совершенно разные задачи по объёму и по времени.

Здесь выплывает два вопроса:

1) А как же ошибки? Как мы можем исправлять баги в закрытом для изменений классе?

Ответ: баги надо исправлять. А принцип OCP относится к проектированию, а не к багфиксу.

2) А как же рефакторинг? Разве можно проводить рефакторинг в классе, который нельзя изменять?

Ответ: проводите рефакторинг. Однако это мероприятие влечет за собой предварительное создание «плана рефакторинга» с прогнозированием «боков» грядущей перестройки. Рефакторинг — это перепроектирование, переделка, новый архитектурный цикл. Тогда как OCP — это ни какое не «пере». Это принцип, призывающий не пороть горячки. Всё равно, как вы бы у себя дома перекладывали проводку всякий раз, когда вам нужно передвинуть торшер.

Таким образом, создать BrandCBoiler, не нарушая принцип подстановки Барбары Лисков и учитывая принцип открытости/закрытости, можно, например, так:

class BrandCBoiler extends Boiler {
   function initializeDevice() {
       //через API BrandC уговариваем устройство поработать
       //передаём по API нужную температуру из $this->getDesirableTemperature()
   }
   function getWaterTemperature() { /*use API BrandC*/ }
   function heatWater() { /*empty*/ }
}

Мы субклассировали Boiler в BrandCBoiler (учли OCP) без подмены поведения базового класса (учли LSP).

Итак, мы видим, что принцип подстановки Барбары Лисков — логический. Мы не сможем проверить его нарушение никакими IDE, синтаксическими анализаторами и т.п. (ну разве что тесты в помощь, если знаете, где копать). Пользоваться DIP способен только человеческий мозг, развивший в себе способности к абстрагированию и философии. Во всяком случае, пока что. Но это уже другая история.

Послесловие

О нарушении любых принципов проектирования: нарушать их можно, а иногда просто необходимо. Но делать это надо, понимая, к каким последствиям это приведёт, пользуясь фармацевтической мерой: «предполагаемая польза превосходит возможный риск».

Всем желающим продолжения рекомендую прочесть книгу Эрика и Элизабет Фримен «Паттерны проектирования» — свои статьи я написала как, по моему субъективному мнению, «недостающие части» к этой прекрасной книге. Я попыталась сохранить её стилистику и ясность изложения. Если вам понравились мои истории — эта книга вам тоже придётся по вкусу.


P.S. Вы ни разу не видели боевых проектов из четырёх классов, основная функциональность которых представлена комментариями? Я тоже. Поэтому, прежде чем писать пост, пожалуйста, задумайтесь, для какой цели были созданы эти примеры. Материал подготовлен мной с учётом моих вкусов, видений и моего персонального чувства прекрасного — он отражает образ моей мысли и может отличаться, как от истины, так и от мнения и видений других людей.

Похожие статьи:
Оператор мобильной связи МТС совместно с ведущей российской антивирусной компанией рекомендует своим абонентам всерьез задуматься о...
The Wine Fight is held on the morning of the 29th of June each year in the historical heart of Rioja just outside the town of Haro (pronounced Aro), and is an exuberant, liberating and totally awesome experience. Unlike the Running of the Bulls...
Мы уже упоминали в новостях о смартфоне BlackBerry Venice в необычном для современного рынка "умных" телефонов форм-факторе вертикального...
Японский оператор NTT DOCOMO сегодня представил свою новую коллекцию мобильных устройств сезона зима 2015 – весна 2016, которая будет...
Все началось давно, в начале двухтысячных, когда я, оказывая услуги по удаленной настройке Linux/FreeBSD серверов разным заказчикам,...
Яндекс.Метрика