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 при ошибке/деструкторе» реализована один раз в базовом классе.
  • Наследники не могут случайно забыть откат или нарушить порядок.
  • Логирование и метрики централизованы и не зависят от реализации