IT Notes

Обработка ошибок: Защитное программирование

Введение

В любой программе, сложнее чем "Hello, world", неизбежно будут происходить ошибки и различные сбои. Поэтому если вы хотите писать надежный и стабильный код, то обязаны заботиться обо всем этом. Конечно, в большинстве случаев нет смысла становиться параноиком и проверять абсолютно все. С другой стороны, во многом это зависит от области применения приложения, которое вы разрабатываете. Если это система контроля банковских переводов или управления полетом спутника, то единственная ошибка в коде может обойтись очень дорого. В этом случае вам на помощь приходит "защитное программирование". Одно из лучших описаний этой методики, на мой взгляд, приводится в следующих книгах:

  1. Ремесло программиста. Практика написания хорошего кода;
  2. Совершенный код.

Суть защитного программирования заключается в аккуратной и последовательной обработке всех возможных ошибок и исключений. О том, какими способами это можно сделать, мы и поговорим.

Примеры кода я буду приводить на 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++ для создания переносимых библиотек; а также разобрались с возможными вариантами реализации функции-обработчика, которая должна проверять коды ошибок. Встретимся во второй части.

Понравилась статья?
Не забудь поделиться ей с друзьями!
Реклама

Похожие публикации

Комментарии

RSS RSS-рассылка

Популярное