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

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

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

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

В С нет исключений (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);

Разбор:

  • int в начале сигнатуры задаёт код возврата функции: по соглашению 0 обычно означает успех, ненулевое значение сигнализирует ошибку.
  • const char *path передаёт путь к конфигу только для чтения; const запрещает случайно менять исходную строку внутри функции.
  • Config *out — выходной параметр: функция заполняет структуру по адресу, а вызывающий код получает готовый результат.
  • Такой контракт разделяет ответственность: результат передаётся через 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;
}

Разбор:

  • fopen(path, "r") открывает файл в режиме чтения и возвращает FILE *; при неуспехе возвращается NULL.
  • Проверка if (f == NULL) отделяет аварийный путь сразу после системного вызова, чтобы не работать с невалидным дескриптором.
  • fprintf(stderr, ...) пишет ошибку в поток stderr, поэтому сообщение не смешивается с обычным выводом программы.
  • strerror(errno) переводит системный код ошибки в читаемый текст, который удобно логировать и анализировать.

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

Разбор strerror(errno):

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

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

Разбор:

  • Локальная переменная saved фиксирует текущее значение errno сразу после сбоя, пока его не перезаписали другие вызовы.
  • Такой подход делает лог стабильным: в сообщение попадёт именно исходная причина ошибки, а не случайный код из позднего вызова.
  • strerror(saved) использует сохранённый код и формирует корректную человекочитаемую диагностику.

Полный цикл работы с потоками (открытие, построчное и блочное чтение, fscanf, копирование файла, fflush) — в файловом ввод-выводе. Там же — метки времени в логах и примеры perror после fopen.


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

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

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

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

Разбор:

  • В parse_int строка s является входом, а out служит каналом для результата; это типичный способ вернуть "значение + статус" в C.
  • Возвращаемый int используется как код выполнения: вызывающий код обязан проверить его до чтения *out.
  • bool queue_pop(...) показывает второй распространённый стиль: true при успешном извлечении элемента и false, когда очередь пуста или операция недопустима.
  • В обоих вариантах семантика одинакова: выходные данные валидны только после успешного статуса.

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

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

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

Разбор:

  • &value передаёт адрес переменной, чтобы функция могла записать результат непосредственно в память вызывающего кода.
  • Условие parse_int(...) != 0 реализует ранний выход при ошибке и не даёт использовать неинициализированные данные.
  • Печать value идёт только после проверки статуса, поэтому поток выполнения остаётся безопасным и предсказуемым.

Минимальная реализация parse_int на strtol:

Код ITЗагрузка примера кода…

Разбор:

  • strtol разбирает строку в long и через end показывает, где парсинг остановился.
  • Проверка end == s отсекает пустую строку и строки без цифр в начале.
  • Условие *end != '\0' гарантирует, что вся строка — одно число, без хвоста вроде "42abc".
  • Сравнение с INT_MIN/INT_MAX защищает от переполнения перед записью в int.

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

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

Код ITЗагрузка примера кода…

Разбор:

  • Переменные FILE *f и char *buf инициализируются NULL; это позволяет безопасно вызывать free/fclose даже при частичной инициализации.
  • Каждый потенциально аварийный шаг (fopen, malloc) делает переход в cleanup, чтобы не дублировать код освобождения ресурсов.
  • Метка cleanup реализует единый "эпилог" функции: освобождение памяти, закрытие файла и возврат итогового статуса.
  • ret = 0 ставится только после успешного прохождения рабочей части, поэтому возвращаемое значение точно отражает исход операции.

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;

Разбор:

  • assert(...) проверяет внутренние инварианты и предназначен для отладки, а не для обработки внешнего пользовательского ввода.
  • Проверка через if (...) return ERR_TOO_LONG; работает и в релизной сборке, поэтому сохраняет защиту программы в продакшене.
  • Смысл разделения простой: assert ловит ошибки разработчика, обычная проверка обрабатывает реальные пользовательские сценарии.

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


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

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

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

Разбор:

  • Условие в начале функции проверяет базовые предусловия: валидный сокет, валидный буфер и ненулевая длина.
  • const void *data показывает, что отправляемые данные не модифицируются функцией, и это фиксирует контракт API.
  • Ранний return -1 отсекает невалидные вызовы до основной логики и уменьшает глубину вложенности в теле функции.

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


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

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

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

Разбор:

  • FILE *out задаёт абстракцию потока вывода, поэтому функция может писать и в файл, и в stdout, и в stderr.
  • const char *message фиксирует неизменяемость входной строки и запрещает побочные записи в исходный буфер.
  • Возвращаемый size_t обычно трактуется как количество записанных байт/символов, что удобно для проверки полноты операции.

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


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

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

/* db.h — публичный контракт */
typedef struct Database Database;

Database *db_open(const char *path);
int db_set(Database *db, const char *key, const char *value);
void db_close(Database *db);

Код ITЗагрузка примера кода…

Разбор:

  • В заголовке виден только неполный тип Database, поэтому клиент не может обратиться к полям напрямую.
  • Полное определение struct Database живёт в .c и может меняться без перекомпиляции всех модулей.
  • db_close централизует освобождение ресурсов, поэтому вызывающий код не дублирует fclose/free.

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


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

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

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