C ++ MythBusters. Міф про віртуальних функціях

  1. Віртуальні функції і ключове слово virtual
  2. Раннє і пізніше зв'язування. Таблиця віртуальних функцій

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

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

Сподіваюся, всі знають, що таке віртуальні функції і як вони використовуються, так як пояснювати це вже не моя задача. Якщо в матеріалі про inline-методи міф був не зовсім очевидний, то в цьому - навпаки. Власне, перейдемо до «міфу».

Віртуальні функції і ключове слово virtual

На мій подив, я дуже часто стикався і стикаюся з людьми (та що там говорити, я і сам був таким же), які вважають, що ключове слово virtual виконує функцію віртуальної тільки на один рівень ієрархії. Поясню, що мається на увазі, на прикладі: #include <cstdlib> #include <iostream> using std :: cout; using std :: endl; struct A {virtual ~ A () {} virtual void foo () const {cout << "A :: foo ()" << endl; } Virtual void bar () const {cout << "A :: bar ()" << endl; } Void baz () const {cout << "A :: baz ()" << endl; }}; struct B: public A {virtual void foo () const {cout << "B :: foo ()" << endl; } Void bar () const {cout << "B :: bar ()" << endl; } Void baz () const {cout << "B :: baz ()" << endl; }}; struct C: public B {virtual void foo () const {cout << "C :: foo ()" << endl; } Virtual void bar () const {cout << "C :: bar ()" << endl; } Void baz () const {cout << "C :: baz ()" << endl; }}; int main () {cout << "pA is B:" << endl; A * pA = new B; pA-> foo (); pA-> bar (); pA-> baz (); delete pA; cout << "\ npA is C:" << endl; pA = new C; pA-> foo (); pA-> bar (); pA-> baz (); delete pA; 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, компілятор позначає, що для цього методу має використовуватися пізніше зв'язування: для початку він створює для класу таблицю віртуальних функцій, а в клас додає новий прихований для програміста член - покажчик на цю таблицю. (Насправді, наскільки я знаю, стандарт мови не наказує, як саме повинен бути реалізований механізм віртуальних функцій, але реалізація на основі віртуальної таблиці стала стандартом де факто.). Розглянемо цей пруфкод:

#include <cstdlib> #include <iostream> struct Empty {}; struct EmptyVirt {virtual ~ EmptyVirt () {}}; struct NotEmpty {int m_i; }; struct NotEmptyVirt {virtual ~ NotEmptyVirt () {} int m_i; }; struct NotEmptyNonVirt {void foo () const {} int m_i; }; int main () {std :: cout << sizeof (Empty) << std :: endl; std :: cout << sizeof (EmptyVirt) << std :: endl; std :: cout << sizeof (NotEmpty) << std :: endl; std :: cout << sizeof (NotEmptyVirt) << std :: endl; std :: cout << sizeof (NotEmptyNonVirt) << std :: endl; return EXIT_SUCCESS; }

Висновок може відрізнятися в залежності від платформи, але в моєму випадку (Win32, msvc2008) він був наступним:

1

4

4

8

4

Що можна зрозуміти з цього прикладу. По-перше, розмір «порожнього» класу завжди більше нуля, тому що компілятор спеціально вставляє в нього фіктивний член. Як пише Еккель, «уявіть процес індексування в масиві об'єктів нульового розміру, і все стане ясно»;) По-друге, ми бачимо, що розмір «непорожньої» класу 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 ()

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

#include <cstdlib> #include <iostream> using std :: cout; using std :: endl; struct Base {Base () {cout << "Base :: Base ()" << endl; } Virtual ~ Base () {cout << "Base :: ~ Base ()" << endl; } Virtual void foo () {cout << "Base :: foo ()" << endl; } Virtual void bar () {cout << "Base :: bar ()" << endl; } Virtual void baz () {cout << "Base :: baz ()" << endl; }}; struct Inherited: public Base {Inherited () {cout << "Inherited :: Inherited ()" << endl; } Virtual ~ Inherited () {cout << "Inherited :: ~ Inherited ()" << endl; } Virtual void bar () {cout << "Inherited :: bar ()" << endl; } Virtual void qux () {cout << "Inherited :: qux ()" << endl; }}; int main () {Base * pBase = new Inherited; pBase-> foo (); pBase-> bar (); pBase-> baz (); // pBase-> qux (); // Помилка delete pBase; return EXIT_SUCCESS; }

Що відбувається під час запуску програми? Спочатку оголошуємо покажчик на об'єкт типу 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 *, але в будь-якому випадку покажчик всього лише зберігає адресу «початку» об'єкта в пам'яті. Якщо ж підвищує приведення виробляти безпосередньо для об'єкта, то він фактично «обрізається» до розміру об'єкта базового класу. Тому логічно, що для виконання функцій «через об'єкт» використовується раннє зв'язування - компілятор і так «знає» фактичний тип об'єкта.

Власне, це все. Чекаю коментарів. Дякуємо за увагу.

PS Дана стаття позначена грифом «Гарантія Скора» ©

(Skor, якщо ти це читаєш, це для тебе;)

(Skor, якщо ти це читаєш, це для тебе;)

Що являє собою таблиця віртуальних функцій і для чого вона потрібна?
»; «Маємо масив покажчиків на базовий клас, кожен з яких вказує на об'єкт одного з похідних класів - скільки у нас буде таблиць віртуальних функцій?
Як це відбувається?

Дополнительная информация

rss
Карта