Почему я должен предпочесть композицию наследству?

109

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

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

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

Class Shape
{
    string name;
  public:
    void getName();
    virtual void draw()=0;
}

Class Circle: public Shape
{
    void draw(/*draw circle*/);
}
MustafaM
источник
57
Нет, когда люди говорят , что предпочитают композиции они действительно означают предпочитают композиции, не никогда никогда никогда не использовать наследование . Весь ваш вопрос основан на ошибочной предпосылке. Используйте наследование, когда это уместно.
Билл Ящерица
2
Вы также можете захотеть взглянуть на programmers.stackexchange.com/questions/65179/...
vjones
2
Я согласен с Биллом, я вижу использование наследования распространенной практикой в ​​разработке GUI.
Prashant Cholachagudda
2
Вы хотите использовать композицию, потому что квадрат - это просто композиция из двух треугольников, на самом деле я думаю, что все формы, кроме эллипса, являются просто композициями из треугольников. Полиморфизм о договорных обязательствах и на 100% исключен из наследства. Когда треугольник получает кучу странностей, потому что кто-то хотел сделать его способным генерировать пирамиды, если вы унаследуете от треугольника, вы получите все это, даже если вы никогда не собираетесь генерировать 3d пирамиду из своего шестиугольника ,
Джимми Хоффа
2
@BilltheLizard Я думаю, что многие люди, которые говорят, что это действительно означает, никогда не используют наследство, но они ошибаются.
immibis

Ответы:

51

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

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

На самом деле, вы в основном ответили на свой вопрос, когда сказали "But I have a feeling that when people say prefer composition, they really mean prefer a combination of composition and interface implementation.". Общее поведение может быть обеспечено через интерфейсы и поведенческую композицию.

Что касается того, что лучше, то ответ несколько субъективен и в действительности сводится к тому, как вы хотите, чтобы ваша система работала, что имеет смысл как в контекстном, так и в архитектурном отношении, и насколько легко будет тестировать и поддерживать.

S.Robins
источник
На практике, как часто появляется «Полиморфизм через Интерфейс», и считается ли он нормальным (в отличие от использования выразительности языков). Держу пари, что полиморфизм через наследование был тщательно продуман, а не следствием языка (C ++), обнаруженного после его спецификации.
Самис
1
Кто бы назвал move () на планете и машине и посчитал бы это одинаковым ?! Вопрос в том, в каком контексте они могут двигаться? Если они оба являются 2D-объектами в простой 2D-игре, они могли бы унаследовать движение, если бы они были объектами в моделировании массивных данных, возможно, не имело бы особого смысла позволять им наследовать от одной и той же базы, и, как следствие, вы должны использовать интерфейс
NikkyD
2
@SamusArin Он показывает вверх везде , и считается совершенно нормальным в языках , которые поддерживают интерфейсы. Что вы имеете в виду, «использование выразительности языка»? Это то , для чего существуют интерфейсы .
Андрес Ф.
@AndresF. Я только что натолкнулся на «Полиморфизм через интерфейс» в примере, когда читал «Объектно-ориентированный анализ и проектирование с приложениями», который демонстрировал паттерн Observer. Затем я понял мой ответ.
Самис
@AndresF. Я думаю, что мое видение было немного ослеплено в связи с этим из-за того, как я использовал полиморфизм в моем последнем (первом) проекте. У меня было 5 типов записей, которые все получены из одной базы. В любом случае спасибо за просветление.
Самис
79

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

Полиморфизм - это то, что ведет себя по-разному. Таким образом, дженерики / шаблоны являются «полиморфной» функцией, поскольку они позволяют одному куску кода изменять свое поведение в зависимости от типов. Фактически, этот тип полиморфизма действительно лучше всего ведет себя и обычно упоминается как параметрический полиморфизм, потому что изменение определяется параметром.

