image

Я — большой фанат Rust, так как в этом языке предоставляется отличное инструментальное оснащение, и, когда я пишу на этом языке, я могу быть вполне уверен, что этот код будет работать надёжно. Но иногда Rust ненавистен. Чтобы написать код на Rust, требуется немало времени, а некоторые вещи реализовать достаточно сложно (да, async, это я о тебе).

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

Что же такое Zig?


Zig характеризуется как «… универсальный язык программирования и инструментарий для поддержки надёжного, оптимального софта, рассчитанного на переиспользование». Довольно невыразительно звучит, да?

Вот «уникальные маркетинговые преимущества» Zig:

  • Никакого скрытого потока управления; вы сами всё контролируете
  • Никакого скрытого выделения памяти; все эти операции явные, причём, можно пользоваться разными стратегиями выделения памяти
  • Ни препроцессора, ни макросов. Непосредственно пишем на Zig код, который обрабатывается во время компиляции
  • Отлично организовано взаимодействие с C/C++; поддерживает кросс-компиляцию, может использоваться в качестве оперативной замены для C.
Язык Zig немного похож на Rust, так как в обоих этих языках делается акцент на производительности и безопасности. В них не применяется сборка мусора, LLVM применяется как машинный интерфейс компилятора, а также предоставляются возможности тестирования кода. Но в них применяется современный синтаксис, и предлагаются такие возможности как обработка ошибок и опции.

const std = @import("std");

/// Removes the specified prefix from the given string if it starts with it.
pub fn removePrefix(input: []const u8, prefix: []const u8) []const u8 {
    if (std.mem.startsWith(u8, input, prefix)) {
        return input[prefix.len..];
    }
    return input;
}

Пусть такой код и многое упрощает, мне нравится считать, что Zig относится к C так, как Rust относится к C++. Есть и такое мнение, что Zig — это наследник С. Эмпирический опыт подсказывает, что целесообразно использовать Zig в тех проектах, где вы ранее воспользовались бы C.

Хорошо ли это?


Обычно, чтобы изучить новый язык программирования, я стараюсь написать на нём несколько простых программ от начала и до конца. В данном случае я решил написать клиент для telnet (это старый сетевой протокол для удалённого доступа к терминалам). Это было непростое начинание, так как telnet гораздо сложнее, чем кажется. О нём можно написать отдельную статью.

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

Что мне не нравится в Zig


В принципе, полюбить Zig довольно легко, так как мне совсем не нравится программировать на C. Поэтому давайте сначала остановимся на том, что меня в Zig не устраивает:

Сообщество и экосистема Zig невелики, для этого языка имеется не так много библиотек. Те, что имеются, пока не слишком хорошо проработаны. В этом Zig очень отличается от Rust, где найдётся хотя бы один очень популярный и качественно проработанный крейт на каждую задачу, решение которой вам хотелось бы вынести на уровень внешней библиотеки.

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

