Zig вместо C: пишем высокопроизводительный CLI-инструмент и заменяем 3000 строк C-кода

Всё началось на второй паре по системному программированию. Нам дали задачу: написать CLI-утилиту для анализа логов - парсить файл, фильтровать записи по уровню ошибок, считать статистику, выводить красиво в консоль. "Ну понятно", - открыл я vim и началось мое долгое приключение...

Неделя. Две. Утилита называлась logz, она умела читать логи nginx и apache, фильтровать по уровню (DEBUG, INFO, WARN, ERROR), по дате, по IP, выводила топ адресов с наибольшим числом ошибок, рисовала простенький bar-chart прямо в терминале через unicode-символы. Только вот я сидел как-то вечером, запустил wc -l main.c - 3147 строк. И смотрел на это число минуты три с таким лицом - O_O.

Сама утилита работала. Но открывая её осознаешь что - это месиво. Одна функция process_file на 400 строк. Сегфолты раз в неделю. Valgrind как лучший друг. И каждый раз когда надо добавить фичу - сначала полчаса вспоминаешь что вообще происходит в коде.

Потом я случайно прочитал пост про Zig на lobste.rs. Заинтересовался и попробовал. Через месяц у меня была та же утилита, но теперь на 1089 строках, которая работала быстрее и не падала.

Тут я понял что вот золотая жила и расскажу о том - зачем Zig, как переписывал, где облажался и что вышло в итоге.

Постановка проблемы: что конкретно бесило в C

Я не скажу что "C - это плохой язык", потому что это чушь. Но конкретно мои боли в этом проекте:

Память вручную. Каждый malloc - это обязательство. Написал функцию, она вернула строку - не забудь освободить. Добавил ранний return по ошибке - не забудь освободить до него. Я честно раза три ловил утечки памяти в logz. Valgrind спасал, но ты не запускаешь valgrind - ты просто пушишь.

Сразу скажу что сегфолты - это когда прога пытается залезть в память к которой ей нельзя.

Сегфолты. Это отдельная история. Утилита читала файлы гигабайтами, и где-то в середине - Segmentation fault (core dumped). Дебажишь час, находишь: буфер на 256 байт, а строка лога оказалась 300. Классика. Починил - появился другой: off-by-one в парсере дат. Я реально начал видеть сегфолты во сне.

Bil-система. Мой Makefile работал, пока я его помнил. Через месяц открываешь - что это вообще такое? Плюс make иногда говорит «up to date» хотя ты только что изменил хедер. Да, знаю про -MMD, но это ещё один уровень вещей которые надо держать в голове.

Объём кода. Элементарный парсер строки вида 2026-01-17 ERROR [nginx] 192.168.1.100 500 на C - это функция строк на 50. Потому что: strtok или ручной парсинг, проверки NULL на каждом шагу, strncpy с ограничением длины, освобождение временных буферов... Это не сложно, но это много кода ради простой вещи.

Обработка ошибок. Функции возвращают -1 или NULL при ошибке, смысл ошибки - в errno или нигде. Пишешь цепочку if (result < 0) { perror("..."); return -1; } и либо ты теряешь контекст, либо таскаешь его через все уровни вручную. 3000 строк такого кода - это уже нечитабельно.

Почему Zig, а не Rust?

Rust читал месяц. Крутой, но borrow checker для утилиты которая читает файл и пишет в stdout - избыточен. Хотел C по духу, но без боли.

Zig зашёл за:

  • Нет hidden control flow - defer file.close() прямо в коде, ничего за кулисами
  • Ошибки через типы - !T значит "либо значение, либо ошибка", компилятор не даст проигнорировать
  • Кросс-компиляция из коробки - zig build -Dtarget=x86_64-windows без тулчейнов
  • Маленький проект собирается за секунду

Кратко: C - это контроль ценой боли, Rust - это безопасность ценой сложности, Zig - это C, только с нормальными инструментами.

Архитектура

logz - CLI для анализа access.log / error.log: logz --file access.log --level ERROR --from 2026-01-17 --top-ip 10

Pipeline из пяти модулей, данные текут строго вниз:

Zig вместо C: пишем высокопроизводительный CLI-инструмент и заменяем 3000 строк C-кода

В C у меня было иначе: одна функция process_file делала всё сразу. Добавить новый формат лога = переписывать её всю

1.Парсинг аргументов

С +- 80 строк было

// args C int parse_args(int argc, char **argv, Config *cfg) { for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--file") == 0) { if (i + 1 >= argc) { fprintf(stderr, "error: --file requires a value\n"); return -1; } cfg->filename = argv[++i]; } else if (strcmp(argv[i], "--level") == 0) { if (i + 1 >= argc) return -1; cfg->level = parse_level(argv[++i]); if (cfg->level < 0) { fprintf(stderr, "error: unknown level\n"); return -1; } } else if (strcmp(argv[i], "--top-ip") == 0) { if (i + 1 >= argc) return -1; cfg->top_ip = atoi(argv[++i]); if (cfg->top_ip <= 0) return -1; } // еще параметры } return 0; }

Zig-версия - стало +- 40 строк

// args.zig const Config = struct { filename: []const u8 = "", level: ?LogLevel = null, top_ip: u32 = 10, from_date: ?i64 = null, }; fn parseArgs(allocator: std.mem.Allocator) !Config { var args = try std.process.argsWithAllocator(allocator); defer args.deinit(); var cfg = Config{}; _ = args.next(); // пропускаю имя программы while (args.next()) |arg| { if (std.mem.eql(u8, arg, "--file")) { cfg.filename = args.next() orelse return error.MissingValue; } else if (std.mem.eql(u8, arg, "--level")) { const lvl = args.next() orelse return error.MissingValue; cfg.level = try LogLevel.parse(lvl); } else if (std.mem.eql(u8, arg, "--top-ip")) { const n = args.next() orelse return error.MissingValue; cfg.top_ip = try std.fmt.parseInt(u32, n, 10); } } return cfg; }

