Обработка исключений. Введение

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

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

Язык C++ имеет встроенный инструмент для обработки ошибок во время выполнения программы. Он получил название «обработка исключений». Хотя это и не относится напрямую к ООП, тем не менее, в сочетании с классами он становится особенно красивым и гибким в реализации.

Первый закономерный вопрос, зачем вообще понадобился такой отдельный инструмент для обработки ошибок? Смотрите, выполнение прикладной программы начинается с функции main. Она, в свою очередь, как правило, вызывает другие функции. А те еще одни. И глубина вызовов может быть довольно большой.

Предположим теперь, что на каком-либо уровне вызова возникла ошибочная ситуация. Например, деление на ноль, или не найден файл, или ошибка связи с сервером, и так далее. Мало зафиксировать ошибку, нужно еще указать программе, как ей выполняться дальше. И вот здесь кроется главная проблема. Часто отдельные функции лишь выполняют свои локальные задачи, фиксируя возможные ошибки. Однако не всегда имеют возможность сразу же их обработать, то есть, «сказать» программе, что нужно сделать, при их возникновении. Например, не найден указанный файл, или недостаточно прав для записи в него данных. Что в этом случае должна сделать вспомогательная функция? Часто ответ можно дать лишь на уровне общей логики программы, то есть, на уровне функции main или специально предназначенных для этого функциях. То есть, возникшую ошибку нужно передать обратно по стеку вызова функций, пока не дойдем до такой, которая в состоянии ее обработать. При этом выполнение других функций должно быть прекращено.

Это довольно типовая ситуация и до появления механизма обработки исключений решалась либо возвратом специального значения из функции, либо через специальные глобальные переменные, либо каким-то еще изощренным способом. Дополнительные сложности здесь представляют вызовы методов через объекты классов. Каждый объект и класс – это отдельная и независимая программная единица и вопрос обработки возникающих ошибок здесь особенно актуален. Поэтому и понадобился отдельный механизм обработки исключений, который бы брал на себя реализацию логики выполнения программы при той или иной непредвиденной ситуации.

Лично у меня этот инструмент ассоциируется с канализацией. Если в доме ее нет, то сами представляете, с какими трудностями столкнутся жильцы. А если она уже встроена, то достаточно к ней подключиться и жизнь дома и города в целом преобразится в лучшую сторону. Обработка исключений подобным образом освобождает нас от многих сложностей, которые бы иначе пришлось реализовывать вручную, как это было на заре эры программирования.

Оператор throw

Давайте теперь посмотрим, как можно воспользоваться обработкой исключений непосредственно в программе на языке C++.

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

int perimetr_tr(int a, int b, int c)
{
    if(a < 0 || b < 0 || c < 0 || a > b+c || b > a+c || c > a+b)
        return ???;
 
    return a+b+c;
}

Особенность этой функции в том, что она дополнительно проверяет корректность переданных длин сторон. И здесь нужно решить, что возвращать, если параметры a, b, c не образуют стороны треугольника. Например, можно вернуть значение -1, т.к. периметр не может быть отрицательным значением и это хороший сигнал для ошибки. Однако в практике программирования не всегда удается так легко и просто определиться с ошибочным возвращаемым значением. Например, если взять известную из языка Си функцию atoi:

int res = atoi("a123");

то она возвращает 0 при ошибке конвертации строки в число. Очевидно, это не лучшее решение, т.к. строка может содержать число 0:

int res = atoi("0");

и как тогда понять, где 0 означает ошибку, а где – корректное преобразование? Если бы подобные функции проектировались с возможностью генерации исключений, то такой проблемы бы не возникало. И мы, в качестве примера, в нашей функции perimetr_tr, как раз так и поступим.

Итак, вместо возвращения числа -1 при ошибочных длинах сторон треугольника, мы сгенерируем исключение. Делается это с помощью оператора throw (англ. – бросить), после которого можно прописать практически любой тип данных, например, так:

int perimetr_tr(int a, int b, int c)
{
    if(a < 0 || b < 0 || c < 0 || a > b+c || b > a+c || c > a+b)
        throw "Error: a, b, c are not triangle lengths";
 
    return a+b+c;
}

Если теперь в функции main вызвать функцию perimetr_tr с неверными длинами:

int main()
{
    int p = perimetr_tr(5, 1, 2);
 
    return 0;
}

то в консоли увидим сообщение:

terminate called after throwing an instance of 'char const*'

Что оно означает и откуда взялось? Смотрите, при возникновении (генерировании) исключения с помощью оператора throw, функция perimetr_tr завершает свою работу и управление передается вышестоящей функции main. Функция main никак не реагирует на это исключение (то есть, не обрабатывает его), поэтому вызывается функция:

std::terminate()

из модуля <exception> стандартной библиотеки C++ с выводом соответствующего сообщения в выходной поток. После этого функция std::terminate вызывает функцию:

