Когда нужны виртуальные деструкторы в C++: разбор в 2025

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Виртуальные деструкторы в 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 (у него свой механизм стирания типа). Но даже с ними виртуальный деструктор в базовом классе — это безопасный стандарт.

Частые ошибки и подводные камни

Ошибка 1: Виртуальный

Блоги

Рекомендаций по книгам