Герб Саттер - Стандарты программирования на С++. 101 правило и рекомендация
[Dewhurst03] §70, §93 • [Meyers97] §33 • [Stroustrup00] §24.2-3, §25.2
36. Предпочитайте предоставление абстрактных интерфейсов
Вы любите абстракционизм? Абстрактные интерфейсы помогают вам сосредоточиться на проблемах правильного абстрагирования, не вдаваясь в детали реализации или управления состояниями. Предпочтительно проектировать иерархии, реализующие абстрактные интерфейсы, которые моделируют абстрактные концепции.
ОбсуждениеПредпочитайте определять абстрактные интерфейсы и выполнять наследование от них. Абстрактный интерфейс представляет собой абстрактный класс, составленный полностью из (чисто) виртуальных функций и не обладающий состояниями (членами-данными), причем обычно без реализации функций-членов. Заметим, что отсутствие состояний в абстрактном интерфейсе упрощает дизайн всей иерархии (см. соответствующие примеры в [Meyers96]).
Желательно следовать принципу инверсии зависимостей (Dependency Inversion Principle, DIP; см. [Martin96a] и [Martin00]). Данный принцип состоит в следующем.
• Высокоуровневые модули не должны зависеть от низкоуровневых. И те, и другие должны зависеть от абстракций.
• Абстракции не должны зависеть от деталей; вместо этого детали должны зависеть от абстракций.
Из DIP следует, что корнями иерархий должны быть абстрактные классы, в то время как конкретные классы в этой роли выступать не должны (см. рекомендацию 35). Абстрактные базовые классы должны беспокоиться об определении функциональности, но не о ее реализации.
Принцип инверсии зависимостей имеет три фундаментальных преимущества при проектировании.
• Повышение надежности. Менее стабильные части системы (реализации) зависят от более стабильных частей (абстракций). Надежный дизайн — тот, в котором воздействие изменений ограничено. При плохом проектировании небольшие изменения в одном месте расходятся кругами по всему проекту и оказывают влияние на самые неожиданные части системы. Именно это происходит, когда проект строится на конкретных базовых классах.
• Повышение гибкости. Дизайн, основанный на абстрактных интерфейсах, в общем случае более гибок. Если абстракции корректно смоделированы, то при появлении новых требований легко разработать новые реализации. И напротив, дизайн, зависящий от многих конкретных деталей, оказывается более жестким в том смысле, что новые требования приводят к существенным изменениям в ядре системы.
• Хорошая модульность. Дизайн, опирающийся на абстракции, обладает хорошей модульностью благодаря простоте зависимостей: высокоизменяемые части зависят от стабильных частей, но не наоборот. Дизайн же, в котором интерфейсы перемешаны с деталями реализации, применить в качестве отдельного модуля в другой системе оказывается очень сложно.
Закон Второго Шанса гласит: "Самая важная вещь — интерфейс. Все остальное можно подправить и позже, но если интерфейс разработан неверно, то может оказаться так, что у вас уже не будет второго шанса его исправить" [Sutter04].
Обычно для того, чтобы обеспечить полиморфное удаление, при проектировании отдается предпочтение открытому виртуальному деструктору (см. рекомендацию 50), если только вы не используете брокер объектов (наподобие СОМ или CORBA), который использует альтернативный механизм управления памятью.
Будьте осторожны при использовании множественного наследования классов, которые не являются абстрактными интерфейсами. Дизайн с использованием множественного наследования может быть очень выразительным, но он труднее в разработке и более подвержен ошибкам. В частности, особенно сложным при использовании множественного наследования становится управление состояниями.
Как указывалось в рекомендации 34, наследование от некоторого типа может привести и к проблемам поиска имен, поскольку в таком случае в поиске участвуют функции из пространства имен наследуемого типа (см. также рекомендацию 58).
ПримерыПример. Программа резервного копирования. При прямом, наивном проектировании высокоуровневые компоненты зависят от низкоуровневых деталей, например, плохо спроектированная программа резервного копирования может иметь архивирующий компонент, который непосредственно зависит от типов или подпрограмм для чтения структуры каталогов, и других, которые записывают данные на ленту. Адаптация такой программы к новой файловой системе и аппаратному обеспечению для резервного копирования потребует существенной переработки проекта.
Если же логика системы резервного копирования построена на основе тщательно спроектированных абстракций файловой системы и устройства резервного копирования, то переработка не потребуется — достаточно будет добавить в систему новую реализацию абстрактного интерфейса. Естественное решение подобной проблемы состоит в том, что новые требования должны удовлетворяться новым кодом, но старый код при этом изменяться не должен.
ИсключенияОптимизация пустого базового класса представляет собой один из примеров, когда наследование (предпочтительно не открытое) используется для сугубо оптимизационных целей (но см. рекомендацию 8).
Может показаться, что в дизайне на основе стратегий высокоуровневые компоненты зависят от деталей реализации (стратегий). Однако это всего лишь использование статического полиморфизма. Здесь имеются абстрактные интерфейсы, просто они не указаны явно посредством чисто виртуальных функций.
Ссылки[Alexandrescu01] • [Cargill92] pp. 12-15, 215-218 • [Cline99] §5.18-20, 21.13 • [Lakos96] §6.4.1 • [Martin96a] • [Martin00] • [Meyers96] §33 • [Stroustrup00] §12.3-4, §23.4.3.2, §23.4.3.5, §24.2-3, §25.3, §25.6 • [Sutter04] §17
37. Открытое наследование означает заменимость. Наследовать надо не для повторного использования, а чтобы быть повторно использованным
Открытое наследование позволяет указателю или ссылке на базовый класс в действительности обращаться к объекту некоторого производного класса без изменения существующего кода и нарушения его корректности.
Не применяйте открытое наследование для того, чтобы повторно использовать код (находящийся в базовом классе); открытое наследование необходимо для того, чтобы быть повторно использованным (существующим кодом, который полиморфно использует объекты базового класса).
ОбсуждениеНесмотря на двадцатилетнюю историю объектно-ориентированного проектирования, цель и практика открытого наследования часто понимается неверно, и многие применения наследования оказываются некорректными.
Открытое наследование в соответствии с принципом подстановки Лисков (Liskov Substitution Principle [Liskov88]) всегда должно моделировать отношение "является" ("работает как"): все контракты базового класса должны быть выполнены, для чего все перекрытия виртуальных функций-членов не должны требовать большего или обещать меньше, чем их базовые версии. Код, использующий указатель или ссылку на Base, должен корректно вести себя в случае, когда указатель или ссылка указывают на объект Derived.
Неверное использование наследования нарушает корректность. Обычно некорректно реализованное наследование не подчиняется явным или неявным контрактам, установленным базовым классом. Такие контракты могут оказаться очень специфичными и хитроумными, и программист должен быть особенно осторожен, когда их нельзя выразить непосредственно в коде (некоторые шаблоны проектирования помогают указать в коде его предназначение — см. рекомендацию 39).
Наиболее часто в этой связи упоминается следующий пример. Рассмотрим два класса — Square (квадрат) и Rectangle (прямоугольник), каждый из которых имеет виртуальные функции для установки их высоты и ширины. Тогда Square не может быть корректно унаследован от Rectangle, поскольку код, использующий видоизменяемый Rectangle, будет полагать, что функция SetWidth не изменяет его высоту (независимо от того, документирован ли данный контракт классом Rectangle явно или нет), в то время как функция Square::SetWidth не может одновременно выполнить этот контракт и свой инвариант "квадратности". Но и класс Rectangle не может корректно наследовать классу Square, если его клиенты Square полагают, например, что для вычисления его площади надо возвести в квадрат ширину, либо используют какое-то иное свойство, которое выполняется для квадрата и не выполняется для прямоугольника
Описание "является" для открытого наследования оказывается неверно понятым при использовании аналогий из реального мира: квадрат "является" прямоугольником в математическом смысле, но с точки зрения поведения Square не является Rectanglе. Вот почему вместо "является" мы предпочитаем говорить "работает как" (или, если вам это больше нравится, "используется как") для того, чтобы такое описание воспринималось максимально правильно.