Обработка исключительных ситуаций (Exceptions handling)

При работе программы могут возникнуть различные проблемы: недостаточное количество ресурсов (невозможно выделить память, закончилось место на диске и т.п.), отсутствие данных (нет файла с нужным именем), пользователь подает на вход недопустимые данные и т.п. Корректная программа должна уметь обнаруживать такие ситуации (exceptions) и некоторым образом их обрабатывать (реагировать).

try/catch

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

 double my_sqrt(double x) {
    if(x < 0)
        throw "SQRT does not support negative arguments!";
    // вычисление корня
    return result;
}
Исключительные ситуации различаются своими типами. В приведенном примере функция my_sqrt может создать (в случае вызова с отрицательным значением аргумента) исключительную ситуацию типа const char *. Если вызвать throw 1234;, то будет создано исключение типа int.

Любой фрагмент кода, например, все содержание какой-то функции или даже main, можно оформить в виде блока try/catch. Внутри блока try { ... } мы пишем программу без явной проверки ошибок. Как будто ошибок вообще не бывает. Блоки catch, которые обязательно следуют за try, содержат действия, которые должны выполняться при возникновении ошибок определенных типов.

Рассмотрим следующий пример.

try {
    do_something(); // эта функция вызывает my_sqrt
    if(!check_condition()) {
        throw 123;
    }
    do_something_else();
}
catch(const char *message) {
    std::cerr << "Что-то идет не так: " << message << std::endl;
}
catch(int code) {
    std::cerr << "Что-то идет не так: код ошибки = " << code << std::endl;
}
catch(...) {
    std::cerr << "Что-то идет СОВСЕМ не так" << std::endl;
}
Если в блоке try создается исключительная ситуация типа T, то "нормальное" выполнение программы прерывается и управление передается в блок catch, который предназначен для обработки ситуаций типа T. В нашем примере, если функция do_something вызовет my_sqrt с отрицательным аргументом, то ее (do_something) выполнение будет прервано, check_condition и do_something_else не будут вызваны, а управление будет передано в блок catch(const char *). Аналогично, если условие check_condition не выполняется, то do_something_else не выполняется, а программа выводит сообщение "Что-то идет не так: код ошибки = 123".

В это смысле оператор throw похож на оператор return: выполнение функции прерывается. Но существует несколько важных отличий:

Блок catch(...) (именно так, с тремя точками внутри круглых скобок) предназначен для обработки всех исключений, которые не были обработаны предыдущими блоках, если таки блоки были.

Исключения собственных типов

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

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

#include <string>

class MyException {
  int code_;
  std::string message_;
public:
  MyException(int code, const std::string& message) : code_(code), message_(message) {}
  const std::string& message() const { return message_; }
  int code() const { return code_; }
};
Такой класс может использоваться следующим образом:
try {
   if(/* условие */) {
      throw MyException(-1, "Very detailed description for the user");
   }
   // ...
}
catch(MyException& my) {
   std::cerr << "My error was detected!" << std::endl;
}
catch(...) {
   std::cerr << "Something wrong" << std::endl;
}

Иерархия типов исключений

Механизм наследования позволяет задавать классификацию возможных ошибок, что приводит к более ясному коду программы. Например, ошибки программы могут быть разделены на ошибки ввода-вывода, ошибки во входных данных, какие-то другие ошибки. Классификация позволяет обрабатывать только те ситуации, которые нужно, и избежать лишних условных операторов. Сравним два примера.

Плохо

try {
// ...
}
catch(MyException &ex) {
  // Коды ошибок соответствуют каким-то типам
  if(ex.code() == 1) {
    // обработка ошибки типа 1 (ввод-вывод)
  } else if(ex.code() == 2) {
    // обработка ошибки типа 2 (ошибочные данные)
  }
}

Лучше

try {
// ...
}
catch(MyInputOutputException &ex) {
    // обработка ошибки типа 1
}
catch(MyDataException &ex) {
    // обработка ошибки типа 2
}

Правило выбора обработчика исключения

Если в теле блока try было создано исключение типа T, то выполнение блока прерывается и производится поиск обработчика (блок catch), который должен быть выполнен. Все обработчики, относящиеся к блоку try, просматриваются в порядке их появления в тексте программы ("сверху вниз"). Управление передается на первый обработчик, который предназначен для обработки исключений типа T. Это в частности означает, что если первым стоит блок catch(...), то остальные блоки catch никогда не будут срабатывать, так как первый блок будет "перехватывать" все исключения.

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

void f() {
    try {
      // что-то делаем

      // Делаем что-то еще, но обрабатываем MyException
      try {
        // ...
        throw SomeException();
      }
      catch(MyException& ex) {
        // ...
      }

      // Делаем что-то третье
    }
    catch(SomeException & ex) {
       std::cerr << "My error was detected!" << std::endl;
    }
}

int main() {
    try {
        f();
    }
    catch(...) {
        // ...
    }
}

Правила хорошего тона

Оказывается, я еще не дописал этот раздел...