Обработка ошибок и освобождение ресурсов на C

Некая усредненная функция обычно занимается чем-то таким:

  1. Получает/захватывает/выделяет необходимые для работы ресурсы.
  2. Выполняет содержательные действия.
  3. Освобождает ресурсы.

На каждом из предыдущих этапов вдобавок должно выполняться обнаружение и обработка ошибок. И вот здесь-то и начинается самое интересное. Казалось бы, нет ничего сложного. Ну, обнаружили ошибку. Ну, обработали. В чем проблема-то? А проблема в деталях. Могут иметь место такие, например, случаи:

  1. Функция получает не один ресурс, а N. Соответственно, все полученные ресурсы нужно освободить. А если при получении i-го ресурса произошла ошибка, то нужно освободить только ресурсы 0…i-1.
  2. Если в содержательных действиях происходит ошибка, то перед освобождением ресурсов может понадобиться эту ошибку как-то обработать. Хорошо если такая ошибка может возникнуть только в одном месте — прямо там и обработаем. А что если в разных местах могут возникнуть ошибки, требующие одинаковой обработки? Еще после возникновения ошибки надо как-то покинуть содержательный код.
  3. Если ошибка происходит в цикле со вложенностью 2 и более, то надо как-то все эти циклы покинуть.
  4. Будем предполагать, что обработка ошибки не ограничивается возвратом кода ошибки, а включает какую-то более сложную обработку: запись в лог, выдача сообщений и т.п.

А еще мы хотим, чтобы наш код нормально читался, быстро работал и был пригоден для дальнейшего расширения. Такие вот мы противоречивые. Что делать будем? Есть несколько вариантов.

Вариант 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 ... Loading ...

6 комментариев

  • А как же SEH под винду?

  • Oleg:

    Если использование С – требование, то выбора не так много. Если же C++ использовать, то проще сделать классы, которые в конструкторах захватывают ресурс и в деструкторе освобождают. И тогда нет никаких проблем больше с освобождением – в любой момент вызываешь return и все освобождается само.
    А в С все печально, да.

    • Конечно, в C++ масса вариантов, включая шибко умные указатели и прочую нечисть. Но, увы, в моем случае компилятора C++ для целевой платформы просто не существует.

  • Dron:

    Какой то 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 можно только понять, где произошла ошибка, но не какая это была ошибка.

Оставьте свой отзыв

Если введен идентификатор OpenID, можно не задавать имя и почту (но имя лучше все же задать).

XHTML: Можно использовать следующие теги: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong> <pre lang="" line="" escaped="">

Это не спам, честное слово