В этой статье разберем ключевые инструменты для реализации полиморфизма (одного из трёх столпов ООП наряду с инкапсуляцией и наследованием) на примере языка С++ — абстрактные классы и виртуальные методы.
Основные понятия
Виртуальный метод — метод класса, который может быть переопределён в классе‑наследнике. При вызове через указатель или ссылку на базовый класс выполняется версия метода из производного класса.
Как работает:
- объявляется с ключевым словом virtual в базовом классе;
- позволяет реализовать позднее связывание (вызов нужной версии метода определяется во время выполнения);
- даёт возможность писать код, работающий с объектами разных типов через единый интерфейс.
Абстрактный класс — класс, который:
- содержит хотя бы один чистый виртуальный метод (= 0);
- не может быть инстанцирован (нельзя создать объект такого класса);
- задаёт контракт — наследники обязаны реализовать все чистые виртуальные методы.
Ключевые особенности:
- служит шаблоном для группы связанных классов;
- определяет общий интерфейс без реализации;
- гарантирует, что все наследники будут иметь требуемые методы.
Работаем с классами в С++
Создадим класс Person c полями спецификатора protected — name и age. Спецификатор — ключевое слово в C++, которое изменяет свойства или поведение элементов языка (типов, функций, методов, переменных). В нашем коде это спецификаторы доступа, которые управляют видимостью членов класса:
- public — доступен отовсюду;
- private — доступен только внутри класса;
- protected — доступен в классе и его наследниках.
Мы не хотим, чтобы пользователь напрямую мог изменить name и age для объекта, но нам нужно, чтобы у класса-наследника был доступ к этим полям, поэтому используем спецификатор protected.
Класс Person является абстрактным, поскольку в нем есть два чистых виртуальных метода — input() и output().
Разбор синтаксиса:
- virtual — метод может быть переопределён в производных классах;
- void input() — сигнатура метода (возвращает void, принимает ничего);
- = 0 — делает метод «чистым», то есть без реализации в этом классе.
Последствия:
- класс с хотя бы одним чисто виртуальным методом становится абстрактным (если виртуальный метод будет не чистым, то класс перестанет быть абстрактым. Обсудим чуть позже);
- нельзя создать объект такого класса: Person p; — ошибка компиляции;
Все производные классы обязаны переопределить этот метод.
Поскольку класс объекта Person создать нельзя, то можно сказать, что это «шаблон» с правилами. Сам по себе не используется, но задаёт, что должны уметь его «дети».
Виртуальные методы выполняют следующие функции:
- Задают контракт (интерфейс). Любой класс, унаследованный от Person, обязан реализовать эти методы.
- Делают класс абстрактным — нельзя создать объект Person.
Кроме виртуальных методов в классе Person есть виртуальный деструктор с реализацией по умолчанию. Что делает:
- virtual — указывает, что деструктор будет вызываться полиморфно (через указатель на базовый класс);
- = default — компилятор сгенерирует стандартную реализацию деструктора (аналог пустого тела {}).
При удалении объекта:
- Сначала вызывается деструктор производного класса (~Student()).
- Затем вызывается деструктор базового класса (~Person()).
Без виртуального деструктора:
- вызывается только ~Person();
- ~Student() не вызывается;
- ресурсы производного класса (например, динамические массивы) не освобождаются — утечка памяти.
Селекторы и модификаторы выполняют классический функционал обычных классов.
Создадим производный класс Student:
Перед классом родителем снова указываем спецификатор — public. Таким образом, все методы public, объявленные в Person, сохранятся открытыми и в Student. Поля protected так же останутся недоступными извне, но доступными для наследования.
В классе Student создаем новое поле — rating, оно будет закрытым.
В Student получают свою функциональность виртуальные методы.
override — спецификатор в C++, который явно указывает: данный метод переопределяет виртуальный метод из базового класса. Компилятор проверяет, действительно ли такой метод существует в иерархии наследования. Если в базовом классе нет подходящего виртуального метода, компилятор выдаст ошибку.
Код работал бы и без спецификатора override, так как компилятор видит совпадение сигнатур и связывает метод производного класса с виртуальным методом базового. Ключевое слово override не требуется для самого механизма переопределения — оно служит для дополнительной проверки и улучшения читаемости кода.
Напишем также реализацию для функции output():
Класс Student является полноценным классом, на основе которого можно создавать объекты, поэтому input() и output() мы тоже можем использовать для решения задач.
Если бы мы не написали код для виртуальных функций в классе Student, то этот класс так же считался бы абстрактным и мы бы не смогли создать на его основе объекты.
О связи с полиморфизмом
Полиморфизм — способность объектов разных классов по‑разному реагировать на один и тот же вызов.
Для полиморфизма нужны:
- Наследование (иерархия классов).
- Виртуальные методы (механизм динамического связывания).
- Указатели/ссылки на базовый класс.
Почему обычные методы не подходят?
При вызове обычного метода компилятор «запоминает» тип указателя на этапе компиляции и всегда вызывает одну и ту же версию метода. А это уже статическое связывание.
Это именно как раз тот случай, когда нам может пригодится виртуальный метод с телом функции, а не чистый.
Разберем на примере. Давайте создадим систему с разными типами людей:
В классе Person виртуальный метод не является чистым, а значит класс не будет являться абстрактным.
Теперь напишем функцию, которая работает с любым типом Person:
Что здесь происходит:
- У нас есть одна функция greet().
- Она принимает указатель на Person.
- Но может работать с любым объектом, унаследованным от Person.
- При вызове p->introduce() программа «смотрит», на какой реальный объект указывает p, и вызывает правильный метод.
Если написать этот же пример без виртуальных методов, то результат будет один для всех объектов — результат работы функции introduce() класса Person.
Абстрактные классы, виртуальные методы и полиморфизм делают код:
- гибким — легко менять поведение через переопределение методов;
- масштабируемым — просто добавлять новые классы‑наследники;
- чистым — меньше дублирования, чёткое разделение обязанностей;
- надёжным — компилятор ловит ошибки на этапе сборки (например, если не реализован обязательный метод).
Буду рад обратной связи и ответить на все ваши вопросы в комментариях.