Раздел 32 · Системное программирование: Zig, ассемблер, Verilog

Значения, типы и поток управления

middle~90 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Значения, типы и поток управления

В прошлом уроке hello.zig прошёл путь от текста до процесса. Сегодня спускаемся на уровень ниже: что такое значение типа u3, сколько способов сложить два байта знает Zig и почему switch в этом языке умеет быть и таблицей, и интерпретатором.

Цели урока

  • Понять, что целое в Zig имеет ровно столько бит, сколько написано в имени типа, и увидеть это на u3, i5 и u128.
  • Разобрать comptime_int и три преобразования @as, @intCast, @truncate как три разных обещания компилятору.
  • Освоить четыре семейства арифметики: проверяемую, +%, +| и @addWithOverflow, и знать, какое где уместно.
  • Привыкнуть к bool, void и опционалам как к обычным типам с известным размером.
  • Написать switch с диапазонами и labeled switch, который заменяет цикл диспетчеризации в интерпретаторе.
  • Использовать for по нескольким срезам с индексом и while с шагом и ветвью else.

Идея

В главе 2 CS:APP есть таблицы, которые в C приходится представлять в уме: что случится с четырёхбитным числом при сложении, как пятибитное значение расширяется до восьми, куда девается старший бит при усечении. В C нет типа на четыре бита, есть только char, short, int, и всё, что уже, приходится эмулировать масками. В Zig u4 и i5 это настоящие типы: у них есть @bitSizeOf, минимум и максимум, их можно сложить, и компилятор проверит, что результат влез. Таблицы книги превращаются в тесты, которые можно запустить.

Вторая мысль урока важнее первой. Переполнение в системном коде это не абстрактная опасность из учебника, а конкретный выбор, который ты делаешь в каждой строке с плюсом. Zig отказывается делать этот выбор за тебя: обычный + обещает, что результат помещается, и в безопасных режимах сборки проверяет обещание. Если тебе нужно другое, ты пишешь другое: +% для счётчика по кругу, +| для уровня громкости, @addWithOverflow для длинной арифметики, где бит переноса нужен как значение. Четыре оператора вместо одного, и у каждого есть смысл, который слышно в имени.

Третья мысль про поток управления. switch в Zig обязан покрыть все случаи, умеет диапазоны и возвращает значение, а с меткой и continue превращается в computed goto. Для системщика это не синтаксический сахар: именно так пишутся быстрые интерпретаторы байткода, конечные автоматы парсеров и обработчики протоколов.

Целые любой ширины

Имя целочисленного типа в Zig это буква знака и число бит: u8, i32, u3, i5, u128, вплоть до u65535. Никаких short и long с размером, который зависит от платформы. Ширина в имени это ширина в битах, а сколько байт значение занимает в памяти, компилятор округляет вверх до ближайшего удобного размера:

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    var buf: [512]u8 = undefined;
    var w = std.Io.File.stdout().writer(init.io, &buf);
    const out = &w.interface;

    inline for (.{ u3, i3, u4, i5, u8, i8, u128 }) |T| {
        try out.print("{s:>4}: {d:>3} бит, {d:>2} байт, от {d} до {d}\n", .{
            @typeName(T),
            @bitSizeOf(T),
            @sizeOf(T),
            std.math.minInt(T),
            std.math.maxInt(T),
        });
    }
    try out.flush();
}
  u3:   3 бит,  1 байт, от 0 до 7
  i3:   3 бит,  1 байт, от -4 до 3
  u4:   4 бит,  1 байт, от 0 до 15
  i5:   5 бит,  1 байт, от -16 до 15
  u8:   8 бит,  1 байт, от 0 до 255
  i8:   8 бит,  1 байт, от -128 до 127
u128: 128 бит, 16 байт, от 0 до 340282366920938463463374607431768211455

Обрати внимание на две вещи. i3 живёт в диапазоне от -4 до 3: это дополнительный код, старший бит весит -4, остальные два дают от 0 до 3. И u3 занимает целый байт: меньше байта адресовать нельзя, поэтому @sizeOf(u3) равен единице. Плотно упаковать три бита рядом с другими можно только внутри packed struct, к этому придём в четвёртом уроке.

Пара слов о том, как этот листинг устроен, потому что дальше такой бойлерплейт будет в каждой программе с выводом. В Zig 0.16 главная функция может принять std.process.Init: там лежит init.io, объект ввода-вывода, который передаётся явно так же, как аллокатор. Запись в stdout идёт через буферизованный Writer с явным буфером, и без flush в конце вывод останется в буфере. inline for по кортежу типов разворачивается на этапе компиляции в семь копий тела цикла, потому что T это тип, а типы существуют только во время компиляции. Подробнее про comptime в шестом уроке, а пока хватит знать, что цикл по типам пишется так.

comptime_int и почему 300 компилируется, а u8 со значением 300 нет

Литерал 300 в Zig не имеет ширины. Его тип называется comptime_int: целое, которое существует только во время компиляции, не ограничено ни в какую сторону и приводится к любому целому типу, куда влезает его значение. Поэтому первая строка ниже компилируется, а вторая нет:

const std = @import("std");

const x = 300; // comptime_int: ширины у него нет
const y: u8 = 300; // ошибка компиляции

pub fn main() void {
    std.debug.print("{d} {d}\n", .{ x, y });
}
comptime_int.zig:4:15: error: type 'u8' cannot represent integer value '300'
const y: u8 = 300; // ошибка компиляции
              ^~~

x не занимает памяти и не имеет размера: это число, известное компилятору, и в момент подстановки в print оно превратится в тот тип, которого ждёт форматтер. Попытка сделать его переменной тоже провалится: var z = 300; даёт error: variable of type 'comptime_int' must be const or comptime, потому что менять во время выполнения можно только то, у чего есть размер. Как только ты пишешь var z: u16 = 300;, ширина появляется, и дальше действуют правила u16.

Из этого следует правило, которое экономит часы отладки: литерал в Zig никогда не переполнится молча. @as(u8, 300) это ошибка компиляции с тем же текстом. Молчаливые усечения возможны только там, где ты сам их попросил.

Ширины в одном выражении и где u3 встречается сам собой

Если сложить u8 и u16, узкий операнд расширится до широкого, и результат получит тип u16. Это единственное неявное приведение, которое Zig делает с целыми: в сторону, где ничего не теряется. Смешать знаковое с беззнаковым уже не выйдет, u8 плюс i8 даёт error: incompatible types: 'u8' and 'i8', потому что нет типа, который вместил бы оба без потерь и без вопросов. Каст пишешь руками, и это хорошо: в C выражение u + s с беззнаковым и знаковым операндом молча превращает знаковый в беззнаковый, и -1 становится четырьмя миллиардами.

А вот где u3 появляется без всякой просьбы: величина сдвига. Сдвинуть u8 можно на 0, 1 и так до 7 позиций, поэтому правый операнд << и >> обязан быть типа u3, и x << by с by: u8 не компилируется: error: expected type 'u3', found 'u8'. Для u64 это u6, и в стандартной библиотеке есть std.math.Log2Int(T), который вычисляет этот тип. В C сдвиг на величину, равную ширине или больше, это неопределённое поведение, в Zig такая величина непредставима в типе, и ошибка переезжает из рантайма в компилятор.

const std = @import("std");
const expectEqual = std.testing.expectEqual;

test "разные ширины: узкий расширяется до широкого" {
    const a: u8 = 250;
    const b: u16 = 10;
    const c = a + b; // тип результата u16
    try expectEqual(u16, @TypeOf(c));
    try expectEqual(@as(u16, 260), c);
}

test "сдвиг: величина сдвига имеет тип Log2Int" {
    const x: u8 = 1;
    const by: u3 = 7; // для u8 сдвиг только на 0..7, тип u3
    try expectEqual(@as(u8, 128), x << by);
    try expectEqual(u3, std.math.Log2Int(u8));
    try expectEqual(u6, std.math.Log2Int(u64));

    const wide: u64 = 1;
    const n: u6 = 63;
    try expectEqual(@as(u64, 1) << 63, wide << n);
}

test "usize это ширина указателя" {
    try expectEqual(@sizeOf(*u8), @sizeOf(usize));
    try expectEqual(@bitSizeOf(usize), @bitSizeOf(isize));
}
1/3 mixed.test.разные ширины: узкий расширяется до широкого...OK
2/3 mixed.test.сдвиг: величина сдвига имеет тип Log2Int...OK
3/3 mixed.test.usize это ширина указателя...OK
All 3 tests passed.

Последний тест про два типа, ширина которых зависит от платформы: usize и isize равны ширине указателя, 64 бита на x86-64. Индексы срезов, длины и размеры в std всегда usize, и любой счётчик, который станет индексом, лучше сразу объявлять этим типом, чтобы не расставлять @intCast на каждом обращении. Есть ещё семейство c_int, c_long, c_char с шириной по правилам C ABI целевой платформы: они нужны только на границе с C, к которой придём в шестом уроке.

Три преобразования, три обещания

В C приведение (char)x означает всё сразу: и расширение, и усечение, и смену знака, и компилятор не спрашивает, что именно ты имел в виду. В Zig таких операций три, и каждая обещает компилятору что-то своё.

const std = @import("std");
const expectEqual = std.testing.expectEqual;

test "@as: только туда, где значение точно помещается" {
    const small: u8 = 200;
    const wide = @as(u32, small); // u8 -> u32 всегда безопасно
    try expectEqual(@as(u32, 200), wide);

    const narrow: i5 = -16;
    const extended = @as(i8, narrow); // i5 -> i8: знак расширяется
    try expectEqual(@as(i8, -16), extended);

    // те же биты глазами: 10000 (i5) превращается в 11110000 (i8)
    try expectEqual(@as(u5, 0b10000), @as(u5, @bitCast(narrow)));
    try expectEqual(@as(u8, 0b1111_0000), @as(u8, @bitCast(extended)));
}

