Наследование против агрегации
Есть две школы мысли о том, как лучше расширять, улучшать и повторно использовать код в объектно-ориентированной системе:
Наследование: расширить функциональность класса, создав подкласс. Переопределите члены суперкласса в подклассах, чтобы обеспечить новую функциональность. Сделайте методы абстрактными / виртуальными, чтобы заставить подклассы "заполнять пробелы", когда суперклассу нужен конкретный интерфейс, но он не зависит от его реализации.
Агрегирование: создайте новую функциональность, взяв другие классы и объединив их в новый класс. Присоедините общий интерфейс к этому новому классу для взаимодействия с другим кодом.
Каковы преимущества, затраты и последствия каждого? Есть ли другие альтернативы?
Я вижу, что эти дебаты возникают регулярно, но я не думаю, что их уже спрашивали о переполнении стека (хотя есть некоторые связанные обсуждения). Там также удивительное отсутствие хороших результатов Google для этого.
12 ответов
Дело не в том, что лучше, а в том, когда что использовать.
В "нормальных" случаях достаточно простого вопроса, чтобы выяснить, нужно ли нам наследование или агрегацию.
- Если Новый класс более или менее как исходный класс. Используйте наследство. Новый класс теперь является подклассом исходного класса.
- Если новый класс должен иметь исходный класс. Используйте агрегацию. Новый класс теперь имеет оригинальный класс в качестве члена.
Тем не менее, есть большая серая зона. Итак, нам нужно несколько других трюков.
- Если мы использовали наследование (или мы планируем его использовать), но мы используем только часть интерфейса, или мы вынуждены переопределить множество функций, чтобы сохранить логическую корреляцию. Тогда у нас сильный неприятный запах, который указывает на то, что нам пришлось использовать агрегацию.
- Если мы использовали агрегацию (или планируем ее использовать), но мы обнаруживаем, что нам нужно скопировать почти все функциональные возможности. Тогда у нас есть запах, который указывает в сторону наследования.
Короче говоря. Мы должны использовать агрегацию, если часть интерфейса не используется или должна быть изменена, чтобы избежать нелогичной ситуации. Нам нужно только использовать наследование, если нам нужны почти все функциональные возможности без серьезных изменений. А если сомневаетесь, используйте агрегацию.
Другая возможность, в случае, когда у нас есть класс, которому требуется часть функциональности исходного класса, - это разделить исходный класс на корневой класс и подкласс. И пусть новый класс наследуется от корневого класса. Но вы должны позаботиться об этом, чтобы не создавать нелогичное разделение.
Давайте добавим пример. У нас есть класс "Собака" с методами: "Ешь", "Прогулка", "Кора", "Играть".
class Dog
Eat;
Walk;
Bark;
Play;
end;
Теперь нам нужен класс "Cat", который нуждается в "Eat", "Walk", "Purr" и "Play". Итак, сначала попробуйте расширить его от собаки.
class Cat is Dog
Purr;
end;
Выглядит хорошо, но подожди. Этот кот может лаять (меня за это убьют любители кошек). А лающий кот нарушает принципы вселенной. Поэтому нам нужно переопределить метод Барка, чтобы он ничего не делал.
class Cat is Dog
Purr;
Bark = null;
end;
Хорошо, это работает, но пахнет плохо. Итак, давайте попробуем агрегацию:
class Cat
has Dog;
Eat = Dog.Eat;
Walk = Dog.Walk;
Play = Dog.Play;
Purr;
end;
Хорошо это хорошо Этот кот больше не лает, даже не молчит. Но все же у него есть внутренняя собака, которая хочет выйти. Итак, давайте попробуем решение номер три:
class Pet
Eat;
Walk;
Play;
end;
class Dog is Pet
Bark;
end;
class Cat is Pet
Purr;
end;
Это намного чище. Нет внутренних собак. И кошки и собаки находятся на одном уровне. Мы можем даже представить других домашних животных, чтобы расширить модель. Если это не рыба, или что-то, что не ходит. В этом случае нам снова нужно провести рефакторинг. Но это что-то другое время.
Разница обычно выражается как разница между "есть" и "имеет". Наследование, отношение "является", хорошо обобщено в принципе замещения Лискова. Агрегация, отношение "имеет", просто показывает, что у агрегирующего объекта есть один из агрегированных объектов.
Существуют и другие различия - частное наследование в C++ указывает на то, что отношение "реализовано в терминах", которое также может быть смоделировано с помощью агрегации (незащищенных) объектов-членов.
Вот мой самый распространенный аргумент:
В любой объектно-ориентированной системе любой класс состоит из двух частей:
Его интерфейс: "публичное лицо" объекта. Это набор возможностей, которые он объявляет остальному миру. Во многих языках набор четко определен как "класс". Обычно это сигнатуры метода объекта, хотя он немного зависит от языка.
Его реализация: "закулисная" работа, которую объект выполняет, чтобы удовлетворить свой интерфейс и обеспечить функциональность. Обычно это код и данные члена объекта.
Один из фундаментальных принципов ООП заключается в том, что реализация инкапсулирована (т.е. скрыта) внутри класса; единственное, что должны видеть посторонние - это интерфейс.
Когда подкласс наследует от подкласса, он обычно наследует и реализацию, и интерфейс. Это, в свою очередь, означает, что вы вынуждены принять оба ограничения в своем классе.
При агрегации вы можете выбрать либо реализацию, либо интерфейс, либо и то и другое, но вас это не заставляет. Функциональность объекта оставлена на усмотрение самого объекта. Он может подчиняться другим объектам так, как ему нравится, но в конечном итоге он отвечает за себя. По моему опыту, это приводит к более гибкой системе, которую легче модифицировать.
Поэтому, когда я занимаюсь разработкой объектно-ориентированного программного обеспечения, я почти всегда предпочитаю агрегацию, а не наследование.
Я дал ответ "Есть" против "Имеет": какой из них лучше?,
По сути, я согласен с другими людьми: используйте наследование, только если ваш производный класс действительно является типом, который вы расширяете, а не просто потому, что он содержит те же данные. Помните, что наследование означает, что подкласс получает как методы, так и данные.
Имеет ли смысл для вашего производного класса иметь все методы суперкласса? Или вы просто спокойно обещаете себе, что эти методы должны игнорироваться в производном классе? Или вы обнаружите, что вы переопределяете методы суперкласса, делая их неактивными, чтобы никто не вызывал их случайно? Или дать подсказки своему инструменту создания документации API, чтобы пропустить метод из документа?
Это убедительные доказательства того, что агрегация является лучшим выбором в этом случае.
Я вижу много ответов "есть против", они концептуально отличаются "по этому и другим вопросам.
Одна вещь, которую я обнаружил в своем опыте, заключается в том, что попытка определить, является ли отношение "есть-а" или "имеет-а", обязательно потерпит неудачу. Даже если вы можете правильно сделать это определение для объектов сейчас, меняющиеся требования означают, что вы, вероятно, ошибетесь в какой-то момент в будущем.
Еще одна вещь, которую я обнаружил, заключается в том, что очень трудно преобразовать наследование в агрегацию, если вокруг иерархии наследования написано много кода. Простое переключение с суперкласса на интерфейс означает изменение почти каждого подкласса в системе.
И, как я уже упоминал в этой статье, агрегация, как правило, менее гибкая, чем наследование.
Итак, у вас есть идеальный шторм аргументов против наследования, когда вам нужно выбрать один или другой:
- Ваш выбор, вероятно, будет неправильным в какой-то момент
- Изменить этот выбор сложно, как только вы его сделали.
- Наследование имеет тенденцию быть худшим выбором, поскольку оно более ограничено.
Таким образом, я склонен выбирать агрегацию - даже когда есть сильные отношения.
Я хотел сделать это комментарием к исходному вопросу, но укусил 300 символов [;<).
Я думаю, что мы должны быть осторожны. Во-первых, есть больше ароматов, чем два довольно конкретных примера, приведенных в вопросе.
Кроме того, я полагаю, что важно не путать цель с инструментом. Кто-то хочет убедиться, что выбранная техника или методология поддерживает достижение основной цели, но я не думаю, что вне контекста обсуждение "техника-это-лучше" очень полезно. Это помогает узнать подводные камни разных подходов, а также их четкие сладкие места.
Например, что вы хотите достичь, с чего вы можете начать, и каковы ограничения?
Вы создаете каркас компонента, даже специального назначения? Являются ли интерфейсы отделимыми от реализаций в системе программирования или это достигается практикой, использующей различные виды технологий? Можете ли вы отделить структуру наследования интерфейсов (если таковые имеются) от структуры наследования классов, которые их реализуют? Важно ли скрывать структуру классов реализации от кода, который опирается на интерфейсы, предоставляемые реализацией? Есть ли несколько реализаций, которые можно использовать в одно и то же время, или это изменение становится более продолжительным в результате обслуживания и улучшения? Это и многое другое необходимо учитывать, прежде чем зацикливаться на инструменте или методологии.
Наконец, важно ли зафиксировать различия в абстракции и как вы относитесь к ней (как в "против" - "против") к различным особенностям технологии ОО? Возможно, и так, если концептуальная структура будет оставаться последовательной и управляемой для вас и других. Но разумно не быть порабощенным этим и извращениями, которые вы могли бы в конечном итоге сделать. Может быть, лучше отойти от уровня и не быть таким жестким (но оставить хорошее повествование, чтобы другие могли сказать, что случилось). [Я ищу то, что делает конкретную часть программы объяснимой, но иногда я стремлюсь к элегантности, когда есть большая победа. Не всегда лучшая идея.]
Я пурист интерфейса, и меня привлекают проблемы и подходы, в которых уместен пуризм интерфейса, будь то создание инфраструктуры Java или организация некоторых реализаций COM. Это не делает это подходящим для всего, даже близко ко всему, хотя я клянусь этим. (У меня есть пара проектов, которые, кажется, предоставляют серьезные контрпримеры против пуризма интерфейса, поэтому будет интересно посмотреть, как мне удается справиться с этим.)
Вопрос обычно формулируется как композиция против наследования, и он уже задавался здесь ранее.
Я расскажу о том, где они могут быть применены. Вот пример того и другого в игровом сценарии. Предположим, есть игра, в которой есть разные типы солдат. У каждого солдата может быть рюкзак, в котором можно хранить разные вещи.
Наследование здесь? Есть морской, зеленый берет и снайпер. Это типы солдат. Итак, есть солдаты базового класса с морскими, зелеными беретами и снайперами в качестве производных классов
Агрегация здесь? Рюкзак может содержать гранаты, оружие (разных типов), нож, аптечку и т. Д. Солдат может быть экипирован любым из них в любой момент времени, плюс он также может иметь пуленепробиваемый жилет, который действует как броня при нападении, и его травма уменьшается до определенного процента. Класс солдат содержит объект класса бронежилета и класс ранца, который содержит ссылки на эти предметы.
Я думаю, что это не дискуссия. Это просто так:
- Отношения is-a (наследование) встречаются реже, чем отношения has-a (составление).
- Наследование сложнее понять, даже когда оно уместно, поэтому нужно проявлять должную осмотрительность, потому что оно может нарушить инкапсуляцию, стимулировать тесную связь, подвергая реализации и так далее.
Оба имеют свое место, но наследование рискованнее.
Хотя, конечно, не имеет смысла иметь класс Shape, имеющий классы Point и Square. Здесь наследство обусловлено.
Люди склонны думать о наследовании в первую очередь, когда пытаются создать что-то расширяемое, вот что не так.
Благосклонность случается, когда оба кандидата готовятся. A и B - варианты, а вы предпочитаете A. Причина в том, что композиция предлагает больше возможностей расширения / гибкости, чем обобщения. Это расширение / гибкость относится главным образом к динамической гибкости.
Благо не сразу видно. Чтобы увидеть выгоду, вам нужно дождаться следующего неожиданного запроса на изменение. Таким образом, в большинстве случаев те, кто придерживается обобщения, терпят неудачу по сравнению с теми, кто принял композицию (за исключением одного очевидного случая, упомянутого позже). Отсюда и правило. С точки зрения обучения, если вы можете успешно внедрить внедрение зависимостей, вы должны знать, какой из них предпочтительнее и когда. Правило также помогает вам принять решение; Если вы не уверены, выберите композицию.
Описание: Состав: связь уменьшается благодаря наличию нескольких более мелких вещей, которые вы подключаете к чему-то большему, а более крупный объект просто вызывает меньший объект обратно. Обобщение: с точки зрения API определение, что метод может быть переопределен, является более строгим обязательством, чем определение, что метод может быть вызван. (очень мало случаев, когда Генерализация побеждает). И никогда не забывайте, что с композицией вы также используете наследование от интерфейса вместо большого класса
Оба подхода используются для решения разных проблем. Вам не всегда нужно объединять более двух или более классов при наследовании от одного класса.
Иногда вам нужно объединить один класс, потому что этот класс запечатан или содержит не виртуальные члены, которые нужно перехватить, поэтому вы создаете прокси-слой, который, очевидно, недопустим с точки зрения наследования, но при условии, что класс вы проксируете имеет интерфейс, на который вы можете подписаться, это может сработать довольно хорошо.