Композиция vs наследование

Как и всем разработчикам, мне часто приходилось читать и слышать утверждение, что «композиция всегда лучше наследования». Наверное, даже слишком часто. Однако я не склонен принимать что-либо на веру, поэтому давайте разберёмся, так ли это.


Итак, какие же преимущества есть у композиции перед наследованием?

1. Нет конфликта имён, возможного при наследовании.
2. Возможность смены агрегируемого объекта в runtime.
3. Полная замена агрегируемого объекта в классах, производных от класса, включающего агрегируемый объект.

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

Если рассматривать, например, C#, не поддерживающий множественное наследование, но позволяющий наследовать от множества интерфейсов и создавать методы расширения для этих интерфейсов, то можно выделить ещё два плюса (речь в данном случае может идти только о поведениях (алгоритмах) в рамках паттерна «Стратегия»):
4. Агрегируемое поведение (алгоритм) может включать в себя другие объекты. Что в частности позволяет переиспользовать посредством агрегации другое поведение.
5. При агрегации есть возможность скрыть определённую часть реализации, а также исходные параметры, необходимые поведению, посредством передачи их через конструктор (при наследовании поведению придётся запрашивать их через методы/свойства собственного интерфейса).

Но как же минусы? Неужели их нет?

1. Итак, если нам необходима возможность смены поведения извне, то композиция, по сравнению с наследованием, имеет принципиально другой тип отношений между объектом поведения и объектом, его использующим. Если при наследовании от абстрактного поведения мы имеем отношение 1:1, то при агрегации и возможности установки поведения извне мы получаем отношение 1:many. Т.е. один и тот же объект поведения может использоваться несколькими объектами-владельцами. Это порождает проблемы с общим для нескольких таких объектов-владельцев состоянием поведения.

Разрешить эту ситуацию можно, запретив установку поведения извне или доверив его, например, generic-методу:
void SetBehavior<TBehavior>()
запретив тем самым создание поведения кем-либо, кроме объекта-владельца. Однако мы не можем запретить использовать поведение «где-то ещё». В языках без сборщика мусора (GC) это порождает понятные проблемы. Конечно, в таких языках можно неправомерно обратиться по ссылке и на сам объект-владелец, но, раздавая отделённые объекты поведения направо и налево, мы получаем в разы больше шансов получить exception.

2. Агрегация (и это, пожалуй, главный нюанс) отличается от наследования в первую очередь тем, что агрегируемый объект не является объектом-владельцем и не содержит информации о нём. Нередки ситуации, когда коду, взаимодействующему с поведением, необходим и сам объект-владелец (например, для получения информации о том, какими ещё поведениями он обладает).

В таком случае, нам придётся или передавать в такой код нетипизированный объект (как object или void*), или создавать дополнительный интерфейс для объекта-владельца (некий IBehaviorOwner), или хранить в поведении циклическую ссылку на объект-владелец. Понятно, что каждый из этих вариантов имеет свои минусы и ещё больше усложняет код. Более того, различные типы поведений могут зависеть друг от друга (и в это вполне допустимо, особенно если они находятся в некоем закрытом самодостаточном модуле).

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