test "@intCast: обещание, что значение влезет" {
    const wide: u32 = 200;
    const byte: u8 = @intCast(wide); // 200 помещается в u8, всё честно
    try expectEqual(@as(u8, 200), byte);

    const neg: i32 = -5;
    const back: i8 = @intCast(neg); // -5 помещается в i8
    try expectEqual(@as(i8, -5), back);
}

test "@truncate: отрезать старшие биты" {
    const wide: u32 = 0x1234_5678;
    const low: u8 = @truncate(wide); // остаётся младший байт
    try expectEqual(@as(u8, 0x78), low);

    const three: u3 = @truncate(@as(u8, 0b1010_1101)); // остаются 3 младших бита
    try expectEqual(@as(u3, 0b101), three);

    const signed: i5 = @truncate(@as(i8, 100)); // 100 = 0b0110_0100, младшие 5 бит 00100
    try expectEqual(@as(i5, 4), signed);
}
1/3 casts.test.@as: только туда, где значение точно помещается...OK
2/3 casts.test.@intCast: обещание, что значение влезет...OK
3/3 casts.test.@truncate: отрезать старшие биты...OK
All 3 tests passed.

@as(T, x) это не каст, а подсказка типа: оно работает только там, где Zig и так согласился бы привести значение сам, то есть при расширении. u8 в u32, i5 в i8, литерал в любой подходящий тип. Расширение знакового типа тянет старший бит на все новые позиции: -16 в i5 это 10000, в i8 это 11110000, и второй тест показывает эти биты через @bitCast. Это расширение знака, о котором книга пишет целый параграф, а Zig делает его при любом @as из узкого знакового в широкий знаковый.

@intCast(x) сужает и обещает компилятору, что значение поместится. Целевой тип берётся из контекста, поэтому пишется const byte: u8 = @intCast(wide);. В Debug и ReleaseSafe обещание проверяется в рантайме, в ReleaseFast нет. Вот как это выглядит на реальном запуске, если собрать программу, которая берёт число из аргумента командной строки:

const std = @import("std");

fn toByte(x: u32) u8 {
    return @intCast(x);
}

pub fn main(init: std.process.Init) !void {
    const args = try init.minimal.args.toSlice(init.arena.allocator());
    const value = try std.fmt.parseInt(u32, args[1], 10);
    std.debug.print("{d} -> {d}\n", .{ value, toByte(value) });
}
$ zig build-exe intcast.zig
$ ./intcast 200
200 -> 200
$ ./intcast 300
thread 45231303 panic: integer does not fit in destination type
intcast.zig:4:12: 0x10048aa07 in toByte (intcast)
    return @intCast(x);
           ^
intcast.zig:10:53: 0x100485abb in main (intcast)
    std.debug.print("{d} -> {d}\n", .{ value, toByte(value) });
                                                    ^

Паника указывает на строку с @intCast и на строку вызова. Та же программа, собранная с -O ReleaseFast, на входе 300 напечатала 044 -> 044: обещание нарушено, проверки нет, и это неопределённое поведение, в котором оптимизатор поверил, что значение влезает в байт, и переписал вывод так, как ему было удобно. Не «неверный ответ», а мусор без гарантий.

@truncate(x) обещает ровно противоположное: старшие биты не нужны, отрежь их. Никакой проверки нет ни в каком режиме, потому что усечение и есть цель. 0x1234_5678 в u8 это 0x78, 0b1010_1101 в u3 это 0b101, а 100 в i5 это 4, потому что после отрезания трёх старших битов от 01100100 остаётся 00100. Ровно та операция, которую книга описывает как «сложение по модулю два в степени k».

Выбирай так: расширяешь, пиши @as или ничего. Сужаешь и уверен, что влезет, пиши @intCast, и пусть Debug проверит уверенность. Сужаешь и хочешь именно младшие биты, пиши @truncate. Если сомневаешься, какое из двух последних, значит тебе нужен @intCast: он хотя бы упадёт громко.

Четыре семейства арифметики

Теперь главное. У обычного сложения в Zig есть контракт: результат помещается в тип операндов. Если нет, в Debug и ReleaseSafe программа паникует, в ReleaseFast результат не определён. Для случаев, когда переполнение это не ошибка, а часть замысла, есть три других сложения. Поиграй с виджетом, прежде чем читать дальше: выбери i5, поставь оба слайдера на 10 и посмотри, как четыре карточки отвечают на одну и ту же сумму.

Окружность это все битовые комбинации типа по кругу: у u3 восемь позиций, у i5 тридцать две. Для беззнакового типа шов между максимумом и нулём, для знакового между 15 и -16: это то место, где 01111 плюс единица даёт 10000. Сложение это шаги по кругу от a на b позиций, и если дуга прошла через шов, точная сумма в тип не влезла. Все четыре карточки считаются той же формулой, что и Zig, и ниже те же примеры в тестах:

const std = @import("std");
const expectEqual = std.testing.expectEqual;

test "обычный плюс: результат обязан поместиться" {
    const a: u8 = 200;
    const b: u8 = 50;
    try expectEqual(@as(u8, 250), a + b);
}

