В любой программе, сложнее чем "Hello, world"
, неизбежно будут происходить ошибки и различные сбои. Поэтому если вы хотите писать надежный и стабильный код, то обязаны заботиться обо всем этом. Конечно, в большинстве случаев нет смысла становиться параноиком и проверять абсолютно все. С другой стороны, во многом это зависит от области применения приложения, которое вы разрабатываете. Если это система контроля банковских переводов или управления полетом спутника, то единственная ошибка в коде может обойтись очень дорого. В этом случае вам на помощь приходит "защитное программирование". Одно из лучших описаний этой методики, на мой взгляд, приводится в следующих книгах:
Суть защитного программирования заключается в аккуратной и последовательной обработке всех возможных ошибок и исключений. О том, какими способами это можно сделать, мы и поговорим.
Примеры кода я буду приводить на C++, однако это не должно стать для вас серьезной помехой, если вы пишите на Java, C# или каком-нибудь другом языке со схожим синтаксисом, поскольку многие техники обработки ошибок являются универсальными.
Обработка ошибок неразрывно связана со способом передачи информации о возникших проблемах. Одним из вариантов сообщения об ошибке является возврат значения. Но даже в этом случае существует несколько подходов. Предположим, что мы пишем функцию для регистрации нового пользователя в системе. На вход мы передаем ей структуру с данными пользователя, а на выходе ожидаем получить идентификатор, который был ему присвоен.
Вот как может выглядеть подобная функция:
int registerUser( const User& user ) {
// …
}
Но как мы узнаем, удалась ли регистрация или нет? Первый подход основывается на предположении, что идентификатор пользователя должен быть неотрицательным числом. Таким образом, в случае ошибки функция registerUser()
может возвращать отрицательное значение, например, -1
. Тогда код взаимодействия с функцией регистрации может выглядеть следующим образом:
User user;
// Заполняем структуру user
int userID = registerUser( user );
if( userID == -1 ) {
// Регистрация не удалась!
}
Такой способ применяется довольно часто, но он имеет некоторые очевидные недостатки. Например, когда мы инициализируем переменную userID
значением, которое возвращает функция registerUser()
, то на самом деле мы врем. Ведь эта переменная хранит идентификатор пользователя ИЛИ код ошибки, то есть должна называться userIDOrErrorCode
. Имя получилось длинным и пользоваться им будет неудобно. Но сразу становится понятно, что эта переменная имеет два назначения (выглядит подозрительно, не правда ли?). Однако даже это не является главным недостатком. В какой-то момент требования к типу идентификатора могут измениться. Например, мы можем решить, что лучше использовать беззнаковое целое число:
typedef unsigned int UserID;
UserID registerUser( const User& user );
Обратите внимание, что в первую очередь я добавил определение typedef
для типа UserID
. Теперь мы сможем менять фактический тип идентификатора всего в одной строке. Для сравнения, когда мы явно пользовались типом int
, то в зависимости от объема кода подобная тривиальная операция могла бы занять несколько часов. Поэтому старайтесь не повторяться (см. Принцип DRY в действии).
Кроме того, теперь мы явно указываем, что функция registerUser()
возвращает именно идентификатор UserID
, а не идентификатор ИЛИ код ошибки. Иначе мы вновь обманываем тех, кто будет вызывать нашу функцию.
Итак, логику мы подправили, но как теперь сообщить о произошедшей ошибке? Один из часто применяемых подходов заключается в том, чтобы вернуть информацию об ошибке через дополнительный параметр. Например:
UserID registerUser( const User& user, bool* ok );
Этой функцией мы можем пользоваться следующим образом:
User user;
// Заполняем структуру user
bool ok = false;
UserID userID = registerUser( user, &ok );
if( !ok ) {
// Регистрация не удалась!
}
Выглядит неплохо. Но у вас мог возникнуть вопрос: "А какое значение будет у переменной userID
, если ok == false
"? Можно предположить, что это будет 0
, то есть функция registerUser()
должна вернуть некое нейтральное значение. Например, в случае, когда ожидается возврат класса или структуры, это может быть Null
-объект или объект с функцией-членом на подобии isValid()
, которая возвращает false
, если экземпляр класса находится в некорректном состоянии. Кстати, если бы registerUser()
была не обычной функцией, а являлась членом класса, то мы бы могли вообще не возвращать никаких признаков ошибки, но добавить в сам класс что-то вроде getLastError()
.
Рассмотренный подход вполне справляется со своими задачами, но обычно для подобных функций в стиле C используют другую сигнатуру. Она выглядит следующим образом:
bool registerUser( const User& user, UserID* userID );
Мы поменяли местами идентификатор и переменную с информацией об успешности выполнения регистрации. Пользоваться этой функцией можно следующим образом:
User user;
// Заполняем структуру user
UserID userID = 0;
bool ok = registerUser( user, &userID );
if( !ok ) {
// Регистрация не удалась!
}
Однако, как вы можете видеть, принципиально ничего не изменилось. Нам все равно приходится инициализировать идентификатор userID
значением по умолчанию, но теперь это происходит не в самой функции, а до ее вызова. Если мы не хотим, чтобы переменная userID
имела смысл без явной инициализации (хотя всегда есть возможность добавить в иерархию классов Null
-объект), то можно воспользоваться альтернативным решением:
bool registerUser( const User& user, UserID** userID );
// …
User user;
// Заполняем структуру user
UserID* userID = NULL;
bool ok = registerUser( user, &userID );
if( !ok ) {
// Регистрация не удалась!
}
// Что-нибудь делаем
if( userID ) {
releaseMemory( &userID );
}
На этот раз registerUser()
принимает не просто указатель на UserID
, а двойной указатель. То есть память для идентификатора пользователя выделяется внутри функции registerUser()
. А чтобы быть последовательными, освобождать память для userID
мы будем не оператором delete
, а с помощью функции releaseMemory()
, которую сами должны предусмотреть.
Подобным образом часто пишутся C-библиотеки, которые представляют собой обертку над ООП-кодом на C++. Это делается для получения высокой совместимости библиотек, что особенно актуально для Windows. Вы не можете подключить библиотеку на C++ к проекту, если версии компиляторов, с которой работаете вы, и с которой была собрана библиотека, отличаются. Поэтому если вы хотите сохранить реализацию своей библиотеки закрытой, то оказываетесь ограничены использованием низкоуровневых типов данных, которые есть в C. Но чтобы не отбрасывать свои наработки и не переписывать весь код заново на чистом C, используется показанная выше методика. При этом в пользовательском коде вся работа ведется не с самими экземплярами классов, а с указателями на них. А для выделения и освобождения памяти используются специально предусмотренные функции, которые предоставляет библиотека. Более того, определений классов в заголовочных файлах библиотеки нет вовсе (для простоты я сделал допущение, что мы можем создать экземпляр класса User
, хотя его тоже не было бы). Вам доступно лишь объявление, которого достаточно для определения указателя на класс. Из-за этого все вызовы функций-членов должны осуществляться также с помощью C-функций, которые ожидают получить указатель на объект, а входные и выходные аргументы должны быть представлены с помощью примитивных типов, на подобии int
или char
. Впрочем, это было небольшое отступление от нашей темы. Однако если у вас есть желание узнать об этом вопросе поподробнее, то пишите свои пожелания в комментариях и я подготовлю соответствующую полноценную заметку.
Со способом возврата ошибок с помощью переменных мы более или менее разобрались. Но я думаю, что вы уже могли заметить, что тип bool
в для подобных целей подходит не лучшим образом. Сейчас мы можем узнать лишь то, что функция отработала нормально или в ней произошла ошибка, но остается лишь догадываться, что именно пошло не так. Попробуем исправить это, добавив свой более информативный код ошибки. Обычно неплохим вариантом для решения подобной задачи являются перечисления:
namespace RegistrationLib {
typedef unsigned int UserID;
class User {
// …
};
enum ResultCode {
SUCCESS,
EMPTY_POINTER,
INVALID_FIELD_VALUE,
DB_CONNECTION_FAILED
};
ResultCode registerUser( const User& user, UserID* userID );
}
Чтобы избежать проблем с областью видимости при объявлении перечисления ResultCode
, мы создали пространство имен RegistrationLib
. Использование namespace
-ов вообще является хорошей практикой при написании кода, который вы планируете кому-то передавать. Теперь registerUser()
не просто говорит о том, что что-то случилось, но и указывает причину. Конечно, в качестве примера я взял простейший набор возможных типов ошибок, которые могли бы случиться. К тому же, они не так детальны, как хотелось бы. Проблем с заполнением структуры User
может быть немало, а уж при работе с базами еще больше. Однако я думаю, что суть вы уловили.
До этого момента мы говорили о различных способах возврата ошибки, когда она уже произошла. Но мы же не можем откладывать момент более тесного знакомства с ней вечно. Поэтому приступим. В коде верхнего уровня нам предстоит сделать выбор, что же делать дальше, и какую стратегию проверки кодов возвращаемых значений использовать. На самом деле, когда мы проектировали нашу функцию registerUser()
, то при ее реализации возникнут те же вопросы. Более того, если она будет основана на применении разных библиотек с разными механизмами возврата ошибок, то это еще серьезнее может осложнить нам жизнь.
Допустим, мы хотим реализовать высокоуровневую функцию, в которой сначала получаем данные пользователя, затем пробуем провести его регистрацию (с помощью нашей функции registerUser()
), а в конце отправляем идентификатор нового пользователя на удаленный сервер, если регистрация удалась. Сначала рассмотрим самый линейный вариант:
ResultCode getUserData( User** user );
ResultCode registerUser( User* user, UserID* userID );
ResultCode sendUserIDToRemoteServer( UserID userID );
void onUserDataReady() {
User* user = NULL;
ResultCode code = getUserData( &user );
if( code == SUCCESS ) {
UserID userID = 0;
code = registerUser( user, &userID );
if( code == SUCCESS ) {
code = sendUserIDToRemoteServer( userID );
if( code == SUCCESS ) {
// Данные отправлены успешно
} else {
// Обработка ошибки отправки идентификатора на удаленный сервер
}
} else {
// Обработка ошибки регистрации пользователя
}
} else {
// Обработка ошибки запроса данных пользователя
}
if( user ) {
releaseMemory( &user );
}
}
Я думаю, у вас не вызовет трудностей разобраться с представленным кодом. В целом он основывается на тех принципах, которые мы уже обсудили выше. Но логика работы функции onUserDataReady()
выглядит довольно запутанной. Таковой она и является. Это связано с тем, что мы использовали множество вложенных условных конструкций, что осложняет чтение кода. Несколько улучшить ситуацию нам поможет следующий прием:
void onUserDataReady() {
User* user = NULL;
ResultCode code = getUserData( &user );
if( code != SUCCESS ) {
// Обработка ошибки запроса данных пользователя
}
if( code == SUCCESS ) {
UserID userID = 0;
code = registerUser( user, &userID );
if( code != SUCCESS ) {
// Обработка ошибки регистрации пользователя
}
}
if( code == SUCCESS ) {
code = sendUserIDToRemoteServer( userID );
if( code != SUCCESS ) {
// Обработка ошибки отправки идентификатора на удаленный сервер
}
}
if( user ) {
releaseMemory( &user );
}
}
Получившийся код выглядит несколько проще. Это достигается за счет уменьшения вложенности проверок. Однако обратите внимание, что перед вызовом registerUser()
и sendUserIDToRemoteServer()
нам приходится проверять результат выполнения предыдущих действий. Такая необходимость появляется по той причине, что если на n
-ом шаге что-то случилось, то все оставшиеся шаги уже не имеют смысла. Фактически после ошибки на каком-то из шагов функция onUserDataReady()
должна завершаться. Для этого мы бы могли попробовать сделать что-то подобное:
void onUserDataReady() {
User* user = NULL;
ResultCode code = getUserData( &user );
if( code != SUCCESS ) {
// Обработка ошибки запроса данных пользователя
return;
}
UserID userID = 0;
code = registerUser( user, &userID );
if( code != SUCCESS ) {
// Обработка ошибки регистрации пользователя
return;
}
code = sendUserIDToRemoteServer( userID );
if( code != SUCCESS ) {
// Обработка ошибки отправки идентификатора на удаленный сервер
return;
}
if( user ) {
releaseMemory( &user );
}
}
Конечно, код стал еще легче, но теперь мы имеем утечку памяти, ведь возврат из функции при завершении обработки ошибок происходит раньше вызова releaseMemory()
. В этом случае у нас есть несколько вариантов. Первый из них заключается в том, чтобы перед каждым return
вставить вызов releaseMemory()
. Но так мы получим крайне неудачную конструкцию с дублированием кода. Поэтому такой способ сразу отбрасываем. Другой подход многие бы не одобрили, но он вполне того стоит в нашей ситуации. Его суть заключается в использовании goto
:
void onUserDataReady() {
User* user = NULL;
ResultCode code = getUserData( &user );
if( code != SUCCESS ) {
// Обработка ошибки запроса данных пользователя
goto FINALLY;
}
UserID userID = 0;
code = registerUser( user, &userID );
if( code != SUCCESS ) {
// Обработка ошибки регистрации пользователя
goto FINALLY;
}
code = sendUserIDToRemoteServer( userID );
if( code != SUCCESS ) {
// Обработка ошибки отправки идентификатора на удаленный сервер
goto FINALLY;
}
FINALLY:
if( user ) {
releaseMemory( &user );
}
}
Обычно goto
не рекомендуют применять. И это вполне оправдано. Ведь при неумелом использовании код быстро становится хаотичным и запутанным (спагетти-код). Но если вы знаете, что делаете, то почему бы нет? Представленный выше фрагмент программы является хорошим примером, что из каждого правила есть исключения.
Однако есть еще более надежный и качественный вариант. Он заключается в использовании принципа RAII. Наиболее известным примером реализации этого механизма является Умный указатель. Более подробно об этой идиоме я расскажу во второй части, а сейчас посмотрим, как будет выглядеть код функции onUserDataReady()
на основе RAII в нашем случае:
void onUserDataReady() {
AutoMemory< User > user = NULL;
ResultCode code = getUserData( &user );
if( code != SUCCESS ) {
// Обработка ошибки запроса данных пользователя
return;
}
UserID userID = 0;
code = registerUser( user, &userID );
if( code != SUCCESS ) {
// Обработка ошибки регистрации пользователя
return;
}
code = sendUserIDToRemoteServer( userID );
if( code != SUCCESS ) {
// Обработка ошибки отправки идентификатора на удаленный сервер
return;
}
}
Эта версия кода выглядит еще лучше. А все потому, что нам не приходится думать об освобождении памяти вручную. Все заботы об этом берет на себя шаблонный класс AutoMemory
, который, впрочем, еще нужно реализовать.
На самом деле, более простой код уже написать и не получится, конечно, если не воспользоваться каким-то принципиально другим механизмом возврата кодов ошибок. Более подробно о нем вы можете узнать из заметки, посвященной исключениям.
Наше знакомство с базовыми принципами обработки ошибок закончено. Мы успели рассмотреть основные способы возврата кодов ошибок; коротко вспомнили принцип создания C-оберток над ООП кодом C++ для создания переносимых библиотек; а также разобрались с возможными вариантами реализации функции-обработчика, которая должна проверять коды ошибок. Встретимся во второй части.