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

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

36 ответов

Предпочитайте композицию наследованию, так как она более гибкая / ее легко изменить позже, но не используйте подход, всегда составляемый. С составом, легко изменить поведение на лету с Инъекцией / Установкой Зависимости. Наследование более жесткое, так как большинство языков не позволяют вам наследовать от более чем одного типа. Таким образом, гусь более или менее готовится, как только вы получаете от TypeA.

Мой кислотный тест для вышеупомянутого:

  • Желает ли TypeB предоставить полный интерфейс (не все общедоступные методы) TypeA так, чтобы TypeB можно было использовать там, где ожидается TypeA? Указывает на наследование.

Например, биплан Cessna покажет полный интерфейс самолета, если не больше. Так что это делает его пригодным для получения из самолета.

  • TypeB хочет только часть / часть поведения, раскрываемого TypeA? Указывает на необходимость в композиции.

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

Обновление: только что вернулся к моему ответу, и теперь кажется, что он неполон без конкретного упоминания принципа подстановки Лискова Барбары в качестве теста для "Должен ли я наследовать от этого типа?"

Думайте о сдерживании как об отношениях. У автомобиля "есть" двигатель, у человека "есть" имя и т. Д.

Думайте о наследовании как об отношениях. Автомобиль "- это" транспортное средство, человек "- это" млекопитающее "и т. Д.

Я не принимаю кредит на этот подход. Я взял это прямо из Второго издания Code Complete от Стива Макконнелла, раздел 6.3.

Если вы понимаете разницу, это легче объяснить.

Процессуальный кодекс

Примером этого является PHP без использования классов (особенно до PHP5). Вся логика закодирована в наборе функций. Вы можете включать другие файлы, содержащие вспомогательные функции и т. Д., И управлять своей бизнес-логикой, передавая данные в функции. Это может быть очень сложно управлять по мере роста приложения. PHP5 пытается исправить это, предлагая более объектно-ориентированный дизайн.

наследование

Это поощряет использование классов. Наследование является одним из трех принципов проектирования ОО (наследование, полиморфизм, инкапсуляция).

class Person {
   String Title;
   String Name;
   Int Age
}

class Employee : Person {
   Int Salary;
   String Title;
}

Это наследство на работе. Сотрудник "является" лицом или наследует от лица. Все наследственные отношения являются отношениями "есть". Employee также скрывает свойство Title от Person, то есть Employee.Title будет возвращать Title для Employee, а не Person.

Состав

Композиция предпочтительнее наследования. Проще говоря, у вас будет:

class Person {
   String Title;
   String Name;
   Int Age;

   public Person(String title, String name, String age) {
      this.Title = title;
      this.Name = name;
      this.Age = age;
   }

}

class Employee {
   Int Salary;
   private Person person;

   public Employee(Person p, Int salary) {
       this.person = p;
       this.Salary = salary;
   }
}

Person johnny = new Person ("Mr.", "John", 25);
Employee john = new Employee (johnny, 50000);

Композиция обычно имеет "имеет" или "использует" отношения. Здесь у класса Employee есть Person. Он не наследуется от Person, но вместо этого получает объект Person, передаваемый ему, поэтому у него "есть" Person.

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

Теперь скажите, что вы хотите создать тип диспетчера, чтобы в итоге получить:

class Manager : Person, Employee {
   ...
}

Этот пример будет работать нормально, однако, что, если Person и Employee оба объявили Title? Должен ли Manager.Title вернуть "Менеджер операций" или "Мистер"? По составу эта двусмысленность лучше решается:

Class Manager {
   public string Title;
   public Manager(Person p, Employee e)
   {
      this.Title = e.Title;
   }
}

Объект "Менеджер" состоит из сотрудника и сотрудника. Название поведения взято от сотрудника. Эта явная композиция устраняет неоднозначность среди других вещей, и вы столкнетесь с меньшим количеством ошибок.

