Avatar ·

Когда нужно использовать виртуальные деструкторы в C++ и что будет, если их не объявить?

Всем привет! Я только начал изучать C++ и никак не могу понять одну вещь. Вот смотрите: У меня есть базовый класс `Base` и класс-наследник `Derived`. В `Derived` я выделяю память в куче. Когда я создаю объект через указатель на базовый класс и потом удаляю его через `delete`, то вызывается только деструктор `Base`, а деструктор `Derived` — нет. Это приводит к утечке памяти, потому что память, выделенная в наследнике, не освобождается. Я знаю, что нужно добавлять `virtual` перед деструктором в базовом классе, и тогда всё работает. Но почему так происходит? Ведь я же явно написал код, который должен вызвать деструктор наследника? Или я что-то путаю? Вот пример кода, который у меня не работает (вернее, работает, но с утечкой): ```cpp class Base { public: ~Base() { std::cout << "Base destructor\n"; } }; class Derived : public Base { private: int* data; public: Derived() { data = new int[100]; } ~Derived() { delete[] data; std::cout << "Derived destructor\n"; } }; int main() { Base* ptr = new Derived(); delete ptr; // Вызывается только ~Base(), память утекает } ``` Объясните, пожалуйста, в чём тут магия виртуальных деструкторов? Когда их обязательно нужно использовать, а когда можно обойтись без них? И есть ли случаи, где virtual destructor вообще не нужен? <<>>

Краткий ответ: всегда объявляйте деструктор виртуальным, если класс предназначен для наследования

Виртуальный деструктор нужен, когда вы удаляете объект производного класса через указатель на базовый класс. Без него вызывается деструктор только по статическому типу (типу указателя), а не по динамическому (реальному типу объекта).

Почему так происходит?

В C++ вызов невиртуальных функций (включая деструкторы) разрешается на этапе компиляции по типу указателя. Когда вы пишете:

Base* ptr = new Derived();
delete ptr;

Компилятор видит, что ptr имеет тип Base*, и вызывает ~Base(). Деструктор ~Derived() просто игнорируется.

Если же деструктор объявлен как virtual, компилятор генерирует вызов через таблицу виртуальных функций (vtable). Во время выполнения программа смотрит на реальный тип объекта (в нашем случае Derived) и вызывает правильный деструктор — сначала ~Derived(), а затем автоматически ~Base().

Правильный код:

class Base {
public:
    virtual ~Base() { std::cout << "Base destructor\n"; }
};

class Derived : public Base {
private:
    int* data;
public:
    Derived() { data = new int[100]; }
    ~Derived() override { 
        delete[] data;
        std::cout << "Derived destructor\n"; 
    }
};

int main() {
    Base* ptr = new Derived();
    delete ptr; 
    // Вывод: Derived destructor -> Base destructor
    // Память освобождена!
}

Когда виртуальный деструктор ОБЯЗАТЕЛЕН?

  • Когда класс предназначен для наследования и вы планируете работать с объектами через указатели/ссылки на базовый класс.
  • Когда есть виртуальные функции. Если у класса есть хотя бы одна виртуальная функция, почти наверняка нужен и виртуальный деструктор.
  • При использовании умных указателей — std::unique_ptr и std::shared_ptr правильно вызывают деструктор только если он виртуальный (иначе UB или утечка).

Когда можно НЕ использовать?

  • Класс не предназначен для наследования (можно пометить final).
  • Нет полиморфного использования — если вы никогда не удаляете объект через указатель на базовый класс.
  • Тривиальные классы без динамической памяти — но даже в этом случае лучше привыкнуть добавлять virtual на случай, если кто-то унаследует.

Современный C++ и best practices

В C++11 и новее:

  • Используйте override для деструкторов в производных классах — это явно показывает намерение.
  • Если класс не предназначен для наследования, пометьте его final — это даст компилятору подсказку для оптимизации.
  • Для интерфейсов (чисто абстрактных классов) часто пишут virtual ~Interface() = default; — пустой, но виртуальный деструктор.

Запомните правило: если у класса есть виртуальная функция, деструктор должен быть виртуальным. Если класс предназначен для наследования — деструктор должен быть виртуальным. Исключения крайне редки.

<<>>

Дополнительный нюанс: виртуальные деструкторы и умные указатели

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

std::unique_ptr<Base> ptr = std::make_unique<Derived>();
// ptr автоматически удалится при выходе из области видимости
// НО: если ~Base() не виртуальный — UB или утечка!

std::unique_ptr по умолчанию вызывает delete через указатель на базовый класс. Без виртуального деструктора это — неопределённое поведение. std::shared_ptr умнее: он запоминает тип при создании, поэтому корректно вызывает деструктор даже без virtual. Но полагаться на это — плохая практика.

Альтернативное решение: CRTP и статический полиморфизм

Если вам критична производительность и вы не хотите платить за vtable, можно использовать CRTP (Curiously Recurring Template Pattern):

template <typename Derived>
class Base {
public:
    ~Base() { 
        static_cast<Derived*>(this)->cleanup(); 
    }
};

class Derived : public Base<Derived> {
    // ...
};

Но это редкий случай. Для 99% проектов используйте обычные виртуальные деструкторы — они понятны, безопасны и не создают проблем с производительностью в реальных приложениях.

Вывод: не пытайтесь экономить на virtual для деструктора в полиморфных классах. Это не оптимизация, а баг. Пусть компилятор делает свою работу.