orelse return error.MissingValue - одна строка вместо трёх проверок. Ошибка явная, не -1.

2.Парсинг строки лога

С-версия +-65 строк

// parser.С LogEntry *parse_line(char *line) { LogEntry *entry = malloc(sizeof(LogEntry)); if (!entry) return NULL; char *saveptr; char *token = strtok_r(line, " ", &saveptr); if (!token) { free(entry); return NULL; } if (parse_timestamp(token, &entry->timestamp) != 0) { free(entry); return NULL; } token = strtok_r(NULL, " ", &saveptr); if (!token) { free(entry); return NULL; } entry->level = level_from_string(token); if (entry->level == LOG_LEVEL_UNKNOWN) { free(entry); return NULL; } // ещё 40 строк в том же духе return entry; } // Важно: вызывающий обязан вызвать free(entry) потом

Zig-версия +- 35-40 строк

// parser.zig const LogEntry = struct { timestamp: i64, level: LogLevel, source: []const u8, ip: []const u8, status: u16, }; fn parseLine(line: []const u8) !LogEntry { var iter = std.mem.splitScalar(u8, line, ' '); const ts_str = iter.next() orelse return error.InvalidFormat; const timestamp = try parseTimestamp(ts_str); const level_str = iter.next() orelse return error.InvalidFormat; const level = try LogLevel.parse(level_str); const source = iter.next() orelse return error.InvalidFormat; const ip = iter.next() orelse return error.InvalidFormat; _ = iter.next(); // пропускаю HTTP-метод и путь const status_str = iter.next() orelse return error.InvalidFormat; const status = try std.fmt.parseInt(u16, status_str, 10); return LogEntry{ .timestamp = timestamp, .level = level, .source = source, .ip = ip, .status = status, }; }

Чем лучше: нет malloc, нет free, нет strtok с его глобальным состоянием. Функция либо возвращает валидный LogEntry, либо ошибку - и это написано прямо в сигнатуре !LogEntry. Вызывающий не может случайно забыть освободить память.

3.Работа с файлами и defer

// main.zig pub fn processFile( allocator: std.mem.Allocator, path: []const u8, cfg: Config, ) !Stats { const file = try std.fs.cwd().openFile(path, .{}); defer file.close(); // закроется при любом выходе из функции var buffered = std.io.bufferedReader(file.reader()); var reader = buffered.reader(); var stats = Stats.init(allocator); defer stats.deinit(); var line_buf: [8192]u8 = undefined; while (try reader.readUntilDelimiterOrEof(&line_buf, '\n')) |line| { const entry = parseLine(line) catch continue; // битые строки просто пропускаем if (cfg.matches(entry)) { try stats.add(entry); } } return stats; }

В C мне надо было писать fclose(file) перед каждым return, а их у меня было четыре разных в этой функции. Каждый раз думать: "я закрыл файл?". С defer эта задача исчезла.

Бенчмарки

Тестовый файл: 5 млн строк, +-800 МБ.

Zig вместо C: пишем высокопроизводительный CLI-инструмент и заменяем 3000 строк C-кода

Zig быстрее на треть - просто потому что я сразу написал нормальный bufferedReader, а в C у меня был fgets без буферизации. Строк кода в три раза меньше - это главный результат.

"Грабли"

Allocators непривычны. Поначалу лепил везде page_allocator. Потом открыл ArenaAllocator - для CLI-утилиты идеально: выделяешь сколько хочешь, в конце arena.deinit() и всё сразу освобождается.

Строки - слайсы, не char*. []const u8 не имеет нуля в конце. Передаёшь в C-функцию напрямую - UB. Надо .ptr и убедиться что строка null-terminated.

Ошибки компилятора длинные. Сообщение на 20 строк - читай только первую, остальное трассировка. Уже привыкаешь.

Язык меняется. Zig пока не 1, я на 0.13. Примеры из интернета 2022 - 23 года часто не компилируются - всегда смотри официальную документацию.

Выводы по Zig

Стоит ли переходить на Zig в 2026?

Естественно да, если: пишешь CLI-утилиты, системные инструменты, что-то встраиваемое. Нужен контроль над памятью, но без сегфолтов каждую неделю. Хочешь кросс-компиляцию без боли. Устал от Makefile.

Лучше остаться на C, если: работаешь с большой существующей C-кодовой базой и интеграция не нужна. Или нужна максимальная стабильность языка - пока не 1, API может меняться между версиями.

Лучше взять Rust, если: проект большой и долгоживущий, важна экосистема (cargo + crates.io несравнимо богаче), работаешь в команде где Rust уже знают.

Для меня Zig занял нишу: "хочу писать низкоуровневый код без страданий". Три месяца назад я смотрел на 3000+ строк C и думал - ну это всё. Сейчас у меня 1089 строк Zig, утилита работает быстрее, и я понимаю каждую строку кода.

Если хочешь попробовать - ziglang.org. Начни с zig init, напиши hello world, потом попробуй открыть и построчно прочитать файл. Язык небольшой, стандартная библиотека читается за вечер.

Вот и все, напишите в комментах если было интересно.Буду рад если кому-нибудь это поможет и он будет пользоваться напостоянке.

1
Начать дискуссию