Хабрахабр

Еще раз о принципе подстановки Лисков, или семантика наследования в ООП

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

Пусть есть у нас класс прямоугольник:

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, потому что действия с объектом базового класса должны давать ровно такой же результат, как и над объектом класса потомка.

Если класс квадрат — это потомок класса прямоугольник, тогда работая с квадратом и выполняя методы прямоугольника, мы не должны даже заметить, что это не прямоугольник.

Исправить эту проблему, можно, например, так:

  1. сделать assert на точное соответствие классу, или сделать if, который будет работать для разных классов по-разному
  2. в 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

А в 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 — нет. Самое важное следствие: только по коду — синтаксически — проверить следование принципу Лисков невозможно, и нужно опираться на (часто) расплывчатую семантику.

Поэтому часто вместо принципа Лисков используются формальные правила на предусловия и постусловия из контрактного программирования:

  • предусловия в подклассе не могут быть усилены — подкласс не должен требовать большего, чем базовый класс
  • постусловия подкласса не могут быть ослаблены — подкласс не должен предоставлять (обещать) меньше, чем базовый класс
  • инварианты базового класса должны сохранятся и в классе потомке.

Например, в методе класса потомока, мы не можем добавить обязательный параметр, которого не было в базовом классе — потому что так мы усиливаем предусловия. Или мы не можем бросать исключений в переопределенном методе, т.к. нарушаем инварианты базового класса. И т.д.

Поэтому если прямо сейчас код удовлетворяет принципу подстановки, это не значит, что правки в коде не изменят этого. Важно не текущее поведение класса, а какие изменения класса подразумевает ответственность или семантика класса.

Код постоянно правится и изменяется.

В момент, когда разработчик приложения унаследовал Square от Rectangle — все было хорошо, классы удовлетворяли принципу подстановки. Допустим есть разработчик библиотечного класса 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 приводит к большим проблемам? Если переиспользование кода не сможет создать в дальнейшем зависимости между клиентским кодом и кодом классов, то тогда даже несмотря на нарушение принципа подстановки Лисков, такой код может не нести с собой больших проблем. Особенно это становится проблемой, если этот клиентский код — это код библиотеки, который нельзя поменять.

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть