Правило 34: Различайте наследование интерфейса и наследование реализации

Правило 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 оказывает сильное влияние на все открыто наследующие ему классы по следующей причине:

• Интерфейс функций-членов наследуется всегда. Как объясняется в правиле 32, открытое наследование означает «является», поэтому все, что верно для базового класса, также верно и для производных от него. Поэтому если функция применима к классу, она остается применимой и для подклассов.

В классе Shape объявлены три функции. Первая, draw, выводит текущий объект на дисплей, подразумеваемый по умолчанию. Вторая, error, вызывается функциями-членами, если необходимо сообщить об ошибке. Третья, objectID, возвращает уникальный целочисленный идентификатор текущего объекта. Каждая из трех функций объявлена по-разному: draw – как чисто виртуальная; error – как просто виртуальная; а objectID – как невиртуальная функция. Каковы практические последствия этих различий?

Рассмотрим первую чисто виртуальную функцию draw:

class Shape {

public:

virtual void draw() const = 0;

...

};

Две наиболее заметные характеристики чисто виртуальных функций – они должны быть заново объявлены в любом конкретном наследующем их классе, и в абстрактном классе они обычно не определяются. Сопоставьте эти два свойства, и вы придете к пониманию следующего обстоятельства:

• Цель объявления чисто виртуальной функции состоит в том, чтобы производные классы наследовали только ее интерфейс.

Это в полной мере относится к функции Shape::draw, поскольку наиболее разумное требование ко всем объектам класса Shape заключается в том, что они должны быть отображены на дисплее, но Shape не может обеспечить разумной реализации этой функции по умолчанию. Алгоритм рисования эллипса очень сильно отличается от алгоритма рисования прямоугольника. Объявление Shape::draw можно интерпретировать как следующее сообщение разработчикам конкретных подклассов: «Вы должны обеспечить наличие функции draw, но у меня нет ни малейшего представления, как вы это собираетесь сделать».

Между прочим, дать определение чисто виртуальной функции возможно. Иными словами, вы можете предоставить реализацию для Shape::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

Кроме перспективы блеснуть перед приятелями-программистами во время вечеринки, знание этой особенности вряд ли даст вам что-то ценное. Тем не менее, как вы увидите ниже, возможность определения чисто виртуальной функции может быть использована в качестве механизма обеспечения более безопасной реализации по умолчанию обычных виртуальных функций.

Ситуация с обычными виртуальными функциями несколько отличается от ситуации с чисто виртуальными функциями. Как всегда, производные классы наследуют интерфейс функции, но обычные виртуальные функции традиционно обеспечивают реализацию, которую подклассы могут переопределить. Если вы на минуту задумаетесь над этим, то поймете, что:

• Цель объявлений обычной виртуальной функции – наследовать в производных классах как интерфейс, так и ее реализацию по умолчанию.

Рассмотрим функцию Shape::error:

class Shape {

public:

virtual void error(const std::string& msg);

...

};

Интерфейс говорит о том, что каждый класс должен поддерживать функцию, которую необходимо вызывать при возникновении ошибки, но каждый класс волен обрабатывать ошибки наиболее подходящим для себя образом. Если класс не предполагает производить специальные действия, он может просто положиться на обработку ошибок по умолчанию, которую предоставляет класс Shape. То есть объявление Shape::error говорит разработчикам производных классов: «Вы должны поддерживать функцию error, но если не хотите писать свою собственную, то можете рассчитывать просто использовать версию по умолчанию из класса Shape».

Оказывается, иногда может быть опасно использовать обычные виртуальные функции, которые обеспечивают как интерфейс функции, так и ее реализацию по умолчанию. Для того чтобы понять, почему имеется такая вероятность, рассмотрим иерархию самолетов в компании XYZ Airlines. XYZ располагает самолетами только двух типов: модель A и модель B, и оба летают одинаково. В связи с этим разработчики XYZ проектирует такую иерархию:

class Airport {…}; // представляет аэропорты

class Airplane {

public:

virtual void fly(const Airport& destination);

...

};

void Airplane::fly(const Airport& destination)

{

код по умолчанию, описывающий полет самолета

в заданный пункт назначения – destination

}

class ModelA: public Airplane {...};

class ModelB: public Airplane {...};

Чтобы выразить тот факт, что все самолеты должны поддерживать функцию fly, и для того чтобы засвидетельствовать, что для разных моделей, в принципе, могут потребоваться различные реализации fly, функция Airplane::fly объявлена виртуальной. При этом во избежание написания идентичного кода в классах ModelA и ModelB в качестве стандартного поведения используется тело функции Airplane::fly, которую наследуют как ModelA, так и ModelB.

