Отправляет email-рассылки с помощью сервиса Sendsay
  Все выпуски  

Виртуальное наследование


Домашняя страница www.devdoc.ru

DevDoc - это новые статьи по программированию каждую неделю.

Заходи и читай!

Домашняя страница Письмо автору Архив рассылки Публикация статьи

Выпуск №25

Здравствуйте уважаемые подписчики, сегодня в номере:

  • Новое на сайте
  • Статья "Виртуальное наследование"

Новое на сайте

Сегодня мы будем говорить о виртуальном наследовании. Мало кто его использует, однако знать о нем весьма полезно. Статья ниже.

На этой неделе в движок сайта были внесены некоторые изменения. Появилась возможность добавлять комментарии/вопросы/пожелания ко всем статьям. Делается это буквально одним кликом мыши, если вы читаете статью с сайта. Я кстати рекомендую это делать, т.к. зачастую материалы содержат рисунки. Кроме того ставить оценки статьям теперь разрешено всем, а не только зарегистрированным пользователям.

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

Сейчас идет подготовка к организации конкурса на лучшую статью для сайта www.devdoc.ru. Мы планируем опубликовать правила участия в следующем выпуске рассылки. Всем участникам достанется подарок, а победители будут награждены ценными призами. Прошу присылать мне свои мысли и предложения по правилам проведению конкурса.

Внимание! Компания CycloneSoft набирает программистов на постоянную работу в г. Ростове-на-Дону. Работа в офисе. Обязательное требование – отличное знание языка C++ и искренний интерес к профессии. Резюме направляйте по адресу resume@raurat.ru.


Постоянная ссылка на статью (с картинками): http://www.devdoc.ru/index.php/content/view/x64porting.htm

Автор: Кудинов Александр
Последняя модификация: 2007-09-03 18:28:04

Виртуальное наследование

Введение

Наследование- это один из трех «китов», на которых строится C++. Эта тема подробно разжевывается в многочисленных учебниках по языку и публикациях в журналах и в сети. Тем не менее, в них упор делается больше на описание синтаксиса, но не объясняется, почему происходит так, а не по-другому. В статье «Виртуальные функции – низкоуровневый взгляд» рассматривается простое наследование. Там приводятся примеры организации памяти при наследовании, а также то, как методы работают с полями класса. Рекомендую прочитать эту статью – она поможет понять настоящий материал.

В настоящем материале будет рассматриваться виртуальное наследование, а также множественное наследование в целом. Как обычно, мы будем рассматривать то, как этот механизм работает изнутри.

Все примеры были протестированы с помощью MS VC 2003 .NET. Многие вещи зависят от реализации, но базовые концепции остаются одинаковыми для всех компиляторов.

Множественное наследование

О проектировании иерархии классов говорили все кому не лень. Кто-то по делу, а кто-то об «имитации объектов реального мира». Я не буду засорять статью рассуждениями о том, зачем это нужно и как правильно. Мы будем рассматривать синтаксис языка, а также то, что происходит по другую сторону компилятора.

Итак, иерархия при множественном наследовании может выглядеть так:

На C++ такое дерево описывается следующим образом:

class A2{int a2;};
class B1{int b1;};
class B2 : public A2{int b2;};
class C : public B1, public B2{int c;};

Как мы уже знаем, при создании объекта память распределяется только для данных класса. Для примера каждый класс содержит одну переменную типа int. При множественном наследовании класс C будет содержать данные всех базовых классов. При этом в памяти они будут группироваться в том же порядке, в каком классы упоминаются в списке наследования. В нашем случае, класс C будет содержать сначала данные класса B1, затем A2, B2 и, наконец,С.

На псевдокоде это будет выглядеть так:

struct C
{
   int b1;
   int a2;
   int b2;
   int c;
};

Конструкторы для инициализации объекта C вызываются как обычно: сначала конструкторы базовых классов, а затем производных. Если базовых классов несколько – конструкторы вызываются в том же порядке, в каком они заданы в списке наследования. В нашем случае конструкторы будут вызываться в следующем порядке:

B1(), A2(), B2(), C().

Деструкторы вызываются в обратном порядке. Все как обычно.

Теперь рассмотрим другую иерархию, в которой классы B1 и B2 производные от класса A.

Она аналогична следующей иерархии, т.к. производные классы содержат в себе все члены базового класса.

