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

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

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

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

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

Комментарии (16)


  1. EvgeniiR
    16.08.2019 12:16
    +2

    Наследование — один из столпов ООП.

    У ООП нет чёткого определения, поэтому я исключительно против таких заявлений. Пока людям навязывают цитируемую выше идею, мы так и будем видеть 5-уровневые иерархии наследования минимально похожих классов и наследование с целью «переиспользования кода»(даже без цели использования полиморфизма подтипов").

    Для переиспользования кода нет никаких аргументов в пользу наследования, кроме того что из-за хайпа и моды на Java/C++ стиль «ООП» новички не интересуются альтернативами и упускают из вида объективные ценности и концепты при проектировании классов. Хотя бы те же Interface Segregation Principle(и, конечно, LSP), и стоящие за ними концепты coupling/cohesion и влияние всего этого на вероятность каскадных изменений и читаемость кода.


  1. ghost404
    16.08.2019 12:30
    +1

    Лучше выделить более общий подтип для обеих фигур.


    Несмотря на кажущееся сходство квадрата и прямоугольника, они разные. Квадрат имеет много общего с ромбом, а прямоугольник с параллелограммом, но они не являются подтипом. Квадрат, прямоугольник, ромб и параллелограмм — это отдельные фигуры со своими собственными свойствами, хотя и схожими.


    Подробнее здесь.


    1. zloy_stas Автор
      16.08.2019 12:55
      +2

      В том и дело, что нельзя строить иерархии наследования классов основываясь на свойствах объектов предметной области (похожи они друг на друга или нет). Все зависит от конкретного поведения класса, а не той абстракцией, которую он пытается моделировать.

      Это на самом деле частая проблема обучающих статей про ООП. Когда пытаются рассказывать, что раз Coffe (кофе) — это подтип Drink (Напитка), то мы должны или не должны наследовать одно от другого. Но в жизни никто НЕ моделирует реальность as-is. И это классическая и грубая ошибка новичка. Если ты делаешь класс для расчета нагрузки на балки, чаще всего тебе не надо моделировать саму балку, а просто запрограммировать функцию расчета в зависимости входных параметров.


      1. ghost404
        16.08.2019 13:31

        Если говорить о предметной области, то это DDD и класс обязан быть построен на свойствах объекта предметной области. И если предметная область говорит нам о том, что есть Напиток и Кофе, и наша предметная область в конкретном ограниченном контексте говорит, что Кофе это частный случай Напитка, то в коде мы обязаны отразить это наследование.


        1. zloy_stas Автор
          16.08.2019 13:42

          Вообще говоря то, что код не удовлетворяет DDD НЕ является реальной проблемой. Реальной проблемой будет — если его будет тяжело читать, поддерживать, рефакторить и т.д и т.п.

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


          1. ghost404
            16.08.2019 13:47

            Безусловно, DDD не единственный способ разработки и не панацея во многих кейсах, но он облегчает разработку, чтение, сопровождение и рефакторинг кода.


            1. Kanut
              16.08.2019 17:36

              Я бы всё таки добавил перед словом «облегчает» слово «часто» или даже слово «иногда». :)


              1. ghost404
                16.08.2019 19:30

                А я бы не добавлял)))


        1. VolCh
          17.08.2019 03:32

          1) отразить факт частного случая может и обязан, но не обязан делать это через наследование. Это может быть просто невозможно сделать технически, например из-за запрета на множественные наследование.
          2) Любая модель предметной области в принципе не обязана отражать все свойства предметной области. Более того, хорошая модель обычно обязана этого не делать, она обязана отражать только важные для контекста свойства, а не все. Например, в программе черчения может быть просто не важно, что некоторые прямоугольники являются каадратами. Или это важно исключительно в момент создания, а инварианта длина равна ширине просто нет, достаточно отдельной фабрики для класса прямоугольник.


          1. ghost404
            18.08.2019 13:33

            Я об этом и говорил. Мы не создаём сущность одну на весь проект. В каждом контексте свои ограничения. Но если мы не отразим в коде ограничения конкретного контекста, то получим сущность нарушающую инварианты.


      1. surly
        16.08.2019 15:46

        нельзя строить иерархии наследования классов основываясь на свойствах объектов предметной области (похожи они друг на друга или нет). Все зависит от конкретного поведения класса, а не той абстракцией, которую он пытается моделировать

        Правильно! Поэтому, как рекомендовали в умной книге (название, конечно, не помню) — не квадрат наследуем от прямоугольника, а наоборот, прямоугольник от квадрата.

        Согласно этой рекомендации, надо забыть, что по математическому определению квадрат является подвидом прямоугольника. А обратить внимание на то, что прямоугольник «во многом ведёт себя как квадрат, только помимо ширины имеет ещё длину, а площадь у него вычисляется вот так: ...»


        1. zloy_stas Автор
          16.08.2019 16:16

          Все так! Попытки проектирования от предметной области, а не от проблемы, которую мы решаем — приводят к таким вот рассуждениям.


          1. VolCh
            17.08.2019 03:37

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


        1. playermet
          16.08.2019 16:31
          +2

          не квадрат наследуем от прямоугольника, а наоборот, прямоугольник от квадрата
          И получаем такое же нарушение принципа подстановки Барбары Лисков, как и в первом способе. Потому что передача объекта Rectangle вместо Square может дать другой результат работы. Квадрат и прямоугольник нельзя наследовать друг от друга, не нарушая LSP.


          1. VolCh
            17.08.2019 03:42

            Иногда можно, иногда нельзя. Важно нарушаем мы контракт базового класса или нет.Если в квадрате мы, например, не обещаем клиенту, что при увеличении ширины в два раза площадь увеличится в четыре, то мы можем в наследнике квадрата увеличить её только в два раза и не нарушить LSP.


  1. telhin
    16.08.2019 14:23

    Справедливости ради, есть некоторые правила, которые с большой вероятностью не дадут нарушить принцип подстановки. Можно себя максимально обезопасить, если запретить все опасные конструкции. Например, для C++ об этом написано у Олега. Но в целом такие правила превращает классы не в совсем классы в классическом понимании.

    То что написано в блоге Олега очень похоже на некоторый вариант анемичной модели.
    Хотя это вроде как почти прямая трансляция Haskell -> C++