Это классический пример объектно-ориентированного проектирования. Два класса имеют общее свойство (способ реализации fly), поэтому оно реализуется в базовом классе и наследуется обоими подклассами. Благодаря этому проект явным образом выделяет общие свойства, что позволяет избежать дублирования, благоприятствует проведению будущих модернизаций и упрощает долгосрочную эксплуатацию – иными словами, обеспечивает все, за что так ценится объектно-ориентированная технология. Программисты компании XYZ Airlines могут собой гордиться.

А теперь предположим, что дела XYZ идут в гору, и компания решает приобрести новый самолет модели C. Эта модель отличается от моделей A и B, в частности, тем, что летает по-другому.

Программисты компании XYZ добавляют в иерархию класс ModelC, но в спешке забывают переопределить функцию fly:

class ModelB: public Airplane {

... // функция fly не объявлена

};

В своем коде потом они пишут что-то вроде этого:

Airport PDX(...); // PDX – аэропорт возле моего дома

Airplane *pa = new ModelC;

...

pa->fly(PDX); // вызывается Airplane::fly!

Назревает катастрофа: делается попытка отправить в полет объект ModelC, как если бы он принадлежал одному из классов ModelA или ModelB. Такой образ действия вряд ли может внушить доверие пассажирам.

Проблема здесь заключается не в том, что Airplane::fly ведет себя определенным образом по умолчанию, а в том, что такое наследование допускает неявное применение этой функции для ModelC. К счастью, легко можно предложить подклассам поведение по умолчанию, но не предоставлять его, если они сами об этом не попросят. Трюк состоит в том, чтобы разделить интерфейс виртуальной функции и ее реализацию по умолчанию. Вот один из способов добиться этого:

class Airplane {

public:

virtual void fly(const Airport& destination) = 0;

...

protected:

void defaultFly(const Airport& destination);

};

void Airplane::defaultFly(const Airport& destination)

{

код по умолчанию, описывающий полет самолета в заданный пункт назначения

}

Обратите внимание, что функция Airplane::fly преобразовна в чисто виртуальную. Она предоставляет интерфейс для полета. В классе Airplane присутствует и реализация по умолчанию, но теперь она представлена в форме независимой функции defaultFly. Классы, подобные ModelA и ModelB, которые хотят использовать поведение по умолчанию, просто выполняют встроенный вызов defaultFly внутри fly (см. также правило 30 о взаимодействии встраивания и виртуальных функций):

class ModelA: public Airplane {

public:

virtual void fly(const Airport& destination)

{ defaultFly(destination};}

...

};

class ModelB: public Airplane {

public:

virtual void fly(const Airport& destination)

{ defaultFly(destination};}

...

};

Теперь для класса ModelC возможность случайно унаследовать некорректную реализацию fly исключена, поскольку чисто виртуальная функция в Airplane вынуждает ModelC создавать свою собственную версию fly.

class ModelC: public Airplane {

public:

virtual void fly(const Airport& destination)

...

};

void ModelC::fly(const Airport& destination)

{

код, описывающий полет самолета ModelC в заданный пункт назначения

}

Эта схема не обеспечивает «защиту от дурака» (программисты все же могут создать себе проблемы копированием/вставкой), но она более надежна, чем исходная. Что же касается функции Airplane::defaultFly, то она объявлена защищенной, поскольку действительно является деталью реализации класса Airplane и производных от него. Пассажиры теперь должны беспокоиться только о том, чтобы улететь, а не о том, как происходит полет.

Важно также то, что Airplane::defaultFly объявлена как невиртуальная функция. Это связано с тем, что никакой подкласс не должен ее переопределять – обстоятельство, которому посвящено правило 36. Если бы defaultFly была виртуальной, перед вами снова встала бы та же самая проблема: что, если некоторые подклассы забудут переопределить defaultFly должным образом?

Иногда высказываются возражения против идеи разделения функций на обеспечивающие интерфейс и реализацию по умолчанию, такие, например, как fly и defaultFly. Прежде всего, отмечают противники этой идеи, это засоряет пространство имен класса близкими названиями функций. Все же они соглашаются с тем, что интерфейс и реализация по умолчанию должны быть разделены. Как разрешить кажущееся противоречие? Для этого используется тот факт, что производные классы должны переопределять чисто виртуальные функции и при необходимости предоставлять свои собственные реализации. Вот как можно было бы использовать возможность определения чисто виртуальных функций в иерархии Airplane:

class Airplane {

public:

virtual void fly(const Airport& destination) = 0;

...

};

void Airplane::fly(const Airport& destination) // реализация чисто

{ // виртуальной функции

код по умолчанию, описывающий полет

самолета в заданный пункт назначения

}

class ModelA: pubic Airplane {

public:

virtual void fly(const Airport& destination)

{ Airplane::fly(destination);}

...

};