Получается, что утверждение «композиция всегда лучше наследования» в ряде случаев спорно и не должно являться догмой. Особенно это касается языков, позволяющих множественное наследование и не имеющих GC. Если в какой-либо ситуации перечисленные выше плюсы не важны, и заранее известно, что при работе с определёнными типами у вас не будет возможности их использовать, стоит всё-таки рассмотреть вариант наследования.
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 12

    +4
    Я не понимаю вывод — Вы сказали истину, которую знают все — нельзя решить все задачи с помощью одного и того же метода, будь то «программирование всего только с помощью С++, потому что он классный» и т.д.

    Конечно есть моменты, когда чётко предельно видно, что нужно использовать наследование/композицию, а во всех остальных случаях советуют экспериментировать, использовать и наследование, и композицию вместе.
      0
      К сожалению, эту истину знают не все и часто слепо следуют каким-то правилам и паттернам. Я просто постарался показать, что религия в разработке — не есть хорошо, и сформулировать плюсы и минусы.
      +2
      Статья помещена в хаб C++, поэтому нужно внести уточнения.

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

      В C++ это не так, потому что при композиции в объекте хранятся сами подобъекты, а не указатели на них (как это происходит в Java в общем случае). Если подобъект мал, то его имеет смысл хранить не через указатель, а напрямую. Если хочется при этом сохранять бинарную совместимость между версиями кода, то можно в объекте-хозяине хранить только указатель, а все поля запихнуть в структуру, доступную через этот указатель в коде реализации (pointer to implementation). Часто это более мудрый путь, чем хранить каждое из полей через указатель.

      Возможность смены агрегируемого объекта в runtime.

      Чтобы воспользоваться данным преимуществом в C++, нужно хранить в объекте указатели на подобъекты или использовать подобъекты одного типа.
        0
        при композиции в объекте хранятся сами подобъекты, а не указатели на них

        может, наоборот — обычно, в объекте хранятся указатели на подобъекты, а не сами подобъекты, но если объкт мал, то его имеет смысл хранить не через указатель, а напрямую.
          0
          В Java хранить подобъект напрямую (не через указатель) нельзя, насколько мне известно. А в C++ поля хранятся не через указатель, поэтому вариант, когда подобъект хранится через указатель, можно рассматривать как поле, содержащее указатель. Какой из способов применяется чаще (подобъект как поле, подобъект через указатель, pimpl (все подобъекты через 1 указатель)), зависит от конкретного случая.
          0
          Если подобъект мал, то его имеет смысл хранить не через указатель, а напрямую.


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


          Спорный момент. Обычно такая ситуация говорит о том, что что-то прогнило в Датском королевстве.
            0
            Сорри, не туда, а удалить не дает
              0
              Возможно спорный, да. Но с другой стороны, почему не может быть ф-ции, которая выполняет какую-либо сложную операцию, основываясь на нескольких поведениях объекта, и при этом, какое-то одно из этих поведений является единственно необходимым, а другие являются вспомогательными и служат для какой-то коррекции результата. При наследовании мы можем «отфильтровать» неподходящие объекты на этапе компиляции. Более того, мы можем создать некий условный пустой класс, наследующий сразу от нескольких поведений и «фильтровать» таким образом сразу по нескольким поведениям, обеспечив гарантию того, что в ф-цию будут приходить объекты, обладающие всеми необходимыми свойствами. При композиции для этого всё равно придётся прибегать к наследованию от дополнительных интерфейсов…
              +5
              Опыт показывает, что если возникают такие сложности с многообразием поведения, хранением сложного состояния и т.д., то у приложения запутанная архитектура. Иногда стремление к излишней гибкости приводит к чрезмерному усложнению и с этим надо бороться в первую очередь, а не пытаться лечить симптомы эмуляцией множественного наследования или ссылками на объект-владелец.

              Как правило, когда удается придумать удачную архитектуру, потом не возникает подобных проблем ни с наследованием, ни с композицией. KISS!
                0
                Я написал выше (в ответе Dair_Targ'у) пример ситуации. К какой архитектуре можно прибегнуть в данном случае? Хотя, если Вы говорите о C# или Java, то там композиция конечно намного эффективнее. Но, имхо, в ряде случае, она там — не пример удачной архитектуры, а «костыль» в отсутствие множественного наследования. Но даже в этих языках, если ни один их обозначенных плюсов не используется, то (опять же, имхо) наследование предпочтительнее. Оно только упросит код.
                  0
                  Наследование от аггрегации отличается необходимостью менять интерфейс, а не только реализацию, при изменении базового класса. И похоже на композицию тем, что требует умения создавать экземпляр базового класса.

                  Только, чтобы это всё понять, необходимо «перевернуть» «содержащий» класс и «содержащийся» — когда речь идёт о наследовании, все программисты путают направленность отношения. Дочерний класс является «содержащим», а родительский — «содержащимся» в дочернем. Это кажется непривычным, но ставит мозги на место, а понятия — по полочкам. Я всю ночь разбирался вот, чтоб понять отличия ассоциации, отношения, аггрегации, композиции и наследования. Заодно UML наконец понял, что там за стрелки странные :-)

                  Only users with full accounts can post comments. Log in, please.