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

Виртуальные функции - низкоуровневый взгляд


DevDoc home page
 
 

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

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

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

Выпуск №12

Сегодня в выпуске статья "Виртуальные функции – низкоуровневый взгляд". Изначально она была ориентирована на новичков в C++, но думаю, что программисты со средним уровнем тоже найдут полезные для себя вещи.

Конкурс по программированию идет своим чередом. Несмотря на то, что участвуют более 40 человек, еще нет ни одного решения. Один из участников нашел ошибку в тестовом приложении. Я уже внес необходимые изменения в задание. Оно опубликовано на сайте и разослано участникам.

В скором времени будет готова бесплатная электронная книга, которая будет содержать в себе материалы с сайта www.devdoc.ru. Теперь вы сможете найти все статьи в одном месте и существенно сократить время на поиск информации. Несмотря на то, что книга бесплатная – в свободном доступе ее не будет. Я буду распространять ее среди активных читателей. Получить ее очень просто: оставляйте свои комментарии к статьям, задавайте вопросы и предложения по улучшению работы рассылки и т.п.

Мало у кого есть возможность получить ответ на свой вопрос, получить улучшение сервиса и при этом получить за это подарок. У Вас эта возможность есть.

Пишите на мой e-mail или через форму в профиле.


Постоянная ссылка на статью: http://www.devdoc.ru/index.php/content/view/virtual_base.htm

Автор: Кудинов Александр
Последняя модификация: 2007-03-12 21:40:22

Виртуальные функции – низкоуровневый взгляд.

Введение

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

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

Данная статья дает ответы на многие вопросы. Понимание низкоуровневых основ позволяет отбросить мишуру определений и многочисленные параграфы стандарта языка.

Тонкости наследования

Виртуальные функции не имеют никакого смысла, если нет наследования. Я не буду подробно на нем останавливаться – основы можно найти в любом учебнике по C++.

Существует несколько вещей, которые знать просто обязательно.

Наследование – это создание новых объектов

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

Скажем, у нас есть классы:

class A
{
   int a;
}
 
class B : public A
{
   int b;
}

Глядя на эту запись, можно подумать, что компилятор генерирует код, в котором есть указание на родственные связи между классами. Это не так!

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

Что же происходит на самом деле? Такая форма записи была придумана, чтобы не дублировать код родительского класса. Это облегчает восприятие программиста и позволяет представить все классы в виде дерева. Безусловно, в своей работе компилятор использует и эту информацию тоже, например, для приведения типов. С другой стороны, в памяти процесса, с точки зрения компилятора, класс B будет выглядеть как монолитный блок памяти, который содержит 2 переменные. Т.е. получается что-то вроде этого:

struct B
{
 int a;
 int b;
}

Я сознательно использовал struct, т.к. в откомпилированном коде нет модификаторов доступа – это соглашения языка. Т.е. видно, что компилятор на выходе просто ОБЪЕДИНИЛ оба класса. При множественном наследовании происходит то же самое, только чуть сложнее: данные в памяти объединяются в том же порядке, что и в списке наследования. Например:

class D : public F, public E
{
  int d;
}

Такой класс превращается в:

struct D
{
    int f;
    int e;
    int d;
}

Еще раз хочу заострить внимание, что struct D надо рассматривать как псевдокод, который служит только для иллюстрации того, что получается в памяти при наследовании.

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

Ну и пару слов о методах. При наследовании в результирующем классе объединяются только члены с данными! Все методы существуют в единственном экземпляре.

Замечание: Приведенные примеры справедливы, если не используется виртуальное наследование, и классы не содержат виртуальных методов. Эти случаи будут рассмотрены далее.

Доступ к данным из методов

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

Это в корне неверно. Хотя все знают про ключевое слово this – не многие задумываются о том, что же это такое на самом деле. Все проще некуда.

Создание класса – это просто выделение блока памяти для данных этого класса и все! Соответственно, размер класса полностью зависит только от количества и размера переменных, но не от методов. Выделение памяти может быть динамическим, статическим или на стеке. Во всех этих случаях компилятор знает адрес нового объекта сразу после создания.

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

