Перейти к основному содержимому

Идиомы кода и обработка ошибок

Разработчику Архитектору

Идиомы кода и обработка ошибок

В С нет исключений (try/catch) и сборщика мусора. Если fopen не открыл файл или malloc вернул NULL, программа продолжает выполнение — автор должен явно проверить результат и решить: повторить, вернуть код ошибки вызывающему, записать в лог.

Идиома — устоявшийся приём в сообществе: «так принято писать на С», даже если язык допускает и другие варианты.

Надёжный код строится на явных контрактах: кто владеет памятью, кто закрывает файл, что означает код возврата. Ниже — практики, которые повторяются в ядрах ОС, в libc и в зрелых библиотеках (в том числе в коде, написанном в духе SQLite или nginx).

Мини-словарь

ТерминСмысл
Код возвратаint из функции: 0 — успех, иначе ошибка
errnoглобальная переменная с кодом последней системной ошибки (POSIX)
Владение (ownership)кто обязан вызвать free / fclose
Инвариантусловие, которое всегда истинно, если функция вызвана правильно
UB (undefined behavior)поведение стандарт не описывает; программа может упасть или «тихо» испортить данные

Одна функция — одна задача

Функция должна делать одно логическое действие: «открыть конфиг», «разобрать строку», «записать буфер в сокет». Смешивание парсинга, сети и логирования в одном теле затрудняет тестирование и обработку ошибок.

Хороший уровень детализации:

int load_config(const char *path, Config *out);

Плохой — функция на двести строк, которая и читает файл, и валидирует JSON-подобный синтаксис, и заполняет глобальные переменные.


Владение ресурсом (ownership)

Для каждого ресурса (память, дескриптор файла, сокет) зафиксируйте правило:

РесурсКто выделяетКто освобождает
malloc внутри модулямодультот же модуль (free) или явная функция module_destroy
буфер, переданный «наружу»документировать в заголовкевызывающий или пара create/destroy
FILE *кто вызвал fopenтот же код вызывает fclose

Правило: на каждый успешный путь выделения должен существовать ровно один путь освобождения. Дублирующий free или пропущенный fclose — классические дефекты.


Коды возврата и errno

Распространённые схемы:

  1. 0 — успех, отрицательное или ненулевое — ошибка (стиль POSIX).
  2. Указатель: NULL — ошибка, иначе валидный объект (malloc, fopen в части API).
  3. Булев стиль: true/false с деталями в errno после сбоя системного вызова.

После функций вроде open, read, strtol при ошибке смотрят errno (заголовок <errno.h>). Важно: errno актуален сразу после неудачного вызова; другие функции могут его перезаписать.

FILE *f = fopen(path, "r");
if (f == NULL) {
fprintf(stderr, "cannot open %s: %s\n", path, strerror(errno));
return -1;
}

Для библиотечного API предпочтительнее не полагаться на глобальный errno в публичных функциях, а возвращать собственный код или структуру статуса — так проще использовать библиотеку из нескольких потоков и из встраиваемых сред без полной POSIX-обвязки.

Разбор strerror(errno):

После неудачного fopen в errno лежит число (например, «файл не найден»). strerror переводит его в человекочитаемую строку для лога. Сохраняйте значение в локальную переменную сразу после сбоя, если между ошибкой и strerror будут другие вызовы.

errno_t saved = errno;
fprintf(stderr, "error %d: %s\n", saved, strerror(saved));

Выходные параметры вместо «двух возвратов»

С не умеет возвращать пару «значение + ошибка» как в Rust. Идиомы:

/* код ошибки; результат через указатель */
int parse_int(const char *s, int *out);

/* bool-стиль */
bool queue_pop(Queue *q, Item *out);

Вызывающий обязан проверять код возврата до использования *out.

Пример использования:

int value;
if (parse_int("42", &value) != 0) {
fprintf(stderr, "bad number\n");
return 1;
}
printf("parsed %d\n", value); /* безопасно только после проверки */

Цепочка очистки с goto

При нескольких ресурсах подряд вложенные if с дублированием free/fclose быстро становятся нечитаемыми. В ядре Linux и в руководствах по стилю С часто используют одну метку очистки:

int process_file(const char *path)
{
int ret = -1;
FILE *f = NULL;
char *buf = NULL;

f = fopen(path, "r");
if (f == NULL)
goto cleanup;

buf = malloc(4096);
if (buf == NULL)
goto cleanup;

/* ... работа ... */
ret = 0;

cleanup:
free(buf);
if (f != NULL)
fclose(f);
return ret;
}

goto здесь — не «прыжок в прошлое», а структурированный выход с единой точкой освобождения. Альтернатива — макросы-обёртки или отдельные функции-обёртки для каждого шага; для среднего размера функций метка cleanup остаётся самой прозрачной.

Порядок в cleanup: освобождать в обратном порядке создания (сначала buf, потом f).


assert и проверки в рантайме

assert(условие) из <assert.h> прерывает программу в отладочной сборке, если условие ложно. Подходит для нарушений, которые «не должны случиться» при корректном использовании API внутри команды (инварианты, NULL там, где контракт запрещает).

Не заменяет проверку внешних данных:

/* плохо: assert на пользовательский ввод */
assert(strlen(user_input) < 100);

/* хорошо: явная проверка с кодом ошибки */
if (strlen(user_input) >= 100)
return ERR_TOO_LONG;

В релизной сборке с NDEBUG утверждения вырезаются — на них нельзя опираться для безопасности.


Инварианты и ранний выход

Проверяйте предусловия в начале функции и выходите сразу:

int send_packet(Socket *s, const void *data, size_t len)
{
if (s == NULL || data == NULL || len == 0)
return -1;
/* основная логика */
}

Так уменьшается вложенность и проще читать «счастливый путь» внизу функции.


Неизменяемые входные данные

Помечайте указатели на данные, которые функция не меняет, как const:

size_t write_log(FILE *out, const char *message);

Это документирует намерение и помогает компилятору ловить случайные записи.


Модули — скрытое состояние

Глобальные изменяемые переменные усложняют тесты и многопоточность. Паттерн непрозрачный указатель (typedef struct Database Database; в заголовке, определение структуры только в .c) сужает API и держит состояние в одном месте.

Публичный заголовок объявляет операции; детали реализации и все free остаются внутри единицы компиляции — см. Архитектура программ на С.


Чего избегать

  • Игнорирование кодов возврата (malloc без проверки на NULL).
  • «Утечка» при раннем return без освобождения (лечится goto cleanup или обёртками).
  • Смешение уровней абстракции: печать в stderr внутри низкоуровневой функции библиотеки — лучше возвращать код, а логировать наверху.
  • setjmp/longjmp для обычной обработки ошибок — оставить для специализированных парсеров и совместимости; для прикладного кода достаточно кодов возврата.

См. также: Память процесса, Многопоточность на С, Справочник.


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).