20.3 – Исключения, функции и раскрутка стека
В предыдущем уроке, «20.2 – Основы обработки исключений», мы объяснили, как работают throw
, try
и catch
для реализации обработки исключений. В этом уроке мы поговорим о том, как обработка исключений взаимодействует с функциями.
Выбрасывание исключений вне блока try
В примерах предыдущего урока инструкции throw
были помещены непосредственно в блок try
. Если бы это было обязательно, обработка исключений имела бы ограниченное применение.
Одним из наиболее полезных свойств обработки исключений является то, что из-за того, как исключения при генерировании распространяются вверх по стеку, инструкции throw
НЕ обязаны размещаться непосредственно внутри блока try
. Это позволяет нам использовать обработку исключений гораздо более модульно. Продемонстрируем это, переписав программу извлечения квадратного корня из предыдущего урока так, чтобы использовать отдельную функцию.
#include <cmath> // для функции sqrt()
#include <iostream>
// Модульная функция извлечения квадратного корня
double mySqrt(double x)
{
// Если пользователь ввел отрицательное число, это условие ошибки
if (x < 0.0)
throw "Can not take sqrt of negative number"; // выбрасываем исключение типа const char*
return std::sqrt(x);
}
int main()
{
std::cout << "Enter a number: ";
double x {};
std::cin >> x;
try // Ищем исключения, возникающие в блоке try,
{ // и направляем их к прикрепленным блокам catch
double d = mySqrt(x);
std::cout << "The sqrt of " << x << " is " << d << '\n';
}
catch (const char* exception) // ловим исключения типа const char*
{
std::cerr << "Error: " << exception << std::endl;
}
return 0;
}
В этой программе мы взяли код, который выполняет проверку на исключение и вычисляет квадратный корень и поместили его в модульную функцию mySqrt()
. Затем мы вызвали эту функцию mySqrt()
из блока try
. Убедимся, что всё по-прежнему работает должным образом:
Enter a number: -4
Error: Can not take sqrt of negative number
Всё работает!
Давайте на мгновение вернемся к тому, что происходит при генерировании исключения. Во-первых, программа проверяет, можно ли обработать исключение немедленно (что означает, что оно было сгенерировано внутри блока try
). Если нет, текущая функция завершается, и программа проверяет, обработает ли исключение вызывающая функция. Если нет, она завершается вызов и проверяет вызывающую функцию уже этой функции. Так последовательно завершается каждая функция, пока не будет найден обработчик исключения, или пока не будет завершена функция main()
без обработки исключения. Этот процесс называется раскручиванием стека (смотрите урок о стеке и куче, если вам нужно напомнить, что такое стек вызовов).
Теперь давайте подробно рассмотрим, как это применимо к этой программе, когда исключение возникает в mySqrt()
. Сначала программа проверяет, не возникло ли исключение в блоке try
внутри функции. В данном случае это не так. Затем начинает раскручиваться стек. Сначала mySqrt()
завершается, и управление возвращается к main()
. Программа проверяет, находимся ли мы теперь внутри блока try
. Да, и есть обработчик const char*
, поэтому исключение обрабатывается блоком try
внутри main()
.
Подводя итог, mySqrt()
выбросила исключение, но тем, кто захватил и обработал исключение, был блок try
/catch
в main()
. Или, другими словами, блоки try
перехватывают исключения не только от инструкций в блоке try
, но и из функций, которые вызываются в блоке try
.
Самая интересное в приведенной выше программе заключается в том, что функция mySqrt()
может генерировать исключение, но это исключение не находится непосредственно внутри блока try
! По сути, это означает, что mySqrt
готова сказать: «Привет, возникла проблема!», но не желает решать эту проблему самостоятельно. По сути, это делегирование ответственности за обработку исключения вызывающей функции (эквивалент того, как использование кода возврата передает ответственность за обработку ошибки обратно вызывающему).
На этом этапе некоторые из вас, вероятно, задаются вопросом, почему лучше передавать ошибки вызывающему. Почему бы просто не заставить mySqrt()
обрабатывать собственную ошибку? Проблема в том, что разные приложения могут по-разному обрабатывать ошибки. Консольное приложение может захотеть напечатать текстовое сообщение. Оконному приложению может потребоваться всплывающее диалоговое окно с сообщением об ошибке. В одном приложении это может быть фатальная ошибка, а в другом – нет. Передавая ошибку по стеку, каждое приложение может обработать ошибку из mySqrt()
способом, наиболее подходящим для него по контексту! В конечном итоге это сохраняет mySqrt()
как можно более модульной, а обработка ошибок может быть помещена в менее модульные части кода.
Еще один пример раскручивания стека
Вот еще один пример, показывающий на практике раскручивание стека при большем размере стека. Хотя эта программа длинная, она довольно проста: main()
вызывает first()
, first()
вызывает second()
, second()
вызывает third()
, third()
вызывает last()
, а last()
вызывает исключение.
#include <iostream>
void last() // вызывается функцией third()
{
std::cout << "Start last\n";
std::cout << "last throwing int exception\n";
throw -1;
std::cout << "End last\n";
}
void third() // вызывается функцией second()
{
std::cout << "Start third\n";
last();
std::cout << "End third\n";
}
void second() // вызывается функцией first()
{
std::cout << "Start second\n";
try
{
third();
}
catch(double)
{
std::cerr << "second caught double exception\n";
}
std::cout << "End second\n";
}
void first() // вызывается функцией main()
{
std::cout << "Start first\n";
try
{
second();
}
catch (int)
{
std::cerr << "first caught int exception\n";
}
catch (double)
{
std::cerr << "first caught double exception\n";
}
std::cout << "End first\n";
}
int main()
{
std::cout << "Start main\n";
try
{
first();
}
catch (int)
{
std::cerr << "main caught int exception\n";
}
std::cout << "End main\n";
return 0;
}
Взгляните на эту программу более внимательно. Сможете ли вы выяснить, что напечатается при запуске, а что нет? Ответ будет таким:
Start main
Start first
Start second
Start third
Start last
last throwing int exception
first caught int exception
End first
End main
Давайте разберемся, что происходит в этом случае. Все инструкции, печатающие "Start", просты и не требуют дополнительных объяснений. Функция last()
печатает "last throwing int exception", а затем выдает исключение типа int
. Здесь начинается самое интересное.
Поскольку last()
не обрабатывает исключение самостоятельно, стек начинает раскручиваться. Функция last()
немедленно завершается, и управление возвращается вызывающей функции, которой является third()
.
Функция third()
не обрабатывает никаких исключений, поэтому она немедленно завершает свою работу, а управление возвращается к second()
.
У функции second()
есть блок try
, и вызов third()
находится внутри него, поэтому программа пытается сопоставить исключение с соответствующим блоком catch
. Однако здесь нет обработчиков исключений типа int
, поэтому second()
немедленно завершается, а управление возвращается к first()
. Обратите внимание, что исключение int
не преобразуется неявно, чтобы соответствовать блоку catch
, обрабатывающему double
.
Функция first()
также имеет блок try
, и вызов second()
находится внутри него, поэтому программа проверяет, есть ли обработчик catch
для исключений типа int
. Он есть! Следовательно, first()
обрабатывает исключение и выводит "first caught int exception".
Поскольку теперь исключение обработано, выполнение продолжается в обычном режиме сразу после блока catch
внутри first()
. Это означает, что first()
печатает "End first", а затем завершается нормальным способом.
Управление возвращается к main()
. Хотя main()
имеет обработчик исключений для int
, наше исключение уже обработано в first()
, поэтому блок catch
в main()
не выполняется. main()
просто печатает "End main" и затем завершается нормальным способом.
Эта программа иллюстрирует несколько интересных принципов:
Во-первых, функции, непосредственно вызвавшую функцию, которая генерирует исключение, не нужно обрабатывать это исключение, если она этого не хочет. В этом случае third()
не обработала исключение, сгенерированное last()
. Она делегировала эту ответственность одной из вызывающих функций вверх по стеку.
Во-вторых, если блок try
не имеет обработчика catch
для типа генерируемого исключения, раскрутка стека происходит так же, как если бы блока try
не было вообще. В этом случае second()
не обработала исключение, потому что у нее не было нужного блока catch
.
В-третьих, как только исключение обработано, порядок выполнения программы продолжается как обычно, начиная сразу после блоков catch
. Это было продемонстрировано путем обработки ошибки функцией first()
и нормального завершения работы. К тому времени, когда программа вернулась к main()
, исключение уже было сгенерировано и обработано – main()
даже не подозревала, что исключение вообще было!
Как видите, раскрутка стека дает нам очень полезное поведение – если функция не хочет обрабатывать исключение, в этом нет необходимости. Исключение будет распространяться вверх по стеку, пока не найдется тот, кто это сделает! Это позволяет нам решить, где в стеке вызовов наиболее подходящее место для обработки любых возможных ошибок.
В следующем уроке мы рассмотрим, что происходит, когда вы не захватываете исключение, и способ предотвратить это.