Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Значения, типы и поток управления
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Значения, типы и поток управления
В прошлом уроке
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 к индексации среза и как это выглядит в ассемблере, который мы сегодня подсмотрели одним глазом.
домашка