Идиомы кода и обработка ошибок
Идиомы кода и обработка ошибок
В С нет исключений (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
Распространённые схемы:
0— успех, отрицательное или ненулевое — ошибка (стиль POSIX).- Указатель —
NULL— ошибка, иначе валидный объект (malloc,fopenв части API). - Булев стиль:
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для обычной обработки ошибок — оставить для специализированных парсеров и совместимости; для прикладного кода достаточно кодов возврата.
См. также: Память процесса, Многопоточность на С, Справочник.