С++ Как сделать класс ненаследуемым. Паттерн проектирования «Ненаследуемый класс».

2011-08-27 papirosnik Паттерны

В грамотно спроектированной программе должно учитываться множество нюансов. К сожалению, хотя  с++  и исповедует парадигму ООП, но делает это несколько своеобразно. Так, например, в нём разрешено множественное наследование классов. На первый взгляд это кажется разумным и такая возможность считается очень привлекательной, но на практике, как правило, сопряжено со многими трудностями и свидетельствует о плохой архитектуре программы. Возьмём к примеру, пресловутое ромбовидное наследование.

Класс А является базовым, А1 и А2 — его наследники, а класс Б унаследован от А1 и А2. Получается что класс Б является как бы «дважды» А. В качестве реального примере можно рассмотреть класс «Человек». От него порождена гендерная ветка классов «Мужчина», «Женщина», «Гермафродит». Другая ветка наследования представляет профессиональную сторону деятельности человека: «Тунеядец», «Маляр», «Президент», «Милиционер» и т.п. И наконец класс, отражающий реального человека наследуется от этих классов. Например, может быть класс «ЖительЭтогоГорода» с именем Вася, порождённый от классов «Маляр» и «Гермафродит» или  «ЖительЭтогоГорода» с именем Маруся: Маруся->Милиционер->Женщина». Подвох здесь в том, что и Женщина — это Человек и Милиционер — это тоже Человек, а жительница с именем Маруся тогда дважды человек. И это только один пример. Следуя логике цппшного ООП можно наворотить в программе такого, что и коровы начнут летать… так как они унаследованы от соответствующих базовых классов (причём зачастую не прямо и очевидно, а косвенно и перекрёстно).

В таких более  «правильных» в плане ООП (на мой взгляд) языках программирования как Java и C# запрещено множественное наследование классов — допустимо только множественному наследование интерфейсов. Какая разница между реализацией класса и реализацией интерфейса — объяснять здесь не стану. Если разница для вас не очевидна — гугл вам в руки. Справедливости ради только замечу, что при таком подходе коровы тоже могут летать, но заставить их это делать придётся более осознанно, то есть умышленно, с полным пониманием того, для чего и как мы это делаем. С++ для решения проблемы ромбовидного наследования имеет средство, носящее название «виртуальное множественное наследование». Оно прекрасно справляется с проблемой двойственности (тройственности, n-ственности) множественного наследования, но порождает ряд ещё более изощрённых и менее очевидных.

Другой подход для решения проблемы множественного наследования лежит не в контексте языка, а в области проектирования программ. Он заключается в отказе от наследования классов и переходе к их композиции. Это также довольно интересное решение, при котором подкласс Б  не является классом А, а владеет им. В контексте программирования это выражается в том, что класс Б не наследник класса А, но хранит у себя указатель на экземпляр класса А (указатель на своего предка) и пользуется его возможностями. Такой подход позволяет более гибко оперировать крупными и/или сильно связанными программными системами. Т.е. масштабирование, перенос, прочая модификация таких систем выполняется при гораздо меньших затратах времени и сил. Человек, имеющий большой практический опыт ООП программирования, в конце концов сам, без всяких теоретических выкладок, понимает выгоду от применения композиции вместо  наследования. При упоре только на наследование в больших проектах неизбежно возникает ситуация, когда он становится не расширяемым, незначительные изменения в небольшом участке кода лавинообразно приводят к необходимости модификации других, казалось бы малосвязанных частей программы. В итоге такой проект легче переписать с нуля, чем пытаться его развивать и поддерживать.

Небезызвестный Скотт Мэйерс в своей замечательной книге  «Эффективное использование С++. 55 верных советов улучшить структуру и код ваших программ» даёт один из весьма ценных советов.  Смысл его заключается в том, что классы и интерфейсы надо проектировать так, чтобы их легко было использовать правильно, и трудно (или невозможно) неправильно. За таким казалось каламбурным и абстрактным советом скрывается ряд практических рекомендаций для достижения этих целей. Не стану их перечислять (отправляю к вышеуказанной книге), но от себя добавлю ещё одно правило. Это правило выдумал не я, а вычитал его в книже про эффективное использование Java. Заключается оно в том, что классы, которые не должны выступать предками (наследоваться) всегда! должны быть объявлены как ненаследуемые.