Теперь предположим, что нам надо создать класс C, производный от B1 и B2. Тогда при классическом множественном наследовании цепочка примет вид:

Как видим, теперь класс C содержит две копии класса A. На C++ такая иерархия описывается так:

class A
{
 int a;
public:
 void foo(){ a = 0; };
};
class B1 : public A{ int b1;};
class B2 : public A{ int b2;};
class C : public B1, public B2{int c;};

Этот код компилируется без ошибок. Если теперь создать объект и вызвать метод foo:

C c;
 c.foo();

Компилятор выдаст ошибку. Почему? Давайте вспомним, как работает приведение типов, и как методы работают с полями объектов через указатель this.

На псевдокоде предыдущий вызов выглядит так:

foo((A*)&c);    //функции передается скрытый указатель this на объект
       //с которым она должна работать.

Ошибка кроется в приведении типа, т.к. класс C содержит две копии класса A. Мы должны явно указать, либо какую копию использовать, либо заново определить метод foo в классе C. Ниже приведены оба варианта решения:

1. 
class C : public B1, public B2
{
 int c;
public:
 using B1::foo;
};
 
2.
class C : public B1, public B2
{
 int c;
public:
 void foo(){ B2::foo(); B1::foo();};
};

Варианты имеют небольшие различия. В первом мы явно указываем, что надо использовать B1::foo(), а во втором мы переопределили метод foo и перенаправили вызов к обеим копиям класса A. В каких_то случаях это может быть приемлемым поведением, но оба метода имеют существенные недостатки. В первом случае – мы будем использовать только одну из копий объекта A. Вторая копия у нас просто «повиснет» и программа в целом может работать неверно, т.к. класс B ничего не знает о том, что его предок не используется. Т.е. вторая копия подобъекта A никак не будет использоваться, но будет занимать память. Во втором варианте – просто выполняется дублирование операций, и как следствие возникает необходимость поддерживать синхронность обоих подобъектов типа A. Это плохой стиль и рассадник всевозможных ошибок.

Ромбовидное «наследование»

Глядя на предыдущий рисунок, напрашивается решение возникшей проблемы. Было бы здорово, если бы класс C содержал в себе только одну копию класса A. Можно решить задачу в лоб. Рассмотрим следующий пример:

class A
{
 int a;
public:
 void foo(){ a = 0;};
};
 
class B1 
{ 
 int b1;
protected:
 A *pClassA;
 B1(A *p) : pClassA(p){};
};
 
class B2 
{
 int b2;
protected:
 A *pClassA;
 B2(A *p) : pClassA(p){};
};
class C : public B1, public B2
{
 int c;
public:
 C(A *p) : B1(p), B2(p){};
 void foo(){ B1::pClassA->foo(); };
};
 
int _tmain(int argc, _TCHAR* argv[])
{
 A *pA = new A;
 C c(pA);
 c.foo();
 
 delete pA;
 return 0;
}

Здесь наследование заменили агрегацией. Диаграмма выглядит следующим образом:

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

Программа создает экземпляр класса A и передает его в качестве параметра в конструктор класса C. Тот в свою очередь инициализирует этим значением указатели в B1 и B2. Т.о, эти два класса ссылаются на один экземпляр объекта A. Что собственно и требовалось получить. Безусловно, создание экземпляра класса A таким способом – не лучшее архитектурное решение. Оптимальный вариант, когда класс C сам будет создавать и разрушать эту копию для избежания путаницы. Это также позволит реализовать стратегию функционального замыкания. Т.е. объект типа A создается в конструкторе класса C, а разрушается в деструкторе. Это гарантированно избавляет нас от утечки памяти.

Обратите внимание, что в классе C пришлось переопределить метод foo. Это нужно для того, чтобы разрешить конфликт. Программа должна использовать указатель либо из объекта B1, либо из B2 для доступа к A. На самом деле их можно использовать вперемешку – все равно они указывают на один и тот же объект.

Обратите внимание, что приведенное решение нельзя назвать «наследованием». Настоящее ромбовидное наследование достигается другими средствами. О них читайте в следующем разделе.

Виртуальное наследование

И вот теперь самое вкусное! Разработчики языка предусмотрели, что при множественном наследовании может образовываться ромбовидная иерархия. Для разрешения конфликта доступа к подобъектам можно использовать виртуальное наследование:

