Интерфейсы как решение проблем множественного наследования

advertisement
Интерфейсы как решение проблем множественного наследования
Евгений Каратаев
В этой работе разбирается проблема множественного наследования в языке
программирования С++ и возможное ее решение путем применения абстракций
интерфейсов.
Множественным наследованием является образование класса путем наследования
одновременно нескольких базовых классов. Штука полезная и одновременно с этим
проблемная. Разберем пример, в котором появляется множественное наследование,
приводящее к проблеме.
Классическим заданием для начинающего программиста является задача написать
классы, реализующие иерархию Человек - Студент - Сотрудник. Обычно первым же
решением есть образование трех классов в виде:
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 имеет два вида поведения если
приведение невозможно - вернуть нулевое значение либо возбудить исключительную
ситуацию. Оба варианта нас полностью устраивают.
Я считаю, что в модели применения интерфейсных классов для решения проблем
множественного наследования будет также красиво построить интерфейсные классы с
конструкторами, не требующими обращения к ядру объекта. Впрочем, это уже из области
философии помехоустойчивого программирования.
Download
Study collections