Объект исключения. Вложенные блоки try/catch

Практический курс по ООП C++: https://stepik.org/a/205781

Смотреть материал на YouTube | RuTube

Давайте теперь внимательнее посмотрим на объект исключения. В последнем случае он был передан по ссылке:

catch(const std::exception & ex)

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

catch(const std::exception ex)

В чем отличие между этими двумя вариантами? Начнем с самого начала – с момента зарождения этого объекта. Где это происходит? В нашем примере – в функции load_data. Давайте сделаем это явным образом. Для этого мы объявим свой класс в качестве типа исключения:

class Exception {
public:
    static int count_create;
    static int count_delete;
 
    Exception() { count_create++; }
    Exception(const Exception& ) { count_create++; }
    ~Exception() { count_delete++; }
};
 
int Exception::count_create {0};
int Exception::count_delete {0};

Этот класс содержит статические поля:

  • count_create – число созданий объектов класса;
  • count_delete – число удалений объектов класса.

А также конструктор по умолчанию и конструктор копирования, которые увеличивают счетчик переменной count_create. И деструктор, который увеличивает счетчик count_delete.

Функцию load_data и функцию main запишем в виде:

void load_data(const char* path, int& x)
{
    std::ifstream ifs;
 
    try {
        ifs.open(path);
        if(!ifs.is_open()) {
            throw Exception();
        }
 
        ifs >> x;
        ifs.close();
    }
    catch(const Exception e) {
        ifs.close();
        throw e;
    }
}
 
int main()
{
    int data {0};
 
    try {
        load_data("123", data);
        std::cout << data << std::endl;
    }
    catch(const Exception ex) {
        std::cout << Exception::count_create << std::endl;
        std::cout << Exception::count_delete << std::endl;
    }
 
    return 0;
}

После запуска программы в консоли увидим числа: 4 – для создания объектов; 2 – для удаления объектов. Давайте разберем порядок работы этой программы.

Очевидно, первый объект класса Exception был создан в функции load_data при вызове оператора throw. Затем, этот же объект передается в блок catch, который отслеживает этот тип исключения. Но в этом блоке дополнительно создается еще один объект (копия) класса Exception. Внутри тела блока срабатывает еще один оператор throw, который пробрасывает исключение дальше по стеку вызова функций. При этом оператор throw создает копию еще одного объекта класса Exception. При завершении выполнения конструкции try/catch первые два объекта удаляются. А в функции main блоком catch отлавливается последний созданный объект класса Exception и дополнительно создается еще один. Отсюда и получаются такие величины: 4 – для создания объектов; 2 – для удаления объектов. Последние два объекта будут удалены при выходе из блока try/catch функции main. Если после него прописать строчки:

    std::cout << Exception::count_create << std::endl;
    std::cout << Exception::count_delete << std::endl;

то увидим числа 4 и 4.

Вот такой круговорот объектов исключений происходит при их генерации и обработки. Также этот пример показывает, что классы исключений практически всегда должны иметь конструктор копирования, иначе в ряде распространенных случаев объект исключения не может создан. И здесь возникает закономерный вопрос, можно ли сократить количество создаваемых копий этих объектов? Да, и самый очевидный шаг, как раз использовать ссылки в блоках catch:

catch(const Exception& ex)

Теперь создаются только два объекта. Но и это число можно сократить, если в функции load_data в блоке catch сделать проброс исключения без создания его копии:

void load_data(const char* path, int& x)
{
...
    catch(const Exception& e) {
        ifs.close();
        throw;
    }
}

Именно поэтому для ускорения работы программы исключения в блоках catch следует получать по ссылке, а пробрасывать пустым оператором throw.

Удаление объектов при генерации исключений

Как я уже отмечал, в момент возникновения исключения в функции дальнейшее ее выполнение прекращается и управление передается функции уровнем выше. Она так же должна либо обработать исключение, либо будет завершена с передачей управления следующей функции. И так, пока не дойдем до функции main. Если и в ней нет обработки возникшего исключения, то программа завершается аварийно. Так вот, при завершении каждой функции (в момент возникновения исключения), для всех ее объектов, которые были созданы в стековом фрейме, автоматически вызываются деструкторы. Давайте я покажу это на следующем примере.

