Различия между ранним и поздним связыванием в Java

ВИРТУАЛЬНЫЕ ФУНКЦИИ______________________________________________________________ 1

Раннее и позднее связывание. Динамический полиморфизм ___________________________________ 1

Виртуальные функции___________________________________________________________________ 1 Виртуальные деструкторы _______________________________________________________________ 4 Абстрактные классы и чисто виртуальные функции___________________________________________ 5

ВИРТУАЛЬНЫЕ ФУНКЦИИ

Раннее и позднее связывание. Динамический полиморфизм

В C++ полиморфизм поддерживается двумя способами.

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

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

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

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

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

Для каждого полиморфного типа данных компилятор создает таблицу виртуальных функций и встраивает в каждый объект такого класса скрытый указатель на эту таблицу. Она содержит адреса виртуальных функций соответствующего объекта. Имя указателя на таблицу виртуальных функций и название таблицы зависят от реализации в конкретном компиляторе. Например, в Visual C++ 6.0 этот указатель имеет имя vfptr , а таблица называетсяvftable (от английского Virtual Function Table). Компилятор автоматически встраивает в начало конструктора полиморфного класса фрагмент кода, который инициализирует указатель на таблицу виртуальных функций. Если вызывается виртуальная функция, код, сгенерированный компилятором, находит указатель на таблицу виртуальных функций, затем просматривает эту таблицу и извлекает из нее адрес соответствующей функции. После этого производится переход на указанный адрес и вызов функции.

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

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

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

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

Функции, у которых известен интерфейс вызова (то есть прототип), но реализация не может быть задана в общем случае, а может быть определена только для конкретных случаев, называются виртуальными (термин, означающий, что функция может быть переопределена в производном классе).

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

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

class Coord

Базовый класс координат

// базовый класс координат

protected:

// защищённые члены класса

double x , y ;

// координаты

public:

// открытые члены класса

Coord () { x = 0 ; y = 0 ; }

// конструктор базового класса

void Input () ;

// объявляет невиртуальную функцию

virtual void Print () ;

// объявляет виртуальную функцию

void Coord:: Input ()

// позволяет вводить координаты с клавиатуры

cout<<"\tx=";

// вводит значение x с клавиатуры

cout<<"\ty=";

// вводит значение y с клавиатуры

void Coord:: Print ()

// выводит значения координат на экран

cout<<"\tx="<

Производный класс точки

class Dot: publicCoord

// наследник класса координат

char name ;

// имя точки

public:

// открытые члены класса

Dot (ch ar N) : Coord () { name = N ; }

// вызывает конструктор базового класса

void Input () ;

void Print () ;

void Dot:: Input ()

// позволяет вводить координаты точки с клавиатуры

char S ="Введите координаты точки ";

CharToOem (S , S) ;

cout<

Coord:: Input () ;

void Dot:: Print()

// выводит значения координат точки на экран

char S ="Координаты точки ";

CharToOem (S , S) ;

// преобразует символы строки в кириллицу

cout<

// выводит на экран заголовок и имя точки

Coord:: Print () ;

// вызывает функцию базового класса

class Vec: publicCoord

Производный класс вектора

// наследник класса координат

char name [ 3 ] ;

// имя вектора

public:

// открытые члены класса

Vec (char * pName) : Coord () { strncpy (name , pName , 3) ; name [ 2 ] = "\0" ; }

void Input () ;

// переопределяет невиртуальную функцию

void Print () ;

// переопределяет виртуальную функцию

void Vec:: Input()

// позволяет вводить проекции вектора с клавиатуры

Лекция 9 Виртуальные функции 3

char S ="Введите проекции вектора "; // объявляет и инициализирует строку приглашения

CharToOem (S , S) ;

// преобразует символы строки в кириллицу

cout<

// выводит на экран приглашение и имя вектора

Coord:: Input () ;

// вызывает функцию базового класса