test "+% заворачивает по модулю 2^n" {
    const a: u8 = 200;
    const b: u8 = 100;
    try expectEqual(@as(u8, 44), a +% b); // 300 - 256

    const c: i8 = 127;
    try expectEqual(@as(i8, -128), c +% 1);

    const d: u3 = 7;
    try expectEqual(@as(u3, 0), d +% 1);
}

test "+| упирается в границу" {
    const a: u8 = 200;
    const b: u8 = 100;
    try expectEqual(@as(u8, 255), a +| b);

    const c: i8 = -100;
    try expectEqual(@as(i8, -128), c -| 100);

    const d: i5 = 10;
    try expectEqual(@as(i5, 15), d +| 10);
}

test "@addWithOverflow отдаёт результат и бит переполнения" {
    const a: u8 = 200;
    const b: u8 = 100;
    const r = @addWithOverflow(a, b);
    try expectEqual(@as(u8, 44), r[0]);
    try expectEqual(@as(u1, 1), r[1]);

    const ok = @addWithOverflow(@as(u8, 1), @as(u8, 2));
    try expectEqual(@as(u8, 3), ok[0]);
    try expectEqual(@as(u1, 0), ok[1]);

    // для знаковых бит означает знаковое переполнение
    const s = @addWithOverflow(@as(i8, 100), @as(i8, 50));
    try expectEqual(@as(i8, -106), s[0]);
    try expectEqual(@as(u1, 1), s[1]);

    const m = @mulWithOverflow(@as(u8, 16), @as(u8, 16));
    try expectEqual(@as(u8, 0), m[0]);
    try expectEqual(@as(u1, 1), m[1]);
}
1/4 four_adds.test.обычный плюс: результат обязан поместиться...OK
2/4 four_adds.test.+% заворачивает по модулю 2^n...OK
3/4 four_adds.test.+| упирается в границу...OK
4/4 four_adds.test.@addWithOverflow отдаёт результат и бит переполнения...OK
All 4 tests passed.

Разберём семейства по одному, потому что различия между ними и есть содержание урока.

Проверяемая арифметика: +, -, *. Контракт «результат влезает». Это то, что ты пишешь по умолчанию, когда переполнение означает ошибку в логике: индекс, размер буфера, счётчик элементов, сумма денег. Вот что происходит, когда контракт нарушен, на программе с двумя аргументами:

const std = @import("std");

fn add(a: u8, b: u8) u8 {
    return a + b;
}

pub fn main(init: std.process.Init) !void {
    const args = try init.minimal.args.toSlice(init.arena.allocator());
    const a = try std.fmt.parseInt(u8, args[1], 10);
    const b = try std.fmt.parseInt(u8, args[2], 10);
    std.debug.print("{d} + {d} = {d}\n", .{ a, b, add(a, b) });
}
$ zig build-exe overflow_panic.zig
$ ./overflow_panic 200 50
200 + 50 = 250
$ ./overflow_panic 200 100
thread 45236535 panic: integer overflow
overflow_panic.zig:4:14: 0x100ce2227 in add (overflow_panic)
    return a + b;
             ^
$ zig build-exe overflow_panic.zig -O ReleaseFast -femit-bin=overflow_fast
$ ./overflow_fast 200 100
200 + 100 = 44

В Debug громкая паника с точной строкой. В ReleaseFast процессор просто сложил байты и отбросил перенос, получилось 44, и никто не узнал. Это ровно поведение C, только в C оно всегда, а в Zig только там, где ты сам выключил проверки на всю сборку. Цена проверки видна в ассемблере той же функции под ReleaseSafe, если пометить её export, чтобы оптимизатор не встроил её в main: после add dil, sil стоит один условный переход jb на вызов паники, а в ReleaseFast остаётся голое lea eax, [rsi + rdi]. Одна инструкция, и она почти всегда предсказывается верно.

Заворачивающая арифметика: +%, -%, *%. Контракт «считай по модулю два в степени n». Знак процента это намёк на остаток от деления. 200 +% 100 для u8 даёт 44, 127 +% 1 для i8 даёт -128, 7 +% 1 для u3 даёт 0. Ни в каком режиме сборки проверки нет, потому что заворот и есть смысл. Применения: хеш-функции, генераторы псевдослучайных чисел, счётчики тактов и порядковые номера пакетов, где «следующее после максимума» это ноль по определению.

Насыщающая арифметика: +|, -|, *|, <<|. Контракт «упрись в границу». Вертикальная черта рисует стенку. 200 +| 100 для u8 это 255, -100 -| 100 для i8 это -128, 10 +| 10 для i5 это 15. Применения: обработка звука и изображений, где яркость 255 + 10 должна остаться 255, а не стать 9; индикаторы и таймауты, где лишняя секунда не должна перевести счётчик в отрицательную область. У процессоров есть SIMD-инструкции с насыщением ровно для этого, и +| на векторах в них и компилируется.