Со всеми неоспоримыми преимуществами наследования, вот некоторые из его недостатков.

Недостатки наследования:

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

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

Другая, очень прагматичная причина, чтобы предпочесть композицию над наследованием, связана с вашей моделью предметной области и отображением ее в реляционную базу данных. Действительно сложно сопоставить наследование с моделью SQL (в итоге вы получаете все виды хакерских обходных путей, таких как создание столбцов, которые не всегда используются, использование представлений и т. Д.). Некоторые ORML пытаются справиться с этим, но это всегда быстро усложняется. Композиция может быть легко смоделирована с помощью отношения внешнего ключа между двумя таблицами, но наследование намного сложнее.

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

Вы должны отдавать предпочтение картофелю, а не кока-коле, когда хотите есть, и кока-коле, если хотите пить.

Создание подкласса должно означать больше, чем просто удобный способ вызова методов суперкласса. Вы должны использовать наследование, когда подкласс "is-a" является суперклассом как структурно, так и функционально, когда он может использоваться как суперкласс, и вы собираетесь его использовать. Если это не так - это не наследство, а что-то еще. Композиция - это когда ваши объекты состоят из других или имеют какое-то отношение к ним.

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

Не нашел удовлетворительного ответа здесь, поэтому я написал новый.

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

Есть два преимущества наследования: подтип и подклассы

  1. Подтипирование означает соответствие сигнатуре типа (интерфейса), то есть набора API, и можно переопределить часть сигнатуры для достижения полиморфизма подтипа.

  2. Подклассирование означает неявное повторное использование реализаций метода.

С этими двумя преимуществами связаны две разные цели наследования: ориентирование на подтипы и повторное использование кода.

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

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

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

  1. Наследование обеспечивает простое повторное использование кода, если оно не переопределяется, а композиция должна перекодировать каждый API, даже если это простая задача делегирования.

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

  3. Наследование подвергает защищенных членов. Это нарушает инкапсуляцию родительского класса, и, если используется подклассом, вводится другая зависимость между дочерним и его родительским классами.

  4. Композиция имеет преимущество инверсии управления, и ее зависимость может быть введена динамически, как показано в шаблоне декоратора и шаблоне прокси.

  5. Композиция имеет преимущество комбинаторно-ориентированного программирования, то есть работает как составной шаблон.

  6. Композиция сразу же следует за программированием интерфейса.

  7. Преимущество композиции заключается в легком множественном наследовании.

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

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

наследование, вероятно, худшая форма сцепления, которую вы можете иметь

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

В статье " Наследование есть зло: эпическая ошибка DataAnnotationsModelBinder" приведен пример этого в C#. Это показывает использование наследования, когда состав должен был использоваться и как это могло быть реорганизовано.

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

Смотрите другие ответы.

Когда следует выбирать наследование над композицией?

Всякий раз, когда предложение "Бар - это Foo, а Бар может делать все, что может делать Foo" имеет смысл.

Общепринятая мудрость гласит, что если предложение "Бар - это Foo" имеет смысл, то это хороший намек на то, что выбор наследования уместен. Например, собака - это животное, поэтому хороший класс - это наследование класса Dog от Animal.

К сожалению, этот простой тест "is-a" не является надежным. Проблема Круг-Эллипс является отличным контрпримером: даже если круг является эллипсом, плохая идея иметь класс Circle, унаследованный от Ellipse, потому что есть вещи, которые эллипсы могут делать, но круги не могут. Например, эллипсы могут растягиваться, а круги - нет. Так что пока вы можете иметь ellipse.stretch()Вы не можете иметь circle.stretch(),

