Ещё раз о том, почему плохо бросать исключения в деструкторах


Многие знатоки C++ (например, Герб Саттер) учат нас, что бросать исключения в деструкторах плохо, потому что в деструктор можно попасть во время раскрутки стека при уже выброшенном исключении, и если в этот момент будет выброшено ещё одно исключение, в результате будет вызван std::terminate(). Стандарт языка C++17 (здесь и далее я ссылаюсь на свободно доступную версию драфта N4713) на эту тему сообщает нам следующее:


18.5.1 The std::terminate() function [except.terminate]

1 In some situations exception handling must be abandoned for less subtle error handling techniques. [ Note:

These situations are:



(1.4) when the destruction of an object during stack unwinding (18.2) terminates by throwing an exception, or



— end note ]

Проверим на простом примере:


#include <iostream>

class PrintInDestructor {

public:
    ~PrintInDestructor() noexcept {
        std::cerr << "~PrintInDestructor() invoked\n";
    }

};

void throw_int_func() {
    std::cerr << "throw_int_func() invoked\n";
    throw 1;
}

class ThrowInDestructor {

public:
    ~ThrowInDestructor() noexcept(false) {
        std::cerr << "~ThrowInDestructor() invoked\n";
        throw_int_func();
    }

private:
    PrintInDestructor member_;

};

int main(int, char**) {

    try {
        ThrowInDestructor bad;
        throw "BANG!";
    } catch (int i) {
        std::cerr << "Catched int exception: " << i << "\n";
    } catch (const char* c) {
        std::cerr << "Catched const char* exception: " << c << "\n";
    } catch (...) {
        std::cerr << "Catched unknown exception\n";
    }

    return 0;

}

Результат:


~ThrowInDestructor() invoked
throw_int_func() invoked
~PrintInDestructor() invoked
terminate called after throwing an instance of 'int'
Aborted

Обратите внимание, что деструктор PrintInDestructor всё же вызывается, т.е. после выбрасывания второго исключения раскрутка стека не прерывается. В Стандарте (тот же самый пункт 18.5.1) на эту тему сказано следующее:


2… In the situation where no matching handler is found,
it is implementation-defined whether or not the stack is unwound before std::terminate() is called. In
the situation where the search for a handler (18.3) encounters the outermost block of a function with a
non-throwing exception specification (18.4), it is implementation-defined whether the stack is unwound,
unwound partially, or not unwound at all before std::terminate() is called ...

Я проверял этот пример на нескольких версиях GCC (8.2, 7.3) и Clang (6.0, 5.0), везде раскрутка стека продолжается. Если вы встретите компилятор, где implementation-defined по-другому, пожалуйста, напишите об этом в комментариях.


Следует заметить также, что std::terminate() при раскрутке стека вызывается только тогда, когда исключение выбрасывается наружу из деструктора. Если внутри деструктора находится try/catch блок, который ловит исключение и не пробрасывается дальше, это не приводит к прерыванию раскрутки стека внешнего исключения.


class ThrowCatchInDestructor {

public:
    ~ThrowCatchInDestructor() noexcept(false) {
        try {
            throw_int_func();
        } catch (int i) {
            std::cerr << "Catched int in ~ThrowCatchInDestructor(): " << i << "\n";
        }
    }

private:
    PrintInDestructor member_;

};

int main(int, char**) {

    try {
        ThrowCatchInDestructor good;
        std::cerr << "ThrowCatchInDestructor instance created\n";
        throw "BANG!";
    } catch (int i) {
        std::cerr << "Catched int exception: " << i << "\n";
    } catch (const char* s) {
        std::cerr << "Catched const char* exception: " << s << "\n";
    } catch (...) {
        std::cerr << "Catched unknown exception\n";
    }

    return 0;

}

выводит


ThrowCatchInDestructor instance created
throw_int_func() invoked
Catched int in ~ThrowCatchInDestructor(): 1
~PrintInDestructor() invoked
Catched const char* exception: BANG!

Как же избегать неприятных ситуаций? В теории всё просто: никогда не бросать исключения в деструкторе. Однако, на практике красиво и изящно реализовать это простое требование бывает не так просто.