Арифметика с флагом: @addWithOverflow, @subWithOverflow, @mulWithOverflow, @shlWithOverflow. Контракт «отдай мне заворачивающий результат и скажи, было ли переполнение». Возвращается кортеж: r[0] результат того же типа, r[1] бит типа u1. Для беззнаковых это бит переноса из старшего разряда, для знаковых бит знакового переполнения: 100 + 50 в i8 даёт -106 и единицу, потому что два положительных числа сложились в отрицательное. Применения: длинная арифметика, где перенос идёт в следующее слово; парсеры чисел, которые должны отклонить 99999999999 вместо того, чтобы упасть; и любые обёртки с явным результатом, вроде той, что ты напишешь в практике. Это ровно флаги CF и OF процессора, поднятые на уровень языка, и в блоке про ассемблер ты увидишь, что @addWithOverflow компилируется в add плюс чтение флага.

Схема выбора на каждый день:

СитуацияОператорЧто при переполнении
Переполнение это багa + bпаника в Debug и ReleaseSafe
Считаем по кругу (хеш, счётчик тактов)a +% bрезультат по модулю два в степени n
Значение упирается в шкалу (яркость, громкость)`a +b`
Нужен и результат, и факт переполнения@addWithOverflow(a, b)кортеж из завёрнутого результата и бита

bool, void и опционалы

Три типа, к которым после C и JavaScript надо привыкать заново, потому что в Zig они честные.

const std = @import("std");
const expectEqual = std.testing.expectEqual;
const expect = std.testing.expect;

test "bool это не число" {
    const flag: bool = 200 > 100;
    try expectEqual(@as(u1, 1), @intFromBool(flag)); // явно, через builtin
    try expectEqual(@as(usize, 1), @sizeOf(bool));
    try expect(flag and !false);
}

test "void занимает ноль байт" {
    try expectEqual(@as(usize, 0), @sizeOf(void));
    // множество: словарь с void-значениями хранит только ключи
    var seen = std.AutoHashMap(u32, void).init(std.testing.allocator);
    defer seen.deinit();
    try seen.put(42, {});
    try expect(seen.contains(42));
    try expect(!seen.contains(7));
}

fn findFirstNegative(items: []const i32) ?usize {
    for (items, 0..) |item, i| {
        if (item < 0) return i;
    }
    return null;
}

test "опционал: либо значение, либо null" {
    const numbers = [_]i32{ 3, 8, -1, 5 };
    const found = findFirstNegative(&numbers);
    try expectEqual(@as(?usize, 2), found);

    // развернуть с запасным значением
    const idx = findFirstNegative(&.{ 1, 2, 3 }) orelse 0;
    try expectEqual(@as(usize, 0), idx);

    // развернуть через if с захватом
    if (found) |i| {
        try expectEqual(@as(i32, -1), numbers[i]);
    } else {
        unreachable;
    }

    // ?u8 хранит флаг отдельно, ?*T прячет null в нулевой адрес
    try expectEqual(@as(usize, 2), @sizeOf(?u8));
    try expectEqual(@sizeOf(*u8), @sizeOf(?*u8));
}
1/3 bool_void_opt.test.bool это не число...OK
2/3 bool_void_opt.test.void занимает ноль байт...OK
3/3 bool_void_opt.test.опционал: либо значение, либо null...OK
All 3 tests passed.

bool не приводится к целому и обратно: if (x) с числом не компилируется, flag + 1 тоже. Если бит нужен как число, есть @intFromBool, и это единственная дорога. Логические операторы пишутся словами and, or, !, и and с or ленивые, как && и || в C.

void это тип с одним значением {} и нулевым размером. Функция без результата возвращает void, а std.AutoHashMap(u32, void) это множество: под значения не выделяется ни байта, остаются только ключи. Тот же приём работает с любым дженериком, который параметризован типом значения.

Опционал ?T это либо значение T, либо null, и достать значение можно только явно: orelse с запасным вариантом, if (x) |v| с захватом, или x.?, который паникует на null в безопасных режимах. Так что нулевого указателя, который C передаёт молча, в Zig не существует: указатель *T всегда на что-то указывает, а «может быть, указатель» это ?*T. Последние две строки теста показывают цену: ?u8 занимает два байта, потому что флаг «есть значение» хранится рядом, а ?*u8 занимает столько же, сколько *u8, потому что адрес ноль свободен и компилятор прячет null в него. Это одна из оптимизаций раскладки, которые увидим в четвёртом уроке.

switch: таблица с диапазонами

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

const std = @import("std");
const expectEqual = std.testing.expectEqual;

const CharClass = enum { digit, lower, upper, space, other };

fn classify(c: u8) CharClass {
    return switch (c) {
        '0'...'9' => .digit,
        'a'...'z' => .lower,
        'A'...'Z' => .upper,
        ' ', '\t', '\n', '\r' => .space,
        else => .other,
    };
}

fn hexValue(c: u8) ?u4 {
    return switch (c) {
        '0'...'9' => @intCast(c - '0'),
        'a'...'f' => @intCast(c - 'a' + 10),
        'A'...'F' => @intCast(c - 'A' + 10),
        else => null,
    };
}

