Всё началось на второй паре по системному программированию. Нам дали задачу: написать 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 из пяти модулей, данные текут строго вниз:

В C у меня было иначе: одна функция process_file делала всё сразу. Добавить новый формат лога = переписывать её всю
Код: Zig рядом с C
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 |
Время выполнения | 4.2 сек | 2.8 сек |
Размер бинарника | 47КБ | 32КБ |
Пиковая память | 124МБ | 89МБ |
Время сборки | 3.1 сек | 1.4 сек |
Строк кода | 3147 | 1089 |
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, потом попробуй открыть и построчно прочитать файл. Язык небольшой, стандартная библиотека читается за вечер.
Вот и все, напишите в комментах если было интересно.Буду рад если кому-нибудь это поможет и он будет пользоваться напостоянке.
