Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ - Скотт Майерс
Шрифт:
Интервал:
Закладка:
И наконец, пришла очередь невиртуальной функции класса Shape – objectID:
class Shape {
public:
int objectID() const;
...
};
Когда функция-член объявлена невиртуальной, не предполагается, что она будет вести себя иначе в производных классах. В действительности невиртуальные функции-члены выражают инвариант относительно специализации, поскольку определяют поведение, которое должно сохраняться независимо от того, как специализируются производные классы. Справедливо следующее:
• Цель объявления невиртуальной функции – заставить производные классы наследовать как ее интерфейс, так и обязательную реализацию.
Вы можете представлять себе объявление Shape::objectID как утверждение: «Каждый объект Shape имеет функцию, которая дает идентификатор объекта, и этот идентификатор всегда вычисляется одним и тем же способом. Этот способ задается определением функции Shape::objectID, и никакой производный класс не должен его изменять». Поскольку невиртуальная функция определяет инвариант относительно специализации, ее не следует переопределять в производных классах (см. правило 36).
Разница в объявлениях чисто виртуальных, просто виртуальных и невиртуальных функций позволяет точно указать, что, по вашему замыслу, должны наследовать производные классы: только интерфейс, интерфейс и реализацию по умолчанию либо интерфейс и обязательную реализацию соответственно. Поскольку эти типы объявлений обозначают принципиально разные вещи, следует тщательно подходить к выбору подходящего варианта при объявлянии функции-члена. При этом вы должны избегать двух ошибок, чаще всего совершаемых неопытными проектировщиками классов.
Первая ошибка – объявление всех функций невиртуальными. Это не оставляет возможности для маневров в производных классах; при этом больше всего проблем вызывают невиртуальные деструкторы (см. правило 7). Конечно, нет ничего плохого в проектировании классов, которые не предполагается использовать в качестве базовых. В этом случае вполне уместен набор из одних только невиртуальных функций-членов. Однако очень часто такие классы объявляются либо из-за незнания различий между виртуальными и невиртуальными функциями, либо в результате необоснованного беспокойства по поводу потери производительности при использовании виртуальных функций. Факт остается фактом: практически любой класс, который должен использоваться как базовый, будет содержать виртуальные функции (см. правило 7).
Если вы обеспокоены тем, во что обходится использование виртуальных функций, позвольте мне напомнить вам эмпирическое правило «80–20» (см. также правило 30), которое утверждает, что в типичной программе 80 % времени исполнения затрачивается на 20 % кода. Это правило крайне важно, потому что оно означает, что в среднем 80 % ваших функций могут быть виртуальными, не оказывая ощутимого влияния на общую производительность программы. Прежде чем начать беспокоиться о том, можете ли вы позволить себе использование виртуальных функций, убедитесь, что вы имеете дело с теми 20 % программы, для которых ваше решение окажет существенное влияние на производительность.
Другая распространенная ошибка – объявление всех функций виртуальными. Иногда это правильно, о чем свидетельствуют, например, интерфейсные классы (см. правило 31). Однако данное решение может также навести на мысль, что у разработчика нет ясного понимания задачи. Некоторые функции не должны переопределяться в производных классах, и в таком случае необходимо недвусмысленно указать на это, объявляя функции невиртуальными. Не имеет смысла делать вид, что ваш класс годится на все случаи жизни, стоит лишь переопределить его функции. Если вы видите необходимость в инвариантности относительно специализации, не бойтесь это признать!
Что следует помнить• Наследование интерфейса отличается от наследования реализации. При открытом наследовании производные классы всегда наследуют интерфейсы базовых классов.
• Чисто виртуальные функции означают, что наследуется только интерфейс.
• Обычные виртуальные функции означают, что наследуются интерфейс и реализация по умолчанию.
• Невиртуальные функции означают, что наследуются интерфейс и обязательная реализация.
Правило 35: Рассмотрите альтернативы виртуальным функциям
Предположим, что вы работаете над видеоигрой и проектируете иерархию игровых персонажей. В вашей игре будут использоваться разные варианты сражений, персонажи могут подвергаться ранениям или иначе терять жизненные силы. Поэтому вы решаете включить в класс функцию-член healthValue, которая возвращает целочисленное значение, показывающее, сколько жизненных сил осталось у персонажа. Поскольку разные персонажи могут вычислять свою жизненную силу по-разному, то представляется естественным объявить функцию healthValue следующим образом:
class GameCharacter {
public:
virtual void healthValue() const; // возвращает жизненную силу персонажа
... // в производных классах можно
}; // переопределить
Тот факт, что healthValue не объявлена как чисто виртуальная, наводит на мысль, что существует алгоритм вычисления жизненной силы по умолчанию (см. правило 34).
Это очевидный подход к проектированию, и в каком-то смысле в очевидности и заключается его слабость. Поскольку решение кажется совершенно естественным, не исключено, что вы забудете уделить должное внимание рассмотрению альтернатив. Чтобы помочь вам выбраться из колеи, рассмотрим некоторые другие подходы к проблеме.
Реализация паттерна««Шаблонный метод» с помощью идиомы невиртуального интерфейса
Начнем с интересной концепции, которая утверждает, что виртуальные функции почти всегда должны быть закрытыми. Сторонники этой школы предполагают, что правильно было бы оставить функцию-член healthValue открытой, но сделать ее невиртуальной и заставить вызывать закрытую виртуальную функцию, которая и выполнит реальную работу. Назовем эту функцию doHealthValue:
class GameCharacter {
public:
int healthValue() const // производные классы не переопределяют
{ // эту функцию, см. правило 36
... // выполнить предварительные действия –
// см. ниже
int retVal = doHealthValue(); // выполнить реальную работу
... // выполнить завершающие действия –
// см. ниже
return retVal;
}
...
private:
virtual int doHealthValue() const // производные классы могут
{ // переопределить эту функцию
... // алгоритм по умолчанию для вычисления
} // жизненной силы персонажа
};
В этом коде (и ниже в данном правиле) я привожу тела функций в определениях классов. Как следует из правила 30, тем самым они неявно объявляются встроенными. Я поступаю так лишь для того, чтобы смысл кода было проще понять. Описываемый подход к проектированию никак не зависит от того, будут ли функции встроенными или нет.
Основная идея этого подхода – дать возможность клиентам вызывать закрытые виртуальные функции опосредованно, через открытые невиртуальные функции-члены – известен под названием идиома невиртуального интерфейса (non-virtual interface idiom – NVI). Это частный случай более общего паттерна проектирования, называемого «Шаблонный метод» (Template Method) (к сожалению, он не имеет никакого отношения к шаблонам C++). Я называю невиртуальную функцию (healthValue) оберткой (wrapper) виртуальной функции.
Преимущество идиомы NVI таится в коде, скрытом за комментариями «выполнить предварительные действия» и «выполнить завершающие действия». Подразумевается, что некоторый код гарантированно будет выполнен перед вызовом виртуальной функции, выполняющей реальную работу, и после возврата из нее. Таким образом, обертка настроит контекст перед вызовом виртуальной функции создания, а после возврата произведет очистку. Например, «предварительные действия» могут заключаться в захвате мьютекса, записи в протокол, проверке инвариантов класса и выполнении предусловий и т. п. В состав «завершающих действий» могут входить освобождение мьютекса, проверка постусловий функции, повторная проверка инвариантов класса и т. п. Будет затруднительно проделать все это, если вы позволите клиентам вызывать виртуальную функцию непосредственно.
Возможно, вас поразила следующая странность: идиома NVI предполагает, что производные классы-наследники переопределяют закрытые виртуальные функции, которых они и вызывать-то не могут! Но здесь нет противоречия. Переопределяя виртуальную функцию, мы говорим, как должно быть выполнено некоторое действие. Вызов же виртуальной функции определяет момент, когда это действие выполняется. Одно от другого не зависит. Идиома NVI позволяет производным классам переопределить виртуальную функцию и, стало быть, управлять тем, как реализована некоторая функциональность. Базовый же класс оставляет за собой право определять, когда должна быть вызвана функция. Поначалу это может показаться странным, но то, что C++ разрешает в производных классах переопределять закрытые виртуальные функции, вполне разумно.