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

в 8:52, , рубрики: liskov, liskov substitution principle, solid, ооп, Программирование

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

Обычно для иллюстрации этой проблемы используют пример про наследование класса Квадрат от класса Прямоугольника, или наоборот.

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

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 {"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

Источник


* - обязательные к заполнению поля