Если нельзя, но очень хочется...


Сразу отмечу, что я не пытаюсь оправдать выбрасывание исключений из деструктора, и вслед за Саттером, Мейерсом и другими гуру C++ призываю вас постараться никогда этого не делать (по крайней мере, в новом коде). Тем не менее, программист в реальной практике вполне может столкнуться с legacy-кодом, который не так просто привести к высоким стандартам. Кроме того, зачастую описанные ниже методики могут пригодиться в процессе отладки.

Например, мы разрабатываем библиотеку с классом-обёрткой, инкапсулирующей работу с неким ресурсом. В соответствии с принципами RAII мы захватываем ресурс в конструкторе и должны освободить его в деструкторе. Но что если попытка освободить ресурс заканчивается ошибкой? Варианты решения этой проблемы:


  • Проигнорировать ошибку. Плохо, потому что мы скрываем проблему, которая может повлиять на другие части системы.
  • Написать в лог. Лучше, чем просто проигнорировать, но всё равно плохо, т.к. наша библиотека ничего не знает о политиках логирования, принятых в системе, которая её использует. Стандартный лог может быть перенаправлен в /dev/null, в результате чего, опять же, ошибку мы не увидим.
  • Вынести освобождение ресурса в отдельную функцию, которая возвращает значение или бросает исключение, и заставлять пользователя класса вызывать её самостоятельно. Плохо, потому что пользователь вообще может забыть это сделать, и мы получим утечку ресурса.
  • Выбросить исключение. Хорошо в обычных случаях, т.к. пользователь класса может поймать исключение и стандартным образом получить информацию о возникшей ошибке. Плохо во время раскрутки стека, т.к. приводит к std::terminate().

Как же понять, находимся ли мы в данный момент в процессе раскрутке стека по исключению или нет? В C++ для этого есть специальная функция std::uncaught_exception(). С её помощью мы можем безопасно кидать исключение в обычной ситуации, либо делать что-либо менее правильное, но не приводящее к выбросу исключения во время раскрутки стека.


class ThrowInDestructor {

public:
    ~ThrowInDestructor() noexcept(false) {
        if (std::uncaught_exception()) {
            std::cerr << "~ThrowInDestructor() stack unwinding, not throwing\n";
        } else {
            std::cerr << "~ThrowInDestructor() normal case, throwing\n";
            throw_int_func();
        }
    }

private:
    PrintInDestructor member_;

};

int main(int, char**) {

    try {
        ThrowInDestructor normal;
        std::cerr << "ThrowInDestructor normal destruction\n";
    } catch (int i) {
        std::cerr << "Catched int exception: " << i << "\n";
    }

    try {
        ThrowInDestructor stack_unwind;
        std::cerr << "ThrowInDestructor stack unwinding\n";
        throw "BANG!";
    } catch (int i) {
        std::cerr << "Catched int exception: " << i << "\n";
    } catch (const char* s) {
        std::cerr << "Catched const char* exception: " << s << "\n";
    } catch (...) {
        std::cerr << "Catched unknown exception\n";
    }

    return 0;

}

Результат:


ThrowInDestructor normal destruction
~ThrowInDestructor() normal case, throwing
throw_int_func() invoked
~PrintInDestructor() invoked
Catched int exception: 1
ThrowInDestructor stack unwinding
~ThrowInDestructor() stack unwinding, not throwing
~PrintInDestructor() invoked
Catched const char* exception: BANG!

Обратите внимание, что функция std::uncaught_exception() является deprecated начиная со Стандарта C++17, поэтому чтобы скомпилировать пример, соответствующий ворнинг приходится подавлять (с.м. репозитарий с примерами из статьи).


Проблема с этой функцией в том, что она проверяет находимся ли мы в процессе раскрутки стека по исключению. Но вот понять, вызван ли текущий деструктор в процессе раскрутки стека, с помощью этой функции невозможно. В результате, если происходит раскрутка стека, но деструктор какого-то объекта вызывается нормальным образом, std::uncaught_exception() всё равно вернёт true.