fn describe(n: i8) []const u8 {
    return switch (n) {
        std.math.minInt(i8)...-1 => "отрицательное",
        0 => "ноль",
        1...9 => "однозначное",
        10...99 => "двузначное",
        100...std.math.maxInt(i8) => "трёхзначное",
    };
}

test "switch по диапазонам символов" {
    try expectEqual(CharClass.digit, classify('7'));
    try expectEqual(CharClass.lower, classify('q'));
    try expectEqual(CharClass.upper, classify('Q'));
    try expectEqual(CharClass.space, classify('\n'));
    try expectEqual(CharClass.other, classify('#'));
}

test "switch как таблица шестнадцатеричных цифр" {
    try expectEqual(@as(?u4, 0), hexValue('0'));
    try expectEqual(@as(?u4, 10), hexValue('a'));
    try expectEqual(@as(?u4, 15), hexValue('F'));
    try expectEqual(@as(?u4, null), hexValue('g'));
}

test "switch без else покрывает весь i8" {
    try expectEqual("отрицательное", describe(-128));
    try expectEqual("ноль", describe(0));
    try expectEqual("трёхзначное", describe(127));
}
1/3 switch_ranges.test.switch по диапазонам символов...OK
2/3 switch_ranges.test.switch как таблица шестнадцатеричных цифр...OK
3/3 switch_ranges.test.switch без else покрывает весь i8...OK
All 3 tests passed.

switch в Zig это выражение, а не набор меток с проваливанием. Три правила: все случаи должны быть покрыты, либо явно, либо через else; ветки не проваливаются друг в друга; диапазоны пишутся через ... и включают оба конца.

В hexValue обрати внимание на @intCast в ветках: c - '0' имеет тип u8, а функция обещает ?u4. Компилятор знает диапазон ветки, но не выводит из него, что результат влезает в четыре бита, поэтому сужение пишем руками, и в Debug оно проверяется. А describe покрывает весь i8 пятью диапазонами без else: попробуй убрать последнюю ветку, и компилятор откажет: error: switch must handle all possibilities. Это та же проверка полноты, что у match в Rust, и она же не даёт забыть новый вариант перечисления во всех switch по нему: для перечисления компилятор к этой ошибке ещё и назовёт пропущенный вариант.

labeled switch: интерпретатор без цикла

Классический интерпретатор байткода выглядит как цикл вокруг switch:

const std = @import("std");
const expectEqual = std.testing.expectEqual;

const Op = enum(u8) { push, add, halt };

fn run(code: []const u8) i32 {
    var stack: [16]i32 = undefined;
    var sp: usize = 0;
    var pc: usize = 0;
    while (true) {
        const o: Op = @enumFromInt(code[pc]);
        switch (o) {
            .push => {
                stack[sp] = code[pc + 1];
                sp += 1;
                pc += 2;
            },
            .add => {
                sp -= 1;
                stack[sp - 1] += stack[sp];
                pc += 1;
            },
            .halt => return stack[sp - 1],
        }
    }
}

test "while + switch" {
    const code = [_]u8{ 0, 2, 0, 3, 1, 2 };
    try expectEqual(@as(i32, 5), run(&code));
}

Каждая инструкция заканчивается одинаково: возврат в начало цикла, чтение опкода, один общий переход по таблице. Общий переход это узкое место: предсказатель ветвлений процессора видит одну инструкцию jmp, через которую проходят все опкоды подряд, и предсказывает её плохо. Интерпретаторы на C решают это расширением GCC goto *table[op], тем самым computed goto, где каждая инструкция сама прыгает на следующую. В Zig это встроено в язык: switch с меткой и continue :label value, который заново входит в тот же switch с новым операндом. Вот полноценная стековая машина без единого while:

const std = @import("std");
const expectEqual = std.testing.expectEqual;

const Op = enum(u8) {
    push, // следующий байт это значение
    add,
    mul,
    dup,
    jump_if_zero, // следующий байт это адрес
    halt,
};

const Vm = struct {
    stack: [16]i32 = undefined,
    sp: usize = 0,
    pc: usize = 0,

    fn push(vm: *Vm, v: i32) void {
        vm.stack[vm.sp] = v;
        vm.sp += 1;
    }

    fn pop(vm: *Vm) i32 {
        vm.sp -= 1;
        return vm.stack[vm.sp];
    }

    fn run(vm: *Vm, code: []const u8) i32 {
        // Один switch, без внешнего while: каждая ветка сама говорит,
        // какой опкод исполнять следующим, через continue :dispatch.
        dispatch: switch (@as(Op, @enumFromInt(code[vm.pc]))) {
            .push => {
                vm.push(code[vm.pc + 1]);
                vm.pc += 2;
                continue :dispatch @enumFromInt(code[vm.pc]);
            },
            .add => {
                const b = vm.pop();
                const a = vm.pop();
                vm.push(a + b);
                vm.pc += 1;
                continue :dispatch @enumFromInt(code[vm.pc]);
            },
            .mul => {
                const b = vm.pop();
                const a = vm.pop();
                vm.push(a * b);
                vm.pc += 1;
                continue :dispatch @enumFromInt(code[vm.pc]);
            },
            .dup => {
                const top = vm.stack[vm.sp - 1];
                vm.push(top);
                vm.pc += 1;
                continue :dispatch @enumFromInt(code[vm.pc]);
            },
            .jump_if_zero => {
                const target = code[vm.pc + 1];
                vm.pc = if (vm.pop() == 0) target else vm.pc + 2;
                continue :dispatch @enumFromInt(code[vm.pc]);
            },
            .halt => return vm.pop(),
        }
    }
};

