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

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

C++ SELF
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[Base part: Animal]
    C --> D[Derived part: Cat fields]

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() идёт по правилам виртуальных функций: выбирается реализация по реальному типу объекта (то есть по тому, чем объект является на самом деле во время выполнения). И это уже динамический полиморфизм.

4. 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&)
&
/
&&
квалификаторы (пока не углубляемся)
редко у новичков, но тоже влияет

5. Статический и реальный тип: как выбирается метод

На этом месте у новичков часто ощущение: «Окей, virtual — это какая-то магия». Чтобы магии стало меньше, держите в голове два типа:

Статический тип — это то, что написано в коде: тип переменной, тип параметра, тип указателя/ссылки. Он известен компилятору.

Реальный (динамический) тип — это то, каким типом объект является на самом деле во время выполнения.

Пример: если у вас есть Cat c;, то и статический тип, и реальный — Cat. А вот если вы делаете Animal& a = c;, то статический тип aAnimal&, но реальный тип объекта, на который он ссылается — 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 имеет смысл: он говорит компилятору «разреши выбирать реализацию по реальному типу объекта, а не по типу ссылки/указателя».

6. Виртуальный вызов через 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*, то есть через базовый тип, и поэтому виртуальность решает, какую реализацию вызвать.

7. Практический пример: действия в консольном приложении

Чтобы тема не оставалась «зоопарком из кошек и собак», давайте добавим полиморфизм в более прикладной контекст. Представим, что до этого вы делали простое консольное приложение (условный “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 << "Tasks: " << app.tasks << '\n'; // Tasks: 0 (или больше)
    }
};

Функция, которая работает через базовый тип

Вот ключевой момент: функция не знает, какое именно действие ей передали. Она видит только Action.

#include <iostream>

void execute(const Action& action, App& app) {
    std::cout << "Executing: " << 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*.

8. Типичные ошибки при работе с 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 лекция
Недоступна
Два действия
Два действия
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