Как работает Zig?

https://blog.jfo.click/how-zig-do/
  • Перевод
От переводчика: этот пост был опубликован в блоге автора 15 марта 2018 года. Так как язык развивается, в настоящее время его синтаксис может отличаться. Всё описанное относится к Zig 0.2.0, текущая версия языка — Zig 0.3.0.

Я связался с автором поста, и он любезно предоставил ссылку на репозиторий с актуальной версией исходников проекта на Zig 0.3.0

Здравствуйте! Давайте напишем интерпретатор Brainfuck! «Зачем?» — можете спросить вы, но вы не найдёте здесь ответа.

Я сделаю это на Zig.

Zig — это….


…новый язык программирования. Он пока в бета-версии, и быстро развивается. Если вы видели код на Zig раньше, код в этом посте может показаться вам немного другим. Он действительно другой! Zig 0.2.0 только что вышел, совпав с релизом LLVM 6 несколько недель назад, и включает в себя множество изменений синтаксиса и общие усовершенствования языка. Главным образом, многие «заклинания» были заменены ключевыми словами. Смотрите здесь для более глубокого объяснения всех изменений!

Zig разработан, чтобы быть читаемым, и относительно интуитивным для тех, кто знаком с компилируемыми и типизированными языками, такими, как C, C++, и, в некоторых моментах, Rust.

Код был скомпилирован и протестирован с Zig 0.2.0, который доступен прямо сейчас, по различным каналам, включая homebrew, если вы на OSX: brew install zig.

Давайте начнём


Для того, чтобы ознакомиться, как работает Brainfuck, смотрите здесь. Там почти нечего изучать, но это тьюринг-полный язык, что означает, что вы можете написать на нём что угодно.

Я разместил код здесь, на случай, если вы хотите увидеть конечный продукт или ранние коммиты.

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

Итак…

// main.zig
fn main() void { }

…и запускаем…

$ zig build-exe main.zig

…выдаёт…

/zig/std/special/bootstrap.zig:70:33: error: 'main' is private
/zigfuck/main.zig:2:1: note: declared here

main должна быть объявлена как public, чтобы быть видимой вне модуля…

// main.zig
pub fn main() void { }

Пусть brainfuck-программа использует массив в 30,000 байт в качестве памяти, я сделаю такой массив.

// main.zig
pub fn main() void {
  const mem: [30000]u8;
}

Я могу объявить константу (const) или переменную (var). Здесь, я объявил mem как массив 30000 беззнаковых (u) байт (8 бит).

Это не компилируется.

/main.zig:3:5: error: variables must be initialized

Эквивалентная программа на C компилировалась бы нормально: я могу объявить переменную без инициализации, но Zig заставляет меня принять решение сейчас, в момент объявления переменной. Мне может быть всё равно, что будет в неё записано, но я должен явно это указать. Я сделаю это, инициализируя переменную неопределённым значением (undefined).

// main.zig
pub fn main() void {
  const mem: [30000]u8 = undefined;
}

Инициализация переменной неопределённым значением не даёт никаких гарантий насчёт значения переменной в памяти. Это то же самое, что неинициализированное объявление переменной в С за исключением того, что необходимо указание на это в явном виде.

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

// main.zig
pub fn main() void {
  const mem = []u8{0} ** 30000;
}

Может показаться странным, но ** — это оператор, используемый для расширения массивов. Я объявляю массив в 0 байт, а затем расширяю его на 30000, и получаю конечное значение инициализации в 30000 нулевых байт. Эта операция происходит один раз, во время компиляции. comptime — одна из больших идей Zig, и я вернусь к ней в одном из следующих постов.

Сейчас напишем программу на brainfuck, которая не делает ничего, кроме инкремента первого слота памяти пять раз!

pub fn main() void {
  const mem = []u8{0} ** 30000;
  const src = "+++++";
}

В Zig, строки — это байтовые массивы. Я не должен объявлять src как байтовый массив, потому что компилятор подразумевает это. Это делать необязательно, но если вы хотите, то возможно:

const src: [5]u8 = "+++++";

Это будет нормально компилироваться. Однако это:

const src: [6]u8= "+++++";

не будет.

main.zig:5:22: error: expected type '[6]u8', found '[5]u8'