fn op(o: Op) u8 {
    return @intFromEnum(o);
}

test "(2 + 3) * 4" {
    // zig fmt: off
    const code = [_]u8{
        op(.push), 2,
        op(.push), 3,
        op(.add),
        op(.push), 4,
        op(.mul),
        op(.halt),
    };
    // zig fmt: on
    var vm = Vm{};
    try expectEqual(@as(i32, 20), vm.run(&code));
}

test "jump_if_zero выбирает ветку" {
    // push 0; jump_if_zero -> 7; push 1; halt; push 9; halt
    // zig fmt: off
    const code = [_]u8{
        op(.push), 0,          // 0..1
        op(.jump_if_zero), 7,  // 2..3
        op(.push), 1,          // 4..5
        op(.halt),             // 6
        op(.push), 9,          // 7..8
        op(.halt),             // 9
    };
    // zig fmt: on
    var vm = Vm{};
    try expectEqual(@as(i32, 9), vm.run(&code));
}
1/2 bytecode.test.(2 + 3) * 4...OK
2/2 bytecode.test.jump_if_zero выбирает ветку...OK
All 2 tests passed.

Прежде чем читать разбор, прогони эту машину по шагам сам. Виджет ниже исполняет ровно тот Vm из листинга: те же шесть опкодов, тот же стек на 16 значений, и каждый шаг это одна ветка switch вместе с её continue :dispatch. Следи, как меняются pc и sp, и куда уходит переход в конце ветки; последняя программа в списке заранее отвечает на вопрос из упражнения про байт без варианта Op.

Прочитай continue :dispatch @enumFromInt(code[vm.pc]) буквально: «продолжи switch с меткой dispatch, но теперь с вот этим значением». Это не возврат к началу цикла, потому что цикла нет. Метка стоит на самом switch, и continue с меткой и операндом перезапускает выбор ветки. Единственный выход из машины это return в ветке halt. Метка перед switch пишется так же, как метка перед блоком или циклом, и break :dispatch value тоже работает, если нужно выйти со значением, не возвращаясь из функции.

Что это даёт на уровне машинного кода, можно посмотреть уже сейчас, хотя ассемблер начнётся только в следующем блоке. Сохрани листинг машины как vm_asm.zig и допиши в конец экспортную обёртку: без неё объектный файл окажется пустым, потому что run вызывают только тесты, а тесты в build-obj не собираются. Экспортная функция принимает указатель и длину вместо среза, потому что срез это не тип из C ABI:

export fn vm_run(vm: *Vm, code: [*]const u8, len: usize) i32 {
    return vm.run(code[0..len]);
}

Соберём её под Linux x86-64 с оптимизацией и попросим компилятор выдать ассемблерный листинг:

$ zig build-obj vm_asm.zig -O ReleaseFast -target x86_64-linux -femit-asm=vm.s -fno-emit-bin

Сокращённая выжимка из vm.s, только переходы и таблица:

vm_asm.vm_run:
        ...
        jmp     vm_asm.Vm.run                    ; обёртка сразу прыгает в run
vm_asm.Vm.run:
        mov     rax, qword ptr [rdi + 8]         ; vm.pc
        movzx   ecx, byte ptr [rsi + rax]        ; code[pc]
        jmp     qword ptr [8*rcx + __jmptab_204] ; прыжок в ветку по опкоду
.LBB1_1:                                         ; .push
        ...
        movzx   ecx, byte ptr [rsi + rax]        ; следующий опкод
        jmp     qword ptr [8*rcx + __jmptab_204] ; и сразу в его ветку
.LBB1_2:                                         ; .add
        ...
        jmp     qword ptr [8*rcx + __jmptab_204]
        ...
.LBB1_6:                                         ; .halt
        ...
        ret
__jmptab_204:
        .quad   .Ltmp5
        .quad   .Ltmp11
        .quad   .Ltmp18
        .quad   .Ltmp26
        .quad   .Ltmp32
        .quad   .Ltmp38

В листинге шесть инструкций jmp через таблицу переходов __jmptab_204 из шести адресов, по одному на ветку: один на входе в run и по одному в конце каждой ветки, кроме halt, которая заканчивается ret. Каждая ветка заканчивается собственным косвенным jmp, а не переходом в общую точку. У предсказателя теперь шесть разных инструкций перехода вместо одной, и у каждой своя история: после push обычно идёт push или add, и процессор это выучит. В варианте с while был бы один jmp на все случаи. Ради этого выигрыша конструкция и появилась в языке: команда Zig вводила её, чтобы ускорить собственный токенизатор и парсер, и замерь разницу сам на своей машине, она зависит от процессора и от программы.

