Обновить

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

Уровень сложностиСредний
Время на прочтение6 мин
Охват и читатели9.9K
Всего голосов 26: ↑23 и ↓3+23
Комментарии57

Комментарии 57

Хм.. Как будто для такой задачи go может еще лучше подойти, что думаете?

Haskell же!

Тоже варианты (D, Go, Rust)

Еще есть c# aot, который специально для этого. И вполне эффективный Nim, если нра Питон - синтаксис.

Неплохая статья

  1. Удивлён, что версия на Zig получилась короче - он более многословный.

  2. Для парсинга аргументов на С стоило тоже взять готовую библиотеку. Сравнение было бы корректнее

  3. На С тоже элементарно делается defer макросами

Но все же Zig это язык уже нового поколения.

И вообще - это перевод? Потому что актуальный Zig 0.16

Почему для С ТОЖЕ стоило взять готовую библиотеку? Вроде в zig все решено с помощью std. И где вы видели актуальный Zig 0.16? На официальном сайте указана версия 0.15.2

Чтобы сравнивать одинаковые подходы в длину кода.

В даунлоадз и у себя на компе 0.16

Я вот на 0.14 сижу и доволен ;) Ну либо автор написал код какое-то время назад, а статью только сейчас.

К сожалению до релиза 1.0 далеко, когда выйдет уже и не нужен будет наверное.

Так ему уже 10 лет, они его полируют, ломают (-ли) совместимость, чтобы к релизу выдать что-то готовое... Не уверен насчёт достоверности, но, вроде, в этом году выйдет стабильный релиз v1.0

LLM выдала со ссылкой на Telegram-канал: "По некоторым прогнозам, версия 1.0 языка программирования Zig выйдет не раньше 2029 года".

Я читал, что автор - перфекционист и стремится создать надёжный инструмент. Однако процесс версионирования может затянуться на следующие 10 лет. За это время многое может измениться в разработке ПО, учитывая быстрый прогресс LLM.

Если у вас есть точная информация о дате выхода версии 1 из roadmap, буду признателен.

Язык С не прибит гвоздями к Makefile, там уже много разных альтернатив придумали.

И мне интересно, почему zig , а не c++?

Чтобы не страдать с Makefile, cmake, automake, premake и прочими сборочными извращениями, которые современные языки решают обычной сабкомандой init. Уж лучше какой-нибудь cppfront взять.

Ох уж эти страдатели, которые маке не осилили)

Даже и пытаться осиливать не стали, разу взяли Shake ;)

Вот его-то как раз проще всего освоить. В сравнении с Zig, который требует только Zig для контроля зависимостей это определённо жирный минус. А уж если один пишет на cmake, второй на autotools, а третий либу собирает через conan веселье умножается пропорционально и можно шутить что неосиливатель маке не осилил ещё каких-то три-четыре сборочных системы. Это ж куда важнее, чем просто писать прогамму на языке.

Кстати, попадаются проекты на С, которые используют Zig чисто как систему сборки.

Он ещё и кэширует (в принципе, так же как и маке) сборку автоматически.

А вот с подгрузкой сторонних пакетов лично не пробовал, но вроде возможности есть.

Так что в плюс. Но каких то особенных прорывов нет.

 используют Zig чисто как систему сборки.

Это как раз один из их способов саморекламы. Многие хвалят компилятор Си у zig за понятные ошибки да и вообще за удобную интероперабельность с Си. Похожую поддержку встречал только у D. Одно время build.zig пытались контрибутить во многие популярные репы на си. Кто-то принял, кто-то послал на три буквы, чтобы не заниматься поддержкой ещё одного языка в проекте. Слышал, что некоторые сишные проекты решили использовать целиком инфраструктуру zig для сборки кода, но живьем не встречал.

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

Так ведь если утилита в одном исходном файле, то borrow checker вы даже не заметите.

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

Множественный return вообще не очень хорошая практика. Корректнее использовать единую точку выхода.

К сожалению, в языках где нет возможности определять блок