B b;
b.foo();

Такую запись можно трансформировать в:

foo(&b);

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

Особую категорию составляют статические методы классов. Они не получают указателя this. Это полностью объясняет их особенность: они не могут работать с обычными полями класса, а имеют доступ только к статическим членам.

Обычные функции VS виртуальные

В C++ существует множество вариаций функций. Они могут отличаться соглашением на передачу аргументов, областью видимости и т.п. Для целей данной статьи стоит выделить только 3 категории. Сразу оговорюсь, что речь идет не о функциях вообще, а о методах класса.

  1. Статические методы класса
  2. Обычные методы
  3. Виртуальные функции

Чем обычные функции отличаются от статических функций можно найти в предыдущем разделе. Эти два типа функций всегда вызываются компиляторам напрямую.

Рассмотрим вызов обычных функций подробнее.

class A
{
 int foo();
 int a1;
 int a2;
 int a3;
}
 
class B
{
 int bar();
 int b1;
 int b2
 int b3;
}
 
class D : public A, public B
{
 int foo();
 int d1;
 int d2;
 int d3;
}
 
A a;
B b;
D d;
A *pA = new D;
 
1: a.foo();
2: b.bar();
3: d.foo();
4: d.bar();
5: pA->foo();

Варианты 1 и 2 в общем-то идентичные. И не нуждаются в комментариях. Гораздо интересней следующие примеры. В 3 примере видно, что вызывается метод foo() для объекта класса D. Компилятор начинает поиск методов с нижнего класса и продвигается вверх по дереву наследования. Т.к. метод foo переопределен в D, то компилятор вызовет именно его. В варианте 4 метод bar не может быть вызван напрямую – компилятор производит поиск, как описано выше и вызывает метод класса B. Тут есть одна тонкость. В предыдущих разделах мы выяснили, что методы не копируются при наследовании. Также мы знаем, как они обращаются к данным класса. Давайте посмотрим на п.4 внимательней. В точке вызова компилятор знает адрес объекта d, т.е. в псевдокоде вызов выглядит:

B::bar(&d);

Если вы внимательны – можно заметить, что скрытый параметр this для bar имеет тип B, а мы передаем тип D. Компилятор автоматически выполняет преобразование типа. Вот как это делается. В псевдокоде класс D выглядит так:

struct D
{
 int a1;
 int a2;
 int a3;
 int b1;
 int b2;
 int b3;
 int d1;
 int d2;
 int d3;
};

Размер этой структуры 12 байт. Предположим, что она располагается по адресу 0x0000000000. Т.е. доступ к a1 осуществляется по адресу 0x0000000000, к a2 по 0x0000000004 и так далее. Как говорилось ранее, компилятор использует дерево наследования при выполнении преобразований типов. Т.е. он знает все смещения, по которым располагаются данные родительских классов. При выполнении операции:

B *p = (B*)d;

Указатель p будет содержать адрес: 0x0000000010. Т.е указывать вовнутрь объекта D. Далее этот указатель передается в B::bar. Теперь код метода bar не может отличить объект класса D от B. С точки зрения метода он работает с объектом класса B. Обратите внимание, что преобразование выполняется с указателями, т.к. в процессе меняется стартовый адрес объекта. Теперь вы знаете, почему компилятор может без ущерба преобразовывать указатель на класс в указатель на родительский класс. Обратное преобразование по умолчанию не допускается, т.к. нет гарантии, что при изменении указателя он будет указывать на правильный объект.
Замечание: Приведение указателей на встроенные типы никогда не приводит к изменению адреса. Если не используется множественное наследование, то преобразование указателя к базовому классу также не вызывает изменения адреса.

Этот интересный механизм позволяет иметь только одну копию методов. Методы могут работать только с тем объектом, для которого они определены.

Ну и, наконец, рассмотрим 5 вариант вызова. В псевдокоде он выглядит так:

A::foo(&d);