Циклы: for по нескольким срезам и while с else

for в Zig ходит только по срезам, массивам и диапазонам, счётчика с условием у него нет. Зато он умеет идти по нескольким последовательностям одновременно и давать индекс через диапазон 0..:

const std = @import("std");
const expectEqual = std.testing.expectEqual;

fn dot(a: []const i32, b: []const i32) i32 {
    var sum: i32 = 0;
    for (a, b) |x, y| { // длины обязаны совпадать, иначе паника в Debug
        sum += x * y;
    }
    return sum;
}

fn indexOfMax(items: []const u8) ?usize {
    var best: ?usize = null;
    for (items, 0..) |item, i| {
        if (best == null or item > items[best.?]) best = i;
    }
    return best;
}

fn firstPrimeAbove(limit: u32) u32 {
    var n: u32 = limit + 1;
    return while (true) : (n += 1) {
        if (isPrime(n)) break n;
    };
}

fn isPrime(n: u32) bool {
    if (n < 2) return false;
    var d: u32 = 2;
    // else срабатывает, когда условие цикла стало ложным, а break не случился
    return while (d * d <= n) : (d += 1) {
        if (n % d == 0) break false;
    } else true;
}

fn contains(haystack: []const u8, needle: u8) bool {
    return for (haystack) |c| {
        if (c == needle) break true;
    } else false;
}

test "for по двум срезам и по индексу" {
    try expectEqual(@as(i32, 32), dot(&.{ 1, 2, 3 }, &.{ 4, 5, 6 }));
    try expectEqual(@as(?usize, 1), indexOfMax(&.{ 3, 9, 4 }));
    try expectEqual(@as(?usize, null), indexOfMax(&.{}));
}

test "while с шагом и else" {
    try expectEqual(true, isPrime(97));
    try expectEqual(false, isPrime(91));
    try expectEqual(@as(u32, 101), firstPrimeAbove(97));
}

test "for с else" {
    try expectEqual(true, contains("zig", 'i'));
    try expectEqual(false, contains("zig", 'x'));
}
1/3 loops.test.for по двум срезам и по индексу...OK
2/3 loops.test.while с шагом и else...OK
3/3 loops.test.for с else...OK
All 3 tests passed.

for (a, b) |x, y| идёт по двум срезам в ногу и в Debug проверяет, что длины равны: в C такой цикл молча вышел бы за границу короткого массива. for (items, 0..) |item, i| даёт индекс без отдельной переменной, а диапазон 0.. без верхней границы значит «сколько будет нужно». Тип индекса usize.

while (cond) : (step) это for из C без инициализации: шаг в скобках после двоеточия выполняется в конце каждой итерации, включая ту, что закончилась continue. Ветка else у цикла выполняется, когда условие стало ложным, и не выполняется после break. Поэтому цикл в Zig это выражение: break value отдаёт значение из тела, else value отдаёт значение, если тело ни разу не прервалось. isPrime и contains целиком состоят из одного такого выражения, без флага found и без отдельного return после цикла. Это та же идея, что for ... else в Python, только здесь она работает и с while, и со значением.

Практика

Пять функций, каждая с явным контрактом на переполнение: сумма с null при переполнении через @addWithOverflow, умножение по модулю, вычитание с насыщением, обрезка i32 в u4 с последующим @intCast и расширение знака из i5 в i8. Заготовка возвращает заглушки, тесты проверяют границы: 127 + 1, -128 - 1, 255 * 255, отрицательные и огромные входы для clampToU4, -16 для signExtend. Сигнатуры менять нельзя, тесты импортируют функции по имени.

Упражнения

Итоги

  • Ширина в имени типа это ширина в битах: u3 и i5 настоящие типы с минимумом, максимумом и проверкой переполнения, а в памяти они округляются до байта.
  • Литерал имеет тип comptime_int без ширины, поэтому const x = 300; компилируется, а const y: u8 = 300; нет. Молчаливого усечения литералов не существует.
  • @as расширяет и тянет знак, @intCast сужает с проверкой в безопасных режимах, @truncate отрезает старшие биты без проверки. В ReleaseFast нарушенный @intCast это неопределённое поведение.
  • Четыре семейства арифметики: + паникует при переполнении, +% заворачивает по модулю, +| упирается в границу, @addWithOverflow возвращает завёрнутый результат и бит.
  • bool не число, void занимает ноль байт, ?T разворачивается только явно, а ?*T стоит столько же, сколько *T.
  • switch обязан покрыть все случаи, умеет диапазоны и возвращает значение. labeled switch с continue :label value это computed goto: каждая ветка компилируется в собственный косвенный jmp через таблицу переходов.
  • for идёт по нескольким срезам в ногу и даёт индекс через 0.., while принимает шаг после двоеточия, и у обоих циклов есть else, который делает цикл выражением.

Дальше

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

домашка

Домашка