JavaRush/Курси/C++ SELF/virtual/override — динамічний поліморфізм

virtual/override — динамічний поліморфізм

Рівень 51, Лекція 0
Відкрита

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
Список параметрів
int f(int)
vs
int f(long)
const
у методу
int f() const
vs
int f()
Спосіб передавання параметрів
void f(std::string)
vs
void f(const std::string&)
&
/
&&
кваліфікатори (поки що не заглиблюємося)
у новачків трапляється рідко, але теж має значення

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. Здоровий інженерний баланс зʼявляється тоді, коли ви свідомо вирішуєте: «ось ця поведінка буде різною — отже, метод має бути віртуальним», а все інше залишаєте звичайними методами.

1
Задача
C++ SELF,  51 рівень,  0 лекція
Недоступна
Два привітання
Два привітання
1
Задача
C++ SELF,  51 рівень,  0 лекція
Недоступна
Дві операції
Дві операції
1
Задача
C++ SELF,  51 рівень,  0 лекція
Недоступна
Два форматери
Два форматери
1
Задача
C++ SELF,  51 рівень,  0 лекція
Недоступна
Дві дії
Дві дії
Коментарі
  • популярні
  • нові
  • старі
Щоб залишити коментар, потрібно ввійти в систему
Для цієї сторінки немає коментарів.