on-exit;
  // выполняем все необходимые перед выходом действия
  ...
  return;

куда передается управление поде каждого return в функции, или хотя бы поддержки сабрутин (аналог подпрограммы в бейсике - отдельный блок кода внутри функции, вызываемый без создания нового уровня стека и не имеющий собственных аргументов и локальных переменных), позволяющий организовать точку выхода

begsr srExit;
  // выполняем все необходимые перед выходом действия
  ...
  return;
endsr;

и позволяющий вместо

if (...) return;

использовать

if (...) exsr srExit;

использование единой точки выхода может потребовать определенной организации кода для избежания многоуровневых if !error.

 Функции возвращают -1 или NULL при ошибке, смысл ошибки - в errno или нигде.

Это, мягко говоря, не так. Есть понятие "структурированной ошибки". В простейшем виде выглядит вот так:

 typedef struct Qus_EC
    {
       int  Bytes_Provided;
       int  Bytes_Available;
       char Exception_Id[7];
       char Reserved;
       char Exception_Data[];           /* Varying length        */
    } Qus_EC_t; 

Передается аргументом в функцию. На выходе достатчоно проверить

if (Qus_EC.Bytes_Available) {
  // функция заполнила ошибку
  ...
};

Bytes_Provided тут - размер структуры (определяется размером блока дополнительных данных Exception_Data) ; Bytes_Available = 0 - ошибки не было, Bytes_Available > 0 - ошибка была; Exception_Id - код ошибки; Exception_Data - блок дополнительных данных произвольного размера, связанных с конкретной ошибкой.

Остальное тоже весьма спорно. Например

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

А зачем было делать именно так? Почему не разбить эту функцию на несколько более мелких? Например, "диспетчер", определяющий формат лога + набор парсеров под разные форматы? Новый формат - пишем парсер и добавляем его в таблицу парсеров диспетчера.

Такое ощущение, что кинулись писать на С особо не продумав что и как, поплясали с бубном на граблях и потом уже, получив понимание что и как, переписали все на Zig. И 90% "бонусов" не от того, что Zig лучше, а от пришедшего понимания тонкостей задачи.

Это всё круто, но как ты ошибки не возвращай, всегда будет одна проблема - компилятор не скажет, где ты забыл обработать ошибку. Один раз не проверил что вернула функция, у тебя с большой вероятностью сегфолт, который ещё попробуй исправь. zig такого не позволяет. Хотя, на самом деле, zig тоже не очень безопасный в этом плане. Забыл освободить ресурс через defer и прога падает или память утекает, но в нём хотя бы есть DebugAllocator и стек вызовов, что позволяет ошибки отловить достаточно рано. То есть на корню все проблемы системного программирования zig не решает, но он однозначно делает многие вещи лучше C.

Ну я не спорю. Писать на С требует тренировки мозга - многое приходится в голове держать. Это только годами практики нарабатывается до автоматизма.

В этом плане Rust интереснее, хотя и требует привыкания к заложенным в нем концепциям.

А структурированная ошибка - это целая концепция на уровне системы.

Есть т.н. message file - таблица где содержится код ошибки, ее текстовка (с возможностью подстановки параметров), уровень серьезности (например, 0 - информация, 10 - предупреждение, 20 - критическая). Есть системное API куда передается вот эта структура, содержащая код и значения параметров для подстановки. API по коду находит в message file данную ошибку, берет ее текстовку, подставляет параметры и возвращает полный текст и уровень серьезности.

Разрабатывая сое ПО вы всегда можете расширять message file добавляя туда характерные для вашего ПО ошибки со своими кодами. Функция в общем случае возвращает получилось/не получилось. Если не получилось, вы можете или анализировать ошибку по коду, или же дернуть API и получить полный текст (уже с подставленными параметрами) и дальше выводить его на экран, в лог или еще куда вам нужно.

Плюс эта же ошибка может пробрасываться в виде исключения (языкового или системного, если таковые поддерживаются).