Вот почему лучшим тестом является то, что "Бар - это Foo, а Бар может делать все, что может делать Foo". Это действительно означает, что Foo может использоваться полиморфно. Тест "is-a" является лишь необходимым условием для полиморфного использования и обычно означает, что все получатели Foo имеют смысл в Bar. Дополнительный тест "можно все" означает, что все установщики Foo также имеют смысл в Bar. Этот дополнительный тест обычно не выполняется, когда класс Bar "is-a" Foo, но добавляет к нему некоторые ограничения, и в этом случае не следует использовать наследование, поскольку Foo нельзя использовать полиморфно. Другими словами, наследование связано не с общими свойствами, а с общими функциями. Производные классы должны расширять функциональность базовых классов, а не ограничивать их.

Это эквивалентно принципу подстановки Лискова:

Функции, которые используют указатели или ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом

В Java или C# объект не может изменить свой тип после создания экземпляра.

Итак, если ваш объект должен выглядеть как другой объект или вести себя по-разному в зависимости от состояния объекта или условий, используйте Composition: см. Шаблоны проектирования состояний и стратегий.

Если объект должен быть одного типа, используйте наследование или реализуйте интерфейсы.

Лично я научился всегда отдавать предпочтение композиции, а не наследованию. Нет программной проблемы, которую вы можете решить с помощью наследования, которую вы не можете решить с помощью композиции; хотя в некоторых случаях вам, возможно, придется использовать интерфейсы (Java) или протоколы (Obj-C). Поскольку C++ не знает ничего подобного, вам придется использовать абстрактные базовые классы, что означает, что вы не можете полностью избавиться от наследования в C++.

Композиция часто более логична, она обеспечивает лучшую абстракцию, лучшую инкапсуляцию, лучшее повторное использование кода (особенно в очень больших проектах) и с меньшей вероятностью что-либо сломает на расстоянии только потому, что вы сделали изолированное изменение в любом месте вашего кода. Это также облегчает соблюдение "Принципа единой ответственности", который часто резюмируется как "Не должно быть более одной причины для изменения класса", и это означает, что каждый класс существует для определенной цели, и он должен есть только методы, которые напрямую связаны с его целью. Кроме того, наличие очень мелкого дерева наследования значительно упрощает обзор, даже когда ваш проект начинает становиться действительно большим. Многие люди думают, что наследование представляет наш реальный мир довольно хорошо, но это не правда. Реальный мир использует гораздо больше композиции, чем наследования. Практически каждый объект реального мира, который вы можете держать в руке, состоит из других, меньших объектов реального мира.

Однако есть и недостатки в композиции. Если вы вообще пропустите наследование и сконцентрируетесь только на композиции, вы заметите, что вам часто приходится писать пару дополнительных строк кода, в которых не было необходимости, если вы использовали наследование. Вы также иногда вынуждены повторяться, и это нарушает принцип СУХОГО (СУХОЙ = не повторяйте себя). Также состав часто требует делегирования, и метод просто вызывает другой метод другого объекта без другого кода, окружающего этот вызов. Такие "двойные вызовы методов" (которые могут легко распространяться на тройные или четырехкратные вызовы методов и даже дальше) имеют гораздо худшую производительность, чем наследование, когда вы просто наследуете метод своего родителя. Вызов унаследованного метода может быть таким же быстрым, как и вызов не унаследованного, или он может быть немного медленнее, но обычно все же быстрее, чем два последовательных вызова метода.

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

Наследование - действительно классная особенность, но я боюсь, что оно использовалось последние пару лет. Люди относились к наследованию как к единому молотку, который может все это прибить, независимо от того, был ли это на самом деле гвоздь, болт или что-то совершенно другое.

Мое общее правило: перед использованием наследования подумайте, имеет ли смысл смысл композиции.

Причина: подклассы обычно означают большую сложность и связанность, то есть сложнее изменять, поддерживать и масштабировать без ошибок.

Гораздо более полный и конкретный ответ Тима Будро из Sun:

