Наследование — один из столпов ООП. Наследование используется для того, чтобы переиспользовать общий код. Но не всегда общий код надо переиспользовать, и не всегда наследование — самый лучший способ для переиспользования кода. Часто получается, так, что есть похожий код в двух разных куска кода (классах), но требования к ним разные, т.е. классы на самом деле друг от друга наследовать может и не стоит.
Обычно для иллюстрации этой проблемы используют пример про наследование класса Квадрат от класса Прямоугольника, или наоборот.
Пусть есть у нас класс прямоугольник:
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 приводит к большим проблемам? Когда из-за различий в поведении клиентский код придется переписывать при изменениях в классе-потомке и наоборот. Особенно это становится проблемой, если этот клиентский код — это код библиотеки, который нельзя поменять. Если переиспользование кода не сможет создать в дальнейшем зависимости между клиентским кодом и кодом классов, то тогда даже несмотря на нарушение принципа подстановки Лисков, такой код может не нести с собой больших проблем.
Вообще, при разработке, наследование можно рассматривать с двух позиций: подклассы — это подтипы, со всеми ограничениями контрактного программирования и принципа Лисков, и подклассы — это способ переиспользовать код, со всеми своими потенциальным проблемами. Т.е. можно либо думать и проектировать ответственности классов и контракты и не переживать про клиентский код. Либо думать про то, какой может быть клиентский код, как классы будут использоваться, и быть готовым к потенциальным проблемам, но в меньшей степени заботится о соблюдении принципа подстановки. Решение как обычно за разработчиком, самое главное, чтобы выбор в конкретной ситуации был осознанный и было понимание какие плюсы, минусы и подводные камни сопровождают то или иное решение.
Автор: zloy_stas