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

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