Общие проблемы использования наследования, как я вижу это:

  • Невинные действия могут привести к неожиданным результатам. Классическим примером этого являются вызовы переопределенных методов из конструктора суперкласса до инициализации полей экземпляра подкласса. В идеальном мире никто бы этого не сделал. Это не идеальный мир.
  • Он предлагает извращенные соблазны для субклассеров делать предположения о порядке вызовов методов и тому подобное - такие предположения, как правило, не являются стабильными, если суперкласс может развиваться со временем. Смотрите также мою аналогию с тостером и кофейником.
  • Классы становятся тяжелее - вы не обязательно знаете, что делает ваш суперкласс в своем конструкторе или сколько памяти он собирается использовать. Поэтому создание невинного потенциального легковесного объекта может оказаться намного дороже, чем вы думаете, и со временем это может измениться, если суперкласс развивается
  • Это поощряет взрыв подклассов. Загрузка классов стоит времени, больше классов - памяти. Это может не быть проблемой, пока вы не имеете дело с приложением в масштабе NetBeans, но там у нас были реальные проблемы, например, с медленным меню, потому что первое отображение меню вызвало массовую загрузку классов. Мы исправили это, перейдя к более декларативному синтаксису и другим методам, но это также потребовало времени на исправление.
  • Это усложняет изменение вещей позже - если вы сделали класс общедоступным, то замена суперкласса приведет к поломке подклассов - это выбор, на котором после того, как вы сделали код общедоступным, вы будете женаты. Поэтому, если вы не изменяете реальную функциональность своего суперкласса, вы получаете гораздо больше свободы, чтобы изменить вещи позже, если вы используете, а не расширять то, что вам нужно. Взять, к примеру, создание подклассов JPanel - это обычно неправильно; и если подкласс где-то публичный, у вас никогда не будет возможности пересмотреть это решение. Если к нему обращаются как JComponent getThePanel(), вы все равно можете это сделать (подсказка: выставить модели для компонентов в качестве вашего API).
  • Иерархии объектов не масштабируются (или сделать их масштабирование позже гораздо сложнее, чем планировать заранее) - это классическая проблема "слишком много слоев". Я расскажу об этом ниже и о том, как шаблон AskTheOracle может решить эту проблему (хотя он может оскорблять пуристов ООП).

...

Мое мнение о том, что делать, если вы разрешаете наследование, которое вы можете взять с небольшим количеством соли:

  • Не выставляйте никаких полей, кроме констант
  • Методы должны быть абстрактными или окончательными.
  • Не вызывать методы из конструктора суперкласса

...

все это относится меньше к маленьким проектам, чем к крупным, и меньше к частным классам, чем к публичным

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

Если вам нужен канонический ответ из учебника, который люди давали с момента возникновения ООП (который вы видите, как многие люди дают в этих ответах), тогда примените следующее правило: «если у вас есть отношение, используйте наследование. Если вы иметь отношение has-a, использовать композицию».

Это традиционный совет, и если он вас удовлетворит, можете не читать здесь и идти своим путем. Для всех остальных...

сравнения is-a/has-a имеют проблемы

Например:

  • Квадрат — это прямоугольник, но если ваш класс прямоугольника имеет setWidth()/ setHeight()методы, то нет разумного способа сделать Squareнаследовать от Rectangleбез нарушения принципа подстановки Лискова.
  • Отношения «есть-а» часто можно перефразировать так, чтобы они звучали как отношения «имеет-а». Например, наемный работник – это человек, но человек также имеет – трудовой статус «занятый».
  • отношения is-a могут привести к неприятным иерархиям множественного наследования, если вы не будете осторожны. В конце концов, в английском языке нет правила, утверждающего, что объект — это ровно одна вещь.
  • Люди быстро распространяют это «правило», но кто-нибудь когда-нибудь пытался подтвердить его или объяснить, почему это хорошая эвристика? Конечно, это хорошо вписывается в идею о том, что ООП должно моделировать реальный мир, но само по себе это не является причиной для принятия принципа.

См. этот вопрос StackOverflow для получения дополнительной информации по этой теме.

