Наследование реализаций: закопайте стюардессу

Ключевое противоречие ООП

Как известно, классическое ООП покоится на трех китах


  1. Инкапсуляция
  2. Наследование
  3. Полиморфизм

Классическая же реализация по умолчанию:


  1. Инкапсуляция — публичные и приватные члены класса
  2. Наследование — реализация функционала за счет расширения одного класса-предка, защищенные члены класса.
  3. Полиморфизм — виртуальные методы класса-предка.

Но еще в 1986 году была обозначена серьезнейшая проблема, кратко формулируемая так:


Наследование ломает инкапсуляцию



  1. Классу-потомку доступны защищенные члены класса-предка. Всем остальным доступен только публичный интерфейс класса. Предельный случай взлома — антипаттерн Паблик Морозов
  2. Реально изменить поведение предка можно только с помощью перекрытия виртуальных методов.
  3. Принцип подстановки Лисков обязывает класс-потомок удовлетворять всем требованиям к классу-предку.
  4. Для выполнения пункта 2 в точном соответствии с пунктом 3 классу-потомку необходима полная информация о времени вызова и реализации перекрытого виртуального метода
  5. Информация из пункта 4 зависит от реализации класса-предка, включая приватные члены и их код.

В теории мы уже имеем былинный отказ, но как насчет практики?


  1. Зависимость, создаваемая наследованием, чрезвычайно сильна.
  2. Наследники гиперчувствительны к любым изменениям предка.
  3. Наследование от чужого кода добавляет адскую боль при сопровождении:
    разработчики библиотеки рискуют получить обструкцию из-за поломанной обратной совместимости при малейшем изменении базового класса, а прикладники — регрессию при любом обновлении используемых библиотек.

Все, кто используют фреймворки, требующие наследования от своих классов (WinForms, WPF, WebForms, ASP.NET), легко найдут подтверждения всем трем пунктам в своем опыте.
Неужели все так плохо?


Теоретическое решение

Влияние проблемы можно ослабить принятием некоторых конвенций


  1. Защищенные члены не нужны.
    Это соглашение ликвидирует пабликов морозовых как класс.
  2. Виртуальные методы предка ничего не делают.
    Это соглашение позволяет сочетать знание о реализации предка с независимостью от нее реализации уже в потомке.
  3. Виртуальные методы предка никогда не вызываются в его коде.
    Это соглашение позволяет потомкам не зависеть от внутренней реализации предка, а также требует публичности всех виртуальных методов.
  4. Экземпляры предка никогда не создаются
    Это соглашение позволяет избавиться от несоответствия требований к виртуальными методам (публичный контракт класса) с одной стороны и обязанностью ничего не делать (защищенный контракт класса) с другой. Теперь принцип подстановки Лисков можно соблюсти, не вступая в порочную связь с закрытым содержимым предка.
  5. Невиртуальных членов у предка нет
    С учетом предыдущих соглашений невиртуальные члены предка становятся бесполезными и подлежат ликвидации.

Результат: если класс-предок состоит из публичных виртуальных пустых методов и требований к ним для потомков, то наследование уже не ломает инкапсуляцию. Что и требовалось доказать.
Попутно получаем возможность решение проблемы ромба для случая множественного наследования от конвенционных предков.
Но это все теория, а нам нужны…


Практические решения
  1. Виртуальные методы-пустышки уже есть во многих языках и носят гордое звание абстрактных.
  2. Классы, экземпляры которых создавать нельзя, тоже есть во многих языках и даже имеют то же звание.
  3. Полное соблюдение указанных соглашений в языке C++ использовалось как паттерн для проектирования и реализации Component Object Model.
  4. Ну и самое приятное: в C# и многих других языках соглашения реализованы как первоклассный элемент «интерфейс».
    Происхождение названия очевидно — в результате соблюдения соглашений от класса остается только его публичный интерфейс. И если множественное наследование от обычных классов — редкость, то от интерфейсов оно доступно без всяких ограничений.

Итоги
  1. Языки, где нет наследования от классов, но есть — от интерфейсов (например, Go), нельзя лишать звания объектно-ориентированных. Более того, такая реализация ООП правильнее теоретически и безопаснее практически.
  2. Наследование от обычных классов (имеющих реализацию) — чрезвычайно специфический и крайне опасный архаизм.
  3. Избегайте наследования реализаций без крайней необходимости.
  4. Используйте модификатор sealed (для .NET) или его аналог для всех классов, кроме специально спроектированных для наследования реализации.
  5. Избегайте публичных незапечатанных классов: пока наследование не выходит за рамки своих сборок, из него еще можно извлечь пользу и ограничить вред.