class MayThrowInDestructor {

public:
    ~MayThrowInDestructor() noexcept(false) {
        if (std::uncaught_exception()) {
            std::cerr << "~MayThrowInDestructor() stack unwinding, not throwing\n";
        } else {
            std::cerr << "~MayThrowInDestructor() normal case, throwing\n";
            throw_int_func();
        }
    }

};

class ThrowCatchInDestructor {

public:
    ~ThrowCatchInDestructor() noexcept(false) {
        try {
            MayThrowInDestructor may_throw;
        } catch (int i) {
            std::cerr << "Catched int in ~ThrowCatchInDestructor(): " << i << "\n";
        }
    }

private:
    PrintInDestructor member_;

};

int main(int, char**) {

    try {
        ThrowCatchInDestructor stack_unwind;
        std::cerr << "ThrowInDestructor stack unwinding\n";
        throw "BANG!";
    } catch (int i) {
        std::cerr << "Catched int exception: " << i << "\n";
    } catch (const char* s) {
        std::cerr << "Catched const char* exception: " << s << "\n";
    } catch (...) {
        std::cerr << "Catched unknown exception\n";
    }

    return 0;

}

Результат:


ThrowInDestructor stack unwinding
~MayThrowInDestructor() stack unwinding, not throwing
~PrintInDestructor() invoked
Catched const char* exception: BANG!

В новом Стандарте C++17 на замену std::uncaught_exception() была представлена функция std::uncaught_exceptions() (обратите внимание на множественное число), которая вместо булевого значения возвращает количество активных в данный момент исключений (вот подробное обоснование).


Вот как описанная выше проблема решается при помощи std::uncaught_exceptions():


class MayThrowInDestructor {

public:
    MayThrowInDestructor() : exceptions_(std::uncaught_exceptions()) {}
    ~MayThrowInDestructor() noexcept(false) {
        if (std::uncaught_exceptions() > exceptions_) {
            std::cerr << "~MayThrowInDestructor() stack unwinding, not throwing\n";
        } else {
            std::cerr << "~MayThrowInDestructor() normal case, throwing\n";
            throw_int_func();
        }
    }

private:
    int exceptions_;

};

class ThrowCatchInDestructor {

public:
    ~ThrowCatchInDestructor() noexcept(false) {
        try {
            MayThrowInDestructor may_throw;
        } catch (int i) {
            std::cerr << "Catched int in ~ThrowCatchInDestructor(): " << i << "\n";
        }
    }

private:
    PrintInDestructor member_;

};

int main(int, char**) {

    try {
        ThrowCatchInDestructor stack_unwind;
        std::cerr << "ThrowInDestructor stack unwinding\n";
        throw "BANG!";
    } catch (int i) {
        std::cerr << "Catched int exception: " << i << "\n";
    } catch (const char* s) {
        std::cerr << "Catched const char* exception: " << s << "\n";
    } catch (...) {
        std::cerr << "Catched unknown exception\n";
    }

    return 0;

}

Результат:


ThrowInDestructor stack unwinding
~MayThrowInDestructor() normal case, throwing
throw_int_func() invoked
Catched int in ~ThrowCatchInDestructor(): 1
~PrintInDestructor() invoked
Catched const char* exception: BANG!

Когда очень-очень хочется выбросить сразу несколько исключений


std::uncaught_exceptions() позволяет избежать вызова std::terminate(), но не помогает корректно обрабатывать множественные исключения. В идеале хотелось бы иметь механизм, который позволял бы сохранять все выброшенные исключения, а затем обработать их в одном месте.


Хочу ещё раз напомнить, что предложенный мной ниже механизм служит только для демонстрации концепции и не рекомендован к использованию в реальном промышленном коде.

Суть идеи состоит в том, чтобы ловить исключения и сохранять их в контейнер, а затем по одному доставать и обрабатывать. Для того, чтобы сохранять объекты исключений, в языке C++ есть специальный тип std::exception_ptr. Структура типа в Стандарте не раскрывается, но говорится, что это по сути своей shared_ptr на объект исключения.


Как же потом обработать эти исключения? Для этого есть функция std::rethrow_exception(), которая принимает указатель std::exception_ptr и выбрасывает соответствующее исключение. Нам нужно только поймать его соответствующей catch-секцией и обработать, после чего можно переходить к следующему объекту исключения.


