Евгений Каратаев
И снова о проектировании классов. Больная тема и место применения множества трюков. Большинство программистов используют трюки по-разному. Видимо, есть три способа их применения - 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, на первых двух успешно строились долго разрабатываемые проекты, сложные по своей организации и слабо зависящие от разнообразия интерфейса (либо получившиеся такими). Вторые два средства использовались для действительно быстрой и качественной разработки проектов, имеющих развитые интерфейсные средства. Сама возможность применения единого визуального дизайнера окон явилась своего рода катализатором возникновения сообщества компонентного подхода.