void Vec:: Print ()

// выводит значения проекций вектора на экран

char S = "Проекции вектора ";

// объявляет и инициализирует строку заголовка

CharToOem (S , S) ;

// преобразует символы строки в кириллицу

cout<

// выводит на экран заголовок и имя вектора

Coord:: Print () ;

// вызывает функцию базового класса

В приведённом примере объявлен базовый класс Coord и два производных классаDot иVec . ФункцияPrint () в производных классах является виртуальной, так как она объявлена виртуальной в базовом классеCoord . ФункцияPrint () в производных классахDot иVec переопределяет функцию базового класса. Если производный класс не предоставляет переопределенной реализации функцииPrint () , используется реализация по умолчанию из базового класса.

Функция Input () объявлена невиртуальной в базовом классеCoord и переопределена в производных классахDot иVec .

void main ()

Coord* pC = new Coord () ;

// объявляет указатель на координаты и выделяет память

Dot* pD = new Dot ("D") ;

// объявляет указатель на точку и выделяет память

Vec* pV = new Vec ("V") ;

// объявляет указатель на вектор и выделяет память

pC->Input () ;

pC->Print () ;

// вызывает виртуальную функцию Coord:: Print ()

// указатель на координаты получает адрес объекта типа точки

pC->Input () ;

// вызывает невиртуальную функцию Coord:: Input ()

pC->Print () ;

// вызывает виртуальную функцию Dot:: Print ()

// указатель на координаты получает адрес объекта типа вектора

pC->Input () ;

// вызывает невиртуальную функцию Coord:: Input ()

pC->Print () ;

// вызывает виртуальную функцию Vec:: Print ()

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

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

Необходимо отметить, что операция присвоения pC = pD , в которая использует операнды различных типов (Coord* иDot* ) без преобразования, возможна только для указателя на базовый класс в левой части. Обратная операция присвоенияpD = pC недопустима и вызывает ошибку синтаксиса.

При выполнении программа выводит на экран:

Координаты точки D:

Проекции вектора V:

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

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

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

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

нельзя объявить глобальную или статическую функцию виртуальной. Ключевое слово virtual может

Связывание в языке C++

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

Связывание методов в этом языке является довольно сложным. Для обычных переменных (не указателей или ссылок) оно осуществляется статически. Но когда объекты обозначаются с помощью указателей или ссылок, используется динамическое связывание. В последнем случае решение о выборе метода статического или динамического типа диктуется тем, описан ли соответствующий метод с помощью ключевого слова virtual. Если он объявлен именно так, то метод поиска сообщения базируется на динамическом классе, если нет на статическом. Даже в тех случаях, когда используется динамическое связывание, законность любого запроса определяется компилятором на основе статического класса получателя.

Рассмотрим, например, следующее описание классов и глобальных переменных: class Mammal

printf («cant speak»);

class Dog: public Mammal

printf («wouf wouf»);

printf («wouf wouf, as well»);

Mammal *fido = new Dog;

Выражение fred.speak() печатает «cant speak», однако вызов fido->speak() также напечатает «cant speak», поскольку соответствующий метод в классе Mammal не объявлен как виртуальный. Выражение fido->bark() не допускается компилятором, даже если динамический тип для fido класс Dog. Тем не менее статический тип переменной всего лишь класс Mammal.

Если мы добавим слово virtual:

virtual void speak()

printf («cant speak»);

то получим на выходе для выражения fido->speak() ожидаемый результат.

Относительно недавнее изменение в языке С++ добавление средств для распознавания динамического класса объекта. Они образуют систему RTTI (Run-Time Type Identification идентификация типа во время выполнения).

В системе RTTI каждый класс имеет связанную с ним структуру типа typeinfo, которая кодирует различную информацию о классе. Поле данных name одно из полей данных этой структуры содержит имя класса в виде текстовой строки. Функция typeid может использоваться для анализа информации о типе данных. Следовательно, следующая ниже команда будет печатать строку «Dog» динамический тип данных для fido. В этом примере необходимо разыменовывать переменную-указатель fido, чтобы аргумент был значением, на которое ссылается указатель, а не самим указателем:

cout << «fido is a» << typeid(*fido).name() << endl;

Можно также спросить, используя функцию-член before, соответствует ли одна структура с информацией о типе данных подклассу класса, соотносящегося с другой структурой. Например, следующие два оператора выдают true и false:

if (typeid(*fido).before (typeid(fred)))…

if (typeid(fred).before (typeid(lassie)))…

До появления системы RTTI стандартный программистский трюк состоял в том, чтобы явным образом закодировать в иерархии класса методы быть экземпляром. Например, для проверки значения переменных типа Animal на принадлежность к типу Cat или к типу Dog можно было бы определить следующую систему методов:

virtual int isaDog()

virtual int isaCat()

class Dog: public Mammal

virtual int isaDog()

class Cat: public Mammal

virtual int isaCat()

Теперь для определения того, является ли текущим значением переменной fido величина типа Dog, можно использовать команду fido->isaDog(). Если возвращается ненулевое значение, то можно привести тип переменной к нужному типу данных.

Возвращая указатель, а не целое число, мы объединяем проверку на принадлежность к подклассу и приведение типа. Это аналогично другой части системы RTTI, называемой dynamic_cast, которую мы вкратце опишем. Если некая функция в классе Mammal возвращает указатель на Dog, то класс Dog должен быть предварительно описан. Результатом присваивания является либо нулевой указатель, либо правильная ссылка на класс Dog. Итак, проверка результата все еще должна осуществляться, но мы исключаем необходимость приведения типа. Это показано в следующем примере:

class Dog; // предварительное описание

virtual Dog* isaDog()

virtual Cat* isaCat()

class Dog: public Mammal

virtual Dog* isaDog()

class Cat: public Mammal

virtual Cat* isaCat()

Оператор lassie = fido->isaDog(); теперь выполним всегда. В результате переменная lassie получает ненулевое значение, только если fido имеет динамический класс Dog. Если fido не принадлежит Dog, то переменной lassie будет присвоен нулевой указатель.

lassie = fido->isaDog();

… // fido и в самом деле относится к типу Dog

… // присваивание не сработало

… // fido не принадлежит к типу Dog

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

Поскольку подобные проблемы встречаются часто, было найдено их общее решение. Функция шаблона dynamic_cast берет тип в качестве аргумента шаблона и, в точности как функция, определенная выше, возвращает либо значение аргумента (если приведение типа законно), либо нулевое значение (если приведение типа неразрешено). Присваивание, эквивалентное сделанному в предыдущем примере, может быть записано так:

// конвертировать только в том случае, если fido является собакой

lassie = dynamic_cast < Dog* > (fido);

// затем проверить, выполнено ли приведение

В язык C++ были добавлены еще три типа приведения (static_cast, const_cast и reinterpret_cast), но они используются в особых случаях и поэтому здесь не описываются. Программистам рекомендуется применять их как более безопасные средства вместо прежнего механизма приведения типов.

2. Проектная часть

Применение рефлексии, позднего связывания и атрибутов

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

Что именно подразумевается под расширяемым приложением? Рассмотрим IDE-среду Visual Studio 2010. При разработке в этом приложении были предусмотрены специальные "ловушки" (hook) для предоставления другим производителям ПО возможности подключать свои специальные модули. Понятно, что разработчики Visual Studio 2010 не могли добавить ссылки на несуществующие внешние сборки.NET (т.е. воспользоваться ранним связыванием), тогда как же им удалось обеспечить в приложении необходимые методы-ловушки? Ниже описан один из возможных способов решения этой проблемы.

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

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

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