using exceptions_queue = std::stack<std::exception_ptr>;

// Get exceptions queue for current thread
exceptions_queue& get_queue() {
    thread_local exceptions_queue queue_;
    return queue_;
}

// Invoke functor and save exception in queue
void safe_invoke(std::function<void()> f) noexcept {
    try {
        f();
    } catch (...) {
        get_queue().push(std::current_exception());
    }
}

class ThrowInDestructor {

public:
    ~ThrowInDestructor() noexcept {
        std::cerr << "~ThrowInDestructor() invoked\n";
        safe_invoke([]() {
            throw_int_func();
        });
    }

private:
    PrintInDestructor member_;

};

int main(int, char**) {

    safe_invoke([]() {
        ThrowInDestructor bad;
        throw "BANG!";
    });

    auto& q = get_queue();
    while (!q.empty()) {
        try {
            std::exception_ptr ex = q.top();
            q.pop();
            if (ex != nullptr) {
                std::rethrow_exception(ex);
            }
        } catch (int i) {
            std::cerr << "Catched int exception: " << i << "\n";
        } catch (const char* s) {
            std::cerr << "Catched const char* exception: " << s << "\n";
        } catch (...) {
            std::cerr << "Catched unknown exception\n";
        }
    }

    return 0;

}

Результат:


~ThrowInDestructor() invoked
throw_int_func() invoked
~PrintInDestructor() invoked
Catched const char* exception: BANG!
Catched int exception: 1

В примере выше для сохранения объектов исключений используется стек, однако обработка исключений будет производиться по принципу FIFO (т.е. логически это очередь — выброшенное первым исключение будет первым же и обработано).


Выводы


Выбрасывание исключений в деструкторах объектов действительно является плохой идеей, и в любом новом коде я настоятельно рекомендую этого не делать, объявляя деструкторы noexcept. Однако, при поддержке и отладке legacy-кода может возникнуть потребность корректно обрабатывать исключения, выбрасываемые из деструкторов, в том числе и при раскрутке стека, и современный C++ предоставляет нам механизмы для этого. Надеюсь, представленные в статье идеи помогут вам на этом нелёгком пути.


Ссылки


Репозитарий с примерами из статьи