Как-то так по итогу оно и вываливается во втрое больший код, потому что сначала нужно написать систему, чтобы было хорошо - то арену добавь, то работу с массивами скопипасти, то обработку ошибок допиши...

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

Ну или если себе строишь набор рабочих инструментов на долгое время.

И в любом серьезном проекте работа с ошибками занимает достаточно большую часть. Если не делать как современные вебклепатели - на все одна ошибка "что-то пошло не так, попробуйте как-нибудь потом"

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

Из чего следует необходимость учить ещё один какой-нибудь язык для того чтобы собирать код на си. Можно конечно использовать какой-нибудь nob, чтобы иметь только компилятор в зависимостях, но там свои квирки и segafult приветы будут.

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

Я вот пишу как минимум на двух (то, что в прод идет). Ну и Rust немного для себя... Правда, языки у меня сильно разные. Один хорош для работы с БД и всяких коммерческих расчетов (это основной), но если что-то более низкоуровневое, то там С/С++ лучше.

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

Одна точка выхода:

if (error1) goto one_exit;
...
if (error2) goto one_exit;
...
if (error3) goto one_exit;
...
one_exit:
...release actions...

Если кому то режет глаз goto, то:

while (TRUE) {
  if (error1) break;
  ...
  if (error2) break;
  ...
  break;
  }
...release actions...

Есть еще вариант с исключениями, но лично я их использование считаю дурным тоном.

Есть еще вариант с исключениями, но лично я их использование считаю дурным тоном.

Да, согласен. Исключения штука ресурсоемкая. В высоконагруженных системах использование их стоит свести к минимуму. Только в случае крайне необходимости.

Когда условий много делал примерно так:

for (stage=0; stage < MAX_STAGE; stage++) {
  switch (stage) {
    case 1:
      ... блок операций где может возникнуть прерывающая ошибка ...
      break;

    case 2:
      ... блок операций где может возникнуть прерывающая ошибка ...
      break;

    ...
  }

  if (error) break;
}

Чуть хуже с точки зрения производительности, но лучше с точки зрения читаемости. Плюс то, что находится в блоках case вынести в отдельные функции

Но суть, да, в том чтобы обеспечить единую точку входа и единую точку выхода.

do {
    ... блок операций где может возникнуть прерывающая ошибка ...
    break;

    ... блок операций где может возникнуть прерывающая ошибка ...
    break;
} while (0);

А почему не так?

Потому что вам придется после каждого блока проверять if (error) break;

А здесь на каждом обороте цикла отработает один блок (тот, что идет по соотв. номеру) и после этого проверится ошибка.

Все равно switch/case выглядит более громоздко.
Придется дробить на блоки не по смыслу, а до первой ошибки.

Громоздко когда после каждого блока будете if error... проверять.

А дробление на логические блоки - это совершенно нормальный паттерн. Выполнили блок - проверили ошибку, если удачно - идем дальше

Такие блоки по уму надо вообще в отдельные функции выводить. Чтобы не получить одну функцию на 10 экранов.

И тогда нормально будет если каждая такая функция возвращает успешно/не успешно + ошибку в параметре. Тогда вообще просто:

if (func1(...) && func2(...) && ... && funcN(...)) {
  // успешное выполнение
} else {
  // была ошибка - разбираемся где
}

Ну не знаю, для меня вот это:

for (stage=0; stage < MAX_STAGE; stage++) {
  switch (stage) {
    case 1:
      ... блок операций где может возникнуть прерывающая ошибка ...
      break;

    case 2:
      ... блок операций где может возникнуть прерывающая ошибка ...
      break;

    ...
  }

  if (error) break;
}

выглядит намного более громоздко, чем вот это:

do {
    ... блок операций где может возникнуть прерывающая ошибка ...
    if (error) break;

    ... блок операций где может возникнуть прерывающая ошибка ...
    if (error) break;

   ...
} while (0);

Ну тут дело личных предпочтений. Суть одна.

Наброшу немного за Rust

1)

Ошибки через типы - !T

