Агрегация или наследование?

Агрегация или наследование?

Евгений Каратаев

И снова о проектировании классов. Больная тема и место применения множества трюков. Большинство программистов используют трюки по-разному. Видимо, есть три способа их применения - 1) неосознанно, 2) осознанно, но с затруднениями при выборе способа и 3) осознанно и, более того, трюки вычисляются. 

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

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

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

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

Сведем сравнительные различия в таблицу.  

Вид различия

Агрегация

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

1. Добавление новых полей

Скорее всего, поскольку следует различать состояния объекта как старого класса и как нового класса

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

2. Переопределение виртуальных функций

Нет смысла

Скорее всего

3. Сохранение работоспособности имеющегося кода

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

Безусловно. Изменения не касаются имеющегося кода.

4. Достижимость поставленной цели

Да, скорее всего.

Да, скорее всего.

5. Наличие особых требований к имеющемуся классу

Нет. Если чего-то в нем не хватает, то это будет дописано.

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

6. Наличие особого внимания к имеющемуся коду класса, контрольные точки

Да. Этапы инициализации и деинициализации объектов должны быть проконтролированы обязательно. Желательно с целью сохранения совместимости с предыдущим поведением объекта.

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

7. Наличие особых требований к предыдущему оперативному окружению на этапе создания / удаления объекта

Никаких. Для окружения ничего не меняется.

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

8. Наличие особых требований к предыдущему оперативному окружению на этапе жизни объекта

Возможно, если модифицированный код меняет свое отношение к внешнему контексту.

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

9. Разрастание кода имеющейся программы

Да, безусловно.

Нет.

Эти пункты следует учитывать, если проектирование классов производится не спонтанно и "по живому", а более-менее ответственно. Тем более, что всегда есть время обдумать свои шаги. 

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

Рассмотрим окна в Windows. Окна в Windows все и независимо ни от чего имеют две основные характеристики - это определение класса и функция обработки сообщений. Это пункт номер раз, который известен всем. Кроме того, окна в Windows имеют четкую градацию на 4 вида, различающиеся как деталями функции обработки сообщений, так и отношением к нему системы управления окнами. Это различие определяется функциями по умолчанию, которые вызывает программист, если не обрабатывает какие-либо сообщения. Это функции DefWindowProc, DefDialogProc, DefMDIChildProc и DefMDIFrameProc. 

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

Реализация как библиотек OWL, так и MFC выбрала путь наследования. Получены дополнительные классы, реализующие соответственно просто несущее окно, контрол частного вида, диалоговое окно, MDI Frame и MDI Child (как сказал Пушкин, "... прости, не знаю как перевести."). В результате для использования каждого из этих классов требуется кроме общего кода, оперирующего базовым классом окна, частный код, который может быть вызван только для конкретных классов. Там, где используется специфика MDI Child, может быть использован код только для него. Более того - код вызова, специфичный для MDI Child, может пользоваться только этим классом либо его наследником. 

Программист, использующий библиотеку классов, обязывается при написании программы создавать наследника соответствующего класса. Таким образом, специфика окон в таком подходе проектирования вылилась не только в код специальных классов, но и в существование кода, применимого только к данному классу и существующему вне его. Этот факт нашел отражение в том, что встроенные в среды разработки Borland C++ и Visual C++ мастера классов при попытке создания новых классов, несущих прикладную нагрузку, обязательно требуют указания базового класса и последующую модификацию производят с учетом этих нескольких базовых классов. Унифицированный визуальный дизайнер окон в таком подходе представляет собой либо практически нереализуемую задачу, либо является объединением 4-х дизайнеров. В обеих этих средах более-менее полноценно реализовано визуальное редактирование лишь для диалоговых окон. 

Реализация библиотеки VCL пошла по пути агрегирования возможностей и написания базового класса (TForm) таким образом, что различие между 4-мя видами окон сводится к указанию значения поля данных. При этом код класса содержит код для всех 4-х видов окон. Становится невозможно построение программы, использующей только диалоговые окна и не содержащей кода поддержки MDI. При этом становится возможным использование единого визуального дизайнера для редактирования всех 4-х видов окон. Наличие же функций поддержки MDI у объекта, используемого в качестве диалогового окна мало кого смущает, поскольку эти функции для диалоговых окон просто не приходит в голову вызывать. Более того - у программиста есть уверенность, что то, что он проектирует в качестве обычного всплывающего окна в интерфейсе SDI, всегда можно будет использовать так же в качестве и MDI Frame, и MDI Child, и простого модального диалога. Более того, сохраняется возможность менять показ этого окна в зависимости от состояния программы. Например, в некоторых случаях MDI Child должен быть показан в модальном режиме. 

Плюсы и минусы выбора графа наследования видны уже на конкретном приведенном примере. В иных случаях следует определяться с выбором, исходя из конкретной задачи. Что интересно, на выбор может оказать влияние знание не только имеющейся задачи и ее целей, но и последующих перспектив работы. Как показала практика применения вышеупомянутых средств разработки, то есть Borland C++ / Visual C++ и Delphi / Borland C++ Builder, на первых двух успешно строились долго разрабатываемые проекты, сложные по своей организации и слабо зависящие от разнообразия интерфейса (либо получившиеся такими). Вторые два средства использовались для действительно быстрой и качественной разработки проектов, имеющих развитые интерфейсные средства. Сама возможность применения единого визуального дизайнера окон явилась своего рода катализатором возникновения сообщества компонентного подхода. 

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

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

Как можно определить, что отнести в добавляемым свойствам при агрегировании, а что к добавляемым / переопределяемым при наследовании? 

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

Следует посмотреть, можно ли часть свойств находящихся в объединении, но не находящихся в пересечении, объединить в одно или более. Являются ли они взаимоисключающими? Могут ли они представлять состояние объекта получаемого класса? Если да, то вероятность успешной агрегации весьма высока. Наличие в получаемом классе свойств и методов, возможность использования которых может зависеть не от класса объекта, а от состояния других свойств, не должна пугать. Просто надо корректно написать код обработки и в одних случаях игнорировать неправильные обращения, в других генерировать ошибку. Например, в библиотеке VCL для формы можно одновременно указать стиль заголовка и его отсутствие. Этот парадокс решается просто - если окно без заголовка, то стиль заголовка игнорируется. 

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