Многие языки предоставляют форму полиморфизма, называемую «перегрузкой» или специальным полиморфизмом, когда несколько процедур с одним и тем же именем определяются специальным образом, и когда одна из них выбирается языком (возможно, наиболее конкретным). Это наименее хорошо управляемый вид полиморфизма, поскольку ничто не связывает поведение двух процедур, кроме разработанного соглашения.

Третий тип полиморфизма - это полиморфизм подтипа . Здесь процедура, определенная для данного типа, также может работать с целым семейством «подтипов» этого типа. Когда вы реализуете интерфейс или расширяете класс, вы, как правило, заявляете о своем намерении создать подтип. Истинные подтипы подчиняются принципу замещения ЛисковаЭто говорит о том, что если вы можете доказать что-то обо всех объектах в супертипе, вы можете доказать это обо всех экземплярах в подтипе. Однако жизнь становится опасной, поскольку в таких языках, как C ++ и Java, люди обычно имеют необоснованные и часто недокументированные предположения о классах, которые могут быть верны или не соответствовать их подклассам. То есть код написан так, как будто больше доказуемости, чем на самом деле, что создает целый ряд проблем, когда вы небрежно вводите подтипы.

Наследование на самом деле не зависит от полиморфизма. Учитывая некоторую вещь "T", которая имеет ссылку на себя, наследование происходит, когда вы создаете новую вещь "S" из "T", заменяя ссылку "T" на себя ссылкой на "S". Это определение намеренно расплывчато, поскольку наследование может происходить во многих ситуациях, но наиболее распространенным является создание подкласса объекта, который заменяет thisуказатель, вызываемый виртуальными функциями, thisуказателем на подтип.

