вторник, 7 мая 2013 г.

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

Введение

Виртуальные функции уже давно стали неотъемлемой частью объектно-ориентированного программирования. Все учебники по С++ содержат утверждение, что C++ держится на трех китах: инкапсуляции, наследовании и полиморфизме. Каждое из этих понятий содержит тонкости, которые не описаны в учебниках по C++. Эта статья понемногу коснется каждого из них, но основное внимание будет уделено виртуальным функциям.
К сожалению, полиморфизм - эта та область, которая вызывает наибольшие трудности в освоении у начинающих. И даже успешно начав применять знания на практике – теневая сторона и особенности низкоуровневой реализации остаются скрытыми. Между тем сегодня сложно представить проект, в котором бы не использовался полиморфизм. Технология COM полностью построена на абстрактных интерфейсах. Плагины для многих популярных программ просто немыслимы без виртуальных функций.
Данная статья дает ответы на многие вопросы. Понимание низкоуровневых основ позволяет отбросить мишуру определений и многочисленные параграфы стандарта языка. Я хочу поблагодарить всех читателей, которые сообщили о неточностях в предыдущей редакции статьи. Особая благодарность участникам форума RSDN за конструктивные замечания и предложения по улучшению материала. Всем огромное спасибо!



Термины

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

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

Виртуальные функции не имеют никакого смысла, если нет наследования. Я не буду подробно на нем останавливаться – основы можно найти в любом учебнике по 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. Следовательно, они не умеют работать с членами классов и вызвать их методы. Статическая функция просто не знает, для какого объекта она вызвана.
Еще одним важным отличием, которое вытекает из вышесказанного, является соглашение о вызове статического метода. Обычные методы вызываются с помощью thiscall – т.е. имеют скрытый параметр this. Это особый тип вызова, который существует только внутри компилятора и не может быть изменен программистом или участвовать в приведении типов. Статический метод вызывается как обычная функция. Это позволяет создавать указатели на статическую функцию. Как следствие ее можно передавать в виде аргументов, использовать как точку входа в поток и т.п.
Замечание: Можно создавать указатели и на методы класса с типом вызова thiscall, но это не так тривиально и надо использовать специальный синтаксис.

Обычные функции 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;
};
Размер этой структуры 36 байт. Предположим, что она располагается по адресу 0x1000000000. Т.е. доступ к a1 осуществляется по адресу 0x1000000000, к a2 по 0x1000000004 и так далее. Как говорилось ранее, компилятор использует дерево наследования при выполнении преобразований типов. Т.е. он знает все смещения, по которым располагаются данные родительских классов. При выполнении операции:
B *p = (B*)&d;
Указатель p будет содержать адрес: 0x100000000C. Т.е указывать вовнутрь объекта 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 pf = foo;
 pf();    //Косвенный вызов.
}
Рассмотрим класс:
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::do(). Если посмотреть его тело:
void VBase::do()
{
 foo();
}
Видно, что он вызывает виртуальный метод. Как говорилось выше, доступ ко всем элементам класса, в т.ч. и методам, выполняется через указатель this. Это позволяет точно идентифицировать объект, с которым должен работать метод. Т.е. вызов можно записать на псевдокоде:
this->foo()
или
foo(this);
Видно, что выполняется косвенный вызов и, следовательно, будет использоваться таблица виртуальных функций.
Помните, что this может указывать на производные классы?
Т.к. вызов do() выполняется для экземпляра класса V, то таблица виртуальных методов содержит указатель на V::foo()! Это фундаментальная вещь, которая позволяет менять поведение базовых классов через производные. Если виртуальная функция вызывается в базовом классе, то ее можно перегрузить в производном. Базовый класс будет использовать новый метод и при этом не требуется перекомпиляция кода.
В C++ существуют абстрактные виртуальные функции. Они определяются следующим образом:
virtual void foo() = 0;
Их особенность в том, что они не имеют тела. В таблице виртуальных функций они представлены с помощью нулевого указателя. Т.е. такая функция не может быть вызвана, точнее может, но это вызовет крах программы. Компилятор следит в меру своих возможностей за вызовом абстрактных методов. Например, он запрещает создавать экземпляры объектов, которые содержат абстрактные методы. Надо сделать производный класс и перегрузить все абстрактные методы, чтобы иметь возможность создать объект. Об использовании абстрактных методов читайте ниже.

Виртуальные функции и наследование

Выше мы рассматривали примеры с простым наследованием для того, чтобы облегчить понимание работы виртуальных функций. При множественном наследовании принцип работы остается неизменным – меняется способ доступа к таблице виртуальных функций. Я не буду описывать процесс в виде формальных правил. Вместо этого я приведу несколько примеров, которые иллюстрируют работу с указателем vtbl. Примеры подготовлены с помощью компилятора MS VC++ 2003 .NET и при использовании других версий компилятора – низкоуровневая реализация может быть другой.

Пример 1