В принципе он ничем не отличается от варианта, рассмотренного выше. Надо только обратить внимание, что компилятор использует метод класса A, хотя фактически мы создавали объект типа D. Это связано с тем, что в точке вызова компилятор работает с указателем на объект класса A и ничего не знает о том, куда на самом деле он указывает.

Теперь перейдем к виртуальным методам. Их главное отличие в способе вызова. Компилятор вызывает обычные методы напрямую в зависимости от типа объекта, для которого он был вызван. Это особенно хорошо видно в примере 5. В общем случае у компилятора нет выбора, т.к. в сложной программе указатель может многократно менять свое значение и указывать на объекты разных типов. Поэтому всегда вызывается A::foo. Преобразование типов происходит в точке присваивания нового значения указателю pA, поэтому в точке вызова у нас уже есть адрес, который будет передан как this в A::foo().

Виртуальные функции вызываются косвенно и в момент компиляции неизвестно, какой метод будет вызван. Это называется позднее связывание. Все слышали этот термин, но только единицы представляют, что за ним кроется и как это работает. Упрощенно, можно считать, что виртуальный метод всегда вызывается через указатель на функцию.

typedef void (*pfoo)();
 
void foo()
{}
 
void call()
{
 pfoo = foo;
 pfoo();    //Косвенный вызов.
}

Рассмотрим класс:

class Vbase
{
 virtual void foo();
 virtual void bar();
 void do()
 {
  foo();
 }
 int v;
}

В псевдокоде его можно записать:

class Vbase
{
 void *m_pVptr;
 int v;
}

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

Vbase cBase;
cBase.foo();

Компилятор преобразует эти строчки в нечто такое на псевдокоде:

cBase.m_pVptr[foo_idx]();

Иными словами, у каждой виртуальной функции есть уникальный индекс в пределах одной иерархии классов. При вызове компилятор находит в таблице виртуальных функций указатель на функцию с указанным индексом и вызывает ее. В нашем случае таблица состоит из 2 элементов.

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

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

class V : public Vbase
{
 virtual void foo();
 virtual void alpha();
}

Как известно, в процессе создания класса вызываются сначала конструкторы базовых классов, а потом собственный конструктор. В нашем случае – сначала Vbase(), а потом V(). Т.к. мы не определили конструктор – компилятор сделает конструктор по умолчанию. Он, как и конструктор заданный явно, выполняет ряд манипуляций для поддержки полиморфизма.

В самом начале своей работы он устанавливает указатель таблицы виртуальных функций. Для нашего примера конструктор Vbase() установит его на таблицу из 2 элементов. Первый – содержит указатель на Vbase::foo, а второй - на Vbase::bar(). Далее вызовется конструктор V() и переустановит указатель на другую таблицу, которая уже содержит 3 элемента: V::foo(), Vbase::bar(), V::alpha().

Конструктор модифицирует указатели на таблицу виртуальных функций для всех базовых классов.

Обратите внимание на следующие вещи:

  1. Если производный класс перегружает метод – меняется запись в таблице виртуальных функций.
  2. Если производный класс содержит виртуальные методы, которые не были объявлены в родительском классе – они добавляются в конец таблицы.

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

Очень важно это понять. Квинтэссенция всех этих теоретических объяснений следующая. Если мы создали класс V, то объект будет содержать указатель на таблицу именно этого класса.

Теперь перейдем к практике, чтобы показать, как это все работает.

V v;
Vbase *pbase = &v;
1: v.foo();
2: pbase->foo();
3: v.bar();
4: pbase->do();

Первый вызов выполняется для экземпляра объекта класса V. Его таблица виртуальных функций содержит указатель на V::foo(), поэтому будет вызван этот метод. Можно заметить, что здесь вызов получится верным, даже если мы не будем использовать таблицу виртуальных функций, т.к. он делается для самого объекта. Какой именно способ будет использоваться, остается на усмотрение компилятора и его оптимизатора.

Второй вариант показывает всю мощь виртуальных методов. Несмотря на то, что указатель имеет тип Vbase – он указывает на объект типа V. Это в частности означает, что указатель внутри объекта указывает на таблицу виртуальных функций класса V. Т.е., в данном случае будет также вызван метод V::foo().