PS: Дополнения и критика традиционно приветствуются.

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

  • 19 сентября 2016 в 05:00

    0

    Теперь понятно почему по умолчанию все классы в Kotlin sealed, а методы final.
    • 19 сентября 2016 в 06:42

      0

      А это общее правило параноика для любого ЯП (хотя больше всего его любят в C++): если вы до конца не определились как вам оформить класс, то делайте его приватным, делайте его конструктор приватным, делайте все методы приватными и невиртуальными, и запрещайте наследование. Чтобы изменить любой из этих пунктов нужна вполне конкретная причина.

      • 19 сентября 2016 в 07:44

        0

        В теории это хорошо, но писать sealed/final быстро надоедает, даже с автокомплитом. Та же ситуация что и с переменными и типами, поведение по умолчанию (mutable, nullable) слишком часто надо переопределять.
  • 19 сентября 2016 в 05:38

    0

    Наследники гиперчувствительны к любым изменениям предка.

    И что же теперь, ограничиваться одним уровнем наследования от абстрактного класса?
    Наследование от обычных классов (имеющих реализацию) — чрезвычайно специфический и крайне опасный архаизм

    Бывают случаи, когда базовый класс сам по себе самостоятелен и реализует заложенный функционал. Но множество частных случаев требуют переопределения всего лишь одного-двух методов. Внимание, вопрос: с точки зрения автора статьи, как обходиться в таких ситуациях?
    • 19 сентября 2016 в 06:55

      0

      Бывают случаи, когда базовый класс сам по себе самостоятелен и реализует заложенный функционал. Но множество частных случаев требуют переопределения всего лишь одного-двух методов. Внимание, вопрос: с точки зрения автора статьи, как обходиться в таких ситуациях?

      Не автор, но прокомментирую :-)


      Часто забывают, что ООП — это от слова «объект», а не от слова «класс». Задача программиста — сделать так, чтобы получались объекты, обладающие требуемым поведением. В среднестатистическом мейнстримном языке типа C#/Java есть 2 подхода:


      1. Если мне нужно новое поведение, я наследую существующий класс и переопределяю какой-то аспект его поведения.
      2. Если мне нужно новое поведение, я немного иначе «строю» объект, поведение которого мне нужно изменить.

      Если весь код написан таким образом, что единственный способ сконструировать объект это new ЧтоТоТам(), конечно тут кроме наследования ЧтоТоТам нет вариантов. Но можно же изначально заложиться на new ЧтоТоТам(new КакЯДелаюВотЭто(), new ИЕщёКакЯДелаюВотЭто()). В таком случае получается на порядок больше мелких классов, где одни классы описывают какой-то конкретный аспект поведения, а другие — просто скручивают несколько таких аспектов поведения в один «настроенный объект». Статья автора, если я правильно понял, про такой подход.

      • 19 сентября 2016 в 07:09

        –1

        1) Это называется АОП — Аспектно-ориентированное программирование. И оно — не панацея, так как не позволяет безболезненно связать между собой разные аспекты.
        2) Это верно только для простых алгоритмов, которые будут вызываться изнутри кода. Вроде SortedVector (SortingAlgorithm); SortedVector (new Bubble ()); SortedVector (new QSort ());
        Если это более сложный класс, который, тем более, должен иметь доступ к приватным полям объекта, ваш подход ещё хуже, так как требует объявить ваши КакЯДелаюВотЭто и ИЕщёКакЯДелаюВотЭто друзьями класса ЧтоТоТам. Далеко не все ЯП позволяют потомкам «друзей» оставаться «друзьями».
        • 19 сентября 2016 в 07:34

          0

          Это называется АОП

          Это ни в коем случае не АОП. Это самое обычное ООП + делегирование.


          Это верно только для простых алгоритмов… Если это более сложный класс, который, тем более, должен иметь доступ к приватным полям объекта, ваш подход ещё хуже

          Посмотрите примеры использования паттерна «Стратегия» — это собственно делегирование в чистом виде и есть.

          • 19 сентября 2016 в 07:41

            0

            Это ни в коем случае не АОП

            Думаете? Подумайте лучше.
          • 19 сентября 2016 в 08:14

            0

            Тогда считайте, что каждый виртуальный метод — это такая запись делегирования, не загромождающая код. Ведь функция — это тоже объект.
      • 19 сентября 2016 в 07:36

        0

        Про последний абзац, в том числе эту идею развивает Егор Бугаенко (yegor256.com), если не слышали о нём рекомендую ознакомится с 105 выпуском подкаста «Разбор полетов».
      • 19 сентября 2016 в 07:38

        0

        Тогда для более-менее серьезного переопределения поведения понадобится создавать сразу несколько аспектов, которые надо еще и как-то инкапсулировать в один «набор аспектов, реализующих вот такое вот поведение». При этом каждый аспект сам по себе будет являться наследником виртуального класса. В конечном итоге мы приходим к куда более сложной и многословной реализации той же самой мысли.
  • 19 сентября 2016 в 06:05

    0

    Для выполнения пункта 2 в точном соответствии с пунктом 3 классу-потомку необходима полная информация о времени вызова и реализации перекрытого виртуального метода

    Достаточно иметь контракт переопределяемого метода. А вызываться он может как угодно в соответствии с контрактом.
    Вам же не требуется для реализации метода интерфейса знание где и как он будет вызываться.

    Например реализовываете вы собственный AbstractSpliterator. Вы можете реализовать только 1 метод в соответствии с контрактом и вообще не разбираться как оно там работает внутри (а работает оно хитро). Можете реализовать 2 или даже 4, если хотите улучшить производительность. Но даже в этом случае знать вам надо только контракты методов, а не то как они используются.

    • 19 сентября 2016 в 06:34

      0

      Точно, сейчас я считаю что большинство выстрелов по ногам происходит из-за нарушения контракта методов, в том числе при переопределении, или их отсутствия.
  • 19 сентября 2016 в 06:18

    0

    Наследование от обычных классов (имеющих реализацию) — чрезвычайно специфический и крайне опасный архаизм.

    Когда вдруг наследование стало архаизмом?

    Есть несколько вариантов как переиспользовать код, и наследование один из них. Просто использовать нужно с умом.

    Наследование от чужого кода добавляет адскую боль при сопровождении

    Это надуманная проблема. Если вы взяли библиотеку и она решает ваши задачи, зачем ее обновлять?
    Да и в любом случае каждая зависимость от сторонних библиотек добавляет риск, и по идее, вы должны понимать этот риск.
    Сейчас какая-то странная тенденция, все хотят обновить все до самой новой версии, но помоему даже незнают зачем им это.
  • 19 сентября 2016 в 06:36

    0

    Автор, попробуйте пожалуйста разобрать конкретный пример — как сделать редизайн какого-нибудь UI фреймворка, чтобы избежать всепроникающего базового класса Control. Я так понимаю, у вас есть опыт с .NET — предложите вариант принципиальных изменений для Windows Forms или WPF, чтобы там не было иерархий типа https://msdn.microsoft.com/en-us/library/system.windows.controls.button (v=vs.110).aspx:


    System.Object
      System.Windows.Threading.DispatcherObject
        System.Windows.DependencyObject
          System.Windows.Media.Visual
            System.Windows.UIElement
              System.Windows.FrameworkElement
                System.Windows.Controls.Control
                  System.Windows.Controls.ContentControl
                    System.Windows.Controls.Primitives.ButtonBase
                      System.Windows.Controls.Button
  • 19 сентября 2016 в 06:42

    –1

    С одной стороны, это действительно теоретическая проблема, выливающаяся в ряд практических проблем, вроде бинарной совместимости модулей и тп.
    С другой стороны, это одно из лучших решений с учётом накладных расходов. Дело не только в уменьшении переписываемого кода, именно наследование позволяет делать как максимально гибкий, так и максимально быстрый в расширении код с наиболее понятными требованиями к программисту-пользователю.
    Отказываться от наследования — всё равно что отказываться от промышленного оборудования из-за того, что долбарабочий может при нарушении ТБ покалечить себя до смерти — высший уровень тупизны. Да, каждый язык имеет некоторые антипаттерны и некоторые спорные технологии. Да что там, даже упомянутый Go не обошёлся без критики. Проблема не в том, что они есть и сколько их имеется, а в том, обучены ли программисты работе с этими технологиями. Чаще всего нет, и, что хуже всего, программисты крайне редко несут ответственность за собственный непрофессионализм*. Худшее, что с ними может произойти — переход в другую фирму после разорения предыдущей.

    *Нет, это не призыв калечить людей.

© Habrahabr.ru