Правило 34: Различайте наследование интерфейса и наследование реализации
Внешне простая идея открытого наследования при ближайшем рассмотрении оказывается состоящей из двух различных частей: наследования интерфейса функций и наследования их реализации. Различие между этими двумя видами наследования соответствует различию между объявлениями и определениями функций, обсуждавшемуся во введении к этой книге.
При разработке классов иногда требуется, чтобы производные классы наследовали только интерфейс (объявления) функций-членов. В других случаях необходимо, чтобы производные классы наследовали и интерфейс, и реализацию функций, но могли переопределять унаследованную реализацию. А иногда вам может понадобиться использование наследования интерфейса и реализации, но без возможности что-либо переопределять.
Чтобы лучше почувствовать различия между этими вариантами, рассмотрим иерархию классов для представления геометрических фигур в графическом приложении:
class Shape {
public:
virtual void draw() const = 0;
virtual void error(const std::string& msg);
int objectID() const;
...
};
class Rectangle: public Shape {…};
class Ellipse: public Shape {…};
Shape – это абстрактный класс; таковым его делает чисто виртуальная функция draw. В результате пользователи не могут создавать объекты класса Shape, а лишь классов, производных от него. Несмотря на это, Shape оказывает сильное влияние на все открыто наследующие ему классы по следующей причине:
В классе Shape объявлены три функции. Первая, draw, выводит текущий объект на дисплей, подразумеваемый по умолчанию. Вторая, error, вызывается функциями-членами, если необходимо сообщить об ошибке. Третья, objectID, возвращает уникальный целочисленный идентификатор текущего объекта. Каждая из трех функций объявлена по-разному: draw – как чисто виртуальная; error – как просто виртуальная; а objectID – как невиртуальная функция. Каковы практические последствия этих различий?
Рассмотрим первую чисто виртуальную функцию draw:
class Shape {
public:
virtual void draw() const = 0;
...
};
Две наиболее заметные характеристики чисто виртуальных функций – они
• Цель объявления чисто виртуальной функции состоит в том, чтобы производные классы наследовали
Это в полной мере относится к функции Shape::draw, поскольку наиболее разумное требование ко всем объектам класса Shape заключается в том, что они должны быть отображены на дисплее, но Shape не может обеспечить разумной реализации этой функции по умолчанию. Алгоритм рисования эллипса очень сильно отличается от алгоритма рисования прямоугольника. Объявление Shape::draw можно интерпретировать как следующее сообщение разработчикам конкретных подклассов: «Вы должны обеспечить наличие функции draw, но у меня нет ни малейшего представления, как вы это собираетесь сделать».
Между прочим, дать определение чисто виртуальной функции
Shape *ps = new Shape; // ошибка! Shape – абстрактный
Shape *ps1 = new Rectangle; // правильно
ps1->draw(); // вызов Rectangle::draw
Shape *ps2 = new Ellipse; // правильно
Ps2->draw(); // вызов Ellipse::draw
ps1->Shape::draw(); // вызов Shape::draw
ps2->Shape::draw(); // вызов Shape::draw
Кроме перспективы блеснуть перед приятелями-программистами во время вечеринки, знание этой особенности вряд ли даст вам что-то ценное. Тем не менее, как вы увидите ниже, возможность определения чисто виртуальной функции может быть использована в качестве механизма обеспечения более безопасной реализации по умолчанию обычных виртуальных функций.