class ModelB: pubic Airplane {

public:

virtual void fly(const Airport& destination)

{ Airplane::fly(destination);}

...

};

class ModelC: pubic Airplane {

public:

virtual void fly(const Airport& destination);

...

};

void ModelC::fly(const Airport& destination)

{

код, описывающий полет самолета ModelC в заданный пункт назначения

}

Это практически такой же подход, как и прежде, за исключением того, что тело чисто виртуальной функции Airplane::fly заменяет собой независимую функцию Airplane::defaultFly. По существу, fly разбита на две основные составляющие. Объявление задает интерфейс (который должен быть использован в производных классах), а определение задает поведение по умолчанию (которое может использоваться производным классом, но только по явному требованию). Однако, производя слияние fly и defaultFly, мы теряем возможность задать для этих функций разные уровни доступа: код, который должен быть защищенным (функция defaultFly), становится открытым (потому что теперь он находится внутри fly).

И наконец, пришла очередь невиртуальной функции класса Shape – objectID:

class Shape {

public:

int objectID() const;

...

};

Когда функция-член объявлена невиртуальной, не предполагается, что она будет вести себя иначе в производных классах. В действительности невиртуальные функции-члены выражают инвариант относительно специализации, поскольку определяют поведение, которое должно сохраняться независимо от того, как специализируются производные классы. Справедливо следующее:

• Цель объявления невиртуальной функции – заставить производные классы наследовать как ее интерфейс, так и обязательную реализацию.

Вы можете представлять себе объявление Shape::objectID как утверждение: «Каждый объект Shape имеет функцию, которая дает идентификатор объекта, и этот идентификатор всегда вычисляется одним и тем же способом. Этот способ задается определением функции Shape::objectID, и никакой производный класс не должен его изменять». Поскольку невиртуальная функция определяет инвариант относительно специализации, ее не следует переопределять в производных классах (см. правило 36).

Разница в объявлениях чисто виртуальных, просто виртуальных и невиртуальных функций позволяет точно указать, что, по вашему замыслу, должны наследовать производные классы: только интерфейс, интерфейс и реализацию по умолчанию либо интерфейс и обязательную реализацию соответственно. Поскольку эти типы объявлений обозначают принципиально разные вещи, следует тщательно подходить к выбору подходящего варианта при объявлянии функции-члена. При этом вы должны избегать двух ошибок, чаще всего совершаемых неопытными проектировщиками классов.

Первая ошибка – объявление всех функций невиртуальными. Это не оставляет возможности для маневров в производных классах; при этом больше всего проблем вызывают невиртуальные деструкторы (см. правило 7). Конечно, нет ничего плохого в проектировании классов, которые не предполагается использовать в качестве базовых. В этом случае вполне уместен набор из одних только невиртуальных функций-членов. Однако очень часто такие классы объявляются либо из-за незнания различий между виртуальными и невиртуальными функциями, либо в результате необоснованного беспокойства по поводу потери производительности при использовании виртуальных функций. Факт остается фактом: практически любой класс, который должен использоваться как базовый, будет содержать виртуальные функции (см. правило 7).

Если вы обеспокоены тем, во что обходится использование виртуальных функций, позвольте мне напомнить вам эмпирическое правило «80–20» (см. также правило 30), которое утверждает, что в типичной программе 80 % времени исполнения затрачивается на 20 % кода. Это правило крайне важно, потому что оно означает, что в среднем 80 % ваших функций могут быть виртуальными, не оказывая ощутимого влияния на общую производительность программы. Прежде чем начать беспокоиться о том, можете ли вы позволить себе использование виртуальных функций, убедитесь, что вы имеете дело с теми 20 % программы, для которых ваше решение окажет существенное влияние на производительность.

Другая распространенная ошибка – объявление всех функций виртуальными. Иногда это правильно, о чем свидетельствуют, например, интерфейсные классы (см. правило 31). Однако данное решение может также навести на мысль, что у разработчика нет ясного понимания задачи. Некоторые функции не должны переопределяться в производных классах, и в таком случае необходимо недвусмысленно указать на это, объявляя функции невиртуальными. Не имеет смысла делать вид, что ваш класс годится на все случаи жизни, стоит лишь переопределить его функции. Если вы видите необходимость в инвариантности относительно специализации, не бойтесь это признать!

Что следует помнить

• Наследование интерфейса отличается от наследования реализации. При открытом наследовании производные классы всегда наследуют интерфейсы базовых классов.

• Чисто виртуальные функции означают, что наследуется только интерфейс.

• Обычные виртуальные функции означают, что наследуются интерфейс и реализация по умолчанию.

• Невиртуальные функции означают, что наследуются интерфейс и обязательная реализация.

Данный текст является ознакомительным фрагментом.