Вы разрабатываете иерархию классов и хотите описать интерфейс для вычислителя, который можно запускать и останавливать. Скорее всего в первой итерации он будет выглядеть так
class Processor {
public:
virtual ~Processor() = default;
virtual void start() = 0;
// stops execution, returns `false` if already stopped
virtual bool stop() = 0;
};
Пользователи интерфейса нареализовывали свойх имплементаций. Все были счастливы, пока кто-то не сделал асинхронную реализацию. С ней почему-то приложение стало падать. Проведя небольшое расследование, вы выяснили, что пользователи интерфейса не позаботились вызвать метод stop()
перед разрушением объекта. Какая досада!
Вы были уставши и злы. А быть может это были и не вы, а какой-то менее опытный коллега, которому поручили доработать интерфейс. В общем, на свет родилась правка
class Processor {
public:
virtual void start() = 0;
// stops execution, returns `false` if already stopped
virtual bool stop() = 0;
virtual ~Processor() {
stop();
}
};
Логично? — Да!
Правильно? — Нет!
Если вам повезет, то достаточно умный компилятор сможет сообщить о проблеме. В конструкторах и деструкторах в C++ виртуальная диспетчеризация методов не работает (В других языках — например, в C# или Java — наоборот, что доставляет свои проблемы).
Почему так? При конструировании часть объекта-наследника, используемая в переопределенном методе, может быть еще не создана: конструкторы вызываются в порядке от базового класса к производному. При деструктурировании наоборот — часть объекта-наследника уже уничтожена, и если позволить динамический вызов, можно легко получить use-after-free.
Радуйтесь! Это одно из немногих мест в C++, где вас защитили от неопределенного поведения со временами жизни!
Хорошо. А если так?
// processor.hpp
class Processor {
public:
void start();
// stops execution, returns `false` if already stopped
bool stop();
virtual ~Processor();
protected:
virtual bool stop_impl() = 0;
virtual void start_impl() = 0;
};
// processor.cpp
Processor::~Processor() {
stop();
}
bool Processor::stop() {
return stop_impl();
}
void Processor::start() {
start_impl();
}
Компиляторы уже не выдают замечательного предупреждения и подвох заметить стало сложнее. А ведь мы повысили уровень индирекции всего на один! А что будет если код нашего базового класса окажется сложнее?.. Наследование имплементаций — источник многих проблем, прячущихся за невинным желанием переиспользовать код.
Вызов виртуальных функций класса в его конструкторах и деструкторах почти всегда является ошибкой сейчас или в будущем. Если же это не ошибка и так и задумывалось, то стоит использовать явный статический вызов с указанием имени класса (name qualified call).
// processor.cpp
Processor::~Processor() {
Processor::stop();
}
Также стоит отметить, что в C++ у pure virtual методов может быть имплементация, к которой можно обращаться. Иногда это даже полезно. Таким образом можно потребовать от пользователя обязательно явно принять решение: изменять поведение метода или использовать поведение по умолчанию.
class Processor {
public:
virtual void start() = 0;
// stops execution, returns `false` if already stopped
virtual bool stop() = 0;
virtual ~Processor() = default;
};
void Processor::start() {
std::cout << "unsupported";
}
class MyProcessor : public Processor {
public:
void start() override {
// call default implementation
Processor::start();
}
};
Вернемся опять к нашей остановке при вызове деструктора. Как же с ней быть?
Есть два пути.
Путь первый: потребовать, чтоб реализующий интерфейс обязательно предоставил свою версию деструктора, которая выполнит корректную остановку.
Насильно, с проверкой на этапе компиляции, к этому никого, к сожалению никого не принудишь. Можно попытаться выразить намерение объявлением деструктора интерфейса чисто виртуальным, но это не поможет, поскольку деструктор, если не указан, всегда генерируется
class Processor {
public:
virtual void start() = 0;
// stops execution, returns `false` if already stopped
virtual bool stop() = 0;
virtual ~Processor() = 0;
};
// required!
Processor::~Processor() = default;
class MyProcessor : public Processor {
public:
void start() override {
}
bool stop() override { return false; }
// ~MyProcessor() override = default; missing destructor does not trigger CE
};
int main() {
MyProcessor p;
}
Путь второй — добавить еще один слой. И пользоваться им во всех публичных API
class GuardedProcessor {
std::unique_ptr<Processor> proc;
// ...
~GuardedProcessor() {
assert(proc != nullptr);
proc->stop();
}
};