Чтобы знать, когда использовать наследование и композицию, нам сначала нужно понять плюсы и минусы каждого из них.

Проблемы с наследованием реализации

Другие ответы проделали замечательную работу по объяснению проблем с наследованием, поэтому я постараюсь не вдаваться здесь в слишком много деталей. Но вот краткий список:

  • Возможно, наследование раскрывает некоторую степень деталей реализации подклассам (через защищенные члены).
  • Может быть трудно следовать логике, которая переплетается между методами базового класса и подкласса.
  • Проблема хрупкого основания . Что еще хуже, большинство ООП-языков допускают наследование по умолчанию — разработчики API, которые заранее не препятствуют людям наследовать от своих общедоступных классов, должны быть особенно осторожны всякий раз, когда они реорганизуют свои базовые классы. К сожалению, проблема хрупкой базы часто понимается неправильно, из-за чего многие не понимают, что нужно для поддержки класса, от которого каждый может наследоваться. (например, вы не можете реорганизовать один метод, чтобы он начал вызывать другой общедоступный метод, даже если это не меняет поведение базового класса, потому что подкласс мог переопределить общедоступный метод)
  • Смертельный алмаз смерти

Проблемы с композицией

  • Иногда это может быть немного многословно.

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

Когда следует использовать наследование?

В следующий раз, когда вы будете рисовать свои причудливые UML-диаграммы для проекта (если вы это сделаете) и подумываете о добавлении некоторого наследования, пожалуйста, придерживайтесь следующего совета: не делайте этого.

По крайней мере, еще нет.

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

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

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

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

  1. Используйте наследование как частную деталь реализации. Не раскрывайте свой базовый класс публично, используйте для этого интерфейсы. Это позволяет вам свободно добавлять или удалять наследование по своему усмотрению, не внося критических изменений.
  2. Держите базовый класс абстрактным . Это упрощает отделение логики, которой необходимо делиться, от логики, которой нет.
  3. Изолируйте свои базовые и дочерние классы. Не позволяйте вашему подклассу переопределять методы базового класса (используйте для этого шаблон стратегии) ​​и избегайте того, чтобы они ожидали существования свойств/методов друг для друга, используйте для этого другие формы совместного использования кода.
  4. Наследство — крайняя мера.

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

  • Вы не раскрываете детали реализации через защищенные члены. Вам не нужен защищенный, если ваши подклассы стремятся не знать о свойствах, которые предлагает базовый класс.
  • Следовать коду намного проще, потому что он не переплетается с базовыми/подклассами.
  • Проблема с хрупким основанием исчезнет. Проблема хрупкой базы связана с тем, что люди могут произвольно переопределять методы базового класса, а вы не понимаете/не помните, что это происходит. Когда наследование изолировано и закрыто, базовый класс будет не более хрупким, чем класс, зависящий от другого через композицию.
  • Смертоносный алмаз смерти больше не проблема, поскольку просто нет необходимости иметь несколько уровней наследования. Если у вас есть абстрактные базовые классы B и C, которые имеют много общих функций, просто переместите эту функциональность из B и C в новый абстрактный базовый класс, класс D. Любой, кто унаследовал от B, должен обновить, чтобы наследовать от обоих B и D, и любой, кто унаследовал от C, должен наследовать от C и D. Поскольку все ваши базовые классы являются частными деталями реализации, не должно быть слишком сложно выяснить, кто от чего наследуется, чтобы внести эти изменения.

Вывод

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

Дальнейшее чтение:

  • Статья , которую я написал на эту тему, погружается еще глубже и содержит примеры.
  • Веб-страница , рассказывающая о трех разных задачах, которые выполняет наследование, и о том, как эти задачи можно выполнять с помощью других средств на языке Go.
  • Список причин, по которым может быть полезно объявить ваш класс ненаследуемым (например, "final" в Java).

