Обычно для иллюстрации этой проблемы используют пример про наследование класса Квадрат от класса Прямоугольника, или наоборот.
Пусть есть у нас класс прямоугольник:
class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
def set_width(self, width):
self._width = width
def set_height(self, height):
self._height = height
def get_area(self):
return self._width * self._height
...
Теперь мы захотели написать класс Квадрат, но чтобы переиспользовать код вычисления площади, кажется, логичным отнаследовать Квадрат от Прямоугольника:
class Square(Rectangle):
def set_width(self, width):
self._width = width
self._height = width
def set_height(self, height):
self._width = height
self._height = height
Кажется, что код классов Square и Rectangle консистентен. Вроде бы Square сохраняет математические свойства квадрата, а т.е. и прямоугольника. А значит, мы можем передавать объекты класса Square вместо Rectangle.
Но если мы будем так делать, мы можем нарушить свойства поведения класса Rectangle:
Например, есть клиентский код:
def client_code(rect):
rect.set_height(10)
rect.set_width(20)
assert rect.get_area() == 200
Если в качестве аргумента в эту функцию передать инстанс класса Square функция станет себя вести по-другому. Что является нарушением контракта на поведение класса Rectangle, потому что действия с объектом базового класса должны давать ровно такой же результат, как и над объектом класса потомка.
Если класс квадрат — это потомок класса прямоугольник, тогда работая с квадратом и выполняя методы прямоугольника, мы не должны даже заметить, что это не прямоугольник.
Исправить эту проблему, можно, например, так:
- сделать assert на точное соответствие классу, или сделать if, который будет работать для разных классов по-разному
- в Square сделать метод set_size() и переопределить методы set_height, set_width чтобы они бросали исключения
и т.д и т.п.
Такой код и такие классы будут работать, в том смысле, что код будет рабочим.
Другой вопрос, что клиентский код, который использует класс Square или класс Rectangle должны будут знать либо про базовый класс и его поведение, либо про класс-потомок и его поведение.
С течением времени мы можем получить, что:
- у класса потомка окажется переопределена большая часть методов
- рефакторинг или добавление методов в базовый класс будет ломать код, использующий потомков
- в коде, использующем объекты базового класса будут if-ы, с проверкой на класс объекта, а поведение для потомков и базового класса отличается
Получается, что клиентский код, написанный для базового класса, становится зависимым от реализации базового класса и класса потомка. Что значительно усложняет разработку со временем. А ООП создавали как раз для того, чтобы можно было править базовый класс и класс потомок независимо друг от друга.
Еще в 80ых годах прошлого века заметили, что чтобы наследование классов хорошо работало для переиспользования кода, мы должны точно знать, что класс потомок можно использовать вместо базового класса. Т.е. семантика наследования — это должно быть не только и не столько данные, сколько поведение. Наследники не должны «ломать» поведение базового класса.
Собственно, это и есть принцип подстановки Лисков или принцип определения подтипа на основе поведения (strong behavioral typing) классов: если можно написать хоть какой-то осмысленный код, в котором замена объекта базового класса на объекта класса потомка, его сломает, то тогда не стоит их друг от друга-то наследовать. Мы должны расширять поведение базового класса в потомках, а не существенным образом изменять его. Функции, которые используют базовый класс, должны иметь возможность использовать объекты подклассов, не зная об этом. Фактически это семантика наследования в ООП.
И в реальном промышленном коде крайне рекомендуется этому принципу следовать и соблюдать описанную семантику наследования. И вот с этим принципом есть несколько тонкостей.
Принципу должны удовлетворять не абстракции уровня предметной области, а абстракции кода — классы. С геометрической точки зрения, квадрат — это прямоугольник. С точки зрения иерархии наследования классов, будет ли класс квадрата наследником класса прямоугольник, зависит от того поведения, которое мы требуем от этих классов. Зависит от того, как и в каких ситуациях мы используем этого код.
Если у класса Rectangle есть только два метода — вычисление площади и отрисовка, без возможности, перерисовки и изменения размеров, то в этом случае Square с переопределенным конструктором будет удовлетворять принципу замещения Лисков.
Т.е. такие классы удовлетворяют принципу подстановки:
class Rectangle:
def draw():
...
def get_area():
...
class Square(Rectangle):
pass
Хотя конечно это не очень хороший код, и даже, наверное, антипаттерн проектирования классов, но с формальной точки зрения он удовлетворяет принципу Лисков.
Еще один пример. Множество — это подтип мультимножества. Это отношение абстракций предметной области. Но код можно написать так, что класс Set мы наследуем от Bag и принцип подстановки нарушается, а можно написать так, чтобы принцип соблюдался. С одной и той же семантикой предметной области.
В общем и целом, наследование классов можно рассматривать как реализацию отношения “IS”, но не между сущностями предметной области, а между классами. И является ли класс потомок подтипом базового класса определяется тем, какие ограничения и контракты поведения классов использует (и в принципе может использовать) клиентский код.
Ограничения, инварианты, контракт базового класса не зафиксированы в коде, а зафиксированы в головах разработчиков, которые код правят и читают. Что такое “ломает”, что такое нарушает “контракт” определяется не кодом, а семантикой класса в голове разработчика.
Любой осмысленный для объекта базового класса код не должен ломаться, если мы его заменим на объект класса потомка. Осмысленный код — это любой клиентский код, который использует объект базового класса (и его потомков) в рамках семантики и ограничений базового класса.
Что крайне важно понимать, что ограничения абстракции, которая реализуется в базовом классе обычно не содержатся в программному коде. Эти ограничения понимает, знает и поддерживает разработчик. Он следит за консистентностью абстракции и кода. Чтобы код выражал то, что он значит.
Например, у прямоугольника есть еще один метод, который возвращает представление в json
class Rectangle:
def to_dict(self):
return {"height": self.height, "width": self.width}
А в Square мы его переопределяем:
class Square:
def to_dict(self):
return {"size": self.height}
Если мы считаем базовым контрактом поведения класса Rectangle, что to_json должен иметь height и width, тогда код
r = rect.to_dict()
log(r['height'], r['width'])
будет осмысленным для объекта базового класса Rectangle. При замещении объекта базового класса на класс наследник Square код меняет свое поведение и нарушает контракт, и тем самым нарушает принцип подстановки Лисков.
Если мы считаем, что базовым контрактом поведения класса Rectangle является то, что to_dict возвращает словарь, который можно сериализовать, не закладываясь на конкретные поля, то тогда такой метод to_dict будет ок.
Кстати, это хороший пример, разрушающий миф, что неизменяемость (immutability) спасает от нарушения принципа.
Формально, любое переопределение метода в классе-потомке опасно, также как и изменения логики в базовом классе. Например, довольно-таки часто классы потомки адаптируются к “неправильному” поведению базового класса, и когда в базовом классе исправляют баг, они ломаются.
Можно максимально все условия контракта и инварианты перенести в код, но в общем случае семантика поведения все-равно лежит вне кода — в проблемной области, и поддерживается разработчиком. Пример, про to_dict — это пример, когда контракт можно описать в коде, но например, проверить, что метод get_hash возвращает действительно хэш со всеми свойствами хэша, а не просто строчку — невозможно.
Когда разработчик использует код, написанный другими разработчиками, он может понять, а какова же семантика класса только непосредственно по коду, названию методов, документации и комментариям. Но в любом случае семантика — это часто область человеческая, а значит и ошибкоемкая. Самое важное следствие: только по коду — синтаксически — проверить следование принципу Лисков невозможно, и нужно опираться на (часто) расплывчатую семантику. Формального (математического), значит, проверяемого и гарантированного, способа проверки strong behavioral typing — нет.
Поэтому часто вместо принципа Лисков используются формальные правила на предусловия и постусловия из контрактного программирования:
- предусловия в подклассе не могут быть усилены — подкласс не должен требовать большего, чем базовый класс
- постусловия подкласса не могут быть ослаблены — подкласс не должен предоставлять (обещать) меньше, чем базовый класс
- инварианты базового класса должны сохранятся и в классе потомке.
Например, в методе класса потомока, мы не можем добавить обязательный параметр, которого не было в базовом классе — потому что так мы усиливаем предусловия. Или мы не можем бросать исключений в переопределенном методе, т.к. нарушаем инварианты базового класса. И т.д.
Важно не текущее поведение класса, а какие изменения класса подразумевает ответственность или семантика класса.
Код постоянно правится и изменяется. Поэтому если прямо сейчас код удовлетворяет принципу подстановки, это не значит, что правки в коде не изменят этого.
Допустим есть разработчик библиотечного класса Rectangle, и разработчик приложения, отнаследовавший Square от Rectangle. В момент, когда разработчик приложения унаследовал Square от Rectangle — все было хорошо, классы удовлетворяли принципу подстановки.
И в какой-то момент разработчик, отвечающий за бибилиотеку, добавил метод reshape или set_width/set_height в базовый класс Rectangle. С его точки зрения, просто произошло расширение базового класса. Но на самом деле, произошло изменение семантики и контрактов, на которые опирался класс потомок. Теперь классы уже не удовлетворяет принципу.
Вообще при наследовании в ООП, изменения в базовом классе, которые будут выглядеть, как расширение интерфейса — будет добавлен еще один метод или поле, могут нарушать предыдущие “естественные” контракты, и тем самым фактически менять семантику или ответственность. Поэтому добавление любого метода в базовый класс — опасно. Можно случайно ненароком изменить контракт.
И с практической точки зрения, в примере с прямоугольник и классом важно, не есть ли сейчас метод reshape или set_width/set_height. С практической точки зрения важно, насколько высока вероятность появления таких изменений в библиотечном коде. Подразумевает ли сама семантика или границы ответственности класса такие изменения. Если подразумевают, то вероятность ошибки и/или дальнейшей необходимости рефакторинга значительно повышается. И если даже небольшая возможность есть, вероятно лучше не наследовать такие классы друг от друга.
Поддерживать определения подтипа на основе поведения — сложно, даже для простых классов с понятной семантикой, что уж говорить про энтерпрайз со сложной бизнес-логикой. Несмотря на то, что базовый класс и класс наследник — это разные куски кода, для них нужно очень внимательно и хорошо продумать интерфейсы и ответственность. И даже при небольшом изменении семантики класса — чего никак не избежать, нам приходится смотреть код, связанных классов, проверять не нарушает ли новый контракт или инвариант то как уже сейчас написано(!) и используются. Почти при любом изменении в развесистой иерархии классов, мы должны посмотреть и проверить много другого кода.
Это одна из причин, почему классическое наследование в ООП некоторые не очень любят. И поэтому часто отдают предпочтение композиции классов, наследованию интерфейсов и т.д и т.п. вместо классического наследования поведения.
Справедливости ради, есть некоторые правила, которые с большой вероятностью не дадут нарушить принцип подстановки. Можно себя максимально обезопасить, если запретить все опасные конструкции. Например, для C++ об этом написано у Олега. Но в целом такие правила превращает классы не в совсем классы в классическом понимании.
С помощью административных методов задача тоже решается не очень хорошо. Здесь можно почитать, как делал дядюшка Мартин в С++ и как это не сработало.
Но в реальном промышленном коде все же довольно-таки часто принцип Лисков нарушается, и это не страшно. Cледить за соблюдением принципа сложно, т.к. 1) ответственность и семантика класса часто не явна и не выразима в коде 2) ответственность класса может меняться — как в базовом классе, так и в классе потомке. Но это не всегда приводит к каким-то уж очень страшным последствиям. Самое частое, простое и элементарное нарушение — это переопределенный метод модифицирует поведение. Как в например, тут:
class Task:
def close(self):
self.status = CLOSED
...
class ProjectTask(Task):
def close(self):
if status == STARTED:
raise Exception("Cannot close a started Project Task")
...
Метод close у ProjectTask будет выкидывать исключение в тех случаях, в которых объекты класс Task нормально отработают. Вообще, переопределение методов базового класса очень часто приводит к нарушению принципа подстановки, но не становится проблемой.
На самом деле в таком случае разработчик воспринимает наследование НЕ как реализацию отношения «IS», а просто как способ переиспользования кода. Т.е. подкласс — это просто подкласс, а не подтип. В этом случае, с прагматической и практической точки зрения имеет большее значение — а какова вероятность того, что будет или уже существует клиентский код, который заметит разную семантику методов класса потомка и базового класса?
Много ли вообще кода, который ожидает объект базового класса, но в который мы передаем объект класса потомка? Для многих задач такого кода вообще никогда не будет.
Когда нарушение LSP приводит к большим проблемам? Когда из-за различий в поведении клиентский код придется переписывать при изменениях в классе-потомке и наоборот. Особенно это становится проблемой, если этот клиентский код — это код библиотеки, который нельзя поменять. Если переиспользование кода не сможет создать в дальнейшем зависимости между клиентским кодом и кодом классов, то тогда даже несмотря на нарушение принципа подстановки Лисков, такой код может не нести с собой больших проблем.
Вообще, при разработке, наследование можно рассматривать с двух позиций: подклассы — это подтипы, со всеми ограничениями контрактного программирования и принципа Лисков, и подклассы — это способ переиспользовать код, со всеми своими потенциальным проблемами. Т.е. можно либо думать и проектировать ответственности классов и контракты и не переживать про клиентский код. Либо думать про то, какой может быть клиентский код, как классы будут использоваться, и быть готовым к потенциальным проблемам, но в меньшей степени заботится о соблюдении принципа подстановки. Решение как обычно за разработчиком, самое главное, чтобы выбор в конкретной ситуации был осознанный и было понимание какие плюсы, минусы и подводные камни сопровождают то или иное решение.
Комментарии (16)
ghost404
16.08.2019 12:30+1Лучше выделить более общий подтип для обеих фигур.
Несмотря на кажущееся сходство квадрата и прямоугольника, они разные. Квадрат имеет много общего с ромбом, а прямоугольник с параллелограммом, но они не являются подтипом. Квадрат, прямоугольник, ромб и параллелограмм — это отдельные фигуры со своими собственными свойствами, хотя и схожими.
Подробнее здесь.
zloy_stas Автор
16.08.2019 12:55+2В том и дело, что нельзя строить иерархии наследования классов основываясь на свойствах объектов предметной области (похожи они друг на друга или нет). Все зависит от конкретного поведения класса, а не той абстракцией, которую он пытается моделировать.
Это на самом деле частая проблема обучающих статей про ООП. Когда пытаются рассказывать, что раз Coffe (кофе) — это подтип Drink (Напитка), то мы должны или не должны наследовать одно от другого. Но в жизни никто НЕ моделирует реальность as-is. И это классическая и грубая ошибка новичка. Если ты делаешь класс для расчета нагрузки на балки, чаще всего тебе не надо моделировать саму балку, а просто запрограммировать функцию расчета в зависимости входных параметров.ghost404
16.08.2019 13:31Если говорить о предметной области, то это DDD и класс обязан быть построен на свойствах объекта предметной области. И если предметная область говорит нам о том, что есть Напиток и Кофе, и наша предметная область в конкретном ограниченном контексте говорит, что Кофе это частный случай Напитка, то в коде мы обязаны отразить это наследование.
zloy_stas Автор
16.08.2019 13:42Вообще говоря то, что код не удовлетворяет DDD НЕ является реальной проблемой. Реальной проблемой будет — если его будет тяжело читать, поддерживать, рефакторить и т.д и т.п.
DDD — это лишь один из способов проектирования. Фактически любой код — это лишь человекочитаемый способ записи большого и сложного вычисления. Как минимум, он может быть организован не только с помощью абстракции ООП, но и функционального, логического и мета программирования и т.д. Кроме того, один тот же кусок кода может быть раздекомпозирован огромным количеством способов даже в рамках ООП.
ghost404
16.08.2019 13:47Безусловно, DDD не единственный способ разработки и не панацея во многих кейсах, но он облегчает разработку, чтение, сопровождение и рефакторинг кода.
VolCh
17.08.2019 03:321) отразить факт частного случая может и обязан, но не обязан делать это через наследование. Это может быть просто невозможно сделать технически, например из-за запрета на множественные наследование.
2) Любая модель предметной области в принципе не обязана отражать все свойства предметной области. Более того, хорошая модель обычно обязана этого не делать, она обязана отражать только важные для контекста свойства, а не все. Например, в программе черчения может быть просто не важно, что некоторые прямоугольники являются каадратами. Или это важно исключительно в момент создания, а инварианта длина равна ширине просто нет, достаточно отдельной фабрики для класса прямоугольник.ghost404
18.08.2019 13:33Я об этом и говорил. Мы не создаём сущность одну на весь проект. В каждом контексте свои ограничения. Но если мы не отразим в коде ограничения конкретного контекста, то получим сущность нарушающую инварианты.
surly
16.08.2019 15:46нельзя строить иерархии наследования классов основываясь на свойствах объектов предметной области (похожи они друг на друга или нет). Все зависит от конкретного поведения класса, а не той абстракцией, которую он пытается моделировать
Правильно! Поэтому, как рекомендовали в умной книге (название, конечно, не помню) — не квадрат наследуем от прямоугольника, а наоборот, прямоугольник от квадрата.
Согласно этой рекомендации, надо забыть, что по математическому определению квадрат является подвидом прямоугольника. А обратить внимание на то, что прямоугольник «во многом ведёт себя как квадрат, только помимо ширины имеет ещё длину, а площадь у него вычисляется вот так: ...»zloy_stas Автор
16.08.2019 16:16Все так! Попытки проектирования от предметной области, а не от проблемы, которую мы решаем — приводят к таким вот рассуждениям.
VolCh
17.08.2019 03:37Решаемая проблема — часть предметной области обычно. Или мы решаем проблему как впихнуть классическое решение в имеющиеся ограничения типа технических, например отсутствие множественноготнаследования в языке.
playermet
16.08.2019 16:31+2не квадрат наследуем от прямоугольника, а наоборот, прямоугольник от квадрата
И получаем такое же нарушение принципа подстановки Барбары Лисков, как и в первом способе. Потому что передача объекта Rectangle вместо Square может дать другой результат работы. Квадрат и прямоугольник нельзя наследовать друг от друга, не нарушая LSP.VolCh
17.08.2019 03:42Иногда можно, иногда нельзя. Важно нарушаем мы контракт базового класса или нет.Если в квадрате мы, например, не обещаем клиенту, что при увеличении ширины в два раза площадь увеличится в четыре, то мы можем в наследнике квадрата увеличить её только в два раза и не нарушить LSP.
telhin
16.08.2019 14:23Справедливости ради, есть некоторые правила, которые с большой вероятностью не дадут нарушить принцип подстановки. Можно себя максимально обезопасить, если запретить все опасные конструкции. Например, для C++ об этом написано у Олега. Но в целом такие правила превращает классы не в совсем классы в классическом понимании.
То что написано в блоге Олега очень похоже на некоторый вариант анемичной модели.
Хотя это вроде как почти прямая трансляция Haskell -> C++
EvgeniiR
У ООП нет чёткого определения, поэтому я исключительно против таких заявлений. Пока людям навязывают цитируемую выше идею, мы так и будем видеть 5-уровневые иерархии наследования минимально похожих классов и наследование с целью «переиспользования кода»(даже без цели использования полиморфизма подтипов").
Для переиспользования кода нет никаких аргументов в пользу наследования, кроме того что из-за хайпа и моды на Java/C++ стиль «ООП» новички не интересуются альтернативами и упускают из вида объективные ценности и концепты при проектировании классов. Хотя бы те же Interface Segregation Principle(и, конечно, LSP), и стоящие за ними концепты coupling/cohesion и влияние всего этого на вероятность каскадных изменений и читаемость кода.