Если расширяемое приложение изначально программируется так, чтобы запрашивать определенные интерфейсы, оно получает возможность определять во время выполнения, может ли активизироваться интересующий тип, и после успешного прохождения типом такой проверки позволять ему поддерживать дополнительные интерфейсы и получать доступ к их функциональным возможностям полиморфным образом. Именно такой подход и предприняли разработчики Visual Studio 2010, причем ничего особо сложного в нем нет.

В первую очередь необходимо создать сборку с типами, которые должна обязательно использовать каждая оснастка, чтобы иметь возможность подключаться к расширяемому приложению. Для этого создадим проект типа Class Library (Библиотека классов), и определим в нем два следующих типа:

Using System; namespace PW_CommonType { public interface IApplicationFunc { void Go(); } public class InfoAttribute: System.Attribute { public string CompanyName { get; set; } public string CompanyUrl { get; set; } } }

Далее потребуется создать тип, реализующий интерфейс IApplicationFunc. Чтобы не усложнять пример создания расширяемого приложения, давайте сделаем этот тип простым. Создадим новый проект типа Class Library на C# и определим в нем тип класса по имени MyCompanyInfo:

Using System; using PW_CommonType; using System.Windows..Go() { MessageBox.Show("Важная информация!"); } } }

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

Теперь нужно добавить в него ссылку на сборку PW_CommonType.dll, но не на библиотекy кода CompanyInfo.dll. Кроме того, необходимо импортировать в главный файл кода формы (для его открытия щелкните правой кнопкой мыши в визуальном конструкторе формы и выберите в контекстном меню пункт View Code (Просмотреть код)) пространства имен System.Reflection и PW_CommonType. Вспомните, что цель создания данного приложения состоит в том, чтобы увидеть, как использовать позднее связывание и рефлексию для проверки отдельных двоичных файлов, создаваемых другими производителям, на предмет их способности выступать в роли подключаемых оснасток.

--- Сборки.NET --- Позднее связывание

Поздним связыванием (late binding) называется технология, которая позволяет создавать экземпляр определенного типа и вызывать его члены во время выполнения без кодирования факта его существования жестким образом на этапе компиляции. При создании приложения, в котором предусмотрено позднее связывание с типом из какой-то внешней сборки, добавлять ссылку на эту сборку нет никакой причины, и потому в манифесте вызывающего кода она непосредственно не указывается.

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

Класс System.Activator

Класс System. Activator (определенный в сборке mscorlib.dll) играет ключевую роль в процессе позднего связывания в.NET. В текущем примере интересует пока что только его метод Activator.CreateInstance() , который позволят создавать экземпляр подлежащего позднему связыванию типа. Этот метод имеет несколько перегруженных версий и потому обеспечивает довольно высокую гибкость. В самой простой версии CreateInstance() принимает действительный объект Type, описывающий сущность, которая должна размещаться в памяти на лету.

Чтобы увидеть, что имеется в виду, давайте создадим новый проект типа Console Application, импортируем в него пространства имен System.I0 и System.Reflection с помощью ключевого слова using и затем изменим класс Program, как-показано ниже:

Using System; using System.Reflection; using System.IO; namespace ConsoleApplication1 { class Program { static void Main() { Assembly ass = null; try { ass = Assembly.Load("fontinfo"); } catch (FileNotFoundException ex) { Console.WriteLine(ex.Message); } if (ass != null) CreateBinding(ass); Console.ReadLine(); } static void CreateBinding(Assembly a) { try { Type color1 = a.GetType("FontColor"); // Используем позднее связывание object obj = Activator.CreateInstance(color1); Console.WriteLine("Объект создан!"); } catch (Exception ex) { Console.WriteLine(ex.Message); } } } }

Прежде чем запускать данное приложение, необходимо вручную скопировать сборку fontinfo.dll в подкаталог bin\Debug внутри каталога этого нового приложения с помощью проводника Windows. Дело в том, что здесь вызывается метод Assembly.Load(), а это значит, что CLR-среда будет зондировать только папку клиента (при желании можно было бы воспользоваться методом Assembly.LoadFrom() и указывать полный путь к сборке, но в данном случае в этом нет никакой необходимости).

Статье я рассказывал, с какой не всем известной особенностью можно столкнуться при работе с подставляемыми функциями. Статья породила как несколько существенных замечаний, так и многостраничные споры (и даже холивары), начавшиеся с того, что inline-функции вообще лучше не использовать, и перешедшие в стандартную тему C vs. C++ vs. Java vs. C# vs. PHP vs. Haskell vs. …

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

Надеюсь, все знают, что такое виртуальные функции и как они используются, так как объяснять это уже не моя задача. Уверен, что RFry в цикле своих статей о C++ рано или поздно доберется и до них.

Если в материале про inline-методы миф был не совсем очевиден, то в этом - напротив. Собственно, перейдем к «мифу».

Виртуальные функции и ключевое слово virtual
К моему удивлению, я очень часто сталкивался и сталкиваюсь с людьми (да что там говорить, я и сам был таким же), которые считают, что ключевое слово virtual делает функцию виртуальной только на один уровень иерархии . Объясню, что имеется в виду, на примере:

  1. #include
  2. #include
  3. using std::cout;
  4. using std::endl;
  5. struct A
  6. virtual ~A() {}
  7. << "A::foo()" << endl; }
  8. << "A::bar()" << endl; }
  9. void baz() const { cout << "A::baz()" << endl; }
  10. struct B: public A
  11. virtual void foo() const { cout << "B::foo()" << endl; }
  12. void bar() const { cout << "B::bar()" << endl; }
  13. void baz() const { cout << "B::baz()" << endl; }
  14. struct C: public B
  15. virtual void foo() const { cout << "C::foo()" << endl; }
  16. virtual void bar() const { cout << "C::bar()" << endl; }
  17. void baz() const { cout << "C::baz()" << endl; }
  18. int main()
  19. cout << "pA is B:" << endl;
  20. A * pA = new B;
  21. pA->foo();
  22. pA->bar();
  23. pA->baz();
  24. delete pA;
  25. cout << "\npA is C:" << endl;
  26. pA = new C;
  27. pA->foo(); pA->bar(); pA->baz();
  28. delete pA;
  29. return EXIT_SUCCESS;

Итак, имеем простую иерархию классов. В каждом классе определены 3 метода: foo() , bar() и baz() . Рассмотрим неверную логику людей, которые находятся под действием мифа :
когда указатель pA указывает на объект типа B имеем вывод:

pA is B:
B::foo() // потому что в родительском классе A метод foo() помечен как virtual
B::bar() // потому что в родительском классе A метод bar() помечен как virtual
A::baz() // потому что в родительском классе A метод baz() не помечен как virtual

когда указатель pA указывает на объект типа С имеем вывод:
pA is C:
С::foo() // потому что в родительском классе B метод foo() помечен как virtual
B::bar() // потому что в родительском классе B метод bar() не помечен как virtual,
// но он помечен как virtual в классе A, указатель на который мы используем
A::baz() // потому что в классе A метод baz() не помечен как virtual

С невиртуальной функцией baz() всё и так ясно. А вот с логикой вызова виртуальных функций есть неувязочка. Думаю, не стоит говорить, что на самом деле вывод будет следующим:

pA is B:
B::foo()
B::bar()
A::baz()

PA is C:
C::foo()
C::bar()
A::baz()

Вывод: виртуальная функция становится виртуальной до конца иерархии, а ключевое слово virtual является «ключевым» только в первый раз, а в последующие разы оно несет в себе чисто информативную функцию для удобства программистов .

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

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

Встретив ключевое слово virtual, компилятор помечает, что для этого метода должно использоваться позднее связывание: для начала он создает для класса таблицу виртуальных функций, а в класс добавляет новый скрытый для программиста член - указатель на эту таблицу. (На самом деле, насколько я знаю, стандарт языка не предписывает, как именно должен быть реализован механизм виртуальных функций, но реализация на основе виртуальной таблицы стала стандартом де факто.). Рассмотрим этот пруфкод:

  1. #include
  2. #include
  3. struct Empty {};
  4. struct EmptyVirt { virtual ~EmptyVirt(){} };
  5. struct NotEmpty { int m_i; };
  6. struct NotEmptyVirt
  7. virtual ~NotEmptyVirt() {}
  8. int m_i;
  9. struct NotEmptyNonVirt
  10. void foo() const {}
  11. int m_i;
  12. int main()
  13. std::cout << sizeof (Empty) << std::endl;
  14. std::cout << sizeof (EmptyVirt) << std::endl;
  15. std::cout << sizeof (NotEmpty) << std::endl;
  16. std::cout << sizeof (NotEmptyVirt) << std::endl;
  17. std::cout << sizeof (NotEmptyNonVirt) << std::endl;
  18. return EXIT_SUCCESS;
* This source code was highlighted with Source Code Highlighter .

Вывод может отличаться в зависимости от платформы, но в моем случае (Win32, msvc2008) он был следующим:

Что можно понять из этого примера. Во-первых, размер «пустого» класса всегда больше нуля, потому что компилятор специально вставляет в него фиктивный член. Как пишет Эккель, «представьте процесс индексирования в массиве объектов нулевого размера, и все станет ясно» ;) Во-вторых, мы видим, что размер «непустого» класса NotEmptyVirt при добавлении в него виртуальной функции увеличился на стандартный размер указателя на void; а в «пустом» классе EmptyVirt фиктивный член, который компилятор ранее добавлял для приведения класса к ненулевому размеру, был заменен на указатель. В то же время добавление невиртуальной функции в класс на размер не влияет (спасибо nullbie за совет). Имя указателя на таблицу отличается в зависимости от компилятора. К примеру, компилятор Visual Studio 2008 называет его __vfptr, а саму таблицу ‘vftable’ (кто не верит, может посмотреть в отладчике:) В литературе указатель на таблицу виртуальных функций принято называть VPTR, а саму таблицу VTABLE, поэтому я буду придерживаться таких же обозначений.

Что представляет собой таблица виртуальных функций и для чего она нужна? Таблица виртуальных функций хранит в себе адреса всех виртуальных методов класса (по сути, это массив указателей), а также всех виртуальных методов базовых классов этого класса.

Таблиц виртуальных функций у нас будет столько, сколько есть классов, содержащих виртуальные функции - по одной таблице на класс. Объекты каждого из классов содержат именно указатель на таблицу, а не саму таблицу! Вопросы на эту тему любят задавать преподаватели, а также те, кто проводит собеседования. (Примеры каверзных вопросов, на которых можно подловить новичков: «если класс содержит таблицу виртуальных функций, то размер объекта класса будет зависеть от количества виртуальных функций, содержащихся в нем, верно?»; «имеем массив указателей на базовый класс, каждый из которых указывает на объект одного из производных классов - сколько у нас будет таблиц виртуальных функций?» и т.д.).

Итак, для каждого класса у нас будет создана таблица виртуальных функций. Каждой виртуальной функции базового класса присваивается подряд идущий индекс (в порядке объявления функций), по которому в последствие и будет определяться адрес тела функции в таблице VTABLE. При наследовании базового класса, производный класс «получает» и таблицу адресов виртуальных функций базового класса. Если какой-либо виртуальный метод в производном классе переопределяется, то в таблице виртуальных функций этого класса адрес тела соответствующего метода просто будет заменен на новый. При добавлении в производный класс новых виртуальных методов VTABLE производного класса расширяется, а таблица базового класса естественно остается такой же, как и была. Поэтому через указатель на базовый класс нельзя виртуально вызвать методы производного класса, которых не было в базовом - ведь базовый класс о них ничего «не знает» (дальше мы все это посмотрим на примере).

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

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

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

Думаю, на примере все станет понятнее. Рассмотрим следующую иерархию:

В данном случае получим две таблицы виртуальных функций:

Base
0
Base::foo()
1 Base::bar()
2 Base::baz()

и
Inherited
0
Base::foo()
1 Inherited::bar()
2 Base::baz()
3 Inherited::qux()

Как видим, в таблице производного класса адрес второго метода был заменен на соответствующий переопределенный. Пруфкод:

  1. #include
  2. #include
  3. using std::cout;
  4. using std::endl;
  5. struct Base
  6. Base() { cout << "Base::Base()" << endl; }
  7. virtual ~Base() { cout << "Base::~Base()" << endl; }
  8. virtual void foo() { cout << "Base::foo()" << endl; }
  9. virtual void bar() { cout << "Base::bar()" << endl; }
  10. virtual void baz() { cout << "Base::baz()" << endl; }
  11. struct Inherited: public Base
  12. Inherited() { cout << "Inherited::Inherited()" << endl; }
  13. virtual ~Inherited() { cout << "Inherited::~Inherited()" << endl; }
  14. virtual void bar() { cout << "Inherited::bar()" << endl; }
  15. virtual void qux() { cout << "Inherited::qux()" << endl; }
  16. int main()
  17. Base * pBase = new Inherited;
  18. pBase->foo();
  19. pBase->bar();
  20. pBase->baz();
  21. //pBase->qux(); // Ошибка
  22. delete pBase;
  23. return EXIT_SUCCESS;
* This source code was highlighted with Source Code Highlighter .

Что происходит при запуске программы? Вначале объявляем указатель на объект типа Base, которому присваиваем адрес вновь созданного объекта типа Inherited. При этом вызывается конструктор Base, инициализирует VPTR адресом VTABLE класса Base, а затем конструктор Inherited, который перезаписывает значение VPTR адресом VTABLE класса Inherited. При вызове pBase->foo() , pBase->bar() и pBase->baz() компилятор через указатель VPTR достает фактический адрес тела функции из таблицы виртуальных функций. Как это происходит? Вне зависимости от конкретного типа объекта компилятор знает, что адрес функции foo() находится на первом месте, bar() - на втором, и т.д. (как я и говорил, в порядке объявления функций). Таким образом, для вызова, к примеру, функции baz() он получает адрес функции в виде VPTR+2 - смещение от начала таблицы виртуальных функций, сохраняет этот адрес и подставляет в команду call. По этой же причине, вызов pBase->qux() приводит к ошибке: несмотря на то, что фактический тип объекта Inherited, когда мы присваиваем его адрес указателю на Base, происходит восходящее приведение типа, а в таблице VTABLE класса Base никакого четвертого метода нет, поэтому VPTR+3 указывало бы на «чужую» память (к счастью, такой код даже не компилируется).

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

Также становится понятно, почему виртуальные функции работают только при обращении по адресу объекта (через указатели либо через ссылки). Как я уже сказал, в этой строке
Base * pBase = new Inherited;
происходит повышающее приведение типа: Inherited* приводится к Base*, но в любом случае указатель всего лишь хранит адрес «начала» объекта в памяти. Если же повышающее приведение производить непосредственно для объекта, то он фактически «обрезается» до размера объекта базового класса. Поэтому логично, что для вызова функций «через объект» используется раннее связывание - компилятор и так «знает» фактический тип объекта.

Собственно, это всё. Жду комментариев. Спасибо за внимание.

P.S. Данная статья помечена грифом «Гарантия Скора»
(Skor, если ты это читаешь, это для тебя;)

P.P.S. Да, забыл сказать… Джависты сейчас начнут кричать, что в Java по умолчанию все функции виртуальные.
_________
Текст подготовлен в