Наследование создает прочные отношения между подклассом и суперклассом; Подкласс должен знать о деталях реализации суперкласса. Создать суперкласс гораздо сложнее, когда нужно подумать о том, как его можно расширить. Вы должны тщательно документировать инварианты классов и указать, какие другие методы могут быть переопределены внутренне.

Наследование иногда полезно, если иерархия действительно представляет собой отношение "есть отношение". Это относится к принципу Open-Closed, который гласит, что классы должны быть закрыты для модификации, но открыты для расширения. Таким образом, вы можете иметь полиморфизм; иметь универсальный метод, который имеет дело с супертипом и его методами, но через динамическую диспетчеризацию вызывается метод подкласса. Это гибко и помогает создавать косвенные ссылки, которые необходимы в программном обеспечении (чтобы меньше знать о деталях реализации).

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

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

Предположим, что у самолета есть только две части: двигатель и крылья.
Тогда есть два способа конструировать самолет класса.

Class Aircraft extends Engine{
  var wings;
}

Теперь ваш самолет может начать с фиксированных крыльев
и меняйте их на поворотные крылья на лету. Это по сути
двигатель с крыльями. Но что, если я хотел изменить
двигатель на лету, а?

Либо базовый класс Engine подвергает мутатора, чтобы изменить его
свойства, или я редизайн Aircraft как:

Class Aircraft {
  var wings;
  var engine;
}

Теперь я могу заменить свой двигатель на лету.

Вам нужно взглянуть на Принцип подстановки Лискова в SOLID принципах проектирования классов Дядей Бобом.:)

Чтобы ответить на этот вопрос с другой точки зрения для новых программистов:

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

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

Звучит великолепно, но на практике это почти никогда, никогда не работает, по одной из нескольких причин:

  • Мы обнаруживаем, что есть некоторые другие функции, которые мы хотим, чтобы наши классы имели. Если мы добавляем функциональность к классам через наследование, мы должны решить - добавим ли мы его в существующий базовый класс, даже если не каждый класс, который наследует его, нуждается в такой функциональности? Создаем ли мы еще один базовый класс? Но как насчет классов, которые уже наследуются от другого базового класса?
  • Мы обнаруживаем, что только для одного из классов, который наследуется от нашего базового класса, мы хотим, чтобы базовый класс вел себя немного иначе. Итак, теперь мы вернемся к нашему базовому классу и, возможно, добавим несколько виртуальных методов или, что еще хуже, код, который говорит: "Если я унаследован от типа A, сделайте это, но если я унаследован от типа B, сделайте это".". Это плохо по многим причинам. Во-первых, каждый раз, когда мы меняем базовый класс, мы эффективно меняем каждый унаследованный класс. Таким образом, мы действительно меняем класс A, B, C и D, потому что нам нужно немного другое поведение в классе A. Как бы мы ни старались, мы можем сломать один из этих классов по причинам, не имеющим к ним никакого отношения. классы.
  • Мы могли бы знать, почему мы решили сделать все эти классы наследуемыми друг от друга, но это может не иметь (вероятно, не будет) смысла для кого-то еще, кто должен поддерживать наш код. Мы могли бы заставить их сделать трудный выбор - сделать ли мне что-то действительно уродливое и грязное, чтобы внести нужные изменения (см. Предыдущий пункт), или просто переписать это.

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

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

Противоядием является принцип единой ответственности. Думайте об этом как об ограничении. Мой класс должен сделать одну вещь. Я должен быть в состоянии дать своему классу имя, которое каким-то образом описывает то, что он делает. (Есть исключения для всего, но абсолютные правила иногда лучше, когда мы учимся.) Из этого следует, что я не могу написать базовый класс с именем ObjectBaseThatContainsVariousFunctionsNeededByDifferentClasses, Независимо от того, какая отдельная функциональность мне нужна, она должна принадлежать своему классу, и тогда другие классы, которым эта функциональность нужна, могут зависеть от этого класса, а не наследоваться от него.

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

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