Ещё одно замечание: так как строк являются просто массивами, они не заканчиваются нулём. Однако вы можете объявить нуль-терминированную строку С. Как литерал, она будет выглядеть так:

c"Hello I am a null terminated string";

Для всеобщего блага...


Я хочу сделать что-нибудь с каждым символом в строке. Я могу это сделать! В начале main.zig я импортирую некоторые функции из стандартной библиотеки:

const warn = @import("std").debug.warn;

import, как и фактически всё, что начинается со знака @, является встроенной функцией компилятора. Такие функции всегда глобально доступны. Импорт здесь работает похоже на javascript — вы можете импортировать что угодно, закопавшись в пространство имён и извлечь оттуда любые доступные публично функции или переменные. В примере выше, я напрямую импортирую функцию warn и присваиваю её, внезапно, константе warn. Сейчас её можно вызывать. Это общий паттерн: импортируем напрямую из пространства имён std и затем либо вызываем std.debug.warn(), либо присваиваем переменной warn. Это выглядит так:

const std = @import("std");
const warn = std.debug.warn;

const warn = @import("std").debug.warn;
// main.zig
pub fn main() void {
  const mem = []u8{0} ** 30000;
  const src = "+++++";

  for (src) |c| {
      warn("{}", c);
  }
}

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

warn принимает строку с форматированием, как printf в C! Код, приведённый выше, напечатает:

4343434343

43 -код ascii символа +. я также могу написать:

warn("{c}", c);

и получить:

+++++

Итак, мы инициализировали пространство памяти, и написали программу. Сейчас мы реализуем сам язык. Я начну с +, и заменю тело цикла for на switch:

for (src) |c| {
    switch(c) {
        '+' => mem[0] += 1
    }
}

Я получаю две ошибки:

/main.zig:10:7: error: switch must handle all possibilities
      switch(c) {
      ^
/main.zig:11:25: error: cannot assign to constant
          '+' => mem[0] += 1
                        ^

Конечно, я не могу присвоить новое значение переменной, которая является константой! mem нужно сделать переменной…

var mem = []u8{0} ** 30000;

как и в случае других ошибок, моя конструкция switch должна знать, что делать в случае, если символ не равен +, даже если ничего делать не нужно. В моём случае, это именно то, что я хочу. Я заполняю этот случай пустым блоком:

for (src) |c| {
    switch(c) {
        '+' => mem[0] += 1,
        else => {}
    }
}

Сейчас я могу скомпилировать программу. Вызываем в конце warn и запускаем:

const warn = @import("std").debug.warn;

pub fn main() void {
  var mem = []u8{0} ** 30000;
  const src = "+++++";

  for (src) |c| {
      switch(c) {
          '+' => mem[0] += 1,
          else => {}
      }
  }

  warn("{}", mem[0]);
}

Я получаю число 5, напечатанное в stderr, как я и ожидал.

Идём дальше…


Аналогично делаем поддержку -.

switch(c) {
    '+' => mem[0] += 1,
    '-' => mem[0] -= 1,
    else => {}
}

Для использования > и < нужно использовать дополнительную переменную, которая служит «указателем» в памяти, которую я аллоцировал для пользовательской программы brainfuck.

var memptr: u16 = 0;

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

на самом деле нам было бы достаточно 15 бит, что позволяет адресовать 32767 байт. Zig допускает типы с различной шириной, но не u15 пока.

вы можете на самом деле сделать u15 таким образом:

const u15 = @IntType(false, 15):

Предлагается сделать, чтобы любой [iu]\d+ тип был допустим как целый тип.

Сейчас вместо использования mem[0], я могу использовать эту переменную.

'+' => mem[memptr] += 1,
'-' => mem[memptr] -= 1,

< и > просто инкрементируют и декрементируют этот указатель.

'>' => memptr += 1,
'<' => memptr -= 1,

Замечательно. Мы можем сейчас написать настоящую программу!

Проверяем 1,2,3


Zig имеет встроенный механизм тестов. В любом месте в любом файле я могу написать тестовый блок:

test "Name of Test" {
  // test code
}

и запустить тест из командной строки: zig test $FILENAME. В остальном тестовые блоки такие же, как обычный код.

Посмотрим на это:

// test.zig
test "testing tests" {}
zig test test.zig
Test 1/1 testing tests...OK

Конечно, пустой тест бесполезен. Я могу использовать assert для реального подтверждения выполнения тестов.

const assert = @import("std").debug.assert;

test "test true" {
    assert(true);
}

test "test false" {
    assert(false);
}

zig test test.zig
"thing.zig" 10L, 127C written
:!zig test thing.zig

Test 1/2 test true...OK
Test 2/2 test false...assertion failure
 [37;1m_panic.7 [0m:  [2m0x0000000105260f34 in ??? (???) [0m
 [37;1m_panic [0m:  [2m0x0000000105260d6b in ??? (???) [0m
 [37;1m_assert [0m:  [2m0x0000000105260619 in ??? (???) [0m
 [37;1m_test false [0m:  [2m0x0000000105260cfb in ??? (???) [0m
 [37;1m_main.0 [0m:  [2m0x00000001052695ea in ??? (???) [0m
 [37;1m_callMain [0m:  [2m0x0000000105269379 in ??? (???) [0m
 [37;1m_callMainWithArgs [0m:  [2m0x00000001052692f9 in ??? (???) [0m
 [37;1m_main [0m:  [2m0x0000000105269184 in ??? (???) [0m
 [37;1m??? [0m:  [2m0x00007fff5c75c115 in ??? (???) [0m
 [37;1m??? [0m:  [2m0x0000000000000001 in ??? (???) [0m

Тест упал. Используйте следующую команду для воспроизведения ошибки:

./zig-cache/test

Stack trace на маке пока в процессе разработки.

Чтобы протестировать это эффективно, мне нужно разбить это на части. Начнём с этого:

fn bf(src: []const u8, mem: [30000]u8) void {
    var memptr: u16 = 0;
    for (src) |c| {
        switch(c) {
            '+' => mem[memptr] += 1,
            '-' => mem[memptr] -= 1,
            '>' => memptr += 1,
            '<' => memptr -= 1,
            else => {}
        }
    }
}

pub fn main() void {
    var mem = []u8{0} ** 30000;
    const src = "+++++";
    bf(src, mem);
}

Вроде бы должно работать, правильно?

Но…

/main.zig:1:29: error: type '[30000]u8' is not copyable; cannot pass by value

это описано в https://github.com/zig-lang/zig/issues/733.

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

Естественный способ обойти это ограничение, это передавать указатель вместо значения (передача по ссылке). Zig использует другую стратегию, срезы (slices). Слайс, это указатель с присоединённой к нему длиной и с проверкой попадания в границы. Синтаксис в сигнатуре функции выглядит так:

fn bf(src: []const u8, mem: []u8) void { ... }

и при вызове функции выглядит так:

bf(src, mem[0..mem.len]);

Обратите внимание, что я определил верхнюю границу, просто сославшись на длину массива. Есть сокращённая форма записи для таких случаев:

bf(src, mem[0..]);

Сейчас я могу начать писать тесты, которые тестируют функцию bf() напрямую. Я пока буду дописывать тестовые функции в конец файла…

test "+" {
    var mem = []u8{0};
    const src = "+++";
    bf(src, mem[0..]);
    assert(mem[0] == 3);
}

Я беру массив mem из одного байта и затем проверяю то, что должно произойти (байт инкрементируется три раза). Это работает!

Test 1/1 +...OK

"-" проверяется аналогично:

test "-" {
    var mem = []u8{0};
    const src = "---";
    bf(src, mem[0..]);
    assert(mem[0] == 253);
}

Не работает! Когда я пытаюсь вычесть 1 из 0, я получаю…

Test 2/2 -...integer overflow

mem — это массив беззнаковых байтов, и вычитание 1 из 0 вызывает переполнение. Снова, Zig заставляет меня объявлять желаемое явным образом. В этом случае, я не должен беспокоиться о переполнении, фактически, я хочу, чтобы оно происходило, так как мы имеем дело с модулярной арифметикой, в соответствии со спецификацией brainfuck. Это означает, что декремент ячейки с числом 0 даст мне 255, и инкремент значения 255 даст мне 0.

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

'+' => mem[memptr] +%= 1,
'-' => mem[memptr] -%= 1,

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

Для тестирования < и >, я перемещаюсь по маленькому массиву и проверяю значение инкрементированной ячейки:

test ">" {
    var mem = []u8{0} ** 5;
    const src = ">>>+++";
    bf(src, mem[0..]);
    assert(mem[3] == 3);
}

и…

test "<" {
    var mem = []u8{0} ** 5;
    const src = ">>>+++<++<+";
    bf(src, mem[0..]);
    assert(mem[3] == 3);
    assert(mem[2] == 2);
    assert(mem[1] == 1);
}

В последнем случае, я могу напрямую сравнивать результат со статическим массивом, используя…

const mem = std.mem;

Вспомним, что я уже импортировал std. В примере ниже, я использую mem.eql в этом пространстве имен:

test "<" {
    var storage = []u8{0} ** 5;
    const src = ">>>+++<++<+";
    bf(src, storage[0..]);
    assert(mem.eql(u8, storage, []u8{ 0, 1, 2, 3, 0 }));
}

… и вспомним, строковые литералы, это просто массивы u8 в zig, и я могу класть в них шестнадцатиричные литералы, т.е. следующий код будет работать таким же образом!

assert(mem.eql(u8, storage, "\x00\x01\x02\x03\x00"));

Добавим "."! Она просто печатает в виде символа значение байта в ячейке, на которую указывает указатель. Сейчас я использую warn, но позже я заменю его на stdout. Это просто сделать концептуально, но несколько запутано в реализации. Я сделаю это позже!

'.' => warn("{c}", storage[memptr]),

Циклы
[ и ] — здесь начинается магия….

[ — если значение текущей ячейки нулевое, пропускаем шаги до закрывающей скобки без исполнения кода.
] — если значение текущей ячейки не нулевое, возвращаемся к открывающей скобке и выполняем код снова.

На этот раз я начну с теста, буду тестировать их совместно (очевидно, не имеет смысла тестировать их по отдельности). Первый тестовый случай — ячейка storage[2] должна быть пустой, хотя цикл должен инкрементировать её, если он запускается:

test "[] skips execution and exits" {
    var storage = []u8{0} ** 3;
    const src = "+++++>[>+++++<-]";
    bf(src, storage[0..]);
    assert(storage[0] == 5);
    assert(storage[1] == 0);
    assert(storage[2] == 0);
}

и я создам заготовки для оператора switch:

'[' => if (storage[memptr] == 0) {
},
']' => if (storage[memptr] == 0) {
},

Что делать сейчас? Можно использовать наивный подход. Я просто увеличиваю указатель src до тех пор, пока не найду ]. Но я не могу использовать для этого цикл for в zig, он создан только для итераций по коллекциям, не пропуская их элементов. Подходящая конструкция здесь — while:

было:

var memptr: u16 = 0;
for (src) |c| {
    switch(c) {
      ...
    }
}

стало…

var memptr: u16 = 0;
var srcptr: u16 = 0;
while (srcptr < src.len) {
    switch(src[srcptr]) {
      ...
    }
    srcptr += 1;
}

Сейчас я могу переназначить указатель srcptr в середине блока, я так и сделаю:

'[' => if (storage[memptr] == 0) {
    while (src[srcptr] != ']')
        srcptr += 1;
},

Это удовлетворяет тесту "[] пропускает выполнение кода и выходит"
This satisfies the test “[] skips execution and exits”, хотя и не вполне надёжно, как мы увидим.

Как насчёт закрывающих скобок? Я считаю, можно записать просто по аналогии:

test "[] executes and exits" {
    var storage = []u8{0} ** 2;
    const src = "+++++[>+++++<-]";
    bf(src, storage[0..]);
    assert(storage[0] == 0);
    assert(storage[1] == 25);
}
']' => if (storage[memptr] != 0) {
    while (src[srcptr] != '[')
        srcptr -= 1;
},

Вы можете видеть, что происходит… Наивное решение с двумя скобками имеет фатальный недостаток и полностью ломается на вложенных циклах. Рассмотрим следующее:

++>[>++[-]++<-]

В результате должно получиться {2, 0}, но первая открытая скобка просто тупо переходит к первой закрывающей скобке, и всё запутывается. Нужно перепрыгивать к следующей закрывающей скобке на том же уровне вложенности. Легко добавить счётчик глубины и отслеживать его по мере продвижения вперёд по строке. Делаем это в обоих направлениях:

'[' => if (storage[memptr] == 0) {
    var depth:u16 = 1;
    srcptr += 1;
    while (depth > 0) {
        srcptr += 1;
        switch(src[srcptr]) {
            '[' => depth += 1,
            ']' => depth -= 1,
            else => {}
        }
    }
},
']' => if (storage[memptr] != 0) {
    var depth:u16 = 1;
    srcptr -= 1;
    while (depth > 0) {
        srcptr -= 1;
        switch(src[srcptr]) {
            '[' => depth -= 1,
            ']' => depth += 1,
            else => {}
        }
    }
},

и соответствующие тесты: отметим, что src в обоих тестах включает внутренний цикл.

test "[] skips execution with internal braces and exits" {
    var storage = []u8{0} ** 2;
    const src = "++>[>++[-]++<-]";
    try bf(src, storage[0..]);
    assert(storage[0] == 2);
    assert(storage[1] == 0);
}

test "[] executes with internal braces and exits" {
    var storage = []u8{0} ** 2;
    const src = "++[>++[-]++<-]";
    try bf(src, storage[0..]);
    assert(storage[0] == 0);
    assert(storage[1] == 2);
}

Отдельно заметим, [-] — идиома brainfuck, означающая «обнулить эту ячейку». Вы можете видеть, что не важно, какое значение у ячейки было вначале, она будет декрементироваться пока не достигнет 0, и затем выполнение продолжится.

Несчастливый путь


Я не рассчитывал на возможность того, что программа на bf будет сломанной. Что будет, если я подам на мой интерпретатор неверную входную программу? Например, просто [ без закрывающей скобки, или <, которая немедленно выйдет за пределы массива памяти? (я могу заворачивать указатель памяти, но лучше рассматривать это как ошибку).

Я собираюсь заглянуть немного вперёд и объяснить все различия в коде. Я вынесу функцию интерпретатора bf в отдельный файл и также вынесу функциональность seekBack и seekForward в свои собственные маленькие функции.

const warn = @import("std").debug.warn;
const sub = @import("std").math.sub;

fn seekBack(src: []const u8, srcptr: u16) !u16 {
    var depth:u16 = 1;
    var ptr: u16 = srcptr;
    while (depth > 0) {
        ptr = sub(u16, ptr, 1) catch return error.OutOfBounds;
        switch(src[ptr]) {
            '[' => depth -= 1,
            ']' => depth += 1,
            else => {}
        }
    }
    return ptr;
}

fn seekForward(src: []const u8, srcptr: u16) !u16 {
    var depth:u16 = 1;
    var ptr: u16 = srcptr;
    while (depth > 0) {
        ptr += 1;
        if (ptr >= src.len) return error.OutOfBounds;
        switch(src[ptr]) {
            '[' => depth += 1,
            ']' => depth -= 1,
            else => {}
        }
    }
    return ptr;
}

pub fn bf(src: []const u8, storage: []u8) !void {
    var memptr: u16 = 0;
    var srcptr: u16 = 0;
    while (srcptr < src.len) {
        switch(src[srcptr]) {
            '+' => storage[memptr] +%= 1,
            '-' => storage[memptr] -%= 1,
            '>' => memptr += 1,
            '<' => memptr -= 1,
            '[' => if (storage[memptr] == 0) srcptr = try seekForward(src, srcptr),
            ']' => if (storage[memptr] != 0) srcptr = try seekBack(src, srcptr),
            '.' => warn("{c}", storage[memptr]),
            else => {}
        }
        srcptr += 1;
    }
}

Это делает switch гораздо проще для чтения, по моему мнению, seekForward и seekBack выгдядят и работают очень похоже, и я испытывал искушение отрефакторить их в что-то более умное и компактное, но, в конце концов, они делают разные вещи, и обрабатывают ошибки также по-разному. Проще скопировать и откорректировать, и так будет яснее. Также я откорректирую seekForward позже, в некоторый момент, возможно, в одном из последующих постов.

Я добавил несколько важных вещей! Заметим, что все три функции сейчас возвращают тип!.. Это новый синтаксис для того, что раньше было типом %T (error union). Это означает, что функция может вернуть либо некоторый определённый тип, либо ошибку. Когда я пытаюсь вызвать такую функцию, я должен либо использовать try перед вызовом функции, котоый пробрасывает ошибку вверх по стеку вызовов, если ошибка происходит, либо использовать catch:

const x = functionCall() catch {}

Где я обрабатываю ошибки в блоке catch. Как и написано, catch может глотать любые ошибки. Это плохая практика, но здесь Zig заставляет нас делать это явно. Если я ловлю ошибку в пустой блок, я тем самым утверждаю, что либо я не думаю, что ошибка может произойти, либо я не нуждаюсь в её обработке. На практике, это может быть чем-то вроде TODO, и фактически очень легко сделать это также явным!

const x = functionCall() catch { @panic("TODO") }

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

Итак, какие ошибки я должен возвращать из seekBack или seekForward?

В seekBack:

ptr = sub(u16, ptr, 1) catch return error.OutOfBounds;

Я заменил декремент указателя на использование функции sub из std lib, которая выбрасывает ошибку overflow, если происходит переполнение. Я хочу перехватывать эту ошибку и возвращать вместо неё ошибку OutOfBounds, которую я здесь же и создаю, просто используя её.

Ошибки Zig — это в основе своей массив кодов ошибок который генерируется компилятором когда вы используете error.Какая-тоОшибка. Они гарантированно уникальны, и могут быть использованы в качестве значений в блоке switch.

Я хочу использовать здесь OutOfBounds потому что, семантически, если указатель памяти становится меньше нуля, я прошу рантайм выйти за пределы пространства памяти, которое я аллоцировал.

аналогично в функции seekForward:

if (ptr >= src.len) return error.OutOfBounds;

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

при вызове:

'[' => if (storage[memptr] == 0) srcptr = try seekForward(src, srcptr),
']' => if (storage[memptr] != 0) srcptr = try seekBack(src, srcptr),

Я пробую (try) вызвать эти функции. Если они вызваны успешно, они выполняются верно, и try возвращает значение srcptr. Если они неуспешны, try прекращает выполнение функции и возвращает ошибку в место вызова всей функции bf.

Вызов может быть из main!

const bf = @import("./bf.zig").bf;

// yes, hello
const hello_world = "++++++++++[>+++++++>++++++++++>+++>+<<<<-]>++.>+.+++++++..+++.>++.<<+++++++++++++++.>.+++.------.--------.>+.>.";

pub fn main() void {
    storage = []u8{0} ** 30000;
    bf(hello_world, storage[0..]) catch {};
}

Я глотаю эту ошибку здесь, и так делать не следует, но отметим важный момент, как легко zig может передавать ошибки вверх по стеку вызовов. Это не ответственность вызывающей функции проверять каждый случай ошибки, но компилятор заставляет вызывать каждую функцию, способную завершиться с ошибкой, с try. Это нужно делать всегда, даже если ошибки игнорируются!

Новый синтаксис try/catch избавляет от множества заклинаний типа %% и %, которые люди так не любят.

Сейчас я реализовал 7 из 8 символов brainfuck-а, и этого достаточно, чтобы запустить «осмысленную» программу.

«Осмысленная» программа


Вот эта программа:

// наш старый друг, ряд Фибоначчи
const fib = "++++++++++++++++++++++++++++++++++++++++++++>++++++++++++++++++++++++++++++++>++++++++++++++++>>+<<[>>>>++++++++++<<[->+>-[>+>>]>[+[-<+>]>+>>]<<<<<<]>[<+>-]>[-]>>>++++++++++<[->-[>+>>]>[+[-<+>]>+>>]<<<<<]>[-]>>[++++++++++++++++++++++++++++++++++++++++++++++++.[-]]<[++++++++++++++++++++++++++++++++++++++++++++++++.[-]]<<<++++++++++++++++++++++++++++++++++++++++++++++++.[-]<<<<<<<.>.>>[>>+<<-]>[>+<<+>-]>[<+>-]<<<-]<<++...";

Запустим…

pub fn main() void {
    storage = []u8{0} ** 30000;
    bf(fib, storage[0..]) catch {};
}

вуаля!

1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 121, 98, 219,

Одно воспоминание возвращается ко мне каждый раз, когда я думаю о ряде Фибоначчи… Я узнал о нём из передач PBS (Public Broadcasting Service, американская некоммерческая служба телевизионного вещания) в 80-е, и я всегда вспоминаю это. Я думал, это забудется, но Youtube — великая вещь.

Как я могу это улучшить?


Я уже намекал на несколько TODO. Я не должен был использовать stderr для вывода. Я хочу использовать stdout.

Каждый раз, когда я открываю интерпретатор, я открываю поток в stdout и печатаю в него:

const io = std.io;
...
pub fn bf(src: []const u8, storage: []u8) !void {
    const stdout = &(io.FileOutStream.init(&(io.getStdOut() catch unreachable)).stream);
    ...
            '.' => stdout.print("{c}", storage[memptr]) catch unreachable,
            ...

Что происходит здесь? Я вызываю io.getStdOut(), который может генерировать ошибки (и снова я явным образом глотаю возможную ошибку с помощью catch unreachable — если эта функция возвратит ошибку, программа упадёт!). Я инициализирую поток, беру указатель на него, и инициализирую его как выходной поток, в который я могу писать вызовом print. print принимает форматированную строку, как и warn, так что замена происходит напрямую. print также может генерировать ошибку, и я глотаю эти ошибки тоже.

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

Что происходит, если я решу, что я хочу превратить мой прототип в релиз? Буду ли я сидеть с чашкой кофе и делать неблагодарную работу по обработке ошибок, полагаясь на десятилетия опыта и знания, позволяющие перечислить каждый возможный случай ошибки и как я могу его обработать? А что, если у меня нет десятилетий опыта и наний? Это нормально, Zig сделает это!

Я хочу продемонстрировать мощную штуку, вывод ошибок!

const bf = @import("./bf.zig").bf;
const warn = @import("std").debug.warn;

const serpinsky = "++++++++[>+>++++<<-]>++>>+<[-[>>+<<-]+>>]>+[ -<<<[ ->[+[-]+>++>>>-<<]<[<]>>++++++[<<+++++>>-]+<<++.[-]<< ]>.>+[>>]>+ ] ";

pub fn main() void {
    var storage = []u8{0} ** 30000;
    bf(serpinsky, storage[0..]) catch unreachable;
}


Я знаю, что bf может генерировать ошибки, потому что он возвращает !void. Я глотаю эту ошибку на стороне вызова, в функции main. Когда я готов принять мою судьбу и делать правильные вещи, я могу ловить возможные ошибки так:

const bf = @import("./bf.zig").bf;
const warn = @import("std").debug.warn;

const serpinsky = "++++++++[>+>++++<<-]>++>>+<[-[>>+<<-]+>>]>+[ -<<<[ ->[+[-]+>++>>>-<<]<[<]>>++++++[<<+++++>>-]+<<++.[-]<< ]>.>+[>>]>+ ] ";

pub fn main() void {
    var storage = []u8{0} ** 30000;
    bf(serpinsky, storage[0..]) catch |err| switch (err) {
    };
}

Компилятор теперь мой друг!

/Users/jfo/code/zigfuck/main.zig:7:46: error: error.OutOfBounds not handled in switch
shell returned 1

Эта ошибка должна быть вам знакома, так как она поднималась из bf и вспомогательных функций! Но давайте представим, что я смотрю на ошибки, генерирующиеся stdout, которые я проглотил в bf. Вместо того, чтобы глотать их, я должен поднимать их вверх по цепочке, используя try. Вспомним, используя вызов функции, генерирующей ошибки, без catch, мы используем try, который завершает функцию при возникновении ошибки, предоставляя вызывающей функции обработку любых потенциальных ошибок.

Итак, вместо:

const io = std.io;
...
pub fn bf(src: []const u8, storage: []u8) !void {
    const stdout = &(io.FileOutStream.init(&(io.getStdOut() catch unreachable)).stream);
    ...
            '.' => stdout.print("{c}", storage[memptr]) catch unreachable,
            ...

Делаем:

const io = std.io;
...
pub fn bf(src: []const u8, storage: []u8) !void {
    const stdout = &(io.FileOutStream.init(&(try io.getStdOut())).stream);
    ...
            '.' => try stdout.print("{c}", storage[memptr]),
            ...

Компилируем:

const bf = @import("./bf.zig").bf;
const warn = @import("std").debug.warn;

const serpinsky = "++++++++[>+>++++<<-]>++>>+<[-[>>+<<-]+>>]>+[ -<<<[ ->[+[-]+>++>>>-<<]<[<]>>++++++[<<+++++>>-]+<<++.[-]<< ]>.>+[>>]>+ ] ";

pub fn main() void {
    var storage = []u8{0} ** 30000;
    bf(serpinsky, storage[0..]) catch |err| switch (err) {
    };
}

и получаем список всех возможных ошибок, которые я могу получить, вызывая функцию!

/Users/jfo/code/zigfuck/main.zig:7:46: error: error.SystemResources not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.OperationAborted not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.IoPending not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.BrokenPipe not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.Unexpected not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.WouldBlock not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.FileClosed not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.DestinationAddressRequired not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.DiskQuota not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.FileTooBig not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.InputOutput not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.NoSpaceLeft not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.AccessDenied not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.OutOfBounds not handled in switch
/Users/jfo/code/zigfuck/main.zig:7:46: error: error.NoStdHandles not handled in switch
shell returned 1

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

pub fn main() void {
    var storage = []u8{0} ** 30000;
    bf(serpinsky, storage[0..]) catch |err| switch (err) {
        error.OutOfBounds => @panic("Out Of Bounds!"),
        else => @panic("IO error")
    };
}

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

Todo


Есть много разных улучшений, которые можно сделать с интерпретатором! Нужно на самом деле правильно делать обработку всех ошибок, очевидно, и нужно реализовать оператор ",", который в brainfuck-е выполняет роль функции getc, позволяя вводить данные в программу при её выполнении. Также нужно сделать возможность чтения исходного файла в буфер и интерпретации его, вместо использования захардкоженного исходного кода bf. Также есть некоторые улучшения, которые не являются строго необходимыми, но могут проиллюстрировать некоторые возможности Zig. Вместо того, чтобы вывалить их все в конце поста, я собираюсь разделить их на части и опубликовать в следующих постах, которые будут меньше и проще в усвоении.

Заключение


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

Я описал только малую часть синтаксиса и возможностей Zig, есть много интересных изменений, пришедших в язык в версии 0.2.0 и выше! Весь код, который я написал, скомпилирован в debug-режиме, который оптимален для проверок безопасности и для сокращения времени компиляции, чтобы делать итерации быстрее! Существуют режимы --release-fast и --release-safe, и в будущем их будет больше. Больше про их различия и объяснения про эти режимы вы можете прочитать здесь.

Я постоянно удивляюсь скоростью и направлением разработки Zig. Очень многое пока в движении, и будет таковым до выхода версии 1.0.0, и если вы решите попробовать Zig, просто помните про это, есть много хороших идей, и я с нетерпением жду их реализации!

Попробуйте, и присоединяйтесь к #zig в freenode в любое время, если у вас есть вопросы.
Поделиться публикацией

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

    +2
    Неплохо. Я сейчас тоже готовлю статью по Zig. Язык действительно интересный и своеобразный, производит впечатление некой альтернативы Си, с более строгими правилами, оригинальной реализацией обработки ошибок (что-то вроде исключений, но другое), сопрограммами и встроенным метапрограммированием непосредственно на самом языке (блоки comptime позволяют писать код на Zig который будет исполняться прямо во время компиляции). Кстати, то что начинается с собачек («builtin functions») это не только встроенные функции, но и встроенные синтаксические макросы, предоставляющие доступ к компилятору. Т.е. под одним названием собрано и то и другое.
      +2
      А нельзя это выразить как структуру вместо передачи кучи параметров?
      P.S. В статье очень много опечаток
        0
        Об опечатках на Хабре принято сообщать в личку. Заранее спасибо.
          –1
          Обычно так и делаю, но тут их что-то прям слишком много.
        –1
        .

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

        Самое читаемое