Протягом мого навчання C++, я натрапив на одну з його основоположних складових: Поліморфізм. Я приділив час, щоб розібратися в деталях цього концепту, зокрема в Поліморфізмі під час виконання (Runtime Polymorphism) та віртуальних функціях, досліджуючи, як компілятор аналізує наш код для реалізації поліморфних класів. Ви дізнаєтесь у цій статті, як працюють поліморфні класи, які механізми лежать в основі їх роботи.
По-перше, чому Поліморфізм під час виконання є корисним? Давайте розглянемо реальний сценарій — уявіть, що ви розробляєте гру за допомогою C++. У вашій грі є персонажі, які взаємодіють і поводяться по-різному. У вас є три типи персонажів: Атакуючі
, Оборонці
та Судді
. Кожен з цих персонажів може реалізувати поведінку Walk()
. Водночас у кожного з цих персонажів є одна особлива здатність, яка відрізняється від інших; скажімо, судді можуть Freeze_Time()
, атакуючі — Attack()
, а оборонці — Defend()
.
Щоб уникнути дублювання коду та зберегти ефективність дизайну гри, ви вирішуєте створити базовий клас під назвою Character
, а потім створюєте три спеціалізовані класи: Attacker
, Defender
та Judge
. Клас Character
виступає базовим для всіх анімованих персонажів. Це базове наслідування.
Тепер ось де вступає в гру поліморфізм; ви будете використовувати вказівник на базовий клас (Character* )
, щоб представляти різні похідні об'єкти (Attacker, Defender, Judge). Це дозволяє обробляти всі типи персонажів однаковим способом, зберігаючи гнучкість для використання унікальних можливостей, а також легко розширювати гру, додаючи нових персонажів без модифікації існуючого коду. Ви можете змусити всіх персонажів ходити, не звертаючи уваги на їх тип, водночас взаємодіючи з ними як з більш спеціалізованими типами.
Ми підкреслимо це, відповідаючи на наступні питання:
- Що таке Поліморфізм під час виконання?
- Що таке Vtables і як вони працюють?
- Як працюють Vpointers на низькому рівні?
- Як виділяється пам'ять для поліморфних об'єктів?
- Чому використовувати віртуальні деструктори при роботі з поліморфними класами?
Що таке Поліморфізм під час виконання?
Коли ви використовуєте вказівник на базовий клас, цей вказівник може доступатися лише до членів (змінних та функцій), які оголошені в базовому класі. Якщо базовий клас і похідний клас мають однакові імена функцій, тоді динамічне викликанння (завдяки virtual
функціям) забезпечить виклик правильної функції для похідного класу.
Похідний клас все одно є частиною об'єкта, навіть коли до нього звертаються через вказівник на базовий клас. Ви не втрачаєте доступ до похідної частини об'єкта, коли взаємодієте з ним через вказівник на базовий клас. Однак специфічні члени похідного класу (які відсутні в базовому класі) не доступні напряму.
Поліморфізм під час виконання — це здатність вказівника на базовий клас посилатися на об'єкти похідних класів, та викликати перевизначені методи (з використанням віртуальних функцій у базових класах), які поводяться по-різному в залежності від об'єкта. Віртуальні функції дозволяють похідним класам надавати специфічні реалізації методів, а також забезпечують виклик правильної функції під час виконання.
Коли ви створюєте об'єкт за допомогою вказівника на поліморфний базовий клас (клас з хоча б однією віртуальною функцією), пам'ять для похідного об'єкта виділяється, що включає простір для частини базового класу.
Базовий клас може посилатися на похідний клас, але зворотне неможливо.
Що таке Vtables і як вони працюють?
Vtable — це механізм, який програма використовує для визначення правильної функції для виклику під час виконання. Кожен похідний клас повинен мати свою унікальну vtable, яка відображає його конкретні перевизначення функцій базового класу.
Давайте розглянемо детальніше:
Коли в базовому класі згадується ключове слово virtual
, створюється vtable для зберігання записів для кожної віртуальної функції, і вона зберігає вказівник на її визначення. Також створюється vtable для похідних класів, що перевизначають віртуальні функції. Vtable похідного класу містить вказівники на функції, оголошені в самому класі або на віртуальні функції, успадковані від базового класу.
Для кожного класу створюється лише одна vtable, якою користуються всі об'єкти цього класу.
Зверніть увагу, що vtable використовується лише для віртуальних функцій, оскільки вона відповідає за реалізацію поліморфізму під час виконання. Для невіртуальних функцій немає необхідності в пошуку під час виконання, оскільки компілятор знає на етапі компіляції, яку функцію потрібно викликати. Тому адреса невіртуальної функції не зберігається в vtable.
Як працюють Vpointers на низькому рівні?
На цьому етапі я замислився, чому похідний клас отримує свою власну vtable? Чому нам потрібні кілька vtables, навіть якщо похідний клас сам по собі не вводить нові віртуальні функції? Відповідь стала очевидною, коли я виявив vpointers.
Компілятор призначає vpointer кожному створеному об'єкту для зберігання адреси відповідної vtable. Vpointer відповідає за знаходження відповідної vtable і, таким чином, за виклик правильної член-функції.
Один vpointer створюється для кожного об'єкта, незалежно від того, скільки vtables існує.
Коли об'єкт тільки створюється і під час виклику конструктора базового класу, vptr вказує на vtable базового класу.
Після завершення конструктора базового класу vptr оновлюється, щоб вказувати на vtable похідного класу, оскільки об'єкт тепер повністю вважається об'єктом типу Derived
. З того моменту, vptr вказує на vtable похідного класу.
Як виділяється пам'ять для поліморфних об'єктів?
Розглянемо приклад нижче:
Коли ви створюєте об'єкт d
типу derived
, пам'ять виділяється для зберігання даних об'єкта. У цьому прикладі в класі derived
немає членів даних, існує лише функція (яка не займає місця в об'єкті). У C++ розмір об'єкта повинен бути завжди хоча б 1 байт, щоб забезпечити унікальну адресу об'єкта в пам'яті. Цей 1 байт представляє мінімальний обсяг пам'яті для об'єкта, навіть якщо в ньому немає членів даних, через те, як пам'ять організована для управління об'єктами в C++.
Тепер давайте успадкуємо від базового поліморфного класу і надрукуємо розмір об'єкта без додавання будь-яких членів даних:
Як ви помітили, цього разу розмір об'єкта d
типу derived
складає 8 байт. Це тому, що коли пам'ять виділяється для поліморфного об'єкта, компілятор додає і збільшує його розмір на sizeof(vpointer)
, щоб включити місце для vpointer, який зазвичай зберігається приховано в об'єкті. Тому, навіть якщо ми не можемо побачити vpointer в коді, він все одно існує, прихований в об'єкті. Розмір vptr зазвичай дорівнює розміру звичайного вказівника на вашій платформі.
Чому використовувати віртуальні деструктори при роботі з поліморфними класами?
Клас base
має віртуальні функції, що робить його поліморфним базовим класом. Коли об'єкт похідного класу видаляється через вказівник на базовий клас, правильний деструктор (тобто деструктор похідного класу) повинен бути викликаний, щоб уникнути непередбачуваної поведінки, такої як витоки ресурсів або неповне знищення об'єкта.
Без віртуального деструктора в базовому класі, деструктор похідного класу не буде викликаний під час видалення об'єкта через вказівник на базовий клас.
Викликається лише деструктор базового класу, що може призвести до витоку пам'яті або неправильного очищення.
Перекладено з: Into the depths of Polymorphism: Exploring C++