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

Связывание в языке 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. Проектная часть

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

В языке Java объектные переменные являются полиморфными (polymorphic). Например:
class King { public static void main(String args) { King king = new King() ; king = new AerysTargaryen() ; king = new RobertBaratheon() ; } } class RobertBaratheon extends King { } class AerysTargaryen extends King { }
Переменная типа King может ссылаться как на объект типа King, так и на объект любого подкласса King.
Возьмем следующий пример:

class King { public void speech() { System .out .println ("I"m the King of the Andals!" ) ; } public void speech(String quotation) { System .out .println ("Wise man said: " + quotation) ; } public void speech(Boolean speakLoudly) { if (speakLoudly) System .out .println ("I"M THE KING OF THE ANDALS!!!11" ) ; else System .out .println ("i"m... the king..." ) ; } } class AerysTargaryen extends King { @Override public void speech() { System .out .println ("Burn them all..." ) ; } @Override public void speech(String quotation) { System .out .println (quotation+ " ... And now burn them all!" ) ; } } class Kingdom { public static void main(String args) { King king = new AerysTargaryen() ; king.speech ("Homo homini lupus est" ) ; } }
Что происходит, когда вызывается метод, принадлежащий объекту king ?
1. Компилятор проверяет объявленный тип объекта и имя метода, нумерует все методы с именем speech в классе AerusTargarien и все открытые методы speech в суперклассах AerusTargarien . Теперь компилятору известны возможные кандидаты при вызове метода.
2. Компилятор определяет типы передаваемых в метод аргументов. Если найден единственный метод, сигнатура которого совпадает с аргументами, происходит вызов. Этот процесс king.speech("Homo homini lupus est") компилятор выберет метод speech(String quotation) , а не speech() .
Если компилятор находит несколько методов с подходящими параметрами (или ни одного), выдается сообщение об ошибке.



Теперь компилятор знает имя и типы параметров метода,подлежащего вызову.
3. В случае, если вызываемый метод является private , static , final или конструктором, используется статическое связывание (early binding ). В остальных случаях метод, подлежащий вызову, определяется по фактическому типу объекта, через который происходит вызов. Т.е. во время выполнения программы используется динамическое связывание (late binding) .

4. Виртуальная машина заранее создает таблицу методов для каждого класса, в которой перечисляются сигнатуры всех методов и фактические методы, подлежащие вызову.
Таблица методов для класса King выглядит так:
  • speech() - King . speech()
  • speech(String quotation) - King . speech(String quotation )
  • King . speech(Boolean speakLoudly )
А для класса AerysTargaryen - так:
  • speech() - AerysTargaryen . speech()
  • speech(String quotation) - AerysTargaryen . speech(String quotation )
  • speech(Boolean speakLoudly) - King . speech(Boolean speakLoudly )
Методы, унаследованные от Object, в данном примере игнорируются.
При вызове king. speech() :
  1. Определяется фактический тип переменной king . В данном случае это AerysTargaryen .
  2. Виртуальная машина определяет класс, к которому принадлежит метод speech()
  3. Происходит вызов метода.
Связывание всех методов в Java осуществляется полиморфно, через позднее связывание. Динамическое связывание обладает одной важной особенностью: оно позволяет модифицировать программы без перекомпиляции их кодов. Это делает программы динамически расширяемыми (extensible ).
А что произойдет, если вызвать в конструкторе динамически связываемый метод конструируемого объекта? Например:
class King { King() { System .out .println ("Call King constructor" ) ; speech() ; //polymorphic method overriden in AerysTargaryen } public void speech() { System .out .println ("I"m the King of the Andals!" ) ; } } class AerysTargaryen extends King { private String victimName; AerysTargaryen() { System .out .println ("Call Aerys Targaryen constructor" ) ; victimName = "Lyanna Stark" ; speech() ; } @Override public void speech() { System .out .println ("Burn " + victimName + "!" ) ; } } class Kingdom { public static void main(String args) { King king = new AerysTargaryen() ; } } Результат:

Call King constructor Burn null! Call Aerys Targaryen constructor Burn Lyanna Stark !
Конструктор базового класса всегда вызывается в процессе конструирования производного класса. Вызов автоматически проходит вверх по цепочке наследования, так что в конечном итоге вызываются конструкторы всех базовых классов по всей цепочке наследования.
Это значит, что при вызове конструктора new AerysTargaryen() будут вызваны:
  1. new Object()
  2. new King()
  3. new AerysTargaryen()
По определению, задача конструктора — дать объекту жизнь. Внутри любого конструктора объект может быть сформирован лишь частично — известно только то, что объекты базового класса были проинициализированы. Если конструктор является лишь очередным шагом на пути построения объекта класса, производного от класса данного конструктора, «производные» части еще не были инициализированы на момент вызова текущего конструктора.

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

Результат работы программы обусловлен выполнение алгоритма иницализации объекта:

  1. Память, выделенная под новый объект, заполняется двоичными нулями.
  2. Конструкторы базовых классов вызываются в описанном ранее порядке. В этот момент вызывается переопределенный метод speech() (да, перед вызовом конструктора класса AerysTargaryen ), где обнаруживается, что переменная victimName равна null из-за первого этапа.
  3. Вызываются инициализаторы членов класса в порядке их определения.
  4. Исполняется тело конструктора производного класса.
В частности из-за таких поведенческих моментов стоит придерживаться следующего правила написания конструкторов:
- выполняйте в конструкторе лишь самые необходимые и простые действия по инициализации объекта
- по возможности избегайте вызова методов, не определенных как private или final (что в данном контексте одно и то же).
Использованы материалы:
  1. Eckel B. - Thinking in Java , 4th Edition - Chapter 8
  2. Cay S. Horstmann, Gary Cornell - Core Java 1 - Chapter 5
  3. Wikipedia

Статье я рассказывал, с какой не всем известной особенностью можно столкнуться при работе с подставляемыми функциями. Статья породила как несколько существенных замечаний, так и многостраничные споры (и даже холивары), начавшиеся с того, что 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 по умолчанию все функции виртуальные.
_________
Текст подготовлен в

Последнее обновление: 04.02.2019

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

Переопределение

Возьмем пример с переопределением методов:

Class Person { public string FirstName { get; set; } public string LastName { get; set; } public Person(string firstName, string lastName) { FirstName = firstName; LastName = lastName; } public virtual void Display() { Console.WriteLine($"{FirstName} {LastName}"); } } class Employee: Person { public string Company { get; set; } public Employee(string firstName, string lastName, string company) : base(firstName, lastName) { Company = company; } public override void Display() { Console.WriteLine($"{FirstName} {LastName} работает в {Company}"); } }

Также создадим объект Employee и передадим его переменной типа Person:

Person tom = new Employee("Tom", "Smith", "Microsoft"); tom.Display(); // Tom Smith работает в Microsoft

Теперь мы получаем иной результат, нежели при сокрытии. А при вызове tom.Display() выполняется реализация метода Display из класса Employee.

Для работы с виртуальными методами компилятор формирует таблицу виртуальных методов (Virtual Method Table или VMT). В нее записывается адреса виртуальных методов. Для каждого класса создается своя таблица.

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

А при вызове виртуального метода из объекта берется адрес его таблицы VMT. Затем из VMT извлекается адрес метода и ему передается управление. То есть процесс выбора реализации метода производится во время выполнения программы. Собственно так и выполняется виртуальный метод. Следует учитывать, что так как среде выполнения вначале необходимо получить из таблицы VMT адрес нужного метода, то это немного замедляет выполнение программы.

Сокрытие

Теперь возьмем те же классы Person и Employee, но вместо переопределения используем сокрытие:

Class Person { public string FirstName { get; set; } public string LastName { get; set; } public Person(string firstName, string lastName) { FirstName = firstName; LastName = lastName; } public void Display() { Console.WriteLine($"{FirstName} {LastName}"); } } class Employee: Person { public string Company { get; set; } public Employee(string firstName, string lastName, string company) : base(firstName, lastName) { Company = company; } public new void Display() { Console.WriteLine($"{FirstName} {LastName} работает в {Company}"); } }

И посмотрим, что будет в следующем случае:

Person tom = new Employee("Tom", "Smith", "Microsoft"); tom.Display(); // Tom Smith

Переменная tom представляет тип Person, но хранит ссылку на объект Employee. Однако при вызове метода Display будет выполняться та версия метода, которая определена именно в классе Person, а не в классе Employee. Почему? Класс Employee никак не переопределяет метод Display, унаследованный от базового класса, а фактически определяет новый метод. Поэтому при вызове tom.Display() вызывается метод Display из класса Person.

ВИРТУАЛЬНЫЕ ФУНКЦИИ______________________________________________________________ 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 может