std.io.Writer (https://ziglang.org/documentation/master/std/#A;std:io.Writer):

image

Полноценное сопоставление с шаблоном в Zig отсутствует. Но операторы switch достаточно мощные и, если собирать из них вложенные конструкции, то можно достичь примерно такого же эффекта, который в Rust достигается при помощи оператора match.

Типажи Rust (в других языках они называются «интерфейсы») обеспечивают полиморфизм — то есть, способность писать такой код, который оперирует разнотипными объектами. Эта мощная возможность очень пригодится, если требуется разрабатывать гибкие программные компоненты, рассчитанные на многократное использование. Но в Zig такая возможность отсутствует. В нём для этой цели полагаются на другие средства, в частности, на указатели функций или на полиморфизм во время компиляции. Такие компоненты могут быть не настолько интуитивно понятны и менее удобны в тех сценариях, которые принято обрабатывать при помощи интерфейсов или типажей.

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

Что мне нравится в Zig


Инструментарий, система сборки и тесты

Притом, что этот язык довольно молод, инструментарий у него отличный! Конечно, пока не такой продвинутый как в Rust с его cargo и clippy. Только в версии v0.11 выкатили официальный менеджер пакетов под названием Zon. Его можно использовать вместе с файлом build.zig (он похож на build.rs в Rust) для того, чтобы без особых хлопот загружать в наш проект библиотеки с GitHub (даже не хочу знать, сколько ценного времени на это тратилось раньше, когда приходилось работать с cmake и make).

$ zig build-exe hello.zig
$ ./hello
Hello, world!

В Zig, как и в Rust, встроены надёжные возможности тестирования. Тесты в Zig пишутся в виде специальных функций, благодаря чему их можно располагать бок о бок с тем кодом, который они проверяют. Для Zig характерна уникальная способность интерпретировать детали тестов во время компиляции. Кроме того, в Zig поддерживается кросс-компиляция при тестировании. Эта черта заслуживает особого упоминания, поскольку разработчики могут с лёгкостью протестировать код сразу под разные целевые архитектуры. Есть люди, которые даже пользуются Zig, чтобы протестировать свой код на C!

const std = @import("std");
const parseInt = std.fmt.parseInt;

// Unit testing
test "parse integers" {
    const ally = std.testing.allocator;

    var list = std.ArrayList(u32).init(ally);
    defer list.deinit();
...

Язык

Сам этот язык хорошо спроектирован и синтаксически очень похож на Rust. В обоих языках есть системы типов, в которых делается акцент на строгую статическую типизацию. Правда, в Zig и Rust отличается подход к обработке и выводу типов.

Обработка ошибок и опциональные значения

Как в Zig, так и в Rust продвигается явная обработка ошибок, хотя, отличаются механизмы, на которых она основана. В Rust для этого используются перечисления Result, тогда как в Zig — (глобальный) тип, которому как единое множество относятся все ошибки. Этот же тип отвечает и за распространение ошибок. Аналогично, в Rust с опциональными типами используется перечисление Option, тогда как в Zig действует модификатор типа (?T). В обоих языках предлагается современный синтаксический сахар, упрощающий обработку таких случаев (call()? и if let Some(value) = optional {} в Rust, try call() и if (optional) |value| {} в Zig). Поскольку в Rust обработка ошибок и работа с опциональными значениями реализуется при помощи стандартной библиотеки, пользователи могут сами расширять эти механизмы, и средства для этого достаточно мощные. Но мне нравится, как эти вопросы решаются в Zig, в котором эти штуки предоставляются как возможности языка. Притом, что такой подход хорошо вписывается во вселенную C, мне не нравится, что отсутствует прагматичный способ дать более широкий контекст для описания ошибки (да, конечно, никаких выделений памяти). Подобные проблемы можно решать при помощи таких библиотек как clap, которые реализуют диагностический механизм.

// Hello World in Zig
const std = @import("std");

pub fn main() anyerror!void {
      const stdout = std.io.getStdOut().writer();
      try stdout.print("Hello, {s}!\n", .{"world"});
}

Интероперабельность с C

В Zig обеспечивается первоклассная интероперабельность с C. Не приходится писать привязок, при работе с Zig просто можно воспользоваться встроенными функциями @cImport и @cInclude (выполняющими синтаксический разбор заголовочных файлов C), чтобы напрямую пользоваться кодом C.

Время компиляции

Можно писать на Zig такой код (без какого-либо специального синтаксиса макросов, как в случае с Rust), который интерпретируется во время компиляции, для этого применяется ключевое слово comptime. Так можно не только оптимизировать код, но и обеспечить рефлексию на уровне типов. Но операции динамического выделения памяти во время компиляции не разрешаются.

Типы

Типы в Zig, как и в Rust – это абстракции с нулевой стоимостью. Здесь есть примитивные типы, массивы, указатели, структуры (они подобны структурам из C, но могут включать методы), перечисления и объединения. Пользовательские типы реализуются на основе структур и дженериков, а именно — путём генерации параметризованных структур во время компиляции.

// std.io.Writer - это функция времени компиляции, возвращающая (обобщённую) структуру
pub fn Writer(
    comptime Context: type,
    comptime WriteError: type,
    comptime writeFn: fn (context: Context, bytes: []const u8) WriteError!usize,
) type {
    return struct {
        context: Context,

        const Self = @This();
        pub const Error = WriteError;

        pub fn write(self: Self, bytes: []const u8) Error!usize {
            return writeFn(self.context, bytes);
        }
...

Выделение памяти

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

Суть стратегии управления памятью в Zig заключается в использовании интерфейса Allocator. Через этот интерфейс разработчикам удобно в точности указывать, как нужно выделять, а затем забирать память. Разработчики могут выбирать из нескольких аллокаторов или реализовать собственные, подогнанные под конкретные нужды или цели оптимизации.

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

Кросс-компиляция

Zig, как и Rust, нативно поддерживает кросс-компиляцию. Его интегрированный инструментарий упрощает компиляцию под разные архитектуры или операционные системы. Задать целевую архитектуру в Zig не сложнее, чем просто передать аргумент команде сборки:

# Сборка под Windows на Linux
zig build -Dtarget=x86_64-windows-gnu

Напротив, при использовании Rust требуется установить инструментарий целевой платформы при помощи rustup, а зачастую и вручную прописать конфигурацию линковщика для целевой платформы.

Заключение


Думаю, что Zig — хорошо спроектированный, интересный и мощный язык. Работать с ним может быть непросто, поскольку у него маленькая экосистема и не хватает документации. Но со временем эта ситуация должна улучшиться, особенно по мере того, как Zig станет набирать популярность. В Zig предоставляется современный синтаксис, отличная система типов, вы полностью контролируете, как выделяется память, а также суперсовременные языковые возможности.

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

Комментарии (16)


  1. NeoCode
    17.05.2024 08:50
    +4

    Я сильно глубоко не вдавался, но мне понравилось что в Zig одно ключевое слово comptime заменяет весь зоопарк с шаблонами, макросами, constexpr, consteval и constinit из С++.


    1. kozlov_de
      17.05.2024 08:50

      использонвание аллокаторов и впрямь странное

      fn createAndSumList(allocator: std.mem.Allocator, size: usize) i32 {
          var list = std.ArrayList(i32).initCapacity(allocator, size) catch |err| {
              std.log.err("Failed to initialize ArrayList: {}", .{err});
              return 0;
          };
          defer list.deinit();
      
          // Заполняем список случайными числами
          for (0..size) |_| {
              const random_num = std.rand.random().int(i32);
              try list.append(random_num);
          }
      
          // Вычисляем сумму элементов
          var sum: i32 = 0;
          for (list.items) |item| {
              sum += item;
          }
      
          return sum;
      }
      
      const std = @import("std");
      
      fn createDynamicArray(comptime T: type, comptime size: usize, allocator: std.mem.Allocator) ![]T {
          var arr = try allocator.alloc(T, size);
          errdefer allocator.free(arr);
          return arr;
      }
      
      fn main() !void {
          const allocator = std.heap.page_allocator;
      
          // Создаем динамический массив i32 размера 5 во время выполнения
          const int_array = try createDynamicArray(i32, 5, allocator);
          defer allocator.free(int_array);
          std.debug.print("int_array: {any}\n", .{int_array});
      
          // Создаем динамический массив f64 размера 3 во время выполнения
          const float_array = try createDynamicArray(f64, 3, allocator);
          defer allocator.free(float_array);
          std.debug.print("float_array: {any}\n", .{float_array});
      }
      


      1. aegoroff
        17.05.2024 08:50
        +1

        а можете пояснить в чем странность? в конструкциях вида `defer allocator.free(int_array);` т.е. явного освобождения выделенной памяти, если это нужно, вместо того, как сделано в раст (память освобождается автоматом при выходе из области видимости)? Если да, то это как раз фишка Zig, - отсутствие скрытого смысла


    1. yatanai
      17.05.2024 08:50

      Меня только constexpr/consteval напрягает. Ибо если мы объявляем функцию, она "рекомендуемо" compile-time или "явно" compile-time, а переменная может быть только constexpr, а consteval низя. (хотя можно было бы наверное сделать как псевдоним)

      В остальном же вполне понятно зачем другие вещи нужны. Зачем только к comptime ты добавил дженерики в минусы С++, ммм...


  1. MountainGoat
    17.05.2024 08:50
    +1

    И опять сравнивают с С. Хоть бы кто дал недвусмысленный ответ на вопрос, зачем нужен Zig если Rust уже есть?

    Я не про то, что не надо языки придумывать, а про то, почему Интернет преисполнился воплей "Zig заменит Rust потому что Zig лучше С"


    1. tessob
      17.05.2024 08:50
      +3

      Zig для многих проще Rust. Go тоже появился как средство борьбы со сложностью в Java и C++.


      1. Kahelman
        17.05.2024 08:50
        +3

        А в С, C++, Java, и т.д. Сложность специально вносили?

        Так и представляю, выходит Строустроп рассказывает о новом языке: вы знаете, C, это очень простой язык и нам не хватает сложности, давайте писать на C++…

        Предлагаю пари на что нибудь не-нужное, если zig наберёт сколько нибудь популярности в версии 3+ Появятся маетности или что-то похоже.

        Вон в Go держались, держались и добавили дженерики.


        1. feelamee
          17.05.2024 08:50
          +2

          сложность вносили не специально

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

          А новые языки ещё и могут учится на опыте старых.

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

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

          Hidden text

          и наделать своих

          а что такое "маетность"?


        1. Siemargl
          17.05.2024 08:50

          В С, Яве и Го сложности нет изначально


          1. feelamee
            17.05.2024 08:50

            советую вам глянуть на это, если вы думаете что Си простой.

            Плюсы тоже не задумывались сложными.

            Сложность появилось по мере развития.

            Насчёт java/go не скажу, тк не писал особон на них. Но, уверен, у них достаточно своих скользких мест.

            Вон выше комментатор написал

            Go тоже появился как средство борьбы со сложностью в Java и C++.

            видно и java не так проста


            1. Siemargl
              17.05.2024 08:50

              Простота языка и простота совершения ошибок ортогональны.

              Плюсы были простыми до появления шаблонов


              1. feelamee
                17.05.2024 08:50

                вы под простотой имеете ввиду простоту понимая концепций языка? Тогда в Си это правда проще, их так в разы меньше, чем в плюсах или любом современном языке.

                Но в java, go их значительно больше, чем в си. На уровне плюсов где-то

                Если выбирать самые простые языки по такой логике, то можно сразу писать на brainfuck или forth. Освоить можно за 5 минут, что может быть проще?)

                Плюсы были простыми до появления шаблонов

                не вижу ничего сложного в шаблонах, идея сама по себе проста

                реализация не идеальная, но вряд ли у кого-то было лучше тогда

                покажите на примере?


          1. sdramare
            17.05.2024 08:50
            +1

            Го это с языком с огромным количеством неочевидного поведения, вещей, которые нельзя вывести логически, а нужно просто "знать" и возможностей выстрелить себе в ногу. Говорить что в го нет сложности можно только если вы на нем не писали.


    1. sdramare
      17.05.2024 08:50

      Достоинства раста это его же недостатки. Провека компилятором корректности освобождения памяти, предотвращение появления dangling pointer, избегание рейс кондишенов - все это приводит к тому, что писать на расте многие задачи в разы сложнее, чем на многих других языках. Zig же позволяет программисту самому решать что можно делать, а что нет, в результате чего проще делать прототипы, воркэраунды, проще реализовывать многие алгоритмы и структуры данных, особено все, что касается графов. Ну и, например, если вы знаете что в вашем приложении в принципе нет многопоточности, то зачем вам форсированный контроль мутабельности компилятором, как в расте? В результате rust это системный язык для задач где очень высока цена ошибки и где надо писать максимально безопастно, zig это системный язык для задач, где важна гибкость в разработке. Ну и для задач, где не нужен системный язык, где можно использовать GC и счет не идет на наносекунды, то любой современный язык с GC типа Go/C#/Kotlin будет для разработки гораздо удобнее раста или зига .


      1. Siemargl
        17.05.2024 08:50

        Ну и, например, если вы знаете что в вашем приложении в принципе нет многопоточности

        В настоящее время это редкий кейс. Кроме весьма слабых микроконтроллерных ЦПУ, везде многопоточка и грех ее не использовать (если алгоритм позволяет)

        Ну и для задач, где не нужен системный язык, где можно использовать GC и счет не идет на наносекунды, то любой современный язык с GC типа Go/C#/Kotlin будет для разработки гораздо удобнее раста или зига

        Уточню - при сравнимом уровне безопасности, и Java в список


        1. sdramare
          17.05.2024 08:50

          В настоящее время это редкий кейс

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

          Java в список

          Ну просто котлин в том же рантайме и более современный чем джава(тот же tailrec есть), я уж не стал все языки перечислять.