20.1 – Необходимость исключений
В предыдущем уроке об обработке ошибок мы говорили о способах использования assert()
, cerr()
и exit()
для обработки ошибок. Однако тогда мы затронули еще одну тему, которую сейчас и рассмотрим: исключения.
Когда коды возврата не работают
При написании кода, который можно использовать повторно, обработка ошибок обязательна. Один из наиболее распространенных способов обработки потенциальных ошибок – использование кодов возврата. Например:
int findFirstChar(const char* string, char ch)
{
const std::size_t stringlength{ strlen(string) };
// Перебираем все символы в строке
for (std::size_t index { 0 }; index < stringlength ; ++index)
// Если символ совпадает с ch, вернуть его индекс
if (string[index] == ch)
return index;
// Если совпадений не найдено, возвращаем -1
return -1;
}
Эта функция возвращает индекс первого символа в строке, совпадающего с ch
. Если символ не найден, функция возвращает -1 в качестве индикатора ошибки.
Основное достоинство этого подхода в том, что он чрезвычайно прост. Однако использование кодов возврата имеет ряд недостатков, которые могут быстро стать очевидными при использовании в нетривиальных случаях.
Во-первых, возвращаемые значения могут быть неочевидными – если функция возвращает -1, пытается ли она указать на ошибку или это действительно допустимое возвращаемое значение? Часто это трудно сказать, не вникая в суть функции.
Во-вторых, функции могут возвращать только одно значение, но что происходит, когда вам нужно вернуть и результат функции, и код ошибки? Рассмотрим следующую функцию:
double divide(int x, int y)
{
return static_cast<double>(x)/y;
}
Эта функция отчаянно нуждается в обработке ошибок, потому что она даст сбой, если пользователь передаст 0 для параметра y
. Однако она также должна возвращать результат x/y
. Как можно сделать и то, и другое? Наиболее распространенный ответ заключается в том, что либо результат, либо код ошибки должны быть переданы обратно в качестве ссылочного параметра, что делает код более уродливым и менее удобным в использовании. Например:
#include <iostream>
double divide(int x, int y, bool& outSuccess)
{
if (y == 0)
{
outSuccess = false;
return 0.0;
}
outSuccess = true;
return static_cast<double>(x)/y;
}
int main()
{
// теперь, чтобы увидеть, был ли вызов успешным,
// мы должны передавать логическое значение
bool success {};
double result { divide(5, 3, success) };
if (!success) // и проверять его, прежде чем использовать результат
std::cerr << "An error occurred" << std::endl;
else
cout << "The answer is " << result << '\n';
}
В-третьих, в последовательностях кода, где многие вещи могут пойти не так, коды ошибок необходимо проверять постоянно. Рассмотрим следующий фрагмент кода, который включает в себя анализ текстового файла на предмет значений, которые должны быть там:
std::ifstream setupIni { "setup.ini" }; // открываем setup.ini для чтения
// Если файл не может быть открыт (например, потому что он отсутствует),
// вернуть какое-то значение перечисления для индикации ошибки
if (!setupIni)
return ERROR_OPENING_FILE;
// Теперь считываем из файла пачку значений
if (!readIntegerFromFile(setupIni, m_firstParameter)) // пытаемся прочитать из файла int
return ERROR_READING_VALUE; // Возвращаем значение перечисления, указывающее,
// что значение не может быть прочитано
if (!readDoubleFromFile(setupIni, m_secondParameter)) // пытаемся прочитать из файла double
return ERROR_READING_VALUE;
if (!readFloatFromFile(setupIni, m_thirdParameter)) // пытаемся прочитать из файла float
return ERROR_READING_VALUE;
Мы еще не рассмотрели доступ к файлам, поэтому не беспокойтесь, если не понимаете, как это работает – просто обратите внимание на тот факт, что каждый вызов требует проверки на ошибку и возврата в вызывающую функцию. А теперь представьте, что у вас двадцать параметров разных типов – по сути, вы двадцать раз проверяете на наличие ошибки и возвращаете ERROR_READING_VALUE
! Вся эта проверка на ошибки и возвращение значений значительно затрудняют определение того, что функция пытается сделать на самом деле.
В-четвертых, коды возврата плохо сочетаются с конструкторами. Что произойдет, если вы создаете объект, и что-то пойдет не так внутри конструктора? Конструкторы не имеют возвращаемого типа для передачи индикатора состояния, а возвращать его через ссылочный параметр не очень красиво и требует явной проверки. Более того, даже если вы это сделаете, объект всё равно будет создан, поэтому затем его нужно будет обработать или утилизировать.
Наконец, когда вызывающему возвращается код ошибки, вызывающая функция не всегда может быть снабжена обработкой ошибок. Если вызывающая функция не хочет обрабатывать ошибку, она должна либо проигнорировать ее (в этом случае она будет потеряна навсегда), либо вернуть ошибку функции, следующей в стеке (той, которая ее вызвала). Это может быть привести к бардаку и ко многим из тех же проблем, о которых говорилось выше.
Подводя итог, основная проблема с кодами возврата заключается в том, что код обработки ошибок в конечном итоге замысловато связан с обычным управлением порядком выполнения кода. Это, в свою очередь, ограничивает как структуру кода, так и приемлемые способы обработки ошибок.
Исключения
Обработка исключений обеспечивает механизм, позволяющий отделить обработку ошибок или других исключительных обстоятельств от обычного управления порядком выполнения вашего кода. Это дает больше свободы в том, когда и как обрабатывать ошибки в какой-либо конкретной ситуации, что облегчает многие (если не все) сложности, вызываемые кодами возврата.
В следующем уроке мы рассмотрим, как исключения работают в C++.