Наследование является опасным, как и все очень мощные вещи, наследование может вызвать хаос. Например, предположим, что вы переопределяете метод при наследовании от некоторого класса: все хорошо, пока какой-то другой метод этого класса не примет метод, который вы наследуете, чтобы вести себя определенным образом, ведь именно так его разработал автор исходного класса. , Вы можете частично защититься от этого, объявив все методы, вызываемые другим из ваших методов, закрытыми или не виртуальными (финальными), если только они не предназначены для переопределения. Даже это не всегда достаточно хорошо. Иногда вы можете увидеть что-то вроде этого (в псевдо-Java, надеюсь, читабельно для пользователей C ++ и C #)

interface UsefulThingsInterface {
    void doThings();
    void doMoreThings();
}

...

class WayOfDoingUsefulThings implements UsefulThingsInterface{
     private foo stuff;
     public final int getStuff();
     void doThings(){
       //modifies stuff, such that ...
       ...
     }
     ...
     void doMoreThings(){
       //ignores stuff
       ...
     }
 }

вы думаете, что это прекрасно, и у вас есть свой собственный способ делать "вещи", но вы используете наследование, чтобы приобрести способность делать "больше вещей",

class MyUsefulThings extends WayOfDoingUsefulThings{
     void doThings {
        //my way
     }
}

И все хорошо. WayOfDoingUsefulThingsбыл разработан таким образом, что замена одного метода не меняет семантику любого другого ... за исключением ожидания, нет, это не так. Похоже, что это было, но doThingsизменилось изменчивое состояние, которое имело значение. Таким образом, даже при том, что он не вызывал никаких переопределяемых функций,

 void dealWithStuff(WayOfDoingUsefulThings bar){
     bar.doThings()
     use(bar.getStuff());
 }

теперь делает что-то другое, чем ожидалось, когда вы передаете это MyUsefulThings. Что еще хуже, вы можете даже не знать, что WayOfDoingUsefulThingsдали эти обещания. Может быть, dealWithStuffпоступает из той же библиотеки, что WayOfDoingUsefulThingsи getStuff()даже не экспортируется библиотекой (вспомните о классах друзей в C ++). Что еще хуже, вы победили статические проверки языка , не осознавая этого: dealWithStuffвзял WayOfDoingUsefulThingsтолько , чтобы убедиться , что она будет иметь getStuff()функцию , которая вела себя определенным образом.

Использование композиции

class MyUsefulThings implements UsefulThingsInterface{
     private way = new WayOfDoingUsefulThings()
     void doThings() {
        //my way
     }
     void doMoreThings() {
        this.way.doMoreThings();
     }
}

возвращает безопасность статического типа. В общем случае композиция проще в использовании и безопаснее наследования при реализации подтипов. Это также позволяет вам переопределить final методы, что означает, что вы можете свободно объявлять все final / non-virtual за исключением интерфейсов, которые подавляющее большинство времени.

В лучшем мире языки будут автоматически вставлять шаблон с delegationключевым словом. Большинство этого не делает, поэтому недостатком являются большие классы. Хотя вы можете заставить свою IDE написать делегирующий экземпляр для вас.

Теперь жизнь не только о полиморфизме. Вам не нужно постоянно вводить подтипы. Целью полиморфизма обычно является повторное использование кода, но это не единственный способ достижения этой цели. Часто имеет смысл использовать композицию без полиморфизма подтипа в качестве способа управления функциональностью.

Кроме того, поведенческое наследование имеет свое применение. Это одна из самых сильных идей в информатике. Просто в большинстве случаев хорошие ООП-приложения могут быть написаны с использованием только наследования интерфейса и композиций. Два принципа

  1. Запретить наследство или дизайн для него
  2. Предпочитаю состав

являются хорошим руководством по вышеуказанным причинам и не несут существенных затрат.

Филипп JF
источник
4
Хороший ответ. Я хотел бы резюмировать это, что попытка добиться повторного использования кода с наследованием - это неверный путь. Наследование является очень сильным ограничением (imho, добавление «power» - неправильная аналогия!) И создает сильную зависимость между унаследованными классами. Слишком много зависимостей = плохой код :) Таким образом, наследование (иначе «ведет себя как»), как правило, сияет для унифицированных интерфейсов (= скрывает сложность унаследованных классов), для всего остального думает дважды или использует композицию ...
MaR
2
Это хороший ответ. +1. По крайней мере, на мой взгляд, кажется, что дополнительное усложнение может быть очень дорогостоящим, и это сделало меня лично немного нерешительным из-за предпочтения композиции. Особенно, когда вы пытаетесь сделать много кода, удобного для модульного тестирования, через интерфейсы, композицию и DI (я знаю, что это добавляет другие вещи), кажется, что очень легко заставить кого-то просматривать несколько разных файлов для поиска очень несколько деталей. Почему это не упоминается чаще, даже когда речь идет только о композиции по принципу наследования?
Panzercrisis
27

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

Типичным примером является мир разработки игр. Предположим, у вас есть базовый класс для всех ваших игровых объектов - космического корабля игрока, монстров, пуль и т. Д .; каждый тип сущности имеет свой собственный подкласс. Подход наследования будет использовать несколько полиморфных методов, например update_controls(), update_physics(), draw()и т.д., и реализовать их для каждого подкласса. Однако это означает, что вы соединяете несвязанную функциональность: не имеет значения, как выглядит объект для его перемещения, и вам не нужно ничего знать о его ИИ, чтобы нарисовать его. Вместо этого композиционный подход определяет несколько базовых классов (или интерфейсов), например EntityBrain(подклассы реализуют ввод AI или игрока), EntityPhysics(подклассы реализуют физику движения) и EntityPainter(подклассы заботятся о рисовании) и неполиморфный классEntityкоторый содержит один экземпляр каждого. Таким образом, вы можете комбинировать любой внешний вид с любой физической моделью и любым ИИ, и, поскольку вы держите их отдельно, ваш код также будет намного чище. Кроме того, проблемы, такие как «Я хочу монстра, который похож на монстра-шарика на уровне 1, но ведет себя как сумасшедший клоун на уровне 15» исчезают: вы просто берете подходящие компоненты и склеиваете их вместе.

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

