Герб Саттер - Стандарты программирования на С++. 101 правило и рекомендация
Открытое наследование действительно связано с повторным использованием, но опять же не так, как привыкло думать множество программистов. Как уже указывалось, цель открытого наследования — в реализации заменимости (см. [Liskov88]). Цель отрытого наследования не в том, чтобы производный класс мог повторно использовать код базового класса для того, чтобы с его помощью реализовать свою функциональность. Такое отношение "реализован посредством" вполне корректно, но должно быть смоделировано при помощи композиции или, только в отдельных случаях, при помощи закрытого или защищенного наследования (см. рекомендацию 34).
Вот еще одно соображение по поводу рассматриваемой темы. При корректности и целесообразности динамического полиморфизма композиция "эгоистична" в отличие от "щедрого" наследования. Поясним эту мысль.
Новый производный класс представляет собой частный случай существующей более общей абстракции. Существующий (динамический) полиморфный код, который использует Base& или Base* путем вызова виртуальных функций Base, должен быть способен прозрачно использовать объекты класса MyNewDerivedType, производного от Base. Новый производный тип добавляет новую функциональность к существующему коду, который при этом не должен изменяться. Однако несмотря на неизменность имеющегося кода, при использовании им новых производных объектов его функциональность возрастает.
Новые требования, естественно, должны удовлетворяться новым кодом, но они не должны требовать внесения изменений в существующий код (см. рекомендацию 36).
До объектно-ориентированного программирования было легко решить вопрос вызова старого кода новым. Открытое наследование упростило прозрачный и безопасный вызов нового кода старым (так что применяйте шаблоны, предоставляющие возможности статического полиморфизма, который хорошо совместим с полиморфизмом динамическим — см. рекомендацию 64).
ИсключенияКлассы стратегий добавляют новое поведение путем открытого наследования, но это не является неверным употреблением открытого наследования для моделирования отношения "реализован посредством".
Ссылки[Cargill92] pp. 19-20 • [Cline99] §5.13, §7.01-8.15 • [Dewhurst03] §92 • [Liskov88] • [Meyers97] §35 • [Stroustrup00] §12.4.1, §23.4.3.1, §24.3.4 • [Sutter00] §22-24
38. Практикуйте безопасное перекрытие
Ответственно подходите в перекрытию функций. Когда вы перекрываете виртуальную функцию, сохраняйте заменимость; в частности, обратите внимание на пред- и постусловия в базовом классе. Не изменяйте аргументы по умолчанию виртуальных функций. Предпочтительно явно указывать перекрываемые функции как виртуальные. Не забывайте о сокрытии перегруженных функций в базовом классе.
ОбсуждениеХотя обычно производные классы добавляют новые состояния (т.е. члены-данные), они моделируют подмножества (а не надмножества) своих базовых классов. При корректном наследовании производный класс моделирует частный случай более общей базовой концепции (см. рекомендацию 37).
Это имеет прямые следствия для корректного перекрытия функций. Соблюдение отношения включения влечет за собой заменимость — операции, которые применимы ко всему множеству, применимы и к любому его подмножеству. Если базовый класс гарантирует выполнение определенных пред- и постусловий некоторой операции, то и любой производный класс должен обеспечить их выполнение. Перекрытие может предъявлять меньше требований и предоставлять большую функциональность, но никогда не должно предъявлять большие требования или меньше обещать — поскольку это приведет к нарушению контракта с вызывающим кодом.
Определение в производном классе перекрытия, которое может быть неуспешным (например, генерировать исключения; см. рекомендацию 70), будет корректно только в том случае, когда в базовом классе не объявлено, что данная операция всегда успешна. Например, скажем, класс Employee содержит виртуальную функцию-член GetBuilding, предназначение которой — вернуть код здания, в котором работает объект Employee. Но что если мы захотим написать производный класс RemoteContractor, который перекрывает функцию GetBuilding, в результате чего она может генерировать исключения или возвращать нулевой код здания? Такое поведение корректно только в том случае, если в документации класса Employee указано, что функция GetBuilding может завершаться неуспешно, и в классе RemoteContractor сообщение о неудаче выполняется документированным в классе Employee способом.
Никогда не изменяйте аргумент по умолчанию при перекрытии. Он не является частью сигнатуры функции, и клиентский код будет невольно передавать различные аргументы в функцию, в зависимости от того, какой узел иерархии обращается к ней. Рассмотрим следующий пример:
class Base {
// ...
virtual void Foo(int x = 0);
};
class Derived : public Base {
// ...
virtual void Foo(int x = 1); // лучше так не делать...
};
Derived *pD = new Derived;
pD->Foo(); // Вызов pD->Foo(1)
Base *pB = pD;
pB->Foo(); // вызов pB->Foo(0)
У некоторых может вызвать удивление, что одна и та же функция-член одного и того же объекта получает разные аргументы в зависимости от статического типа, посредством которого к ней выполняется обращение.
Желательно добавлять ключевое слово virtual при перекрытии функций, несмотря на его избыточность — это сделает код более удобным для чтения и понимания.
Не забывайте о том, что перекрытие может скрывать перегруженные функции из базового класса, например:
class Base{ // ...
virtual void Foo(int);
virtual void Foo(int, int);
void Foo(int, int, int);
};
class Derived : public Base { // ...
virtual void Foo(int); // Перекрывает Base::Foo(int),
// скрывая остальные функции
};
Derived d;
d.Foo(1); // Все в порядке
d.Foo(1, 2); // Ошибка
d.Foo(1, 2, 3); // Ошибка
Если перегруженные функции из базового класса должны быть видимы, воспользуйтесь объявлением using для того, чтобы повторно объявить их в производном классе:
class Derived : public Base { // ...
virtual void Foo(int); // Перекрытие Base::Foo(int)
using Base::Foo; // вносит все прочие перегрузки
// Base::Foo в область видимости
};
ПримерыПример: Ostrich (Страус). Если класс Bird (Птица) определяет виртуальную функцию Fly и вы порождаете новый класс Ostrich (известный как птица, которая не летает) из класса Bird, то как вы реализуете Ostrich::Fly? Ответ стандартный — "по обстоятельствам". Если Bird::Fly гарантирует успешность (т.е. обеспечивает гарантию бессбойности; см. рекомендацию 71), поскольку способность летать есть неотъемлемой частью модели Bird, то класс Ostrich оказывается неадекватной реализацией такой модели.
Ссылки[Dewhurst03] §73-74, §78-79 • [Sutter00] §21 • [Keffer95] p. 18
39. Виртуальные функции стоит делать неоткрытыми, а открытые — невиртуальными
В базовых классах с высокой стоимостью изменений (в частности, в библиотеках) лучше делать открытые функции невиртуальными. Виртуальные функции лучше делать закрытыми, или защищенными — если производный класс должен иметь возможность вызывать их базовые версии (этот совет не относится к деструкторам; см. рекомендацию 50).
ОбсуждениеБольшинство из нас на собственном горьком опыте выучило правило, что члены класса должны быть закрытыми, если только мы не хотим специально обеспечить доступ к ним со стороны внешнего кода. Это просто правило хорошего тона обычной инкапсуляции. В основном это правило применимо к членам-данным (см. рекомендацию 41), но его можно с тем же успехом использовать для любых членов класса, включая виртуальные функции.
В частности, в объектно-ориентированных иерархиях, внесение изменений в которые обходится достаточно дорого, предпочтительна полная абстракция: лучше делать открытые функции невиртуальными, а виртуальные функции — закрытыми (или защищенными, если производные классы должны иметь возможность вызывать базовые версии). Это — шаблон проектирования Nonvirtual Interface (NVI). (Этот шаблон похож на другие, в особенности на Template Method [Gamma95], но имеет другую мотивацию и предназначение.)
Открытая виртуальная функция по своей природе решает две различные параллельные задачи.