std::abort()

для экстренного (аварийного) завершения всей программы.

Операторы try/catch

Конечно, мы бы хотели избежать аварийного завершения программы, а значит, должны перехватить и обработать возникшее исключение. Как это сделать? Для этого в языке C++ появились два оператора: try и catch. Оператор try определяет программный блок, в котором возможно возникновение исключений, а оператор catch описывает тип отлавливаемого исключения и способ его обработки. Например, для нашей функции perimetr_tr обработка исключений может быть записана так:

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

Обратите внимание, сначала обязательно должен быть записан блок try, а после него один или несколько блоков catch для обработки разных типов исключений. Сейчас мы отлавливаем только один тип – строковый литерал, представленный в виде константного указателя типа char. А обработка заключается в выводе перехваченного сообщения в консоль. Если теперь запустить программу, то увидим строку:

Error: a, b, c are not triangle lengths

Если же все длины сторон будут корректны:

        int p = perimetr_tr(2, 1, 2);

то блок catch не сработает и в консоль ничего выводиться не будет.

То есть, в блоке try прописывается критический программный код, который может генерировать различные ошибки (исключения). Следом за ним прописываются блоки catch (от одного и более) для обработки возникающих исключений, если они появляются.

Давайте для примера в функции perimetr_tr будем генерировать два разных типа исключений следующим образом:

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

Если сейчас вызвать функцию с отрицательными длинами:

    try {
        int p = perimetr_tr(-5, 1, 2);
    }
    catch(const char* e) {
        std::cout << e << std::endl;
    }

то блок catch не поймает исключение типа int и в консоли появится строка:

terminate called after throwing an instance of 'int'

Чтобы это исправить, добавим еще один блок catch:

    try {
        int p = perimetr_tr(-5, 1, 2);
    }
    catch(const char* e) {
        std::cout << e << std::endl;
    }
    catch(int) {
        std::cout << "Lengths must be positive digitals." << std::endl;
    }

Обратите внимание, что после catch указан просто тип int без какой-либо переменной, т.к. она нам в данном случае не нужна.

В итоге отработает второй блок catch, а первый будет проигнорирован. Причем блоки просматриваются в порядке их записи: сначала для строки, а затем, для целого типа int. Сработать может только один из них (или ни одного). Как только какой-либо блок catch отрабатывает, все последующие блоки игнорируются (пропускаются). Если исключение не было поймано ни одним из блоков, то оно пробрасывается дальше по стеку вызова функций.

В качестве демонстрации этого механизма пропишем прямо в функции perimetr_tr обработку исключения типа int следующим образом:

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;
        return x;
    }
 
    if(a > b+c || b > a+c || c > a+b)
        throw "Error: a, b, c are not triangle lengths";
 
    return a+b+c;
}

Здесь при отрицательных сторонах треугольника формируется целочисленное исключение со значением -1 и сразу же отлавливается в блоке catch. В самом блоке идет вывод в консоль значения -1 и завершение функции с помощью оператора return. Если этого не сделать, то программа продолжилась бы, и по условию сгенерировалось бы следующее исключение.

После запуска увидим в консоли значение -1. То есть, возникшее исключение типа int было обработано в функции perimetr_tr и дальше (в функцию main) уже не распространялось. Это очень удобно. Исключения можно обрабатывать на разных уровнях выполнения программы, там, где это возможно и необходимо. Ну а самые высокоуровневые ошибки могут отправляться по цепочке вызовов вплоть до функции main.

Интересно, что вместо оператора return в блоке catch можно прописать оператор throw без параметров, который бы пробрасывал исключение дольше после некоторой обработки:

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;
}

В этом случае сработают два блока catch: один на уровне функции perimetr_tr, а второй – на уровне функции main. Этот прием используют на практике, если нужно выполнить промежуточную обработку исключения и пробросить его дальше. Например, пусть имеется функция, которая читает целочисленное значение из файла:

void load_data(const char* path, int& x)
{
    std::ifstream ifs;
    ifs.exceptions( std::ios::failbit ); // для включения генерации исключений
 
    try {
        ifs.open(path);
        ifs >> x;
        ifs.close();
    }
    catch(...) {
        ifs.close();
        throw;
    }
}

Обратите внимание, что блок catch записан с многоточием. Это допустимый синтаксис, который означает отлавливать любые типы исключений. В данном случае нам не важно, что произойдет, главное, закрыть файловый поток, а потом, бросить исключение дальше по стеку вызова функций.

Если в функции main прописать:

int main()
{
    int data {0};
 
    try {
        load_data("123", data);
        std::cout << data << std::endl;
    }
    catch(const std::exception & ex) {
        std::cout << "Error read data from file." << std::endl;
    }
 
    return 0;
}

то при отсутствии файла 123 будет сгенерировано исключение в виде объекта класса std::exception и в блоке catch в консоль будет выведена строка.

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

Видео по теме