Виртуальные деструкторы в C++: почему это до сих пор актуально (и когда можно забыть)
Если вы хоть раз писали на C++ и сталкивались с наследованием, вы наверняка видели этот вопрос на Stack Overflow с десятками тысяч просмотров: «When to use virtual destructors?». В 2025 году тема не потеряла остроты — утечки памяти из-за пропущенного virtual продолжают собирать свою жатву в продакшене. Но есть и обратная сторона: бездумное добавление virtual везде, где можно, тоже не лучшая практика.
Давайте разберёмся раз и навсегда: когда виртуальный деструктор обязателен, когда он — просто хороший тон, а когда его наличие — признак плохой архитектуры. Поехали.
Короткий ответ (для тех, кто спешит)
Виртуальный деструктор нужен тогда и только тогда, когда вы собираетесь удалять объект производного класса через указатель на базовый класс. Если такого сценария в коде нет — virtual не требуется. Но есть нюансы, которые мы разберём ниже.
Почему это вообще проблема? Анатомия утечки
Давайте посмотрим на классический антипаттерн, который ломает код новичков (и не только).
#include <iostream>
class Base {
public:
~Base() {
std::cout << "Base destructor\n";
}
};
class Derived : public Base {
private:
int* big_data = new int[1000]; // ресурсы, которые нужно освободить
public:
Derived() { std::cout << "Derived created\n"; }
~Derived() {
delete[] big_data;
std::cout << "Derived destructor\n";
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // Упс! Вызовется только ~Base()
return 0;
}
Вывод:
Derived created
Base destructor
Массив big_data утёк. Деструктор Derived не вызвался, потому что компилятор, видя тип Base*, вызвал деструктор именно этого класса. А он не виртуальный.
Решение — добавить virtual перед деструктором Base:
class Base {
public:
virtual ~Base() = default; // или пустое тело
};
Теперь при delete ptr сначала выполнится ~Derived(), потом ~Base(). Ресурсы освобождены, память не течёт, статический анализатор доволен.
Когда виртуальный деструктор обязателен (железобетонное правило)
Есть всего один безусловный случай: если класс предназначен для полиморфного использования через указатель или ссылку на базовый класс, и объекты производных классов будут удаляться через этот указатель.
На практике это означает:
- Класс имеет хотя бы одну виртуальную функцию (чистую или нет)
- Вы планируете использовать
Base*илиBase&для работы сDerived - Объекты создаются в куче (
new) и удаляются черезdelete
Пример — классическая иерархия фигур:
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default; // обязательно для абстрактного класса
};
class Circle : public Shape {
double radius;
public:
explicit Circle(double r) : radius(r) {}
double area() const override { return 3.14159 * radius * radius; }
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(5.0));
// unique_ptr сам позаботится о delete, но деструктор Shape должен быть virtual
return 0;
}
Обратите внимание: даже если вы используете std::unique_ptr (а это рекомендованный подход в 2025), виртуальный деструктор всё равно нужен. unique_ptr вызывает delete через указатель на базовый класс — и без virtual утечка гарантирована.
Когда виртуальный деструктор НЕ нужен (спокойно спите)
Многие разработчики добавляют virtual «на всякий случай» во все классы. Это приводит к лишним накладным расходам: каждый объект получает указатель на vtable, размер увеличивается, кэш-промахи становятся чаще. В высоконагруженных системах это может быть критично.
Вот сценарии, где virtual деструктор избыточен:
1. Класс не предназначен для наследования
Используйте final — это явно укажет намерение и запретит наследование:
class Point final {
int x, y;
public:
// деструктор не виртуальный — и это правильно
~Point() = default;
};
2. Наследование есть, но полиморфного удаления нет
Вы наследуетесь для переиспользования кода (например, CRTP или миксины), но объекты всегда хранятся по конкретному типу:
template<typename Derived>
class BaseMixin {
// никогда не удаляется через BaseMixin*
~BaseMixin() = default; // virtual не нужен
};
3. RAII-обёртки и легковесные типы
Классы вроде std::string_view, std::span или собственные простые обёртки не должны иметь виртуальных функций вообще.
Что говорит Стандарт и практика 2025 года
C++11 ввёл правило «трёх», затем «пяти», а в C++17/20 добавились новые соображения. В 2025 году лучшие практики таковы:
- Правило нуля: если класс не управляет ресурсами напрямую (использует RAII-контейнеры), не пишите деструктор вообще — пусть компилятор сгенерирует его сам. Даже виртуальный.
- Правило пяти: если вы определяете деструктор (или копирующие/перемещающие операции), определите их все. И если деструктор виртуальный — явно укажите
virtual. - Используйте
overrideиfinal: они помогают компилятору отлавливать ошибки на этапе сборки. - Отдавайте предпочтение умным указателям:
std::unique_ptrс кастомным делитером илиstd::shared_ptr(у него свой механизм стирания типа). Но даже с ними виртуальный деструктор в базовом классе — это безопасный стандарт.