class A
{
public:
 A() : a1(0xa1111111), a2(0xa2222222), a3(0xa3333333){};
 void a(){ a1 = 1;};
 int a1, a2, a3;
};
 
class C : public A
{
public:
 C() : A(), c1(0xc4444444){};
 virtual void goo(){};
 int c1;
};
Обратите внимание, что класс A не содержит виртуальных методов. Интересен способ формирования объекта C. Он создается не просто добавлением новых полей к A, но и изменением смещения полей в объекте.

Рассмотрим вызов:
C c;
c.a();
Казалось бы, что метод A::a перестанет работать, т.к. изменились смещения полей внутри объекта. Это не так. Компилятор «знает» о внесенных изменениях и при вызове делает автоматическое приведение типов для указателя this – указатель будет указывать на подобъект A.

Пример 2

Усложним пример и рассмотрим множественное наследование:
class A
{
public:
 A() : a1(0xa1111111), a2(0xa2222222), a3(0xa3333333){};
 void a(){ a1 = 1;};
 virtual void foo(){};
 int a1, a2, a3;
};
 
class B
{
public:
 B() : b1(0xb1111111), b2(0xb2222222), b3(0xb3333333){};
 void b(){ b1 = 1;};
 virtual void bar(){};
 int b1, b2, b3;
};
 
class C : public A, public B
{
public:
 C() : A(), B(), c1(0xc4444444){};
 virtual void goo(){};
 int c1;
};
В памяти объект класса C будет иметь вид:

Тут надо обратить внимание на следующее:
  • Таблица виртуальных методов самого нижнего класса в иерархии доступна через первый указатель vptr.
  • Каждый подобъект, который содержит виртуальные методы, имеет свою таблицу виртуальных функций.
Остановимся подробнее на слиянии таблиц при наследовании. Из определения виртуальных функций понятно, что если в классе C переопределить метод – то в соответствующую ячейку в таблице родительского объекта будет записан указатель на новый метод. Если же в классе C добавляются новые функции – они дописываются в конец первой таблицы. Такой алгоритм становится понятен, если рассмотреть возможные преобразования типов:
  1. С -> A. Через указатель на класс A можно вызывать только методы, которые прописаны в этом классе.
  2. C -> B. Ситуация аналогична, только мы можем вызывать методы, определенные в классе B.
Новые виртуальные методы (которых нет в родительских классах) можно использовать только через указатель на класс C. В этом случае всегда используется первая таблица виртуальных функций.
Замечание: методы подобъектов всегда используют первую таблицу. Т.е. первая таблица подобъекта B является второй для класса C.
Во время преобразования типов – меняется адрес указателя:
C c;
B *p = &c;
Указатель p – будет содержать адрес объекта c + смещение подобъекта B. Т.е. все вызовы методов через такой указатель будут использовать вторую таблицу виртуальных методов объекта C.

Виртуальные функции и конструкторы

Попробуйте найти ошибку в коде:
class A
{
public:
 A(){foo();};
 virtual void foo() = 0;
};
 
class C : public A{
public:
 C() : A(){};
 virtual void foo(){};
};
Если вы ее нашли – поздравляю, Вы внимательно прочитали предыдущий материал. Рассмотрим по шагам, что происходит при создании объекта класса C.
  1. Выделяется память под объект
  2. Вызывается конструктор A::A().
    a. Он устанавливает указатель vptr на свою таблицу виртуальных функций.
    b. Далее выполняется тело конструктора
    3. Вызывается конструктор C::C(), который устанавливает vptr на свою таблицу виртуальных функций.
На шаге 2b программа сгенерирует исключение, потому что произойдет попытка вызывать абстрактный метод A::foo().

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

Виртуальные деструкторы работают по тем же правилам, что и виртуальные функции. Служат они для того, что бы правильно разрушать объекты.
Рассмотрим пример из предыдущего раздела. Если написать такой код:
A *pa = new B;
delete pa;
Объект разрушиться неверно. При удалении объекта будет вызван только деструктор класса A. Если же объявить виртуальные деструкторы – все сработает правильно. Деструктор будет вызываться через таблицу виртуальных функций, поэтому будет вызван B::~B(), который в свою очередь сделает разрушение родительских объектов.

Интерфейсы

Интерфейс - это соглашение о вызове функций, для какого-либо модуля. Интерфейсы могут быть самые разные. Это может быть просто список прототипов функций, которые экспортируются из 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 v;
v.push_back(new CLine());
v.push_back(new CPoint());  //CPoint производный класс от IDraw
Конкретные способы применения интерфейсов зависят только от Вашей фантазии.

Заключение

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



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

Автор: Кудинов Александр

2 комментария:

  1. Огромное спасибо!! Наконец-то полезная статья про наследование, которая наводит порядок в голове)

    ОтветитьУдалить
  2. Отличная статья! Спасибо огромное за проделанный труд!

    ОтветитьУдалить