Интерфейсы как решение проблем множественного
наследования
Евгений Каратаев
В
этой работе разбирается проблема множественного наследования в языке
программирования С++ и возможное ее решение путем применения абстракций
интерфейсов.
Множественным
наследованием является образование класса путем наследования одновременно
нескольких базовых классов. Штука полезная и одновременно с этим проблемная.
Разберем пример, в котором появляется множественное наследование, приводящее к
проблеме.
Классическим
заданием для начинающего программиста является задача написать классы,
реализующие иерархию Человек - Студент - Сотрудник. Обычно первым же решением
есть образование трех классов в виде:
class
Человек { ... };
class
Сотрудник : public Человек { ... };
class
Студент : public Человек { ... };
В
классе Человек декларируются несколько виртуальных и, возможно, абстрактных,
функций, которые переопределяются / реализуются в классах-наследниках. Схема на
первый взгляд совершенно очевидна и практически ни у кого не вызывает
подозрений. Схема реализуется в программе и программа сдается в работу.
Проблема
возникает позже, когда оператор приходит и говорит:
-
У меня есть человек, который одновременно и сотрудник и студент. Что мне
делать?
Реализованная
схема, вообще говоря, не предполагает такого варианта - могут быть либо
сотрудник, либо студент. Но что-то делать надо. В этот момент приходит на
помощь множественное наследование. Программист, не долго думая, создает еще
один класс, образованный наследованием и от Сотрудник и от Студент:
class
СтудентСотрудник : public Студент, public Сотрудник { ...};
На
первый взгляд все в порядке, на второй - полный бардак. Дело в том, что
класс Сотрудник, как он был декларирован, содержит в себе полную копию класса
Человек. То же самое относится и к классу Студент. Таким образом, класс
СтудентСотрудник будет содержать в себе уже 2 копии класса Человек. При этом
функции класса Сотрудник будут работать со своим экземпляром класса Человек, а
функции класса Студент - со своим. В результате корректного поведения добиться
практически очень трудно. В классе СтудентСотрудник придется переопределять все
функции базовых классов и вызывать соответствующие функции базовых классов,
чтобы модификации обеих копий класса Человек прошли когерентно.
Обнаружив
такую ситуацию путем тяжелой отладки, программист приходит к необходимости
применения виртуального наследования для исключения дублирования класса
Человек. Проблема состоит в том, что виртуальное наследование требует
модификации графа наследования базовых классов. Требуемая схема имеет вид:
class
Человек { ... };
class
Студент : virtual public Человек { ... };
class
Сотрудник : virtual public Человек { ... };
class
СтудентСотрудник : public Студент, public Сотрудник { ...
};
В
этом варианте решена проблема однозначной входимости класса Человек во все
классы. Но остается вопрос - не возникнет ли такой же проблемы и дальше с
полученным классом СтудентСотрудник? И будет ли возможность произвести
модификацию уже работающего кода? В такой ситуации руки могут опуститься -
следует либо согласиться с существованием проблемного кода либо действительно
идти на полную переработку программы.
Тем
не менее элегантное решение существует. Это реализация базовых классов по
принципу интерфейсов. Язык С++ не содержит языковой поддержки интерфейсов в
явном виде, поэтому будем их эмулировать. Принцип интерфейса состоит в том, что
его задачей является не столько реализация класса, сколько его декларация.
Нормализуем исходную задачу:
class
БытьЧеловеком { ... };
class
БытьСтудентом { ... };
class
БытьСотрудником { ... };
Исходя
из нормализованного множества классов, получим дополнение:
class
Человек : public БытьЧеловеком { ... };
class
Сотрудник : public БытьЧеловеком, public БытьСотрудником { ... };
class
Студент : public БытьЧеловеком, public БытьСтудентом { ...};
class
СтудентСотрудник : public БытьЧеловеком, public БытьСтудентом,
public БытьСотрудником { ... };
Формально
говоря, такая схема построения классов вполне работоспособна за исключением
того, что во многих случаях программисты относятся к интерфейсам слишком уж
буквально - оставляют в них только абстрактные функции и реализуют эти функции
только в классах-наследниках. В результате полностью выхолащивается идея
повторного использования кода. Основанием для нереализации функций в
интерфейсных классах обычно служит то, что в классе - интерфейсе нет
"ядра" объекта. В нашем случае ядром объекта или классом, реализующим
возможность существования объекта, может выступать класс БытьЧеловеком.
Возможным
решением проблемы является передача конструктору интерфейсного класса указателя
на конструируемый объект с тем, чтобы его запомнить в своем частном поле данных
и использовать при реализации функций интерфейса. Примерно по схеме:
class
БытьСтудентом
{
БытьЧеловеком& m_БытьЧеловеком;
public:
БытьСтудентом( БытьЧеловеком& init)
: m_БытьЧеловеком( init)
{ ... };
};
class
Студент : public БытьЧеловеком, public БытьСтудентом
{
public:
Студент()
: БытьЧеловеком(), БытьСтудентом( *this)
{ ...};
};
В
этой схеме, согласно стандарту, также есть проблема - стандарт не гарантирует
инициализации конструкторов, указанных в списке инициализации, в том порядке, в
котором они перечислены в этом списке. Поэтому мы, передавая *this как аргумент
конструктора базового класса, получаем ссылку на негарантированно определенный
объект. Выйти из этой ситуации можно, если декларировать конструктор без
аргументов и создать дополнительную функцию инициализации, зависящую от *this.
Но дублирование ссылок, хранимых в интерфейсных классах, тем не менее,
сохраняется и это есть некрасиво.
Для
решения этой задачи есть чрезвычайно красивое, на мой взгляд, решение. Решение
заключается в том, чтобы не хранить ссылку на ядро объекта, а получать ее
динамически. Для этого применяется оператор приведения типа dynamic_cast,
применяемый не к классу, а к объекту в процессе работы программы. Пример:
class
БытьСтудентом
{
public:
БытьСтудентом(){};
virtual void Func( void);
// пример функции, обращающейся к ядру
объекта
{
БытьЧеловеком* ptr = dynamic_cast( this);
if( ptr)
{
// используем ядро
}
};
};
На
первый взгляд, приведение типа БытьСтудентом к типу БытьЧеловеком невозможно,
поскольку никто их этих классов ни от кого не наследован. Но дело в том, что
оператор dynamic_cast определен не для классов, а для объектов. И если при
исполнении кода Func реальный объект, для которого эта функция выполняется,
имееет класс, унаследованый от БытьЧеловеком, то оператор вернет правильное
значение. Согласно стандарту, оператор приведения типа dynamic_cast имеет два
вида поведения если приведение невозможно - вернуть нулевое значение либо
возбудить исключительную ситуацию. Оба варианта нас полностью устраивают.
Я
считаю, что в модели применения интерфейсных классов для решения проблем
множественного наследования будет также красиво построить интерфейсные классы с
конструкторами, не требующими обращения к ядру объекта. Впрочем, это уже из
области философии помехоустойчивого программирования.
Список литературы
Для
подготовки данной работы были использованы материалы с сайта http://karataev.nm.ru/