В Rust еще круче есть - Result<T>: https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html#recoverable-errors-with-result

2)

Кросс-компиляция из коробки - zig build -Dtarget=x86_64-windows без тулчейнов

В Rust: https://crates.io/crates/cargo-zigbuild

3)

Маленький проект собирается за секунду

В Rust - за доли секунд

4)

Rust - это безопасность ценой сложности

Это ложно. Корректный код на Rust в миллиарды раз проще писать, чем на C, Zig и, даже, Go. Проверено

5)

1089 строк на Zig

На Rust вышло бы и того меньше за счет отсутствия defer (это "фичей" назвать трудно, если честно; только в ироничном ключе), функционального подхода и удобнейших инструментов для работы со строками

6)

Ошибки компилятора длинные

В Rust прекрасные ошибки от компилятора, часто содержащие примеры исправлений. На зависть многим языкам

7)

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

Почти в любом случае - нет, железобетонно. Нужен язык, использовать который можно без боли, быстрый, без gc - Rust, без серьезных альтернатив

Рекомендую, если целью является обучиться крутому инструменту production-ready и стать востребованным разработчиком, изучить Rust и/или Go. C - deprecated, Zig - тупик.

ИМХО

PS: вспомнилось мне интервью на youtube-канале ThePrimeTime с Алексеем Кладовым о Zig и его фичах. Зашла речь про то, что Алексею понадобилось быстренько написать небольшой инструмент для работы с большим строковым файлом. Он ответил, что выбрал Rust. Очень показательно )
https://www.youtube.com/watch?v=V8Bg55lTUCw&t=3701s

С Rust есть "всего лишь одна маленькая проблема" - нужно вникнуть в заложенные в нем концепции, принять их, немного перестроить мозги. И после этого работа с ним становится простоя и естественной.

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

На Rust вышло бы и того меньше за счет отсутствия defer

Скорее всего не стало бы меньше а +/- столько же, сколько и у Zig, если писать единым rs. Обработка result наверняка заполнила эти пробелы.

Парсил несколько гигов хмлников на шарпе за 5 секунд, от проца и ио конечно зависит, но чистый си это всегда геморрой конечно. Не знаю кто такой зиг, но если бы дожал на си, было бы быстрее.

Zig тоже LLVM. Так что по компиляции будет паритет

Читаю статью - и мозг постоянно цепляется за нейросетевые паттерны текста. Автор, признайся честно - нейросетка писала статью?

Очевидно, что это вывод LLM. А так как автор студент, то меня преследует смутное сомнение, что это один из тех случаев, когда преподаватель ставит задачу "написать на хабр". Сколько раз так уже было.

Страшная, конечно, смесь - студент и LLM.

пишешь CLI-утилиты, системные инструменты, что-то встраиваемое

Ну не знаю, не знаю, как-то сомнительно если честно. Для перового и второго лучше взять что-то из троицы C++, Rust, Go. Для встраиваемых систем можно использовать Rust если уже пишешь на нём, если же до этого писал на C/C++, смысла переходить особо нет, максимум какую головную боль вылечишь - безопасный код писать можно, но ты не обязан.

И моя личная головная боль - сертификация по EN 50128, где по факту C это стандарт, он просто самый предсказуемый и проверяемый, и сертификация под него дешевле, а Ferrocene пока не дотягивает. Тем более что зачастую есть только компилятор для C99, о существовании C++ разработчики не слышали, и приходится терпеть терпеть, как например для 8-16-разрядных контроллеров. А с учётом жизненного цикла кода в 25 лет, Rust или что-то другое я увижу не скоро.

сертификация по EN 50128

C23 самую большую дырку с массивами закрыл. И с многопоточкой поработали

Он раньше пройдёт все сертификации.

А Раст туда попадет не раньше чем no-panic и nostd стандартизуют

Zig выигрывает у многих для эмбеда за счёт идеологии - никаких скрытых действий (аллокаций, паник, исключений итп) и кросс компиляции из коробки.

