Обработка ошибок и освобождение ресурсов на C
Некая усредненная функция обычно занимается чем-то таким:
- Получает/захватывает/выделяет необходимые для работы ресурсы.
- Выполняет содержательные действия.
- Освобождает ресурсы.
На каждом из предыдущих этапов вдобавок должно выполняться обнаружение и обработка ошибок. И вот здесь-то и начинается самое интересное. Казалось бы, нет ничего сложного. Ну, обнаружили ошибку. Ну, обработали. В чем проблема-то? А проблема в деталях. Могут иметь место такие, например, случаи:
- Функция получает не один ресурс, а N. Соответственно, все полученные ресурсы нужно освободить. А если при получении i-го ресурса произошла ошибка, то нужно освободить только ресурсы 0…i-1.
- Если в содержательных действиях происходит ошибка, то перед освобождением ресурсов может понадобиться эту ошибку как-то обработать. Хорошо если такая ошибка может возникнуть только в одном месте — прямо там и обработаем. А что если в разных местах могут возникнуть ошибки, требующие одинаковой обработки? Еще после возникновения ошибки надо как-то покинуть содержательный код.
- Если ошибка происходит в цикле со вложенностью 2 и более, то надо как-то все эти циклы покинуть.
- Будем предполагать, что обработка ошибки не ограничивается возвратом кода ошибки, а включает какую-то более сложную обработку: запись в лог, выдача сообщений и т.п.
А еще мы хотим, чтобы наш код нормально читался, быстро работал и был пригоден для дальнейшего расширения. Такие вот мы противоречивые. Что делать будем? Есть несколько вариантов.
Вариант 0, «тупой»
Что думаю, то и пишу.
int function(...) { // ...здесь объявления переменных... if (get_res1(&res1)) return ERROR1; if (get_res2(&res2)) { release_res1(&res1); return ERROR2; { // ... if (get_res25(&res25)) { release_res1(&res1); release_res2(&res2); // ... release_res24(&res24); } // ...какой-то содержательный код... // ...ошибка! if (error) { release_res1(&res1); release_res2(&res2); // ... release_res25(&res25); return SOME_ERROR; } release_res1(&res1); release_res2(&res2); // ... release_res25(&res25); return 0; }
Плюсы: не обнаружены.
Минусы:
- массовое дублирование кода; чем больше ресурсов, тем больше дублирования;
- выход из функции в различных точках;
- ужасно читается;
- большие затраты на модификацию.
Вариант 1, «правильный»
Как прилежные студенты, мы должны были почерпнуть из умных книжек и от преподавателей, что действия, выполняемые функцией, должны находиться на одном уровне абстракции. Поэтому наша гипотетическая функция должна выглядеть как-то так:
int function(...) { struct data data; int r_code; r_code = get_resources(&data); if (!r_code) do_work(&data); if (r_code) handle_error(r_code, &data); release_resources(&data); return r_code; }
Что там делают функции get_resources(), do_work(), handle_error() и release_resources() — нам неинтересно. Конечно, приведенный выше код — всего лишь демонстрация идеи. Очевидно, если все функции писать в таком духе, то до нижнего уровня, который, собственно, и делает фактическую работу, мы никогда не дойдем. Так и погрязнем в бесконечных слоях абстракций. Поэтому вместо do_work() обычно пишется компактный код, выполняющий требуемую работу, но без излишнего углубления в детали.
Плюсы:
- компактный код, хорошая читабельность;
- возможность повторного использования (например, функции обработки ошибок);
- одна точка выхода;
- хорошо поддается рефакторингу.
Минусы:
- относительно низкая эффективность;
- наличие синтетической структуры данных (
struct data), хранящей состояние ресурсов.
Вариант 2, «тру-хакерский»
Настоящие хакеры не боятся goto. Настоящие хакеры плевать хотели на предрассудки. Мнение о вредности goto было специально распространено ими среди начинающих; goto в коде хакера — как катана в руках самурая: новичок может покалечить себя, а мастер — всех остальных.
int function(...) { // ...здесь объявления переменных... if (get_res1(&res1)) { ret = ERROR1; goto exit; } if (get_res2(&res2)) { ret = ERROR2; goto exit1; } // ... if (get_res25(&res25)) { ret = ERROR25; goto exit24; } // ...какой-то содержательный код... // ...ошибка! if (error) { ret = SOME_ERROR; goto error; } // ... goto exitOK; error: // ... exitOK: release_res25(&res25); exit24: release_res24(&res24); // ... exit2: release_res2(&res2); exit1: release_res1(&res1); exit: return ret; }
Идея схожа с предыдущим вариантом: локализовать места обработки ошибок и освобождения ресурсов. За счет fallthrough-стиля освобождение происходит очень изящно. Главная проблема при использовании goto для обработки ошибок и освобождения ресурсов — соблюдать меру. Не зря ведь в моем примере 25 ресурсов: в случае использования варианта №1 размер кода не зависит от их количества, здесь же мы получаем спагетти. Использование goto позволяет сократить код по сравнению с вариантом №0, но таит в себе опасность бесконечного увеличения размера функции, так как возможности рефакторинга здесь ограничены.
Такой подход можно встретить, например, в ядрах Linux и FreeBSD.
Плюсы:
- неплохая читабельность при аккуратной реализации;
- высокая эффективность;
- одна точка выхода.
Минусы:
- ограниченные возможности рефакторинга: части функции жестко связаны между собой переходами;
- при дальнейшем развитии функции она может стать плохо читаемой.
Вариант 3, «gotoфобский»
Еще один вариант, также часто встречающийся в реальном коде, — использование цикла в качестве конструкции, ограничивающей «зону безошибочного выполнения», и оператора break в качестве аналога goto из предыдущего варианта.
int function(...) { // ...здесь объявления переменных... do { if (get_res1(&res1)) { ret = ERROR1; break; } if (get_res2(&res2)) { ret = ERROR2; break; } // ... if (get_res25(&res25)) { ret = ERROR25; break; } // ...какой-то содержательный код... // ...ошибка! if (error) { ret = SOME_ERROR; break; } // ... } while(0); if (ret) ret = handle_error(ret, ...); if (res1_allocated(&res1)) release_res1(&res1); if (res2_allocated(&res2)) release_res2(&res2); // ... if (res25_allocated(&res25)) release_res25(&res25); return ret; }
Признаться, я долгое время был сторонником такого подхода, пока не поумнел.
Плюсы:
- отсутствует goto;
- слабая связность фрагментов функции, хорошо поддается рефакторингу;
- одна точка выхода.
Минусы:
- требуется отслеживать состояние ресурсов с помощью флагов, некорректных значений или чего-то еще;
- проблемы с выходом из вложенных циклов, приходится или многократно проверять код ошибки, или использовать goto;
- средняя эффективность.
Итого
Конечно, можно придумать еще мульён вариантов, один другого краше. Есть даже отдельная когорта любителей «метапрограммирования» (тыц, тыц). Я такие вещи не понимаю и не одобряю. Если уж есть такая тяга к синтаксису try-catch, то почему бы не пользоваться C++? В подавляющем большинстве случаев нет причины, которая позволяла бы пользоваться C, но исключала возможность использования C++ (хотя у меня именно такой редкий случай).
Чем же пользоваться? Мое мнение такое:
- всегда писать согласно варианту 1;
- если в результате работает слишком медленно, переписать отдельные функции (или даже модули) по варианту 2.
Жду гневные, но конструктивные комментарии.


А как же SEH под винду?
Эмм… SEH на C? И потом, портабельность…
Если использование С – требование, то выбора не так много. Если же C++ использовать, то проще сделать классы, которые в конструкторах захватывают ресурс и в деструкторе освобождают. И тогда нет никаких проблем больше с освобождением – в любой момент вызываешь return и все освобождается само.
А в С все печально, да.
Конечно, в C++ масса вариантов, включая шибко умные указатели и прочую нечисть. Но, увы, в моем случае компилятора C++ для целевой платформы просто не существует.
Какой то gotoфобский вариант излишне изощеренный, в C-ях для этой цели преврасно реализован switch, позволяющий почти буквально повторить идею goto подхода
int function(...) {
// ...здесь объявления переменных...
do {
if (get_res1(&res1)) {
ret = ERROR1;
break;
}
if (get_res2(&res2)) {
ret = ERROR2;
break;
}
// ...
if (get_res25(&res25)) {
ret = ERROR25;
break;
}
// ...какой-то содержательный код...
// ...ошибка!
if (error) {
ret = SOME_ERROR;
break;
}
// ...
} while(0);
switch (ret){
deafault:
release_res25(&res25);
case ERROR25:
release_res24(&res24);
...
case ERROR1:
return ret;
}
}
Да, обычно fallthrough-switch может заменить макаронину из if-ов. Но что если переменная ret служит для индикации ошибки, а не для указания того, сколько ресурсов было получено? Выходит, что в вашем коде по ret можно только понять, где произошла ошибка, но не какая это была ошибка.