Третий вариант аналогичен второму. Исключение составляет то, что таблица виртуальных функций класса V содержит указатель на Vbase::bar(), т.к. этот метод не был перегружен. Он и будет вызван.

Третий вариант вызова тоже не должен вызвать вопросов, если вы разобрались с вариантом 1 и вызовом обычных методов. Будет вызван Vbase::bar().

Четвертый вариант представляет особый интерес. Очевидно, что будет вызван метод Vbase::do(). Если посмотреть его тело:

void VBase::do()
{
 foo();
}

Видно, что он вызывает виртуальный метод. Как говорилось выше, доступ ко всем элементам класса, в т.ч. и методам, выполняется через указатель this. Это позволяет точно идентифицировать объект, с которым должен работать метод. Т.е. вызов можно записать на псевдокоде:

this->foo()

или

foo(this);

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

Помните, что this может указывать на производные классы?

Т.к. вызов do() выполняется для экземпляра класса V, то таблица виртуальных методов содержит указатель на V::foo()! Это фундаментальная вещь, которая позволяет менять поведение базовых классов через производные. Если виртуальная функция вызывается в базовом классе, то ее можно перегрузить в производном. Базовый класс будет использовать новый метод и при этом не требуется перекомпиляция кода.

В C++ существуют абстрактные виртуальные функции. Они определяются следующим образом:

virtual void foo() = 0;

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

Интерфейсы

Интерфейс - это соглашение о вызове функций, для какого-либо модуля. Интерфейсы могут быть самые разные. Это может быть просто список прототипов функций, которые экспортируются из DLL. Прототип класса тоже можно рассматривать как интерфейс. Для технологии COM интерфейс это вообще фундаментальное понятие.

При программировании на C++ под интерфейсом обычно понимают полиморфный класс, который состоит из абстрактных методов. Сам по себе интерфейс не представляет существенной ценности. Он дает своеобразное соглашение о способе использования объекта.

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

class IDraw
{
 void virtual Draw() = 0;
}

Он содержит всего один абстрактный метод. Конкретные экземпляры используют его так:

class CLine : public IDraw
{void virtual Draw()
 {
  //Рисуем объект
 };
};

Теперь если мы сделаем указатель:

IDraw *p = new CLine();
p->Draw();

В игру вступает таблица виртуальных функций и, несмотря на то, что указатель p имеет тип IDraw, - вызовется метод Cline::Draw(). Это очень удобно, т.к. используя один и тот же указатель, мы можем рисовать объекты любого типа, которые являются производными от IDraw.

Помимо унифицированных вызовов, интерфейсы позволяют хранить указатели на разные типы в одном месте:

std::vector<IDraw> v;
v.push_back(new CLine());
v.push_back(new CPoint());  //CPoint производный класс от IDraw

Конкретные способы применения интерфейсов зависят только от Вашей фантазии.

Заключение

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

Толчком к написанию этой статьи послужило большое число бесед с разными программистами. Среди них были выпускники университетов и те, кто отработал по специальности пару лет. Среди сотни человек находится всего десяток действительно знающих программистов. Большинство же имеет очень низкий уровень. И не их в том вина. Система образования в области программирования не совершенна – это факт. Учебники содержат только начальные сведения и инструкции, в каком меню и куда надо ткнуть мышью. На сегодняшний день нас учат, как сделать какую-нибудь специфическую вещь, но не показывают путь, по которому надо идти, чтобы расти в профессиональном направлении.

В этой статье я постарался собрать ответы на те вопросы, которые возникают у начинающих чаще всего. И я надеюсь, что она помогла открыть для себя какие-то грани языка.

Это черновой вариант, который будет дополняться со временем. Поэтому у меня огромная просьба ко всем написать мне свои вопросы, комментарии и пожелания. Для этого нажмите ссылку. Все ваши сообщения будут направлены на улучшение этого материала.


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

Copyright (C) Kudinov Alexander, 2006-2007

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


В избранное