1. Навіщо потрібен динамічний поліморфізм
Коли ви пишете програму, майже завжди хочеться спростити собі життя: мати єдиний код, який «щось робить», а не розкидати по всьому проєкту умови на кшталт «якщо це кішка — нявкай, якщо це собака — гавкай». Так, if/else — корисна річ, але інколи вона перетворюється на громіздкий комбайн умов, до якого страшно торкатися: додали новий тип — і вже треба правити десяток місць.
Динамічний поліморфізм у C++ розвʼязує саме цю проблему: ми описуємо спільний інтерфейс у базовому класі, даємо нащадкам перевизначати поведінку, і метод викликається «правильно» навіть тоді, коли ми працюємо з обʼєктом через базовий тип — тобто через посилання або вказівник. У стандарті C++ навіть є окремий термін — «virtual function call», який підкреслює: це особливий вид виклику, де реалізацію обирають під час виконання.
Наслідування і сенс is-a
Наслідування — це не просто «спосіб повторно використовувати код» (хоча інколи воно так і виглядає), а передусім відношення типів. Якщо ви пишете class Cat : public Animal, ви стверджуєте: «Кіт — це тварина». Тобто будь-який Cat має бути доречним там, де очікують Animal. Це важливіше за сам синтаксис.
Сам синтаксис доволі простий: після імені класу ставиться двокрапка та імʼя базового типу. Ключове слово public означає, що публічний інтерфейс базового класу лишається публічним і в нащадку. Для наших навчальних цілей це майже завжди саме те, що потрібно.
#include <string>
struct Animal {
std::string name;
};
struct Cat : public Animal {
int lives = 9;
};
Тут у Cat є все, що є в Animal, плюс власні поля. Це можна уявляти так: усередині Cat «вбудована» базова частина Animal (інколи кажуть base subobject), а поруч розташовані додаткові поля кота.
Невелика схема для наочності:
flowchart TB
C[Cat object] --> B[Базова частина: Animal]
C --> D[Похідна частина: поля Cat]
2. Звичайні та віртуальні методи: різні правила
Дуже поширена помилка новачків виглядає так: людина створює базовий клас, пише в ньому метод sound(), потім у нащадка теж пише метод sound() з таким самим імʼям — і чекає, що «все перевизначиться само собою». А C++ у відповідь каже: «Я нічого не обіцяв».
Якщо метод не virtual, то під час виклику через Base&/Base* обирається реалізація за типом посилання або вказівника в коді, тобто за статичним типом. Спершу така поведінка може здаватися «несправедливою», але це просто правило мови.
Порівняймо це на короткому прикладі.
Без virtual: поліморфізму не буде
Ось приклад, який трохи розчаровує, зате дуже добре все пояснює.
#include <iostream>
#include <string>
struct Animal {
std::string sound() const { return "???"; } // не virtual
};
struct Cat : public Animal {
std::string sound() const { return "meow"; }
};
void print_sound(const Animal& a) {
std::cout << a.sound() << '\n'; // ??? (а не meow)
}
int main() {
Cat c;
print_sound(c);
}
Чому друкується "???"? Тому що print_sound приймає const Animal&, і компілятор обирає Animal::sound() буквально за записом у коді, адже метод не є віртуальним.
Із virtual: вмикаємо динамічний вибір реалізації
Тепер додамо virtual у базовий клас — і все почне поводитися так, як ми інтуїтивно й очікували.
#include <iostream>
#include <string>
struct Animal {
virtual std::string sound() const { return "???"; }
};
struct Cat : public Animal {
std::string sound() const { return "meow"; }
};
void print_sound(const Animal& a) {
std::cout << a.sound() << '\n'; // meow
}
int main() {
Cat c;
print_sound(c);
}
Тепер виклик a.sound() відбувається за правилами віртуальних функцій: реалізацію обирають за реальним типом обʼєкта, тобто за тим, чим обʼєкт є насправді під час виконання. Ось це і є динамічний поліморфізм.
3. override: захист від «майже перевизначив»
Коли ви починаєте працювати з віртуальними методами, зʼявляється ще один тип помилок: «я думав, що перевизначив метод, а насправді оголосив інший». Причини зазвичай дрібні й прикрі: забули const, переплутали параметр за посиланням і за значенням, десь додали &, а десь прибрали.
Для цього в C++ є ключове слово override. Його пишуть у нащадку, і воно означає: «Компіляторе, будь ласка, перевір, що я справді перевизначаю віртуальний метод базового класу».
Дуже корисне правило: якщо перевизначаєте — завжди пишіть override. Це не «для краси», а надійна страховка від помилок рівня «чому воно не працює».
Помилка сигнатури: override рятує
struct Base {
virtual int value() const { return 1; }
};
struct Derived : public Base {
// int value() override { return 2; } // помилка компіляції: немає const
int value() const override { return 2; }
};
Тут override змушує компілятор сказати: «Стоп, друже, ти хотів перевизначити метод, але сигнатура не збіглася». Без override код міг би скомпілюватися, і ви отримали б два різні методи. Тоді поліморфізм працював би зовсім не так, як ви очікували.
Що має збігатися в сигнатурі
| Що має збігатися | Приклад «дрібниці», яка ламає override |
|---|---|
| Список параметрів | vs |
у методу |
vs |
| Спосіб передавання параметрів | vs |
/ кваліфікатори (поки що не заглиблюємося) |
у новачків трапляється рідко, але теж має значення |
4. Статичний і реальний тип: як обирається метод
На цьому етапі в новачків часто виникає відчуття: «Гаразд, virtual — це якась магія». Щоб магії стало менше, тримайте в голові два типи:
Статичний тип — це те, що ви бачите в коді: тип змінної, параметра, вказівника чи посилання. Він відомий компілятору.
Реальний (динамічний) тип — це фактичний тип обʼєкта під час виконання.
Наприклад, якщо у вас є Cat c;, то і статичний тип, і реальний — Cat. Але якщо ви записуєте Animal& a = c;, то статичний тип a — Animal&, а реальний тип обʼєкта, на який посилається a, — Cat.
Схема, щоб закріпити ідею:
sequenceDiagram
participant Code as Код (статичний тип)
participant Obj as Обʼєкт (реальний тип)
Code->>Obj: Animal& a = c (c — Cat)
Note over Code: a має тип Animal&
Note over Obj: обʼєкт усе ще Cat
Code->>Obj: a.sound()
Note over Obj: для virtual обираємо Cat::sound()
Саме через цю різницю virtual і має сенс: воно каже компілятору «дозволь обирати реалізацію за реальним типом обʼєкта, а не за типом посилання чи вказівника».
5. Віртуальний виклик через Base& і Base*
Важливо зрозуміти: віртуальність особливо корисна саме тоді, коли ви звертаєтеся до обʼєкта через базовий тип. Якщо ви викликаєте метод напряму, наприклад у записі Cat c; c.sound();, то й без поліморфізму все очевидно: викличеться метод кота.
Динамічний поліморфізм проявляється в ситуаціях, де ви приймаєте параметр типу Base&/Base*, зберігаєте вказівник на базовий клас або повертаєте базове посилання чи вказівник із функції.
Виклик через посилання
#include <iostream>
#include <string>
struct Animal {
virtual std::string sound() const { return "???"; }
};
struct Dog : public Animal {
std::string sound() const override { return "woof"; }
};
void print_sound(const Animal& a) {
std::cout << a.sound() << '\n'; // woof
}
int main() {
Dog d;
print_sound(d);
}
Виклик через вказівник
#include <iostream>
#include <string>
struct Animal {
virtual std::string sound() const { return "???"; }
};
struct Cat : public Animal {
std::string sound() const override { return "meow"; }
};
int main() {
Cat c;
Animal* p = &c;
std::cout << p->sound() << '\n'; // meow
}
Зверніть увагу на p->sound(): це саме звернення через Animal*, тобто через базовий тип, і саме тому віртуальність визначає, яку реалізацію треба викликати.
6. Практичний приклад: дії в консольному застосунку
Щоб тема не зводилася до «зоопарку» з кішок і собак, перенесімо поліморфізм у практичніший контекст. Уявімо, що до цього ви писали простий консольний застосунок, умовний «TaskTracker», де є обʼєкт стану App і кілька дій: додати завдання, вивести список, показати довідку. Зазвичай новачок у такій ситуації пише один великий if/else для розбору рядка команди.
Тепер зробімо крок до охайнішої архітектури: опишемо базовий клас Action і кількох нащадків. Ми не будемо будувати складні колекції поліморфних обʼєктів — це окрема велика тема, — а просто покажемо сам механізм віртуального виклику.
Базовий клас дії
Спочатку створімо базову сутність — «дію». Вона вміє повертати назву для виведення й виконувати саму дію.
#include <string>
struct App {
int tasks = 0; // умовно: кількість завдань
};
struct Action {
virtual std::string title() const { return "unknown"; }
virtual void run(App& app) const { (void)app; }
};
Тут обидва методи віртуальні, щоб нащадки могли змінювати поведінку.
Дія «додати завдання»
#include <string>
struct AddTaskAction : public Action {
std::string title() const override { return "add"; }
void run(App& app) const override {
++app.tasks;
}
};
Дія «показати статистику»
#include <iostream>
#include <string>
struct StatsAction : public Action {
std::string title() const override { return "stats"; }
void run(App& app) const override {
std::cout << "Завдань: " << app.tasks << '\n'; // Завдань: 0 (або більше)
}
};
Функція, яка працює через базовий тип
Ось ключовий момент: функція не знає, яку саме дію їй передали. Вона бачить лише Action.
#include <iostream>
void execute(const Action& action, App& app) {
std::cout << "Виконуємо: " << action.title() << '\n';
action.run(app);
}
А тепер зберімо невеликий сценарій:
int main() {
App app;
AddTaskAction add;
StatsAction stats;
execute(add, app);
execute(stats, app);
}
І саме тут відбувається те, заради чого все й затівалося: execute приймає const Action&, але фактично викликає різні реалізації title() і run() залежно від реального типу переданого обʼєкта.
Зауважте важливу, хоч і трохи підступну, деталь: ми передали add і stats за посиланням. Саме це зберігає «звʼязок» із реальним обʼєктом і дозволяє віртуальному виклику працювати. У наступних лекціях ми детально обговоримо, які способи передавання та зберігання можуть порушувати очікування від поліморфізму. А поки що просто запамʼятайте практичне правило: поліморфне використання майже завжди означає роботу через Base& або Base*.
7. Типові помилки під час роботи з virtual/override
Помилка № 1: метод у базовому класі забули зробити virtual, але чекають «підміни поведінки».
Такий код компілюється і виглядає логічно, але під час звернення через Base&/Base* викликатиметься базова версія методу. Через це можна довго дивитися на програму з виразом обличчя «ну я ж точно написав Cat::sound()». Рішення просте: якщо ви очікуєте динамічного вибору реалізації, то в базовому класі метод має бути virtual.
Помилка № 2: перевизначення «майже збіглося» за сигнатурою, і вийшов новий метод.
Класика жанру: забули const у методу, переплутали std::string і const std::string&, або змінили тип параметра. У результаті ви не перевизначили метод, а просто додали інший. Саме тому override — не прикраса, а щоденний пасок безпеки: він змусить компілятор насваритися одразу.
Помилка № 3: розраховують на поліморфізм там, де немає звернення через базовий тип.
Якщо ви створюєте Cat c; і викликаєте c.sound(), то ви й так викликаєте метод кота — тут virtual нічого принципово не змінює. Поліморфізм проявляється тоді, коли ви навмисно дивитеся на обʼєкт «як на Base», тобто використовуєте Base& або Base*.
Помилка № 4: наслідування використовують «заради спільної реалізації», хоча по суті це не відношення is-a.
Інколи хочеться успадкувати class Logger : public std::string або class Button : public Vector, бо «так простіше отримати потрібну функціональність». Зазвичай це закінчується дивним дизайном і несподіваними обмеженнями: тип починає обіцяти світу те, чим він не є. Корисна звичка: перед тим як писати : public Base, промовте вголос фразу «Derived є Base». Якщо це звучить безглуздо, краще зупинитися й подумати.
Помилка № 5: не формулюють, який саме метод є точкою розширення.
Коли в базовому класі забагато virtual, нащадки стають «заручниками» бази, і будь-яка зміна інтерфейсу перетворюється на мініапокаліпсис для всього проєкту. Коли virtual замало, ви знову повертаєтеся до if/else. Здоровий інженерний баланс зʼявляється тоді, коли ви свідомо вирішуєте: «ось ця поведінка буде різною — отже, метод має бути віртуальним», а все інше залишаєте звичайними методами.