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

Продолжая серию «ООП — это просто», на этот раз я попытаюсь рассказать о принципе подстановки Барбары Лисков (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. Вы ни разу не видели боевых проектов из четырёх классов, основная функциональность которых представлена комментариями? Я тоже. Поэтому, прежде чем писать пост, пожалуйста, задумайтесь, для какой цели были созданы эти примеры. Материал подготовлен мной с учётом моих вкусов, видений и моего персонального чувства прекрасного — он отражает образ моей мысли и может отличаться, как от истины, так и от мнения и видений других людей.

Похожие статьи:
В Україні можуть зʼявитися місця, де звʼязок буде забезпечений за будь-якої ситуації з допомогою Tesla Powerwall і Starlink. Про це сказав...
Наверное, каждый из тех, кто работает с иностранными заказчиками и командами, сталкивался с проблемами коммуникации. И дело...
За третій квартал цього року в Україні задекларували 26,6 млн євро та 29,9 млн доларів «податку на Google». В гривнях ця сума...
На DOU розміщено понад 450 вакансій для .NET-розробників, що свідчить про популярність цієї технології. Редакція DOU зібрала...
На нашем YouTube канале появились новые видеоролики.Видеообзор «голографического смартфона» Estar Takee:Видеообзор Samsung...
Яндекс.Метрика