В Java это достигается путём простого добавления ключевого слова final (окончательный) перед объявлением класса, в C# для этого применяют ключевое слово sealed (зацементированный), а наш старый добрый сиплюсплюс начисто лишён такой возможности. Стоп, не может быть — возмутятся истинные патриоты этого языка и отчасти окажутся правы. Всё-таки язык С++ довольно гибкий, и практически всегда находится более-менее приемлемое решение любой проблемы. Для многих это даже и не проблема, они рассуждают примерно так: «Зачем мне объявлять класс ненаследуемым, может я его потом захочу наследовать» или «ничего страшного не случится, если я унаследую класс, который изначательно для этого не был предназначен. Язык же этого не запрещает». И, размышляя так, пишут наследников от std::vector и т.п. жуткие вещи. Чем это чревато — становится ясно намного позже…  Наследовать ненаследуемые классы  допустимо (с большой натяжкой) в экстренных случаях, только если вы написали и код класса, и код его клиента, то есть своим классом будете пользоваться только вы… если вы его не будете модифицировать через полтороа-два года… если вы регулярно принимаете препараты, улучшающие память и т.п. Слишко много «если» взамен одного ключевого слова.

В С++ одним ключевым словом мы не отделаемся. Хотя скорее-всего можно было бы и одним словом при помощи директивы препроцессора #define, но эта вещь ещё поковарнее множественного наследования. Здесь проблему «ненаследования» класса будем решать, используя виртуальное (и возможно множественное) наследование, которое было обругано в начале статьи.
Вот решение: объявляем «конструктор по-умолчанию» ненаследуемого класса закрытым, то есть помещаем его в секцию private. Когда вызывается конструктор производного класса, он сперва вызывает «конструктор по-умолчанию» своего базового класса (если такого конструктора вы не писали, компилятор сам его создаст втихую). А поскольку в базовом классе конструктор закрыт, то доступа к нему нет и компилятор заругается. Таким образом, вы лишены возможности порождать класс  Б (желаемый) от класса А (ненаследуемого), у которого закрыт конструктор по-умолчанию. Но… вы также и лишены возможности создавать экземпляры класса А (по той же причине).

Решение подсказывает нам сам Создатель сиплюсплюса Бьерн Страуструп.  Он предлагает класс, который нельзя наследовать (назовём его класс Y), порождать от «вспомогательного» класса, у которого «конструктор по-умолчанию» недоступен (класс X). Но чтобы сам по себе класс Y можно было использовать, мы объявляем его другом для класса X.

1
2
3
4
5
6
7
8
9
10
11
12
class X
{
friend class Y;
private:
 X(); // Конструктор не может вызвать никто, кроме самого класса X и его "друга" Y
};
 
class Y: public X
{
public:
 Y() { ... } // Конструктор доступен снаружи для какого угодно
};

Здесь при попытке создания экземпляра класса Х мы получим ошибку компилятора, так как у него конструктор защищён от внешнего мира. А вот при создании экземпляра класса Y — все нормально, так как «друзья» класса имеют доступ к его защищённым членам и методам.
Теперь предположим, что мы хотим создать класс Z, который унаследован от класса Y.

1
2
3
4
5
class Z: public Y
{
public:
 Y() { ... } // Конструктор доступен снаружи для какого угодно
};

О!  Никакой ошибки нет, компилятор спокойно позволяет создавать класс Z и его экземпляры, то есть наследников от «ненаследуемого» класса Y. Не работает идея?