Объявим еще один простой класс с именем FileTry:

class FileTry {
    std::ifstream& ifs;
public:
    FileTry(std::ifstream& ifs) : ifs(ifs)
        { }
    ~FileTry()
    {
        std::cout << "FileTry: destructor" << std::endl;
 
        if(ifs.is_open()) {
            ifs.close();
            std::cout << "Close file" << std::endl;
        }
    }
};

Здесь есть один конструктор и один деструктор. В конструкторе инициализируется ссылка на файловый поток, а в деструкторе он закрывается с выводом сообщений в консоль, чтобы мы видели, как он отрабатывает. С использованием этого класса функцию load_data можно переписать в следующем виде:

void load_data(const char* path, int& x)
{
    std::ifstream ifs;
 
    try {
        FileTry file(ifs);
        
        ifs.open(path);
        if(!ifs.is_open()) {
            throw Exception();
        }
 
        ifs >> x;
    }
    catch(const Exception& e) {
        throw;
    }
}

Смотрите, мы здесь создаем объект класса FileTry в стековом фрейме текущей функции. Деструктор объекта этого класса будет гарантированно вызван либо при возникновении исключения, либо при штатном завершении блока try/catch. После запуска программы увидим строчку:

FileTry: destructor

Исключение возникло, но деструктор объекта был вызван. И это всегда так для любых объектов классов в стековом фрейме. Конечно, если объект создается в куче с помощью оператора new:

FileTry* file = new FileTry(ifs);

то деструктор уже не вызывается. И это логично, т.к. при таком создании программист самостоятельно должен его удалять. Автоматически уничтожаются только локальные объекты.

Вернем запись, как было и вынесем ее за пределы блока try:

void load_data(const char* path, int& x)
{
    std::ifstream ifs;
    FileTry file(ifs);
 
    try {
    ...
    }
    ...
}

В этом случае деструктор тоже отработает для объекта file. То есть, не важно где был создан объект в теле функции, для любого из них автоматически вызывается деструктор при возникновении исключения.

Если же исключение не генерируется, то, очевидно, объект удаляется после завершения функции. Или, если он расположен в блоке try, то после его завершения. Таким образом, гарантируется штатное завершение работы функций при возникновении исключений.

Вложенные блоки try/catch

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

Вернемся к функции вычисления периметра треугольника, которая генерирует два типа исключений:

int perimetr_tr(int a, int b, int c)
{
    try {
        if(a < 0 || b < 0 || c < 0)
            throw -1;
    }
    catch(int x) {
        std::cout << x << std::endl;
        throw;
    }
 
    if(a > b+c || b > a+c || c > a+b)
        throw "Error: a, b, c are not triangle lengths";
 
    return a+b+c;
}

А в функции main выполним обработку этих исключений следующим образом:

int main()
{
    try {
        try {
            int res = perimetr_tr(5, 3, 1);
        }
        catch(int e) {
            std::cout << e << std::endl;
        }
    }
    catch(const char* e) {
        std::cout << e << std::endl;
    }
 
    return 0;
}

Работает все достаточно просто. Сначала для возникшего исключения ищется подходящий блок catch во вложенном операторе try/catch. Если он находится, то ошибка считается обработанной и выше к внешнему блоку она не распространяется. Если же нужного блока catch не найдено, то исключение передается во внешний блок, где оно так же может быть обработано своими блоками catch. В нашем примере функция perimetr_tr генерирует исключение типа const char* и оно обрабатывается внешним блоком. Если же во внутреннем блоке добавить обработчик того же типа, например, так:

int main()
{
    try {
        try {
            int res = perimetr_tr(5, 3, 1);
        }
        catch(int e) {
            std::cout << e << std::endl;
        }
        catch(const char* e) {
            std::cout << "Inner: " << e << std::endl;
        }
    }
    catch(const char* e) {
        std::cout << e << std::endl;
    }
 
    return 0;
}

То будет отработан именно он и до внешнего блока исключение уже не дойдет. Во всем остальном эта конструкция работает по аналогии с обычным одиночным блоком try/catch.

Практический курс по ООП C++: https://stepik.org/a/205781

Видео по теме