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