class A
{
 int a;
public:
 void foo(){ a = 0;};
};
 
class B1 : virtual public A
{ 
 int b1;
};
 
class B2 : virtual public A
{
 int b2;
};
class C : public B1, public B2
{
 int c;
};

Такой код порождает следующую иерархию:

Как видим, этот пример не сильно отличается от того, который использует классическое множественное наследование. Различие только в списке базовых классов для B1, B2. Там добавилось ключевое слово virtual. Оно дает указание компилятору, что эти классы могут образовывать ромбовидные структуры, как на рисунке выше.

Низкоуровневая реализация

Ну а теперь давайте перейдем к тому, ради чего писалась вся эта статья. Попробуем понять, как компилятор поддерживает ромбовидное наследование.

На самом деле компилятор делает практически то же самое, что мы рассматривали в разделе Ромбовидное «наследование». Он добавляет в начало классов B2 и B1 дополнительный указатель на таблицу виртуальных классов. Теперь, когда требуется обратиться к полям класса A – компилятор определяет адрес единственного экземпляра через эту таблицу. Несмотря на то, что внутренняя структура виртуального наследования больше похожа на агрегацию, с точки зрения языка она ничем не отличается от обычного.

class A
{
 int a;
public:
 void foo(){ a = 0;};
};
 
class B1 : virtual public A
{ 
 int b1;
};
 
class B2 : virtual public A
{
 int b2;
};
class C : public B1, public B2
{
 int c;
};
 
int _tmain(int argc, _TCHAR* argv[])
{
 С c;
 c.foo();
 
 return 0;
}

Теперь вызов c.foo() не вызывает никаких проблем, т.к. компилятор автоматически выполняет все необходимые преобразования для приведения типов. Сейчас в иерархии находится только один подобъект класса A, поэтому конфликта не возникает.

Реализация таблицы виртуальных классов (ТВК) никак не регламентируется и лежит на совести разработчиков компилятора. Я для примера разберу, что делает компилятор MS VC++ 2003 .NET, чтобы вы лучше понимали что происходит.

Итак, будем рассматривать классы из предыдущих примеров. При создании класса C компилятор формирует в памяти следующие структуры:

Таблицы виртуальных классов содержат смещения относительно начала класса для доступа к полям класса А. Так ТВК для B1 содержит смещение 0x18 (24) относительно начала класса B1 для доступа к полям класса A. Обратите внимание, что первый элемент ТВК содержит нулевое смещение. Очевидно, что это смещение для доступа к собственным полям. ТВК для B2 содержит уже другое смещение, т.к. его позиция в пределах всего класса C уже другая. ТВК, как и таблицы виртуальных функций, распределяются статически. Т.е. одна копия таблицы используется всеми классами типа C.

Обратите внимание еще на одно отличие: распределение памяти для класса C. В нашем примере с агрегацией мы использовали динамическое распределение, в то время как компилятор просто размещает единственную копию подобъекта A в конце объекта. Тому есть ряд объективных причин. Выделяя память для объекта единым блоком существенно уменьшаются накладные расходы. Кроме того, появляется возможность распределять память для объекта на стеке. Объект C занимает одну непрерывную область памяти. Поэтому ТВК вместо указателей содержат смещения. Это тоже существенно экономит ресурсы, т.к. позволяет иметь только одну копию ТВК для всех классов. Все смещения всегда константы, независимо от того, где выделена память для объекта.

Инициализация базового класса

Внимательные читатели уже наверняка заметили одну интересную вещь, связанную с инициализацией базового класса. Рассматривая предыдущий пример, можно увидеть, что поля класса A доступны двумя способами: B1::a и B2::a. Если не используется виртуальное наследование, то инициализация происходит как обычно. Сначала вызываются конструкторы базовых классов, а потом производных. Даже если иерархия содержит два и более одинаковых объекта – они будут проинициализированы. Каждый из них по своей ветке дерева наследования.

При виртуальном наследовании ситуация другая. Возвращаясь к нашему примеру, видно, что объект C содержит только одну копию подобъекта A. Тем не менее инициализация базового класса может происходить по двум ветвям дерева. Какой вариант выбирает компилятор? Никакой! Оба ведут к неоднозначности. Использование инициализации по обоим веткам – лишние накладные расходы и причина ошибок, т.к. конструктор будет вызываться несколько раз. Если же базовый класс содержит несколько конструкторов и каждая ветка при инициализации будет использовать свой... Тушите свет!

