7.15 – Обнаружение и обработка ошибок
В уроке «7.14 – Распространенные семантические ошибки при программировании на C++» мы рассмотрели многие типы распространенных семантических ошибок, с которыми сталкиваются начинающие программисты при работе с языком C++. Если ошибка является результатом неправильного использования языковой функции или логической ошибки, исправить ее можно просто.
Но большинство ошибок в программе возникает не в результате непреднамеренного неправильного использования языковых функций – скорее, большинство ошибок возникает из-за ошибочных предположений, сделанных программистом, и/или из-за отсутствия надлежащего обнаружения/обработки ошибок.
Например, в функции, предназначенной для поиска оценки учащегося, вы могли предположить, что:
- просматриваемый студент будет существовать;
- имена всех студентов будут уникальными;
- в предмете используется числовая оценка (вместо «зачет/незачет»).
Что, если какое-либо из этих предположений неверно? Если программист не предвидел этих случаев, программа при возникновении таких случаев, скорее всего, завершится со сбоем (обычно в какой-то момент в будущем, через долгое время после того, как функция была написана).
Есть три ключевых места, где обычно возникают ошибки предположений:
- Когда функция возвращает значение, программист мог предположить, что вызов функции будет успешным, хотя это не так.
- Когда программа получает входные данные (либо от пользователя, либо из файла), программист мог предположить, что ввод будет в правильном формате и семантически корректен, хотя это не так.
- Когда функция была вызвана, программист мог предположить, что параметры будут семантически допустимыми, хотя это не так.
Многие начинающие программисты пишут код, а затем проверяют только счастливый путь: только те случаи, когда ошибок нет. Но вы также должны планировать и проверять печальные пути, на которых что-то может пойти и пойдет не так. В уроке «3.10 – Поиск проблем до того, как они станут проблемами», мы определили защитное программирование как попытку предвидеть все способы неправильного использования программного обеспечения конечными пользователями или разработчиками (либо самим программистом, либо другими). Как только вы ожидаете (или обнаруживаете) какое-то неправильное использование, следующее, что вам нужно сделать, – это обработать его.
В этом уроке мы поговорим о стратегиях обработки ошибок (что делать, если что-то пойдет не так) внутри функции. В следующих уроках мы поговорим о проверке ввода данных пользователем, а затем представим полезный инструмент, помогающий документировать и проверять предположения.
Обработка ошибок в функциях
Функции могут давать сбой по любому количеству причин – вызывающий мог передать аргумент с недопустимым значением, или что-то может дать сбой в теле функции. Например, функция, открывающая файл для чтения, может не работать, если файл не может быть найден.
Когда это произойдет, в вашем распоряжении будет несколько вариантов. Лучшего способа справиться с ошибкой нет – это на самом деле зависит от характера проблемы и от того, можно ли устранить проблему или нет.
Можно использовать 4 основные стратегии:
- обработать ошибку в функции;
- передать ошибку вызывающему, чтобы он разобрался с ней;
- остановить программу;
- выбросить исключение.
Обработка ошибки в функции
Если возможно, наилучшей стратегией является восстановление после ошибки в той же функции, в которой возникла ошибка, так, чтобы ошибку можно было локализовать и исправить, не влияя на какой-либо код вне функции. Здесь есть два варианта: повторять попытки до успешного завершения или отменить выполняемую операцию.
Если ошибка возникла из-за чего-то, не зависящего от программы, программа может повторять попытку, пока не будет достигнут успех. Например, если программе требуется подключение к Интернету, и пользователь потерял соединение, программа может отобразить предупреждение, а затем использовать цикл для периодической повторной проверки подключения к Интернету. В качестве альтернативы, если пользователь ввел недопустимые входные данные, программа может попросить пользователя повторить попытку и выполнять этот цикл до тех пор, пока пользователь не введет корректные входные данные. Мы покажем примеры обработки недопустимого ввода и использования циклов для повторных попыток в следующем уроке (7.16 – std::cin
и обработка недопустимого ввода).
Альтернативная стратегия – просто игнорировать ошибку и/или отменить операцию. Например:
void printDivision(int x, int y)
{
if (y != 0)
std::cout << static_cast<double>(x) / y;
}
В приведенном выше примере, если пользователь ввел недопустимое значение для y
, мы просто игнорируем запрос на печать результата операции деления. Основная проблема при этом заключается в том, что у вызывающей функции или у пользователя нет возможности определить, что что-то пошло не так. В таком случае может оказаться полезным напечатать сообщение об ошибке:
void printDivision(int x, int y)
{
if (y != 0)
std::cout << static_cast<double>(x) / y;
else
std::cerr << "Error: Could not divide by zero\n";
}
Однако если вызывающая функция ожидает, что вызываемая функция выдаст возвращаемое значение или какой-либо полезный побочный эффект, тогда просто игнорирование ошибки может быть недопустимым вариантом.
Передача ошибок вызывающей функции
Во многих случаях обработать ошибку с помощью функции, которая ее обнаруживает, невозможно. Например, рассмотрим следующую функцию:
double doDivision(int x, int y)
{
return static_cast<double>(x) / y;
}
Если y
равно 0, что нам делать? Мы не можем просто пропустить логику программы, потому что функция должна возвращать какое-то значение. Мы не должны просить пользователя ввести новое значение для y
, потому что это функция вычисления, и введение в нее процедур ввода может быть или не быть подходящим для программы, вызывающей эту функцию.
В таких случаях лучшим вариантом может быть передача ошибки обратно вызывающей функции в надежде, что вызывающая сторона сможет с ней справиться.
Как мы можем это сделать?
Если функция имеет тип возвращаемого значения void
, его можно изменить, чтобы она возвращала логическое значение, указывающее на успех или на неудачу. Например, вместо:
void printDivision(int x, int y)
{
if (y != 0)
std::cout << static_cast<double>(x) / y;
else
std::cerr << "Error: Could not divide by zero\n";
}
Мы можем сделать так:
bool printDivision(int x, int y)
{
if (y == 0)
{
std::cerr << "Error: could not divide by zero\n";
return false;
}
std::cout << static_cast<double>(x) / y;
return true;
}
Таким образом, вызывающий может проверить возвращаемое значение, чтобы узнать, не завершилась ли функция по какой-либо причине неудачей.
Если функция возвращает обычное значение, всё немного сложнее. В некоторых случаях полный диапазон возвращаемых значений не используется. В таких случаях, чтобы указать на ошибку, мы можем использовать возвращаемое значение, которое иначе было бы невозможно. Например, рассмотрим следующую функцию:
// Обратное (reciprocal) к x равно 1/x
double reciprocal(double x)
{
return 1.0 / x;
}
Обратное к некоторому числу x
определяется как 1/x
, а число, умноженное на обратное, равно 1.
Однако что произойдет, если пользователь вызовет эту функцию как reciprocal(0.0)
? Мы получаем ошибку деления на ноль и сбой программы, поэтому очевидно, что мы должны защититься от этого случая. Но эта функция должна возвращать значение doube
, так какое же значение мы должны вернуть? Оказывается, эта функция никогда не выдаст 0.0 как допустимый результат, поэтому мы можем вернуть 0.0, чтобы указать на случай ошибки.
// Обратное (reciprocal) к x равно 1/x; возвращает 0.0, если x=0
double reciprocal(double x)
{
if (x == 0.0)
return 0.0;
return 1.0 / x;
}
Однако если требуется полный диапазон возвращаемых значений, то использование возвращаемого значения для указания ошибки будет невозможно (поскольку вызывающий не сможет определить, является ли возвращаемое значение допустимым значением или значением ошибки). В таком случае выходной параметр (рассмотренный в уроке «11.3 – Передача аргументов по ссылке») может быть жизнеспособным вариантом.
Фатальные ошибки
Если ошибка настолько серьезна, что программа не может продолжать работать правильно, она называется неисправимой ошибкой (или фатальной ошибкой). В таких случаях лучше всего завершить программу. Если ваш код находится в main()
или в функции, вызываемой непосредственно из main()
, лучше всего позволить main()
вернуть ненулевой код состояния. Однако если вы погрузились в какую-то глубоко вложенную подфункцию, передать ошибку обратно в main()
может быть неудобно или невозможно. В таком случае можно использовать инструкцию остановки (например, std::exit()
).
Например:
double doDivision(int x, int y)
{
if (y == 0)
{
std::cerr << "Error: Could not divide by zero\n";
std::exit(1);
}
return static_cast<double>(x) / y;
}
Исключения
Поскольку возврат ошибки из функции обратно вызывающей функции сложен (и множество различных способов сделать это приводит к несогласованности, а несогласованность ведет к ошибкам), C++ предлагает совершенно отдельный способ передачи ошибок обратно вызывающей стороне: исключения.
Основная идея состоит в том, что при возникновении ошибки «выбрасывается» исключение. Если текущая функция не «улавливает» ошибку, то уловить ошибку есть шанс у вызывающей функции. Если вызывающая функция не обнаруживает ошибку, то обнаружить ошибку есть шанс у функции, вызвавшей вызывающую функцию. Ошибка постепенно перемещается вверх по стеку вызовов до тех пор, пока она не будет обнаружена и обработана (в этот момент выполнение продолжается в обычном режиме), или пока main()
не сможет обработать ошибку (в этот момент программа завершится с ошибкой исключения).
Мы рассмотрим обработку исключений в главе 20 этой серии обучающих статей.