Если между двумя сущностями не существует иерархической связи в реальном мире, не используйте наследование, а используйте композицию. Композиция представляет отношения "ИМЕЕТ".

Например, у автомобиля есть колеса, кузов, двигатель и т. Д. Но если вы наследуете здесь, он становится CAR IS A Wheel - что неверно.

Для дальнейших объяснений, обратитесь к предпочтению композиции над наследованием

Когда вы хотите "скопировать"/ выставить API базового класса, вы используете наследование. Если вы хотите только "скопировать" функциональность, используйте делегирование.

Один из примеров этого: вы хотите создать стек из списка. В стеке есть только pop, push и peek. Вы не должны использовать наследование, если вы не хотите использовать функциональность push_back, push_front, removeAt и т. Д. В стеке.

Эти два способа могут прекрасно жить вместе и фактически поддерживать друг друга.

Композиция просто играет модульно: вы создаете интерфейс, похожий на родительский класс, создаете новый объект и делегируете ему вызовы. Если эти объекты не должны знать друг о друге, это довольно безопасная и простая в использовании композиция. Здесь так много возможностей.

Однако, если родительский класс по какой-то причине нуждается в доступе к функциям, предоставляемым "дочерним классом" для неопытного программиста, может показаться, что это отличное место для использования наследования. Родительский класс может просто вызвать свой собственный абстрактный "foo()", который перезаписывается подклассом, и затем он может дать значение абстрактной базе.

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

Зачем?

Потому что наследование - плохой способ передачи информации.

У композиции здесь есть реальное преимущество: связь может быть обратной: "родительский класс" или "абстрактный работник" могут агрегировать любые конкретные "дочерние" объекты, реализующие определенный интерфейс + любой дочерний объект может быть установлен внутри любого другого типа родителя, который принимает это тип. И может быть любое количество объектов, например, MergeSort или QuickSort могут отсортировать любой список объектов, реализующих абстрактный интерфейс Compare. Или, говоря иначе: любая группа объектов, которые реализуют "foo()", и другая группа объектов, которые могут использовать объекты, имеющие "foo()", могут играть вместе.

Я могу думать о трех реальных причинах использования наследования:

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

Если это правда, то, вероятно, необходимо использовать наследование.

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

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

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

Когда у вас есть отношение is- между двумя классами (например, собака - собака), вы переходите на наследство.

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

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

Простой способ разобраться в этом состоит в том, что наследование следует использовать, когда вам нужно, чтобы объект вашего класса имел тот же интерфейс, что и его родительский класс, чтобы его можно было таким образом рассматривать как объект родительского класса (апкастинг), Более того, вызовы функций для объекта производного класса будут оставаться одинаковыми везде в коде, но конкретный метод для вызова будет определен во время выполнения (т. Е. Реализация низкого уровня отличается, интерфейс высокого уровня остается тем же).

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

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

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

Когда мы говорим, что число 5 имеет тип integer, мы утверждаем, что число 5 относится к набору возможных значений (например, посмотрите возможные значения для примитивных типов Java). Мы также утверждаем, что существует допустимый набор методов, которые я могу выполнять со значением, например сложение и вычитание. И, наконец, мы заявляем, что есть набор свойств, которые всегда выполняются, например, если я добавлю значения 3 и 5, я получу 8 в результате.

Чтобы привести другой пример, подумайте об абстрактных типах данных, Набор целых чисел и Список целых чисел, значения, которые они могут содержать, ограничены целыми числами. Они оба поддерживают набор методов, таких как add(newValue) и size(). И оба они имеют разные свойства (инвариант класса), Sets не допускают дублирования, в то время как List разрешает дублирование (конечно, есть и другие свойства, которым они оба удовлетворяют).

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

class List {
  data = new Array();

  Integer size() {
    return data.length;
  }

  add(Integer anInteger) {
    data[data.length] = anInteger;
  }
}