Всё дело в том, что класс Z не вызывает прямо конструктор класса X, он лишь имеет дело с классом Y, а тот имеет право доступа к закрытому конструктору Х. Казалось бы, замкнутый круг. Но тут нам на помощь приходит ключевое слово virtual, которое спасает от «двойственности» при множественном наследовании. Только здесь мы будем использовать его нетрадиционным способом. Его скрытая особенность  заключается в том, что конструктор базового виртуального класса вызывается в первую очередь. Это связано с тем, что в хитросплетении множественного наследования должна быть гарантия того, что конструкторы базовых классов вызываются до конструктора производного.  А такую гарантию в паутине наследников можно дать, лишь вызвав его самым первым. Таким образом, схема вызовов конструкторов теперь не такая: Z->Y->X, а такая Z->X->Y. Конструктор X теперь уже не вызывается из «дружественного» конструктора Y. Он теперь вызывается напрямую из конструктора «недружественного» класса Z и поэтому компилятор сообщит, что не может породить класс Z. Вот код, демонстрирующий вышесказанное:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class X
{
friend class Y;
private:
 X(); // Конструктор доступен только внутри самого класса X и его "друга" Y
};
 
class Y: public virtual X
{
public:
 Y() { ... } // Конструктор доступен снаружи для какого угодно
};
 
class Z: public Y
{
public:
 Z() { ... } // Тут ошибка, так как конструктор X не доступен данному классу (Z - не друг)
};

Казалось бы всё — цель достигнута, если бы не одно маленькое «но». На данный момент мы не можем создавать экземпляры класса X (они нам и не нужны, и не доступны). Мы не можем создавать экземпляры класса Z (они зачем то нам понадобились, но не доступны). Мы можем создавать только экземпляры класса Y и точка. Таким образом, класс Y — ненаследуемый класс, что нам и требовалось (класс X — вспомогательный для этих целей — об этом ещё немного ниже).

«Но» заключается в том, что неудобно каждый раз писать вспомогательный класс для ненаследуемых классов. Можно написать один и использовать его для всех ненаследуемых классов, но… ) в таком случае в нём придётся перечислить всех возможных его друзей. Они на начальной стадии проектирования вовсе неизвестны и дописывать их туда по мере надобности крайне неудобно. Похожие на эту реализации, встречающиеся в литературе и интернете, на этой стадии и останавливаются. Неудобно — что ж, придётся мириться в угоду надёжности архитектуры.

Но раз вспомогательный класс общий для всех классов, то что мешает его сделать шаблонным? Таким образом, мой паттерн проектирования «ненаследуемый класс» в окончательном варианте выглядит так:

Файл templates.h

1
2
3
4
5
6
7
8
9
10
...
// ----------------------------------------- Underivable ------------------------------------
template <class FriedClass>
class tUnderivable
{
	friend FriendClass;
private:
	tUnderivable() { }
};
...

Теперь, если мы грамотные программисты (а мы такие) и всегда объявляем ненаследуемым класс, не предназначенный для наследования, то мы пишем так:

#include "templates.h"

// класс CSomething в целях минимизации геморроя не должен иметь наследников
class CSomething: public virtual tUnderivable<CSomething>
{
 // ...
};

Кратко, ёмко, лаконично (хотя не настолько, как в C# или Java), а главное — такой простой строчкой мы защищаем себя от множества проблем, могущих возникнуть, если мы не будем применяь на практике выстраданные советы тех, кто уже прошёл по этому тернистому пути.

Но такой уж С++ язык, что ещё без одного «но» не обойтись. Ключевое слово при применении этого паттерна — virtual. Если его опустить, то всё также прекрасно скомпилируется, и так же молча компилятор разрешит вам наследовать класс, для этого не предназначенный. Как заставить пользователя (в 1-ю очередь себя) не забывать писать в данном случае слово virtual (компилятор должен предупреждать, что этот класс можно наследовать только виртуально) — я ещё не придумал.

Пользуйтесь.

One Response to “С++ Как сделать класс ненаследуемым. Паттерн проектирования «Ненаследуемый класс».”


Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Powered by WordPress. Designed by elogi.