Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Ошибки, defer и аллокаторы
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Ошибки, defer и аллокаторы
В Zig ошибка это число, которое функция возвращает вместо результата, а память это параметр, который функция получает снаружи. Из двух этих решений вырастает весь стиль языка:
tryвместо исключений,deferвместо деструкторов и аллокатор, который можно подменить в тесте на такой, что сам найдёт утечку.
Цели урока
В этом уроке ты:
- Разберёшь наборы ошибок: как они сливаются, выводятся компилятором и во что превращаются в рантайме.
- Освоишь
try,catchсо значением по умолчанию и соswitch,catch unreachableи трассировку ошибок в Debug. - Увидишь на реальном выводе, в каком порядке срабатывают
defer, что делаетdeferв цикле и когда стреляетerrdefer. - Прочитаешь
std.mem.Allocatorкак структуру из двух указателей и напишешь свой аллокатор-обёртку. - Сравнишь
page_allocator,FixedBufferAllocator,ArenaAllocatorиDebugAllocatorна одной и той же функции. - Научишься работать с
std.ArrayListбез владельца и отдавать память наружу черезtoOwnedSlice.
Идея: ошибка это значение, память это параметр
Возьми функцию, которая читает конфиг: скопировала имя в кучу, пошла разбирать порт, а порт оказался «80x0». Функция возвращает ошибку. Кто вернёт скопированное имя? В языке с исключениями ответ размазан по деструкторам и блокам finally, в C это метка cleanup и goto. В Zig ответ уместится в одну строку, и к концу урока ты увидишь её в выводе программы. Но сначала два решения, из которых эта строка вырастает.
В первом уроке ты видел, что Zig ничего не прячет: нет скрытых вызовов, нет скрытых преобразований, нет скрытой раскладки структур. Сегодня то же правило применяется к двум вещам, которые в большинстве языков скрыты сильнее всего: к пути ошибки и к пути памяти.
Ошибка в Zig это не объект и не раскрутка стека. Это значение типа error: маленькое целое число с именем, и функция либо возвращает результат, либо возвращает такое число. Компилятор знает набор возможных ошибок каждой функции и заставляет вызывающего решить, что с ними делать. Никакого throw, который летит через десять кадров и ловится там, где никто его не ждал. Если ты проходил Result и ? в Rust, модель та же, только без обёртки-перечисления: ошибка и значение живут в одном типе E!T.
Память в Zig это не глобальный malloc. Функция, которой нужна память, принимает std.mem.Allocator первым аргументом. Кто вызывает, тот решает, откуда память: из буфера на стеке, из арены, которая освободится одним махом, или из отладочного аллокатора, который в конце программы напечатает список утечек со стеком вызова. Аллокатор это интерфейс, и в конце урока ты напишешь свой.
Между ошибками и памятью есть мост: defer и errdefer. Функция, которая взяла память и вернула ошибку на полпути, обязана память вернуть. В C ту же работу делают метка cleanup и goto, в C++ деструкторы. В Zig это одна строка сразу после аллокации, и компилятор сам ставит её в каждую точку выхода.
Наборы ошибок
Набор ошибок это error{ A, B }: тип, чьи значения выглядят как error.A. Два набора сливаются оператором ||, а функция возвращает ошибку или результат через тип E!T: слева набор, справа значение.
const std = @import("std");
const ParseError = error{ MissingEquals, InvalidNumber };
const IoError = error{ Disconnected, Timeout };
// Слияние двух наборов оператором ||.
const AppError = ParseError || IoError;
fn parseDigit(c: u8) ParseError!u8 {
if (c < '0' or c > '9') return error.InvalidNumber;
return c - '0';
}
// Без явного набора компилятор выводит его сам:
// сюда попадут ошибки parseDigit и error.Empty.
fn firstDigit(s: []const u8) !u8 {
if (s.len == 0) return error.Empty;
return parseDigit(s[0]);
}
// try это сахар над catch |e| return e.
fn firstDigitLong(s: []const u8) !u8 {
const d = parseDigit(s[0]) catch |e| return e;
return d;
}
test "ошибка это значение" {
const err: AppError = error.Timeout;
try std.testing.expectEqualStrings("Timeout", @errorName(err));
try std.testing.expect(@intFromError(err) != 0);
}
test "try пробрасывает ошибку наверх" {
try std.testing.expectEqual(@as(u8, 7), try firstDigit("7x"));
try std.testing.expectError(error.InvalidNumber, firstDigit("x7"));
try std.testing.expectError(error.Empty, firstDigit(""));
try std.testing.expectEqual(@as(u8, 4), try firstDigitLong("42"));
}
test "маленький набор вкладывается в большой без приведения" {
const narrow: ParseError = error.InvalidNumber;
const wide: AppError = narrow;
try std.testing.expectEqual(error.InvalidNumber, wide);
}
Три вещи, на которые стоит смотреть.
Вывод набора. !u8 без набора слева значит «набор выведи сам». Компилятор собирает все return error.X в теле и все наборы вызванных функций. В приватных функциях это экономит строки, а у публичного API набор лучше писать явно: тогда добавленная внутри ошибка не просочится наружу незаметно, компилятор потребует её объявить.
Ошибка это число. @errorName возвращает имя как строку, @intFromError возвращает номер. Номера уникальны на всю программу: error.Timeout из двух разных наборов это одно и то же значение, поэтому маленький набор вкладывается в большой без приведения. Обратное направление, из AppError в ParseError, требует явного @errorCast или switch.
try это сахар. Запись try f() разворачивается в f() catch |e| return e. Никакой магии, только короткая форма самого частого действия: пробросить ошибку вызывающему.
Есть и набор всех ошибок сразу, anyerror. Функция с параметром anyerror принимает любую ошибку программы. В switch по нему обязательна ветка else, потому что перечислить все ошибки нельзя.
const std = @import("std");
fn describe(err: anyerror) []const u8 {
return switch (err) {
error.OutOfMemory => "памяти нет",
error.FileNotFound => "файла нет",
else => @errorName(err),
};
}
test "anyerror принимает любую ошибку" {
try std.testing.expectEqualStrings("памяти нет", describe(error.OutOfMemory));
try std.testing.expectEqualStrings("Timeout", describe(error.Timeout));
}
Пользуйся anyerror там, где ошибка только логируется. Там, где по ошибке принимается решение, нужен конкретный набор: компилятор проверит, что каждая ветка switch разобрана.
try, catch и switch по ошибке
Проброс наверх это не единственный ответ на ошибку. catch даёт три формы: значение по умолчанию, разбор по ветвям и утверждение, что ошибка невозможна.
const std = @import("std");
const ParseError = error{ MissingEquals, InvalidNumber, Overflow };
fn parsePort(s: []const u8) ParseError!u16 {
var value: u32 = 0;
for (s) |c| {
if (c < '0' or c > '9') return error.InvalidNumber;
value = value * 10 + (c - '0');
if (value > 65535) return error.Overflow;
}
if (s.len == 0) return error.InvalidNumber;
return @intCast(value);
}
// catch с дефолтом: ошибка превращается в значение.
fn portOrDefault(s: []const u8) u16 {
return parsePort(s) catch 8080;
}
// catch |e| switch (e): по каждой ошибке своё решение.
fn portOrExplain(s: []const u8) []const u8 {
_ = parsePort(s) catch |e| return switch (e) {
error.InvalidNumber => "not a number",
error.Overflow => "too big for u16",
error.MissingEquals => unreachable,
};
return "ok";
}
test "catch с дефолтом" {
try std.testing.expectEqual(@as(u16, 443), portOrDefault("443"));
try std.testing.expectEqual(@as(u16, 8080), portOrDefault("http"));
}
test "catch со switch по ошибке" {
try std.testing.expectEqualStrings("ok", portOrExplain("80"));
try std.testing.expectEqualStrings("not a number", portOrExplain("8O"));
try std.testing.expectEqualStrings("too big for u16", portOrExplain("70000"));
}
test "catch unreachable, когда ошибка невозможна" {
// Литерал точно парсится, ошибка здесь была бы багом программы,
// и в Debug такой catch превращается в панику.
const port = parsePort("22") catch unreachable;
try std.testing.expectEqual(@as(u16, 22), port);
}
Обрати внимание на switch в portOrExplain: компилятор знает, что parsePort возвращает ровно три ошибки, и требует разобрать все три. Ветка с unreachable для MissingEquals это честное заявление «в этой функции такое невозможно», и если когда-нибудь parsePort начнёт её возвращать, Debug-сборка упадёт ровно в этом месте, а не где-то дальше по цепочке.
catch unreachable это то же заявление на уровне целого вызова. В Debug и ReleaseSafe оно проверяется и превращается в панику, в ReleaseFast компилятор верит на слово и выкидывает проверку. Пиши его только там, где ошибка действительно означает баг, а не плохой ввод.
defer: порядок и цикл
defer откладывает выражение до выхода из текущего блока. Несколько defer в одном блоке срабатывают в обратном порядке, как стек. Это ровно тот порядок, который нужен для ресурсов: открыл файл, потом взял блокировку, значит сначала отпускаешь блокировку, потом закрываешь файл.
const std = @import("std");
pub fn main() void {
std.debug.print("open\n", .{});
defer std.debug.print("defer 1: close\n", .{});
defer std.debug.print("defer 2: unlock\n", .{});
{
defer std.debug.print("defer 3: leave block\n", .{});
std.debug.print("inside block\n", .{});
}
for (0..3) |i| {
defer std.debug.print("loop defer {d}\n", .{i});
std.debug.print("loop body {d}\n", .{i});
}
std.debug.print("end of main\n", .{});
}
Вывод zig run:
open
inside block
defer 3: leave block
loop body 0
loop defer 0
loop body 1
loop defer 1
loop body 2
loop defer 2
end of main
defer 2: unlock
defer 1: close
Два наблюдения. Внутренний блок в фигурных скобках это отдельная область: его defer сработал сразу при выходе из скобок, задолго до конца main. И defer в цикле привязан к телу итерации, а не к циклу целиком: он выполняется в конце каждой итерации, по разу на проход. Это делает defer удобным для «выделил в начале итерации, освободил в конце», но опасным, если ты хотел отложить что-то до конца всего цикла.
Так Zig получает RAII без деструкторов. Разница в том, что в C++ и в Rust через Drop освобождение спрятано в типе, а в Zig оно написано в коде на строку ниже захвата. Правило: сразу после строки, которая что-то взяла, пиши defer, который это вернёт. Тогда любой ранний return посреди функции не потеряет ресурс.
errdefer и захват ошибки
Есть случай, когда обычный defer не годится: функция создаёт объект и возвращает его вызывающему. Освобождать его на успешном пути нельзя, он уходит наружу. А на пути с ошибкой освободить обязательно, иначе утечка. Для этого есть errdefer: он срабатывает только когда блок покидается с ошибкой.
const std = @import("std");
const Config = struct { name: []u8, port: u16 };
fn parsePort(s: []const u8) error{InvalidNumber}!u16 {
return std.fmt.parseInt(u16, s, 10) catch error.InvalidNumber;
}
fn loadConfig(allocator: std.mem.Allocator, name: []const u8, port: []const u8) !Config {
const owned = try allocator.dupe(u8, name);
// Сработает только если функция выйдет с ошибкой.
errdefer |e| {
std.debug.print("rollback after {s}: free name\n", .{@errorName(e)});
allocator.free(owned);
}
const parsed = try parsePort(port);
return .{ .name = owned, .port = parsed };
}
pub fn main() !void {
var debug: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug.deinit();
const allocator = debug.allocator();
const ok = try loadConfig(allocator, "api", "8080");
defer allocator.free(ok.name);
std.debug.print("loaded {s}:{d}\n", .{ ok.name, ok.port });
const failed = loadConfig(allocator, "web", "80x0");
if (failed) |_| unreachable else |e| std.debug.print("loadConfig failed: {s}\n", .{@errorName(e)});
}
loaded api:8080
rollback after InvalidNumber: free name
loadConfig failed: InvalidNumber
Первый вызов прошёл: имя скопировано, порт разобран, структура ушла наружу, errdefer промолчал. Второй вызов упал на parsePort, и errdefer освободил уже скопированное имя. Форма errdefer |e| захватывает саму ошибку, и откат попадает в лог с её именем. Заметь, что main тоже проверяет себя: debug.deinit() в конце напечатал бы утечку, если бы откат не сработал.
Так устроен любой конструктор в Zig:
- Взял первый ресурс.
- Сразу под ним поставил
errdefer, который его вернёт. - Взял следующий, снова
errdefer. - Собрал результат и вернул.
Каждая следующая аллокация защищена откатом всех предыдущих, а на успешном пути ни один откат не срабатывает.
Трассировка ошибок в Debug
Ошибка это число, и у числа нет стека. Но пока ты в Debug или ReleaseSafe, компилятор ведёт для каждой ошибки трассировку возврата: каждый try и каждый return error.X дописывают в неё адрес. Если ошибка долетает до main, рантайм печатает весь путь.
const std = @import("std");
fn readPort(s: []const u8) !u16 {
return std.fmt.parseInt(u16, s, 10);
}
fn loadConfig(port: []const u8) !u16 {
const p = try readPort(port);
return p;
}
pub fn main() !void {
const port = try loadConfig("80x0");
std.debug.print("port {d}\n", .{port});
}
Вывод zig run error_trace.zig (пути к std сокращены):
error: InvalidCharacter
std/fmt.zig:578:24: 0x100f79f27 in charToDigit (error_trace)
if (value >= base) return error.InvalidCharacter;
^
std/fmt.zig:443:23: 0x10102942b in parseIntWithSign__anon_27225 (error_trace)
const digit = try charToDigit(math.cast(u8, c) orelse return error.InvalidCharacter, buf_base);
^
std/fmt.zig:331:5: 0x10102752f in parseIntWithGenericCharacter__anon_27221 (error_trace)
return parseIntWithSign(Result, Character, buf, base, .pos);
^
std/fmt.zig:318:5: 0x1010271db in parseInt__anon_27083 (error_trace)
return parseIntWithGenericCharacter(T, u8, buf, base);
^
error_trace.zig:4:5: 0x1010259cb in readPort (error_trace)
return std.fmt.parseInt(u16, s, 10);
^
error_trace.zig:8:15: 0x101025a83 in loadConfig (error_trace)
const p = try readPort(port);
^
error_trace.zig:13:18: 0x101025ba7 in main (error_trace)
const port = try loadConfig("80x0");
^
Это не стек вызовов в момент падения, это путь ошибки: от строки, где она родилась в charToDigit, через каждый try, до main. Такой трассировки нет ни в Go, ни в Rust без внешних крейтов, а в Zig она не требует ни строки кода и стоит несколько инструкций на каждый try в безопасных режимах. В ReleaseFast её нет, и try там превращается в одно сравнение и переход.
Аллокатор это два указателя
Теперь к памяти. std.mem.Allocator это не абстрактный класс и не трейт, это обычная структура из двух полей:
// Фрагмент std/mem/Allocator.zig, не для компиляции.
ptr: *anyopaque,
vtable: *const VTable,
pub const VTable = struct {
alloc: *const fn (*anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8,
resize: *const fn (*anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) bool,
remap: *const fn (*anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) ?[*]u8,
free: *const fn (*anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void,
};
ptr это указатель на состояние конкретного аллокатора, стёртый до *anyopaque. vtable это указатель на vtable, таблицу из четырёх функций. Каждая получает тот самый ptr первым аргументом и приводит его обратно к своему типу. Удобные методы вроде alloc(T, n), dupe, create, free это обёртки в самой структуре Allocator: они считают байты и выравнивание из типа и зовут vtable.alloc.
Четыре функции в 0.16 такие. alloc выделяет len байт с выравниванием и возвращает null, если не может. resize пытается изменить размер блока на месте, не двигая его, и отвечает bool. remap тоже меняет размер, но имеет право переместить блок и вернуть новый адрес, это то, что нужно realloc. free возвращает блок. Параметр ret_addr это адрес вызывающего, отладочные аллокаторы записывают его, чтобы потом показать, откуда пришла утечка.
Раз это обычная структура, свой аллокатор пишется без наследования. Вот обёртка, которая считает вызовы и живые байты, а всю работу делегирует другому аллокатору:
const std = @import("std");
const Allocator = std.mem.Allocator;
const Alignment = std.mem.Alignment;
/// Аллокатор-обёртка: считает байты и вызовы, работу делает child.
const CountingAllocator = struct {
child: Allocator,
allocs: usize = 0,
frees: usize = 0,
live_bytes: usize = 0,
const vtable: Allocator.VTable = .{
.alloc = alloc,
.resize = resize,
.remap = remap,
.free = free,
};
pub fn allocator(self: *CountingAllocator) Allocator {
return .{ .ptr = self, .vtable = &vtable };
}
fn alloc(ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8 {
const self: *CountingAllocator = @ptrCast(@alignCast(ctx));
const result = self.child.rawAlloc(len, alignment, ret_addr);
if (result != null) {
self.allocs += 1;
self.live_bytes += len;
}
return result;
}
fn resize(ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) bool {
const self: *CountingAllocator = @ptrCast(@alignCast(ctx));
const ok = self.child.rawResize(memory, alignment, new_len, ret_addr);
if (ok) self.live_bytes = self.live_bytes - memory.len + new_len;
return ok;
}
fn remap(ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) ?[*]u8 {
const self: *CountingAllocator = @ptrCast(@alignCast(ctx));
const result = self.child.rawRemap(memory, alignment, new_len, ret_addr);
if (result != null) self.live_bytes = self.live_bytes - memory.len + new_len;
return result;
}
fn free(ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void {
const self: *CountingAllocator = @ptrCast(@alignCast(ctx));
self.child.rawFree(memory, alignment, ret_addr);
self.frees += 1;
self.live_bytes -= memory.len;
}
};
test "обёртка считает, child выделяет" {
var counting: CountingAllocator = .{ .child = std.testing.allocator };
const a = counting.allocator();
const nums = try a.alloc(u32, 6);
try std.testing.expectEqual(@as(usize, 24), counting.live_bytes);
const word = try a.dupe(u8, "zig");
try std.testing.expectEqual(@as(usize, 27), counting.live_bytes);
try std.testing.expectEqual(@as(usize, 2), counting.allocs);
a.free(word);
a.free(nums);
try std.testing.expectEqual(@as(usize, 0), counting.live_bytes);
try std.testing.expectEqual(@as(usize, 2), counting.frees);
}
test "Allocator это два указателя" {
try std.testing.expectEqual(2 * @sizeOf(usize), @sizeOf(Allocator));
}
Разбери allocator(): он собирает структуру из указателя на self и адреса статической таблицы vtable. Дальше любая функция, принимающая std.mem.Allocator, будет через эту таблицу попадать в наши четыре функции, а те уже через rawAlloc и остальные raw* вызывать таблицу дочернего аллокатора. Это ровно та схема, по которой устроены ArenaAllocator и DebugAllocator в стандартной библиотеке: каждый из них оборачивает child_allocator и добавляет свою политику. Второй тест напоминает, что весь интерфейс это шестнадцать байт на 64-битной машине, передаётся по значению и копируется свободно.
page_allocator: прямо у ядра
Самый простой аллокатор стандартной библиотеки, std.heap.page_allocator, вообще не имеет состояния: alloc это mmap, free это munmap. Ядро выдаёт память страницами, поэтому даже один байт обходится в целую страницу.
const std = @import("std");
pub fn main() !void {
const page = std.heap.page_allocator;
const one = try page.alloc(u8, 1);
defer page.free(one);
const next = try page.alloc(u8, 1);
defer page.free(next);
const distance = @intFromPtr(next.ptr) -% @intFromPtr(one.ptr);
std.debug.print("page size {d}, two 1-byte allocs are {d} bytes apart\n", .{ std.heap.pageSize(), distance });
}
page size 16384, two 1-byte allocs are 16384 bytes apart
Это macOS на Apple Silicon с шестнадцатикилобайтной страницей; на Linux x86-64 будет 4096. Вывод один: page_allocator не для мелких объектов, это источник крупных кусков для других аллокаторов. Так его и используют: как child для арены или DebugAllocator.
FixedBufferAllocator: bump-указатель над буфером
FixedBufferAllocator берёт готовый срез байт, чаще всего массив на стеке, и раздаёт его по порядку. Всё состояние это один индекс end_index: где кончилась последняя выдача. Такой аллокатор называют bump-аллокатором, и он самый быстрый из возможных: выделение это сложение и сравнение.
const std = @import("std");
pub fn main() void {
var buffer: [64]u8 = undefined;
var fba: std.heap.FixedBufferAllocator = .init(&buffer);
const a = fba.allocator();
const first = a.alloc(u8, 24) catch unreachable;
std.debug.print("alloc 24 -> end_index {d}\n", .{fba.end_index});
const second = a.alloc(u8, 8) catch unreachable;
std.debug.print("alloc 8 -> end_index {d}\n", .{fba.end_index});
a.free(second);
std.debug.print("free 8 -> end_index {d} (последний блок, откат)\n", .{fba.end_index});
const third = a.alloc(u8, 40) catch |e| {
std.debug.print("alloc 40 -> {s}, свободно {d}\n", .{ @errorName(e), buffer.len - fba.end_index });
return;
};
_ = third;
_ = first;
}
alloc 24 -> end_index 24
alloc 8 -> end_index 32
free 8 -> end_index 24 (последний блок, откат)
Последней строки про OutOfMemory нет: после отката сорок байт влезли ровно впритык, 24 + 40 = 64. Если бы free не откатил индекс, было бы 32 + 40 = 72 и ошибка. Это стоит проверить тестом, вместе с тем, что free не последнего блока ничего не делает:
const std = @import("std");
test "free не последнего блока ничего не возвращает" {
var buffer: [64]u8 = undefined;
var fba: std.heap.FixedBufferAllocator = .init(&buffer);
const a = fba.allocator();
const first = try a.alloc(u8, 24);
const second = try a.alloc(u8, 8);
try std.testing.expectEqual(@as(usize, 32), fba.end_index);
a.free(first);
try std.testing.expectEqual(@as(usize, 32), fba.end_index);
a.free(second);
try std.testing.expectEqual(@as(usize, 24), fba.end_index);
fba.reset();
try std.testing.expectEqual(@as(usize, 0), fba.end_index);
const big = try a.alloc(u8, 64);
try std.testing.expectEqual(@as(usize, 64), big.len);
try std.testing.expectError(error.OutOfMemory, a.alloc(u8, 1));
}
В исходнике std/heap/FixedBufferAllocator.zig функция free сводится к одной проверке isLastAllocation: если buf.ptr + buf.len совпадает с концом выданной области, end_index -= buf.len, иначе ничего. Освобождение первого блока при живом втором это не ошибка и не утечка в привычном смысле: эти байты вернутся только через reset() или когда буфер уйдёт вместе с кадром стека. FixedBufferAllocator хорош там, где размер известен заранее и всё живёт недолго: разбор одного запроса, сборка одной строки, буфер под std.fmt.bufPrint.
Ещё одно свойство: OutOfMemory здесь настоящая ошибка, которую ты получишь при 65-м байте, а не абстрактная «память кончилась» из учебника. Код, который честно обрабатывает error.OutOfMemory, легко проверить, просто дав ему маленький буфер.
ArenaAllocator: освободить всё разом
Арена устроена как список буферов: первый чанк берётся у дочернего аллокатора, внутри него работает тот же bump-указатель, а когда чанк кончается, у дочернего аллокатора просят следующий, крупнее. deinit возвращает все чанки одним проходом. Это идеальный аллокатор для данных, которые живут ровно столько же, сколько одна операция: распарсили запрос, обработали, снесли всё вместе.
const std = @import("std");
const Token = struct { text: []const u8, line: u32 };
fn tokenize(allocator: std.mem.Allocator, src: []const u8) ![]Token {
var list: std.ArrayList(Token) = .empty;
errdefer list.deinit(allocator);
var lines = std.mem.splitScalar(u8, src, '\n');
var line: u32 = 1;
while (lines.next()) |text| : (line += 1) {
var words = std.mem.tokenizeScalar(u8, text, ' ');
while (words.next()) |word| {
try list.append(allocator, .{ .text = word, .line = line });
}
}
return list.toOwnedSlice(allocator);
}
pub fn main() !void {
var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
defer arena.deinit();
const a = arena.allocator();
const tokens = try tokenize(a, "let x = 1\nlet y = x + 2");
const scratch = try a.alloc(u8, 100);
a.free(scratch);
std.debug.print("tokens: {d}, arena holds {d} bytes\n", .{ tokens.len, arena.queryCapacity() });
for (tokens) |t| std.debug.print(" line {d}: {s}\n", .{ t.line, t.text });
}
tokens: 10, arena holds 864 bytes
line 1: let
line 1: x
line 1: =
line 1: 1
line 2: let
line 2: y
line 2: =
line 2: x
line 2: +
line 2: 2
Заметь, что tokenize ничего не знает про арену: она честно вызывает append и toOwnedSlice через переданный аллокатор, и её можно вызвать с любым другим. Ради этого аллокатор и передают явно: функция описывает, сколько ей нужно, а политику выбирает вызывающий. defer arena.deinit() в main освобождает и список токенов, и его промежуточные буферы при росте, и scratch, и всё остальное разом.
Что делает free внутри арены, легко проверить по шагам:
const std = @import("std");
pub fn main() !void {
var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
defer arena.deinit();
const a = arena.allocator();
std.debug.print("init -> capacity {d}\n", .{arena.queryCapacity()});
const name = try a.alloc(u8, 24);
std.debug.print("alloc 24 -> capacity {d}\n", .{arena.queryCapacity()});
const tmp = try a.alloc(u8, 8);
std.debug.print("alloc 8 -> capacity {d}, tmp at +{d}\n", .{ arena.queryCapacity(), @intFromPtr(tmp.ptr) - @intFromPtr(name.ptr) });
a.free(tmp);
const big = try a.alloc(u8, 40);
std.debug.print("free 8, alloc 40 -> capacity {d}, big at +{d}\n", .{ arena.queryCapacity(), @intFromPtr(big.ptr) - @intFromPtr(name.ptr) });
a.free(name);
std.debug.print("free 24 (not last) -> capacity {d}\n", .{arena.queryCapacity()});
const more = try a.alloc(u8, 4096);
std.debug.print("alloc 4096 -> capacity {d}, more at +{d}\n", .{ arena.queryCapacity(), @intFromPtr(more.ptr) -% @intFromPtr(name.ptr) });
}
init -> capacity 0
alloc 24 -> capacity 74
alloc 8 -> capacity 74, tmp at +24
free 8, alloc 40 -> capacity 74, big at +24
free 24 (not last) -> capacity 74
alloc 4096 -> capacity 8256, more at +4160
Первый чанк арена просит размером 98 байт: 24 под свой заголовок, остальное под данные, отсюда 74. free(tmp) откатил указатель, и big лёг на то же место +24, что и tmp до него. free(name) не последнего блока ничего не изменил. А запрос на 4096 байт не влез в первый чанк, и арена взяла у page_allocator второй, куда крупнее. Так что арена это цепочка bump-аллокаторов, и free в ней работает ровно как в FixedBufferAllocator: только для последнего блока. Полагаться на это не стоит, честная модель арены «free ничего не делает, всё освобождает deinit».
DebugAllocator: отчёт об утечке
std.heap.DebugAllocator это аллокатор общего назначения с включённой безопасностью: он хранит таблицу живых блоков, помнит для каждого адрес возврата (тот самый ret_addr из vtable), ловит двойное освобождение, освобождение не того размера и, главное, при deinit печатает каждый блок, который так и не вернули.
const std = @import("std");
fn work(allocator: std.mem.Allocator) !void {
const name = try allocator.alloc(u8, 24);
defer allocator.free(name);
const tmp = try allocator.alloc(u8, 8);
allocator.free(tmp);
const big = try allocator.alloc(u8, 40);
_ = big; // забыли free
}
pub fn main() !void {
var debug: std.heap.DebugAllocator(.{}) = .init;
defer {
const check = debug.deinit();
std.debug.print("deinit: {s}\n", .{@tagName(check)});
}
try work(debug.allocator());
}
Вывод zig run leak_demo.zig:
error(DebugAllocator): memory address 0x1005c0000 leaked:
leak_demo.zig:10:36: 0x10045daa3 in work (leak_demo)
const big = try allocator.alloc(u8, 40);
^
leak_demo.zig:20:13: 0x10045dc23 in main (leak_demo)
try work(debug.allocator());
^
std/start.zig:698:59: 0x10045e08b in callMain (leak_demo)
if (fn_info.params.len == 0) return wrapMain(root.main());
^
???:?:?: 0x183c1c4e3 in start (/usr/lib/dyld)
deinit: leak
Здесь всё, что нужно для починки: адрес блока, строка с alloc, которая его создала, и цепочка вызовов до main. deinit возвращает std.heap.Check, значение .ok или .leak, поэтому в main его результат обычно либо проверяют, либо сознательно отбрасывают через _ =. DebugAllocator(.{}) это функция от конфига: в скобках можно включить .safety = false, .retain_metadata, размер стека для трассы и другое.
Тот же аллокатор стоит за std.testing.allocator. Каждый тест получает его, и если после теста остались живые блоки, тест падает с точно таким же отчётом:
const std = @import("std");
fn joinWords(allocator: std.mem.Allocator, a: []const u8, b: []const u8) ![]u8 {
const left = try allocator.dupe(u8, a);
const result = try allocator.alloc(u8, left.len + 1 + b.len);
@memcpy(result[0..left.len], left);
result[left.len] = ' ';
@memcpy(result[left.len + 1 ..], b);
return result; // left так и не освобождён
}
test "joinWords склеивает через пробел" {
const joined = try joinWords(std.testing.allocator, "hello", "zig");
defer std.testing.allocator.free(joined);
try std.testing.expectEqualStrings("hello zig", joined);
}
Вывод zig test testing_leak.zig, сокращён до значимых строк:
1/1 testing_leak.test.joinWords склеивает через пробел...OK
[DebugAllocator] (err): memory address 0x105180000 leaked:
std/mem/Allocator.zig:454:40: 0x104f2b88b in dupe__anon_8301 (test)
const new_buf = try allocator.alloc(T, m.len);
^
testing_leak.zig:4:36: 0x10503187b in joinWords (test)
const left = try allocator.dupe(u8, a);
^
testing_leak.zig:13:33: 0x105031eff in test.joinWords склеивает через пробел (test)
const joined = try joinWords(std.testing.allocator, "hello", "zig");
^
All 1 tests passed.
1 errors were logged.
1 tests leaked memory.
error: the following test command failed with exit code 1:
Сам expect прошёл, строка склеена верно, но тест всё равно красный: left остался жить. Это главный инструмент курса против утечек: любой код, который принимает аллокатор, ты гоняешь под std.testing.allocator, и утечка становится падающим тестом, а не тикетом через полгода.
Рядом лежит std.testing.FailingAllocator: он оборачивает другой аллокатор и отказывает на N-м вызове. Функция std.testing.checkAllAllocationFailures гоняет твою функцию столько раз, сколько в ней аллокаций, и на каждом прогоне проваливает одну из них. Если хоть в одном прогоне память утекла или OutOfMemory был проглочен, тест падает. В задаче ниже она включена в проверку.
Демонстрация: три аллокатора на одной функции
Возьми функцию work из примера с утечкой и мысленно прогони её под тремя аллокаторами. Ниже это можно сделать по шагам: слева программа, справа три дорожки. Числа взяты из реальных запусков выше: буфер 64 байта у FixedBufferAllocator, первый чанк 74 байта данных у арены поверх page_allocator, отдельные блоки у DebugAllocator.
Пройди сначала как есть. free(tmp) откатывает end_index в первых двух дорожках, и big ложится на место tmp. Потом сними галочку «освобождать tmp». Теперь у FixedBufferAllocator сорок байт не влезают, try выбрасывает OutOfMemory из work, а defer всё равно пытается вернуть name. У арены чанк на два байта больше, и всё влезло. У DebugAllocator на deinit два живых блока вместо одного. Один и тот же код, три разных исхода, и ни один из них не спрятан: аллокатор пришёл параметром, и его поведение написано в его vtable.
std.ArrayList без владельца
std.ArrayList(T) это динамический массив: срез items, поле capacity и стратегия роста. В 0.16 он не хранит аллокатор внутри, отсюда слово «unmanaged» в старых обсуждениях: аллокатор передаётся в каждый метод, который может выделять или освобождать память. У такого дизайна два плюса. Размер: структура это []T и usize. Честность: каждая точка, где возможна аллокация, видна в вызове.
const std = @import("std");
fn squares(allocator: std.mem.Allocator, n: u32) ![]u32 {
var list: std.ArrayList(u32) = .empty;
errdefer list.deinit(allocator);
for (1..n + 1) |i| {
try list.append(allocator, @intCast(i * i));
}
return list.toOwnedSlice(allocator);
}
test "ArrayList без владельца: аллокатор в каждом вызове" {
const a = std.testing.allocator;
var list: std.ArrayList(u32) = .empty;
defer list.deinit(a);
try list.append(a, 10);
try list.appendSlice(a, &.{ 20, 30 });
try std.testing.expectEqual(@as(usize, 3), list.items.len);
try std.testing.expect(list.capacity >= 3);
try std.testing.expectEqual(@as(u32, 30), list.pop().?);
try std.testing.expectEqualSlices(u32, &.{ 10, 20 }, list.items);
}
test "toOwnedSlice отдаёт память вызывающему" {
const a = std.testing.allocator;
const result = try squares(a, 4);
defer a.free(result);
try std.testing.expectEqualSlices(u32, &.{ 1, 4, 9, 16 }, result);
}
Четыре точки, которые нужно запомнить. Пустой список это .empty, константа без аллокации. append(allocator, x) может вернуть OutOfMemory, поэтому перед ним try. deinit(allocator) освобождает буфер. toOwnedSlice(allocator) ужимает буфер до items.len, отдаёт его как срез и оставляет список пустым: с этого момента память принадлежит вызывающему, и освобождает её он через allocator.free.
Посмотри на squares ещё раз, это готовый шаблон для задачи ниже: .empty, сразу errdefer list.deinit(allocator), цикл с try list.append, в конце toOwnedSlice. Если append упадёт на середине, errdefer вернёт буфер; если всё пройдёт, errdefer промолчит и буфер уйдёт наружу.
Практика
Напиши parseKeyValues(allocator, input): строка вида a=1;bb=22;c=-3 превращается в срез Pair, ключи указывают внутрь input, числа разбираются как i64. Запись без знака равенства даёт error.MissingEquals, запись с не-числом после него даёт error.InvalidNumber, пустые записи пропускаются. Собирай результат в std.ArrayList(Pair) и отдай через toOwnedSlice.
Скрытые тесты запускают функцию под std.testing.allocator и под checkAllAllocationFailures, так что ошибка в середине строки и отказ любой аллокации не должны оставить ни байта. Один errdefer сразу после объявления списка закрывает оба случая.
Упражнения
Итоги
- Ошибка это значение из набора
error{...}; наборы сливаются через||,!Tвыводит набор автоматически,anyerrorпринимает всё,@errorNameи@intFromErrorпоказывают, что внутри. tryэтоcatch |e| return e;catchдаёт значение по умолчанию,catch |e| switch (e)разбирает ошибки по одной,catch unreachableзаявляет, что ошибка невозможна, и проверяется в безопасных режимах.deferсрабатывает при выходе из блока в обратном порядке, в цикле по разу на итерацию;errdeferтолько при выходе с ошибкой и умеет захватывать её через|e|.- В Debug и ReleaseSafe у каждой ошибки есть трассировка возврата: путь от
return error.Xчерез всеtryдо места, где её обработали. std.mem.Allocatorэтоptrплюсvtableиз четырёх функций:alloc,resize,remap,free. Свой аллокатор это структура, которая заполняет таблицу.page_allocatorработает страницами,FixedBufferAllocatorи арена это bump-указатель, откатывающийся только для последнего блока,DebugAllocatorведёт таблицу блоков и печатает утечки со стеком,std.testing.allocatorделает утечку падающим тестом.std.ArrayList(T):.empty,append(allocator, x),deinit(allocator),toOwnedSlice(allocator), иerrdefer deinitсразу после объявления.
Дальше
Аллокатор пришёл параметром, и любая функция, которая его принимает, работает с любым из четырёх. Но std.ArrayList(u32) это функция от типа, вызванная на этапе компиляции, и DebugAllocator(.{}) тоже. В следующем уроке разберём comptime: как Zig делает дженерики обычными функциями над типами, что показывает @typeInfo, как устроен zig test и build.zig, и как подключить C-код, не выходя из одного компилятора.
домашка