Некая усредненная функция обычно занимается чем-то таким:
- Получает/захватывает/выделяет необходимые для работы ресурсы.
- Выполняет содержательные действия.
- Освобождает ресурсы.
На каждом из предыдущих этапов вдобавок должно выполняться обнаружение и обработка ошибок. И вот здесь-то и начинается самое интересное. Казалось бы, нет ничего сложного. Ну, обнаружили ошибку. Ну, обработали. В чем проблема-то? А проблема в деталях. Могут иметь место такие, например, случаи:
- Функция получает не один ресурс, а 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.
Жду гневные, но конструктивные комментарии.




(Еще не оценили)
Loading ...