Разработчики языка предусмотрели такой вариант. Взгляните еще раз на пример, где мы заменили наследование агрегацией. Компилятор делает все примерно так же! Инициализация класса A выполняется из самого нижнего производного класса. Делается это таким образом:

class A
{
 int a;
public:
 A(int iInit) : a(iInit){};
 void foo(){ a = 0;};
};
 
class B1 : virtual public A
{ 
 int b1;
public:
 B1() : b1(0xb1), A(0xb10a){};
};
 
class B2 : virtual public A
{
 int b2;
public:
 B2() : b2(0xb2), A(0xb20a){};
};
class C : public B1, public B2
{
 int c;
public:
 C() : c(0x0c), A(0x0c0a){};
};
 
class D: public C
{
 int d;
public:
 D() : d(0x0d), A(0x0d0a){};
};
 
int _tmain(int argc, _TCHAR* argv[])
{
 D d;
 d.foo();
 
 return 0;
}

Перед вами немного модифицированный пример. Как видите, каждый класс, который является производным от классов с виртуальным наследованием, должен вызывать конструктор базового класса A. Это делается для того, чтобы можно было использовать каждый тип для создания объектов. В нашем случае происходит следующее. Когда мы создаем объект класса D, вызывается его конструктор. Он начинает инициализацию с вызова конструкторов базовых классов. В первую очередь он вызывает конструктор A::A(int). Далее вызывается C::C и потом выполняется инициализация самого объекта D. Конструктор класса C также действует похожим образом. Его список инициализации тоже содержит обращение к конструктору A::A(int). Однако перед его вызовом проверяется, был ли подобъект А проинициализирован. Если инициализация выполнялась, то вызов A::A(int) не производится. И так дальше по цепочки. На самом деле в конструкторе D::D() тоже проверяется, нужно ли инициализировать класс A. Это сделано для того, чтобы весь код был типовым.

Заключение

Язык C++ сложен в использовании. Несмотря на то, что он является языком высокого уровня, C++ позволяет разрабатывать системные приложения, программы для микроконтроллеров и т.п. К сожалению, в литературе наибольшее внимание уделяется синтаксису и мало внимания уделяется сути происходящих процессов. Например, вызов конструкторов базового класса при виртуальном наследовании - классический тому пример. Если знать, как работает виртуальное наследование – сообщения компилятора об ошибке вполне достаточно, чтобы догадаться в чем проблема. Если же знать только синтаксис – надо копаться в документации, чтобы понять, почему компилятор требует вызова конструктора в «неположенном» месте.

Изучать все особенности генерации кода на разных компиляторах нет смысла. Все они похожи друг на друга. Главное - уловить идею того, как компилятор реализует сложные конструкции. Как выполняются операции, в какой последовательности и т.п. Это сильно помогает в поисках «волшебных» ошибок и в разработке надежного, красивого и высокопроизводительного кода.

Мастерство использования C++ растет от свободного обмена идеями, а не формального изучения. Идея написания этой статьи возникла по просьбам читателей. Когда я начинал работу, я знал практически все о множественном и виртуальном наследовании, но не имел опыта практического использования. В процессе работы пришлось готовить примеры, просматривать ассемблерный код, проверять граничные условия в поведении компилятора и т.п. И все это для того, чтобы систематически изложить весь материал. Мне эта статья помогла глубже понять механизмы виртуального наследования. Правило нашло очередное подтверждение: написание статьи - это хороший способ структурировать свои знания и углубить понимание предмета.

Надеюсь, что для Вас статья также принесла пользу, и Вы сможете преодолеть еще одну ступеньку мастерства.

Ссылки по теме

  1. Виртуальные функции – низкоуровневый взгляд
  2. Порядок инициализации C++ объекта – это важно!

Если вам нравиться эта рассылка рекомендуйте ее своим друзьям. Подписаться можно по адресу http://subscribe.ru/catalog/comp.soft.prog.devdoc

Copyright (C) Kudinov Alexander, 2006-2007

Перепечатка и использование материалов запрещена без писменного разрешения автора.


В избранное