Комментарии (12)



  1. kovserg
    22.12.2018 12:19

    Всегда не хватало в C++ возможности вызывать перед деструкторм просто виртуальный метод

    struct A {
      A();
      virtual void init();
      virtual void done();
      virtual ~A();
    };
    struct B : A {
      virtual void init();
      virtual void done();
    };
    

    Так что бы после new B() сразу вызывался init(), а перед delete — done(); Приходилось это делать с помощью смартпоинтеров.


    1. Door
      22.12.2018 12:42
      +1

      В том контексте что привели вы, init() после new B() приведет к вызову все того-же B::init(), то есть всю инициализацию можно сделать в конструкторе B. Как по мне, так двухфазная инициализация — усложняет код и лучше ее избегать. То что я видел:


      1. Использование init() чтобы обработать ошибки создания объекта в случае когда эксепшины отключены.
        Иметь is_valid() — это хорошая альтернатива.
      2. Использование init() для настройки "зависимостей" объекта в том случае когда существуют циклические и другие проблемные зависимости объектов.
        Тут, к сожалению, нужно править код, если можно.


      1. kovserg
        22.12.2018 18:34

        Именно что в конструкторе нельзя разместить логику развёртывания. Т.к. в конструкторе нельзя оперировать перегруженными виртуальными методами.

        struct A {
          A();
          virtual void init() { fn(); }
          virtual void fn();
          virtual void done() { fn(); }
          virtual void ~A();
        };
        struct B:A {
          virtual void fn();
        };
        struct C:B {
          virtual void fn();
        };
        // C* c=new C(); c->init(); ... c->done(); delete c;
        

        A::init() вполне может оперировать элементами C. В то время как в конструкторе такой возможности нет. В деструкторе таже проблема. Т.к. они первым делом таблицу виртуальных функций заменяют на свою.


        1. Door
          22.12.2018 19:30

          Если подумать, я представляю только один случай когда это действительно нужно: когда инициализацию подобьектов нужно выполнить "всю за один раз". То есть по сути, конструкторы базовых классов пустые, а инициализация всего происходит в init(). Но, опять же, мне кажется это больше проблема дизайна класса и/или API. Даже с примером выше — все красиво можно передать в качестве аргументов конструктора базового класса, если ему нужны какие-то дополнительные данные.
          Я могу ошибаться, но не представляю случая где двухэтапной инициализации нельзя избежать.
          Может у вас есть конкретный пример ?


          1. kovserg
            22.12.2018 19:52
            +1

            1. Если делать клас TThread то join нельзя совать в деструктор, а в done можно.
            2. Если у вас если загрузка контролов. То её лучше разместить в init но не в конструкторе.
            и т.п.


  1. Door
    22.12.2018 12:20

    Как по мне, всё равно — сомнительно: в случае активного эксепшина, вы все равно пишите в лог, то есть оригинальный эксепшн — теряется. Если на нем построена какая-то логика приложения (восстановки, повторов, прочее) — это логика также не будет выполнена. Если это нормально — то есть не делать какую-то обработку такого-то эксепшина — то почему-бы его и не выбрасывать совсем? Мне кажется, что у вас это имеет смысл из-за второй части статьи: все эксепшины — собираются вместе, ничего не игнорируется и потом все обрабатывается. Но, опять-же, при таком подходе — все деструкторы noexcept, то есть все что сказано в первой части статьи — не имеет смысла.


    По поводу:


    Когда очень-очень хочется выбросить сразу несколько исключений

    мне кажется или вы говорите об std::throw_with_nested и семействе [1] ?


    [1] nested_exception: https://en.cppreference.com/w/cpp/error/nested_exception


    1. igorsemenov Автор
      22.12.2018 20:01

      Первая часть статьи вводит в проблему, так сказать, рассматривает стандартные подходы и показывает, почему они не решают всех проблем. Кому-то это может быть очевидным, кто-то найдёт что-то новое для себя.

      Что касается std::nested_exception — я рассматривал и их, но там всё же несколько про другое. Я думал некоторое время над тем, как решить проблему обработки множественных исключений, прилетающих из библиотечного кода, с помощью std::nested_exception, но придумать не смог. Этот механизм требует всё же изначально выбрасывать исключения не совсем стандартным способом. Если вам известно, как можно приспособить этот механизм для обсуждаемых в статье примеров, мне будет очень интересно его увидеть.


  1. jahr
    22.12.2018 22:52

    Спасибо большое, Вы избавили меня от большой занозы, портившей мои отношения с плюсами в течение многих лет: что делать с неудачным закрытием файла в деструкторе класса?


    1. iCpu
      24.12.2018 07:30

      А можно мне для расширения кругозора рассказать, в каких случаях (кроме невалидного указателя) закрытие файла может завершиться ошибкой?


      1. Antervis
        24.12.2018 09:47

        деструктор почти любого файлового сокета перед закрытием вызывает flush, а он может провалиться если, например, кончилось место


  1. ser-mk
    22.12.2018 23:00

    Заголовок спойлера
    Проигнорировать ошибку. Плохо, потому что мы скрываем проблему, которая может повлиять на другие части системы.
    Написать в лог. Лучше, чем просто проигнорировать, но всё равно плохо, т.к. наша библиотека ничего не знает о политиках логирования, принятых в системе, которая её использует. Стандартный лог может быть перенаправлен в /dev/null, в результате чего, опять же, ошибку мы не увидим.
    Вынести освобождение ресурса в отдельную функцию, которая возвращает значение или бросает исключение, и заставлять пользователя класса вызывать её самостоятельно. Плохо, потому что пользователь вообще может забыть это сделать, и мы получим утечку ресурса.
    Выбросить исключение. Хорошо в обычных случаях, т.к. пользователь класса может поймать исключение и стандартным образом получить информацию о возникшей ошибке. Плохо во время раскрутки стека, т.к. приводит к std::terminate().