18.4 – Виртуальные деструкторы, виртуальное присваивание и игнорирование виртуализации
Виртуальные деструкторы
Хотя C++ предоставляет для ваших классов деструктор по умолчанию, если вы не предоставляете его самостоятельно, иногда бывает так, что вам нужно предоставить свой собственный деструктор (особенно, если классу необходимо освободить память). Если вы имеете дело с наследованием, то всегда должны делать свои деструкторы виртуальными. Рассмотрим следующий пример:
#include <iostream>
class Base
{
public:
~Base() // обратите внимание: не виртуальный
{
std::cout << "Calling ~Base()\n";
}
};
class Derived: public Base
{
private:
int* m_array;
public:
Derived(int length)
: m_array{ new int[length] }
{
}
// обратите внимание: не виртуальный (ваш компилятор может вас об этом предупредить)
~Derived()
{
std::cout << "Calling ~Derived()\n";
delete[] m_array;
}
};
int main()
{
Derived *derived { new Derived(5) };
Base *base { derived };
delete base;
return 0;
}
Примечание. Если вы попытаетесь скомпилировать приведенный выше пример, ваш компилятор может предупредить вас о не виртуальном деструкторе (что для этого примера сделано специально). Для выполнения компиляции может потребоваться отключить флаг компилятора, который рассматривает предупреждения как ошибки.
Поскольку base
является указателем Base
, при удалении base
программа проверяет, является ли деструктор Base
виртуальным. Поскольку это не так, предполагается, что нужно только вызвать деструктор Base
. И мы убеждаемся в этом, поскольку в приведенном выше примере печатается:
Calling ~Base()
Однако на самом деле мы хотим, чтобы функция удаления вызывала деструктор Derived
(который, в свою очередь, вызывает деструктор Base
), иначе m_array
не будет удален. Мы можем сделать это, сделав деструктор Base
виртуальным:
#include <iostream>
class Base
{
public:
virtual ~Base() // обратите внимание: виртуальный
{
std::cout << "Calling ~Base()\n";
}
};
class Derived: public Base
{
private:
int* m_array;
public:
Derived(int length)
: m_array{ new int[length] }
{
}
virtual ~Derived() // обратите внимание: виртуальный
{
std::cout << "Calling ~Derived()\n";
delete[] m_array;
}
};
int main()
{
Derived *derived { new Derived(5) };
Base *base { derived };
delete base;
return 0;
}
Теперь эта программа дает следующий результат:
Calling ~Derived()
Calling ~Base()
Правило
Всякий раз, когда вы имеете дело с наследованием, вы должны сделать любые явные деструкторы виртуальными.
Как и в случае с обычными виртуальными функциями-членами, если функция базового класса является виртуальной, все производные переопределения будут считаться виртуальными независимо от того, указаны ли они как таковые. Нет необходимости создавать пустой деструктор производного класса только для того, чтобы пометить его как виртуальный.
Обратите внимание, что если вы хотите, чтобы в вашем базовом классе был виртуальный деструктор, который в противном случае был бы пустым, вы можете определить свой деструктор следующим образом:
virtual ~Base() = default; // генерируем виртуальный деструктор по умолчанию
Виртуальное присваивание
Оператор присваивания можно сделать виртуальным. Однако, в отличие от случая с деструктором, где виртуализация всегда является хорошей идеей, виртуализация оператора присваивания на самом деле открывает ящик Пандоры, и затрагивает некоторые сложные темы, выходящие за рамки данного руководства. Следовательно, мы собираемся порекомендовать вам пока оставить свои присваивания не виртуальными, для простоты.
Игнорирование виртуализации
Хотя и редко, но вы можете захотеть проигнорировать виртуализацию функции. Например, рассмотрим следующий код:
class Base
{
public:
virtual ~Base() = default;
virtual const char* getName() const { return "Base"; }
};
class Derived: public Base
{
public:
virtual const char* getName() const { return "Derived"; }
};
Могут быть случаи, когда вы хотите, чтобы указатель Base
на объект Derived
вызывал Base::getName()
вместо Derived::getName()
. Для этого просто используйте оператор разрешения области видимости:
#include <iostream>
int main()
{
Derived derived;
const Base &base { derived };
// Вызывает Base::getName() вместо виртуализированной Derived::getName()
std::cout << base.Base::getName() << '\n';
return 0;
}
Вероятно, вы не будете использовать это очень часто, но хорошо знать, что это, по крайней мере, возможно.
Должны ли мы делать все деструкторы виртуальными?
Это частый вопрос, который задают начинающие программисты. Как отмечено в примере выше, если деструктор базового класса не помечен как виртуальный, то программа подвергается риску утечки памяти, если программист позже удалит указатель базового класса, указывающий на объект производного класса. Один из способов избежать этого – пометить все ваши деструкторы как виртуальные. Но должны ли вы это делать?
Легко сказать «да», и в дальнейшем вы сможете использовать любой класс в качестве базового, но это приведет к снижению производительности (к каждому экземпляру вашего класса добавляется виртуальный указатель). Таким образом, вы должны найти компромисс между этими затратами со своими намерениями.
Традиционная мудрость (как первоначально была высказана Хербом Саттером, уважаемым гуру C++) предлагала избегать ситуации утечки памяти, связанной с невиртуальным деструктором, следующим образом: «Деструктор базового класса должен быть либо открытым и виртуальным, либо защищенным и невиртуальным». Класс с защищенным деструктором нельзя удалить с помощью указателя, что предотвращает случайное удаление объекта производного класса с помощью указателя базового класса, когда базовый класс содержит невиртуальный деструктор. К сожалению, это также означает, что и базовый класс нельзя удалить с помощью указателя базового класса, что, по сути, означает, что объект этого класса не может быть динамически размещен или удален, кроме как производным классом. Это также исключает для таких классов использование умных указателей (таких как std::unique_ptr
и std::shared_ptr
), что ограничивает полезность этого правила (мы рассмотрим умные указатели в следующей главе). Это также означает, что объект базового класса не может быть размещен в стеке. Это довольно жесткий набор ограничений.
Теперь, когда в язык был введен спецификатор final
, наши рекомендации заключаются в следующем:
- Если вы собираетесь наследовать свой класс, убедитесь, что ваш деструктор виртуальный.
- Если вы не собираетесь наследовать свой класс, отметьте его как конечный (
final
). Это, в первую очередь, предотвратит наследование от него других классов, не налагая каких-либо других ограничений на использование самого класса.