NVI
Идиома NVI (Non-Virtual Interface, «невиртуальный интерфейс») решает проблему хрупкого базового класса (Fragile Base Class Problem).
Суть в том, что изменение базового класса может неожиданно сломать классы-наследники, хотя их собственный код не менялся. Наследники могут неявно зависеть от деталей реализации базового класса: порядка вызовов, того, какие методы и когда вызываются, какие инварианты поддерживаются и т.д. Если автор базового класса меняет эту внутреннюю логику, наследники могут перестать работать корректно.
Пример:
class Base {
public:
virtual void doWork() { // публичный И виртуальный
// общий код «до»
preWork();
// ... логика базового класса
// общий код «после»
postWork();
}
virtual ~Base() = default;
protected:
void preWork();
void postWork();
};
class Derived : public Base {
public:
void doWork() override {
// общий код «до»
Base::preWork();
// ... логика класса-наследника
// общий код «после»
Base::postWork();
}
};
В NVI публичные методы базового класса делаются невиртуальными, а наследники переопределяют только отдельные приватные или защищённые виртуальные функции, которые вызываются из этих публичных методов.
Пример:
class Base {
public:
// Публичный НЕвиртуальный метод
void doWork() {
preWork();
doWorkImpl(); // логика наследника
postWork();
}
virtual ~Base() = default;
protected:
virtual void doWorkImpl() {}
private:
void preWork();
void postWork();
};
class Derived : public Base {
protected:
void doWorkImpl() override {
// Только своя логика. Всё остальное уже выполнено каркасом
// базового класса в нужном порядке.
// ...
}
};
По сути, NVI - это способ реализации паттерна «Шаблонный метод» (Template Method) в C++.
Рассмотрим пример с реальным сценарием. Допустим, есть иерархия транзакционных объектов (SQL, файл, сетевой протокол). Каждый конкретный тип реализует свои шаги doBegin(), doCommit(), doRollback(), но гарантии должны быть едиными:
- если
commit()бросает — автоматически делаетсяrollback(); - если объект уничтожается до явного
commit()— тожеrollback(); - логирование и метрики вокруг всех операций.
Классическая реализация без NVI:
class Transaction {
public:
virtual void begin() = 0;
virtual void commit() = 0;
virtual void rollback() = 0;
virtual ~Transaction() = default;
};
class SqlTransaction : public Transaction {
public:
void begin() override { /* ... */ }
void commit() override { /* ... */ }
void rollback() override { /* ... */ }
};
Проблемы:
- Каждый наследник должен сам помнить про логирование, try/catch, rollback в деструкторе.
- Легко забыть
rollback()в каком‑то пути выполнения или сделать это по‑разному. - Трудно гарантировать инвариант «транзакция либо коммичена, либо откатана».
Реализация с NVI:
class Transaction {
public:
void begin() {
log("begin");
doBegin();
committed_ = false;
}
void commit() {
if (committed_) return;
try {
log("commit");
doCommit();
committed_ = true;
} catch (...) {
log("commit failed, rollback");
doRollback();
throw;
}
}
~Transaction() {
if (!committed_) {
log("dtor rollback");
doRollback();
}
}
protected:
virtual void doBegin() = 0;
virtual void doCommit() = 0;
virtual void doRollback() = 0;
private:
bool committed_ = false;
void log(const char* msg);
};
class SqlTransaction : public Transaction {
protected:
void doBegin() override { /* SQL BEGIN */ }
void doCommit() override { /* SQL COMMIT */ }
void doRollback() override { /* SQL ROLLBACK */ }
};
Преимущества:
- Политика «rollback при ошибке/деструкторе» реализована один раз в базовом классе.
- Наследники не могут случайно забыть откат или нарушить порядок.
- Логирование и метрики централизованы и не зависят от реализации