Затем я записываю Набор целых чисел как подкласс Списка целых чисел:

class Set, inheriting from: List {
  add(Integer anInteger) {
     if (data.notContains(anInteger)) {
       super.add(anInteger);
     }
  }
}

Наш класс набора целых чисел является подклассом списка целых чисел, но не является подтипом, поскольку он не удовлетворяет всем возможностям класса списка. Значения и сигнатура методов выполняются, а свойства - нет. Поведение метода add(Integer) было явно изменено, без сохранения свойств родительского типа. Подумайте с точки зрения клиента ваших классов. Они могут получить набор целых чисел, где ожидается список целых чисел. Клиент может захотеть добавить значение и добавить это значение в список, даже если это значение уже существует в списке. Но она не получит такого поведения, если ценность существует. Большой сюрприз для нее!

Это классический пример неправильного использования наследования. Используйте композицию в этом случае.

(фрагмент из: правильно использовать наследование).

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

Плюсы наследования:

  1. Он устанавливает логическое отношение "есть ". Если автомобиль и грузовик являются двумя типами транспортных средств (базовый класс), дочерний класс является базовым классом.

    т.е.

    Автомобиль - это Автомобиль

    Грузовик - это транспортное средство

  2. С наследованием вы можете определить / изменить / расширить возможности

    1. Базовый класс не обеспечивает реализацию, а подкласс должен переопределять завершенный метод (аннотация) => Вы можете реализовать контракт
    2. Базовый класс обеспечивает реализацию по умолчанию, а подкласс может изменить поведение => Вы можете переопределить контракт
    3. Подкласс добавляет расширение к реализации базового класса, вызывая super.methodName() в качестве первого оператора => Вы можете продлить контракт
    4. Базовый класс определяет структуру алгоритма, а подкласс переопределит часть алгоритма => Вы можете реализовать Template_method без изменений в скелете базового класса

Минусы композиции:

  1. В наследовании подкласс может напрямую вызывать метод базового класса, даже если он не реализует метод базового класса из-за отношения IS A. Если вы используете композицию, вы должны добавить методы в контейнерный класс, чтобы предоставить доступ к API класса.

Например, если Автомобиль содержит Автомобиль, и если вам нужно получить цену на Автомобиль, которая была определена в Транспортном средстве, ваш код будет таким

class Vehicle{
     protected double getPrice(){
          // return price
     }
} 

class Car{
     Vehicle vehicle;
     protected double getPrice(){
          return vehicle.getPrice();
     }
} 

Я согласен с @Pavel, когда он говорит, что есть места для композиции и есть места для наследования.

Я думаю, что наследство следует использовать, если ваш ответ положительный на любой из этих вопросов.

  • Является ли ваш класс частью структуры, которая выигрывает от полиморфизма? Например, если у вас есть класс Shape, который объявляет метод draw(), то нам явно нужно, чтобы классы Circle и Square были подклассами Shape, чтобы их клиентские классы зависели от Shape, а не от конкретных подклассов.
  • Нужно ли вашему классу повторно использовать взаимодействия высокого уровня, определенные в другом классе? Шаблон дизайна метода шаблона будет невозможно реализовать без наследования. Я считаю, что все расширяемые рамки используют этот шаблон.

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

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

Эмпирическое правило, которое я слышал, это наследование, которое следует использовать, когда это отношение "есть", и композицию, когда это "имеет". Даже при этом я чувствую, что вы всегда должны склоняться к композиции, потому что это устраняет много сложности.

Я вижу, что никто не упомянул проблему алмазов, которая может возникнуть при наследовании.

На первый взгляд, если классы B и C наследуют A и оба переопределяют метод X, а четвертый класс D наследует от обоих B и C и не переопределяет X, какую реализацию X D предполагается использовать?

Википедия предлагает хороший обзор темы, обсуждаемой в этом вопросе.

Другие вопросы по тегам