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 |
|---|---|
| Список параметров | vs |
у метода |
vs |
| Ссылочность параметров | vs |
/ квалификаторы (пока не углубляемся) |
редко у новичков, но тоже влияет |
5. Статический и реальный тип: как выбирается метод
На этом месте у новичков часто ощущение: «Окей, virtual — это какая-то магия». Чтобы магии стало меньше, держите в голове два типа:
Статический тип — это то, что написано в коде: тип переменной, тип параметра, тип указателя/ссылки. Он известен компилятору.
Реальный (динамический) тип — это то, каким типом объект является на самом деле во время выполнения.
Пример: если у вас есть Cat c;, то и статический тип, и реальный — Cat. А вот если вы делаете Animal& a = c;, то статический тип a — Animal&, но реальный тип объекта, на который он ссылается — 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. Хорошая инженерная середина появляется, когда вы явно решаете: “вот это поведение будет разным — значит, метод виртуальный”, а всё остальное — обычные методы.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