Когда нужно использовать виртуальные деструкторы в C++?
Корень проблемы: статическое vs динамическое связывание деструкторов
Вы столкнулись с классической проблемой, которая возникает при полиморфном использовании объектов. Дело в том, что shared_ptr (как и unique_ptr) — это умные указатели, но они не являются волшебной палочкой. Они корректно вызывают деструктор того типа, который был указан при их создании, только если этот деструктор виртуальный.
В вашем примере std::shared_ptr<Animal> pet = std::make_shared<Dog>(); тип указателя — Animal*. Когда умный указатель уничтожается, он вызывает delete для хранимого указателя. Но если деструктор Animal не виртуальный, то C++ использует статическое связывание: он смотрит на тип указателя (статический тип — Animal*) и вызывает деструктор именно этого класса без учета того, что на самом деле объект является Dog. Это приводит к неполной очистке.
Решение: объявляем виртуальный деструктор
Достаточно добавить ключевое слово virtual перед деструктором базового класса:
class Animal {
public:
Animal() { std::cout << "Animal created\n"; }
virtual ~Animal() { std::cout << "Animal destroyed\n"; } // Теперь virtual!
virtual void speak() { std::cout << "Animal sound\n"; }
};
class Dog : public Animal {
public:
Dog() { std::cout << "Dog created\n"; }
~Dog() override { std::cout << "Dog destroyed\n"; } // override для ясности
void speak() override { std::cout << "Woof!\n"; }
};
Теперь при выполнении вашего кода вы увидите:
Animal created
Dog created
Woof!
Dog destroyed
Animal destroyed
Магия в том, что виртуальный деструктор заставляет C++ использовать динамическое связывание (через vtable). Компилятор понимает: "Указатель типа Animal*, но я знаю, что реальный объект — Dog. Вызову деструктор Dog, который затем вызовет деструктор Animal".
Правило большого пальца (Rule of Thumb)
- Если в классе есть хотя бы одна виртуальная функция (как
speak()), объявляйте деструктор виртуальным. Почти всегда это верно. - Если класс предназначен для наследования (даже без виртуальных функций), подумайте о виртуальном деструкторе. Иначе кто-то может случайно удалить объект через указатель на базовый класс.
- Если класс не предназначен для наследования (например,
finalили просто POD-структура), виртуальный деструктор не нужен.
А что с умными указателями?
shared_ptr и unique_ptr не решают проблему автоматически, если деструктор не виртуальный. Однако есть одна хитрость: shared_ptr запоминает тип, который был передан в make_shared, и может вызвать правильный деструктор даже без виртуального деструктора, но только если вы не используете приведение типов. Например:
auto pet = std::make_shared<Dog>(); // Тип pet — shared_ptr<Dog>
// Здесь деструктор Dog вызовется корректно
Но как только вы присваиваете его shared_ptr<Animal>, теряется информация о конкретном типе, и без виртуального деструктора ничего не работает. Поэтому всегда делайте деструктор виртуальным в полиморфных базовых классах — это защитит вас и от случайных ошибок, и от неочевидного поведения.
Важное предостережение
Не делайте все деструкторы виртуальными без необходимости. Каждый виртуальный деструктор добавляет указатель на vtable в каждый объект класса (обычно 8 байт на 64-битных системах). Для тысяч маленьких объектов это может быть критично. Но для полиморфных иерархий — это обязательное требование.
<<Альтернативный взгляд: когда виртуальный деструктор не нужен
Есть ситуации, когда можно обойтись без виртуального деструктора, даже если используется наследование. Рассмотрим два случая:
1. Неполиморфное наследование (примесь)
Если базовый класс не содержит виртуальных функций и используется только для добавления функциональности (например, через CRTP или как миксин), и вы никогда не удаляете объекты через указатель на базовый класс, то виртуальный деструктор не обязателен. Но это очень рискованно — один неосторожный delete приведет к неопределенному поведению.
2. Использование shared_ptr с делегирующим удалителем
Можно создать shared_ptr, который будет хранить кастомный удалитель, явно вызывающий деструктор нужного типа:
std::shared_ptr<Animal> pet(new Dog(), [](Animal* ptr) {
delete static_cast<Dog*>(ptr); // Явно вызываем деструктор Dog
});
Но это костыль, который легко забыть и сложно поддерживать. Гораздо проще и безопаснее просто объявить виртуальный деструктор в базовом классе. Это стандартная практика, которой следуют все профессиональные C++ разработчики.
Итог: если вы сомневаетесь — делайте деструктор виртуальным. Это добавит 8 байт к каждому объекту, но спасет от трудноуловимых багов с утечкой памяти или неопределенным поведением.