«Разделение интересов» - это ключевая фраза: представление физики, реализация ИИ и рисование объекта - это три задачи, объединение их в объект - это четвертое. При композиционном подходе каждая проблема моделируется как один класс.

tdammers
источник
1
Это все хорошо, и это подтверждается в статье, связанной с оригинальным вопросом. Я был бы рад (когда бы у вас не было времени), если бы вы могли привести простой пример, включающий все эти сущности, и в то же время поддерживать полиморфизм (в C ++). Или укажите мне на ресурс. Благодарю.
MustafaM
Я знаю, что ваш ответ очень старый, но я сделал то же самое, что вы упомянули в моей игре, и теперь собираюсь использовать композицию, а не наследование.
Сн
«Я хочу монстра, который выглядит как ...», как это имеет смысл? Интерфейс не дает никакой реализации, вам придется копировать внешний вид и код поведения так или иначе
NikkyD
1
@NikkyD Берешь MonsterVisuals balloonVisualsи MonsterBehaviour crazyClownBehaviourи экземпляр Monster(balloonVisuals, crazyClownBehaviour), наряду с Monster(balloonVisuals, balloonBehaviour)и Monster(crazyClownVisuals, crazyClownBehaviour)экземплярами, экземпляры которых были на уровне 1 и 15 уровня
Caleth
13

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

Делегирование является одним из примеров использования композиции вместо наследования. Делегирование позволяет вам изменять поведение класса без создания подклассов. Рассмотрим класс, который обеспечивает сетевое соединение, NetStream. Подкласс NetStream может быть естественным для реализации общего сетевого протокола, поэтому вы можете использовать FTPStream и HTTPStream. Но вместо создания очень специфического подкласса HTTPStream для одной цели, скажем, UpdateMyWebServiceHTTPStream, часто лучше использовать простой старый экземпляр HTTPStream вместе с делегатом, который знает, что делать с данными, которые он получает от этого объекта. Одна из причин, почему это лучше, - это то, что он избегает распространения классов, которые необходимо поддерживать, но которые вы никогда не сможете использовать повторно.

Калеб
источник
11

Вы много увидите этот цикл в дискурсе разработки программного обеспечения:

  1. Обнаружено, что некоторая функция или шаблон (назовем его «Шаблон X») полезна для какой-то конкретной цели. Сообщения в блоге написаны, превознося достоинства Pattern X.

  2. Шумиха заставляет некоторых людей думать, что вы должны использовать Pattern X, когда это возможно .

  3. Другие люди раздражаются, увидев, что Pattern X используется в тех случаях, когда он не подходит, и они пишут в блоге сообщения о том, что вы не всегда должны использовать Pattern X и что это вредно в некоторых случаях.

  4. Обратная реакция заставляет некоторых людей полагать, что Шаблон X всегда вреден и никогда не должен использоваться.

Вы увидите, что этот цикл шумихи / обратной реакции происходит практически с любой функцией, от GOTOшаблонов до SQL, NoSQL и, да, наследования. Противоядием является всегда учитывать контекст .

После Circleспускаются от Shapeэто точно , как предполагается наследование для использования в объектно- ориентированных языках , поддерживающих наследование.

Практическое правило «предпочесть композицию наследованию» действительно вводит в заблуждение без контекста. Вы должны предпочитать наследование, когда наследование более уместно, но предпочитать композицию, когда композиция более уместна. Предложение предназначено для людей на стадии 2 в цикле рекламы, которые думают, что наследование должно использоваться везде. Но цикл продолжился, и сегодня кажется, что некоторые люди думают, что наследование как-то плохо само по себе.

Думайте об этом как молоток против отвертки. Стоит ли предпочитать отвертку молотку? Вопрос не имеет смысла. Вы должны использовать инструмент, подходящий для работы, и все зависит от того, какую задачу вам нужно выполнить.

JacquesB
источник