Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ
Шрифт:
Этот дизайн сложнее того, что использует только закрытое наследование, потому что здесь используются и открытое наследование, и композиция, а ко всему еще и новый класс (WidgetTimer). Честно говоря, я показал этот вариант в первую очередь для того, чтобы напомнить о существовании различных подходов к решению одной задачи. Стоит привыкать к тому, чтобы не ограничиваться единственным решением (см. также правило 35). Тем
Во-первых, вы можете спроектировать класс Widget так, чтобы ему можно было наследовать, но при этом запретить производным классам переопределять функцию onTick. Если Widget наследуется от Timer, то это невозможно, даже в случае закрытого наследования. (Напомню, что согласно правилу 35 производные классы могут переопределять виртуальные функции, даже если не могут вызывать их). Но если WidgetTimer – это закрытый класс внутри Widget, который наследует Timer, то производные от Widget классы не имеют доступа к WidgetTimer, а значит, не могут ни наследовать ему, ни переопределять его виртуальные функции. Если вам приходилось программировать на языках Java или C# и вы не обратили внимания на то, как можно запретить производным классам переопределять функции базового (с помощью ключевого слова final в Java или sealed в C#), то теперь вы знаете, как добиться примерно того же эффекта в C++.
Во-вторых, вы можете захотеть минимизировать зависимости Widget на этапе компиляции. Если Widget наследует классу Timer, то определение Timer должно быть доступно во время компиляции Widget, поэтому файл, определяющий Widget, вероятно, должен содержать директиву #include "Timer.h". С другой стороны, если WidgetTimer вынести из Widget, а в Widget оставить только указатель на WidgetTimer, тогда Widget сможет обойтись простым объявлением класса WidgetTimer; так что необходимость включать заголовочный файл для Timer будет устранена. Для больших систем такая развязка может оказаться важной. Подробнее о минимизации зависимостей на этапе компиляциии см. правило 31.
Я уже отмечал, что закрытое наследование удобно прежде всего тогда, когда предполагаемым производным классам нужен доступ к защищенным частям базового класса или у них может возникнуть потребность в переопределении одной или более виртуальных функций, но концептуальное отношение между этими классами выражается не словами «является разновидностью», а «реализован посредством». Я также говорил, что существуют ситуации, в частности, связанные с оптимизацией использования памяти, когда закрытое наследование оказывается предпочтительнее композиции.
Граничный случай – действительно граничный: речь идет о классах, в которых вообще нет никаких данных. Такие классы не имеют ни нестатических членов-данных, ни виртуальных функций (поскольку наличие этих функций означает добавление указателя vptr в каждый объект – см. правило 7), ни виртуальных базовых классов (поскольку в этом случае тоже имеют место дополнительные расходы памяти – см. правило 40). Концептуально, объекты таких пустых классов вообще не занимают места, потому что в них не хранится никаких данных. Однако есть технические причины, по которым C++ требует, чтобы любой автономный объект должен иметь ненулевой размер, поэтому для следующих объявлений:
оказывается, что sizeof(HoldsAnlnt) > sizeof(int); член данных Empty занимает какую-то память. Для большинства компиляторов sizeof(Empty) будет равно 1, потому что требование C++ о том, что не должно быть объектов нулевой длины, обычно удовлетворяется молчаливой вставкой одного байта (char) в такой «пустой» объект. Однако из-за необходимости выравнивания (см. правило 50) компилятор может оказаться вынужден дополнить классы, подобные HoldsAnInt, поэтому вполне вероятно, что размер объектов HoldsAnInt увеличится больше чем на char, скорее всего, речь может идти о росте на размер int. На всех компиляторах, где я тестировал, происходило именно так.
Возможно, вы обратили внимание, что, говоря о ненулевом размере, я упомянул «автономные» объекты. Это ограничение не относится к тем частям производного класса, которые унаследованы от базового, поскольку они уже не считаются «автономными». Если вы наследуете Empty вместо того, чтоб включать его,
то почти наверняка обнаружите, что sizeof(HoldsAnlnt) = sizeof(int). Это явление известно как оптимизация пустого базового класса (empty base optimization – EBO), и оно реализовано во всех компиляторах, которые я тестировал. Если вы разрабатываете библиотеку, пользователям которой небезразлично потребление памяти, то знать о EBO будет полезно. Но имейте в виду, что в общем случае оптимизация EBO применяется только для одиночного наследования. Действующие в C++ правила размещения объектов в памяти обычно делают невозможной такую оптимизацию, если производный класс имеет более одного базового.
На практике «пустые» классы на самом деле не совсем пусты. Хотя они и не содержат нестатических данных-членов, но часто включают typedefbi, перечисления, статические члены-данные, или невиртуальные функции. В библиотеке STL есть много технически пустых классов, которые содержат полезные члены (обычно typedef). К их числу относятся, в частности, базовые классы unary_function и binary_function, которым обычно наследуют классы определяемых пользователями функциональных объектов. Благодаря широкому распространению реализаций EBO такое наследование редко увеличивает размеры производных классов.
Но вернемся к основам. Большинство классов не пусты, поэтому EBO редко может служить оправданием закрытому наследованию. Более того, в большинстве случаев наследование выражает отношение «является», а это признак открытого, а не закрытого наследования. Как композиция, так и закрытое наследование выражают отношение «реализован посредством», но композиция проще для понимания, поэтому использует ее всюду, где возможно.
Закрытое наследование чаще всего оказывается разумной стратегией проектирования, когда вы имеете дело с двумя классами, не связанными отношением «является», причем один из них либо нуждается в доступе к защищенным членам другого, либо должен переопределять одну или несколько виртуальных функций последнего. И даже в этом случае мы видели, что сочетание открытого наследования и композиции часто помогают реализовать желаемое поведение, хотя и ценой некоторого усложнения. Говоря о продумывании подхода к применению закрытого наследования, я имею в виду, что прибегать к нему стоит лишь тогда, когда рассмотрены все другие альтернативы и выяснилось, что это лучший способ выразить отношение между двумя классами в вашей программе.
• Закрытое наследование означает «реализован посредством». Обычно этот вариант хуже композиции, но все же приобретает смысл, когда производный класс нуждается в доступе к защищенным членам базового класса или должен переопределять унаследованные виртуальные функции.
• В отличие от композиции, закрытое наследование позволяет проводить оптимизацию пустого базового класса. Это может оказаться важным для разработчиков библиотек, которые стремятся минимизировать размеры объектов.