Наследование в C++: частые ошибки и чем за них платишь
Невиртуальный деструктор, срезка объекта, ромбовидное наследование без virtual — четыре ошибки, из-за которых C++ течёт памятью и падает. Разбираем, почему так и как чинить.

Класс с виртуальными методами, указатель на базу, delete — и деструктор наследника не вызывается. Утечка на каждом объекте. Компилятор молчит, тесты проходят, а под нагрузкой процесс раздувается. Это не экзотика, а одна из четырёх типовых ошибок наследования в C++, которые стабильно проходят ревью и всплывают в проде. Разбираем их на компилируемых примерах для C++17 и новее (GCC 13 / Clang 17), с ценой каждой в памяти, крашах и времени отладки.
Ошибка 1. Невиртуальный деструктор у базового класса
Если у вас есть иерархия и вы удаляете наследника через указатель на базу, деструктор базы обязан быть виртуальным. Иначе поведение неопределённое: на практике вызовется только ~Base(), а ~Derived() пропустят. Всё, что наследник захватил — память, файлы, сокеты, — утечёт.
#include <memory>
#include <vector>
struct Base {
// без virtual деструктор наследника не вызовется
~Base() = default;
};
struct Derived : Base {
std::vector<int> payload{1, 2, 3}; // будет утечка
~Derived() { /* очистка не произойдёт через Base* */ }
};
int main() {
Base* obj = new Derived();
delete obj; // UB: ~Derived() не вызван
}
Правило простое: как только у класса появился хотя бы один virtual-метод, деструктор тоже делают виртуальным.
struct Base {
virtual ~Base() = default; // теперь delete через Base* корректен
};
Если класс используется полиморфно — деструктор виртуальный. Если класс не предназначен для наследования — пометьте его final, и вопрос снимется на этапе компиляции.
Обратная сторона: виртуальный деструктор добавляет классу указатель на таблицу виртуальных функций1. Для мелкого value-типа, который создают миллионами и не наследуют, это лишние 8 байт на объект. Не вешайте virtual на классы, которые не участвуют в полиморфизме.
1. vtable (таблица виртуальных функций)
Скрытая таблица указателей на реализации виртуальных методов. Объект полиморфного класса хранит указатель на неё (vptr), через него в рантайме выбирается нужная реализация. Отсюда и накладные расходы — и по памяти, и на разыменование.
Ошибка 2. Срезка объекта (object slicing)
Наследник передаётся по значению туда, где ожидается база. Компилятор молча копирует только базовую часть — поля и виртуальность наследника отбрасываются. Никакого предупреждения, никакого краша: просто объект работает не так, как вы думаете.
#include <iostream>
#include <vector>
struct Shape {
virtual double area() const { return 0.0; }
virtual ~Shape() = default;
};
struct Circle : Shape {
double radius;
explicit Circle(double r) : radius(r) {}
double area() const override { return 3.14159 * radius * radius; }
};
// принимает Shape по значению — здесь и происходит срезка
void printArea(Shape s) {
std::cout << s.area() << "\n"; // всегда 0.0
}
int main() {
Circle c(2.0);
printArea(c); // 0.0, а не 12.56 — Circle срезан до Shape
}
Особенно коварна срезка при складывании наследников в контейнер по значению: std::vector<Shape> хранит только базовые части. Полиморфизм ломается для всей коллекции сразу.
Лечение — работать с полиморфными объектами только через ссылки или указатели:
void printArea(const Shape& s) { // по ссылке — виртуальность сохранена
std::cout << s.area() << "\n";
}
// контейнер владеющих указателей вместо значений
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(2.0));
Цена ошибки — не краш, а тихо неправильный результат. Такие баги живут в продакшене месяцами, потому что тесты часто гоняют конкретный тип, а не полиморфный вызов.
Ошибка 3. Тень вместо переопределения и спасение через override
Вы хотели переопределить виртуальный метод, но ошиблись в сигнатуре: другой тип аргумента, потерянный const, опечатка в имени. Метод не переопределяется, а перекрывается (shadowing). Через указатель на базу вызывается версия базы, и вы час ищете, почему наследник «не срабатывает».
struct Base {
virtual void handle(int value) const;
};
struct Derived : Base {
// забыли const — это НЕ переопределение, а новый метод
void handle(int value); // компилируется, но не переопределяет
};
Ключевое слово override (появилось в C++11) превращает такую ошибку в ошибку компиляции: компилятор проверит, что метод действительно переопределяет что-то из базы.
struct Derived : Base {
void handle(int value) const override; // не компилируется без точного совпадения
};
Ставьте override на каждый переопределяемый метод. Это не косметика: она превращает молчаливый баг рантайма в ошибку сборки, которую видно сразу.
Компилятор поможет найти уже написанные ловушки: соберите проект с -Wsuggest-override (GCC/Clang) или -Winconsistent-missing-override (Clang), и он подсветит методы, где override напрашивается, но не проставлен.
Ошибка 4. Ромбовидное наследование без virtual
Класс наследует от двух баз, у которых общий предок. Без виртуального наследования предок дублируется — в объекте оказывается две копии его полей, а обращение к ним становится неоднозначным.
struct Device {
int id = 0;
};
struct Reader : Device {};
struct Writer : Device {};
// две копии Device внутри ReadWriter
struct ReadWriter : Reader, Writer {};
int main() {
ReadWriter rw;
// rw.id; // ошибка компиляции: неоднозначно, Reader::id или Writer::id?
rw.Reader::id = 1; // приходится указывать ветку явно
}
Виртуальное наследование делает Device единственным в объекте:
struct Reader : virtual Device {};
struct Writer : virtual Device {};
struct ReadWriter : Reader, Writer {}; // одна копия Device
int main() {
ReadWriter rw;
rw.id = 1; // теперь однозначно
}
Плата за virtual-наследование: дополнительный уровень косвенности при доступе к полям общей базы и более сложная инициализация — самый производный класс сам вызывает конструктор виртуальной базы. Прежде чем строить ромб, спросите себя, не решается ли задача композицией: часто «содержит», а не «является».
Сводка: что стоит за каждой ошибкой
| Ошибка | Что происходит | Как проявляется | Чем чинить |
|---|---|---|---|
| Невиртуальный деструктор | ~Derived() не вызывается через Base* | Утечка памяти и ресурсов | virtual ~Base() или final |
| Срезка объекта | Копируется только базовая часть | Тихо неверный результат | Ссылки / указатели, unique_ptr в контейнере |
| Тень метода | Метод перекрыт, а не переопределён | Вызывается версия базы | override на каждом методе |
| Ромб без virtual | Дублирование общего предка | Неоднозначность, ошибка сборки | virtual-наследование или композиция |
Что делать дальше
Прогоните сборку со строгими флагами: -Wall -Wextra -Wnon-virtual-dtor -Wsuggest-override для GCC/Clang. Первый же прогон обычно вскрывает пару скрытых теней и невиртуальных деструкторов. Дальше подключите статический анализатор — clang-tidy с набором проверок cppcoreguidelines-* и bugprone-* ловит и срезку, и проблемные иерархии. И заведите привычку: полиморфные объекты живут только за указателем или ссылкой, а override ставится всегда — это дешевле любой отладки.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: C++ Core Guidelines: C.35 — базовый класс с виртуальными функциями должен иметь виртуальный деструктор, cppreference: override specifier
Частые вопросы
А если у меня старый компилятор без C++11 — override недоступен?
Виртуальный деструктор заметно бьёт по производительности?
Как поймать срезку объекта, раз компилятор молчит?
Всегда ли ромбовидное наследование — это плохо?
Я поставил virtual ~Base(), но утечка осталась — почему?
override и final — это одно и то же?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.