А если переписать эту утилиту с zig на Python, получится еще в два раза меньше строк кода.

И время работ с гигабайтными файлами сократится с пары секунд до жалких минут, да.

И производительность упадет раз так в 1000.

Интересный вопрос. Думаю, не в 1000 раз, а меньше.

Куда уж меньше ))
Если серьезно, Python по бенчмаркам в 10 раз медленнее PHP, а это оба интерпретируемые языки, куда им до скомпилированного машинного кода.

Плюсовый иострим может с этим поспорить =)

Ну, в PHP еще с версии 7.4 появился плохонький JIT-компилятор. В версии 8.3 его переписали полностью и включили по умолчанию (раньше нужно флаг выставлять в php.ini), и он прямо хорош, кроме шуток. Основная боль PHP — блокирующий ввод-вывод, т. е. программа на PHP большую часть времени ждет, когда ей данные прилетят или когда придет результат вывода.

Теперь ближе к Zig. Мне тут нужно было распарсить JSON больше 120 Мб, предварительно собрав словарь из 200+ HTML-файлов (тоже парсинг по сути), и всё это положить в БД SQLite.

Так вот, программа на PHP с включением транзакции при добавлении записей в БД отработала за 5.4 секунды. А версия на Zig, написанная из чистого интереса, — 2.7 секунды. Разница для этой задачи вообще не драматическая, при том, что я и на том, и на том языке пишу одинаково хорошо. Однако версия на PHP заняла в разработке меньше часа, а версия на Zig — почти два, просто потому, что многие вещи, которые на Zig приходилось выписывать руками, в PHP встроены в язык. Т. е. вдвое более высокая производительность кода на Zig компенсировалась вдвое большим временем разработки.

Такие дела.

Очевидно, системный язык плохо приспособлен для прикладных задач.

Может и так, хотя Zig позиционируют, как язык общего назначения с уклоном в низкоуровневое программирование. У него, кстати, вчера вышла версия 0.16.0 и в первой строке Release Notes написано: «Zig — это язык программирования общего назначения и набор инструментов для создания надежного, оптимального и многократно используемого программного обеспечения.»

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

Тут надо уточнить, что если сегфолта нет (то есть прога залезла в память, к которой ей можно), это все равно не исключает ошибки. Потому как не факт, что это именно та часть памяти, которая подразумевалась.

  • Нет hidden control flow - defer file.close() прямо в коде, ничего за кулисами

В языках без gc деструктор выглядит безопаснее и понятнее

Язык без GC и объектов. Хм, дайте подумать....

Приятнее, да=) для птиц

На самом деле gc нужен там, где есть активная работа с динамической памятью. Которая в целом отрицательно сказывается на производительности есть это высоконагруженная система (чтобы хоть как-то это нивелировать начинаются пляски с кастомными аллокаторами).

Поэтому языков, где динамическая работа с памятью сведена к минимуму, хватает. И GC там просто не нужен.

Почему-то все боятся написать правду. Я пишу на Си с 1985 года, я написал на нём миллионы строк, от ядра операционной системы до ядра Яндекс.Маркета. Писал на Си СУБД, графику, обработку данных, код для микроконтроллеров и супермашин - всё писал. 40 лет писал.

Ну пора уже в 2026 году сказать честно: для 1985 года он был прорывом. Сегодня Си - это сущий кошмар, а не язык, и писать на нём надо только под дулом пистолета.

Если кто-то хочет возразить, пусть сначала прочтёт, поймёт и объяснит результат вычисления вот такого выражения:

a = 2["abcdefg"];

Если смысл его вам не очевиден - не торопитесь выступать в защиту Си. :)

(И как человек, который прыгал до неба когда вышел C++ - увы, его тоже довели до состояния невменяемости, и его тоже надо избегать.)

Ну и, почему-то, все забыли https://dlang.org/

Кстати, современная Java вообще позволяет запускать однофайловый исходник без компиляции, как Питон, и писать на ней легко и просто. Особенно если привык к Си.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации