From 9ded5962c4d928e44840a40d1dbc4e3c5ff8e1e2 Mon Sep 17 00:00:00 2001 From: zed Date: Thu, 16 Nov 2023 18:38:18 +0300 Subject: [PATCH] On branch main modified: src/ch06.md new file: src/ex-ch06-03.zig new file: src/ex-ch06-04.zig --- src/ch06.md | 434 +++++++++++++++++++++++++++++++++++++++------ src/ex-ch06-03.zig | 73 ++++++++ src/ex-ch06-04.zig | 73 ++++++++ 3 files changed, 525 insertions(+), 55 deletions(-) create mode 100644 src/ex-ch06-03.zig create mode 100644 src/ex-ch06-04.zig diff --git a/src/ch06.md b/src/ch06.md index ea39592..2cc9d6f 100644 --- a/src/ch06.md +++ b/src/ch06.md @@ -1,5 +1,5 @@ -# Динамическая память и аллокаторы +# Динамическая память и распределители памяти Всё, с чем имели дело до сих пор, требовало знаний размеров всех сущностей во время компиляции. Массивы всегда имеют размер, известный во время @@ -16,7 +16,7 @@ модифицированы), переменные в стеке живут, пока работает функция, который принадлежит этот стековый кадр. -Эта глава содержит 2 темы. Первая это общий обзор нашей третьей области +Эта глава посвящена двум темам. Первая это общий обзор нашей третьей области памяти, кучи. А вот вторая более интересна - в языке Zig имеется вполне ясный, но тем не менее уникальный подход к управлению динамической памятью. Даже если Вы так или иначе умеете работать с кучей @@ -71,15 +71,15 @@ fn getRandomCount() !u8 { } ``` -Вскоре мы рассмотрим аллокаторы более подробно, а пока нам нужно знать -только то, что `allocator` в этом примере это `std.mem.Allocator`. Мы -используем два его метода, `alloc` и `free`. Из того, что мы вызываем -`allocator.alloc` при помощи `try`, следует, что этот метод может -завершиться с ошибкой. На настоящий момент возможна только одна ошибка, -`OutOfMemory`. Параметры этого метода и возвращаемое значение, в -принципе, говорят нам о том, как он работает: он требует тип (`T`) и -количество элементов, при успешном завершении возвращает полный срез -выделенного в куче массива. Выделение памяти происходит во время +Вскоре мы рассмотрим распределители памяти (аллокаторы) более подробно, а +пока нам нужно знать только то, что `allocator` в этом примере это +`std.mem.Allocator`. Мы используем два его метода, `alloc` и `free`. Из +того, что мы вызываем `allocator.alloc` при помощи `try`, следует, что +этот метод может завершиться с ошибкой. На настоящий момент возможна +только одна ошибка, `OutOfMemory`. Параметры этого метода и возвращаемое +значение, в принципе, говорят нам о том, как он работает: он требует тип +(`T`) и количество элементов, при успешном завершении возвращает полный +срез выделенного в куче массива. Выделение памяти происходит во время выполнения, иначе никак, поскольку длина становится известной только во время исполнения. @@ -369,24 +369,24 @@ fn init(allocator: std.mem.Allocator, id: u64, power: i32) !*User{ ## Аллокаторы -Одним из базовых принципов Zig является принцип "никаких скрытых выделений памяти". -В зависимости от Вашего предыдущего опыта программирования, -возможно, для Вас в этом принципе нет ничего особенного. -Тем не менее, это сильно контрастирует с тем, что мы имеем в стандартной -библиотеке языка C, а там для выделения памяти мы имеем `malloc`. -В C для того, чтобы понять, использует ли та или иная функция кучу, -нужно глядеть в исходный код этой функции и искать там `malloc` -и родственные ей, например, `calloc`. +Одним из базовых принципов Zig является принцип "никаких скрытых +выделений памяти". В зависимости от Вашего предыдущего опыта +программирования, возможно, для Вас в этом принципе нет ничего +особенного. Тем не менее, это сильно контрастирует с тем, что мы имеем в +стандартной библиотеке языка C, а там для выделения памяти мы имеем +`malloc`. В C для того, чтобы понять, использует ли та или иная функция +кучу, нужно глядеть в исходный код этой функции и искать там `malloc` и +родственные ей, например, `calloc`. -А вот в Zig нет аллокатора "по умолчанию". Во всех примерах выше -функции, которые размещают сущности в куче, принимают в качестве -параметра `std.mem.Allocator`. По соглашению, обычно это первый параметр. -Вся стандартная библиотека Zig, а также большинство сторонних библиотек -требуют, чтобы вызывающая сторона передала аллокатор в случае, -если этим библиотекам требуется выделять память динамически. +А вот в Zig нет аллокатора "по умолчанию". Во всех примерах выше функции, +которые размещают сущности в куче, принимают в качестве параметра +`std.mem.Allocator`. По соглашению, обычно это первый параметр. Вся +стандартная библиотека Zig, а также большинство сторонних библиотек +требуют, чтобы вызывающая сторона передала аллокатор в случае, если этим +библиотекам требуется выделять память динамически. -Такое явное указание аллокаторов может принимать две формы. -В простых случаях, аллокатор передаётся каждой функции, например: +Такое явное указание аллокаторов может принимать две формы. В простых +случаях, аллокатор передаётся каждой функции, например: ```zig onst say = std.fmt.allocPrint(allocator, "It's over {d}!!!", .{user.power}); @@ -394,41 +394,365 @@ defer allocator.free(say); ``` Функция `std.fmt.allocPrint` похожа на `std.debug.print`, только вместо -того, чтобы печатать в `stderr`, она выделяет память под строку и "печатает" -в эту строку (как `sprintf` в C). +того, чтобы печатать в `stderr`, она выделяет память под строку и +"печатает" в эту строку (как `sprintf` в C). -Другая форма это когда аллокатор передаётся в "конструктор" (`init`), -там запоминается и далее используется для внутренних нужд объекта. -Мы видели такую форму выше, в реализации структуры `Game`. -Такая форма менее явна, потому что Вы знаете, что объект будет -использовать динамическую память, но не знаете, в каких именно своих -методах он будет это делать. Такой подход более практичен для долго живущих сущностей. +Другая форма это когда аллокатор передаётся в "конструктор" (`init`), там +запоминается и далее используется для внутренних нужд объекта. Мы видели +такую форму выше, в реализации структуры `Game`. Такая форма менее явна, +потому что Вы знаете, что объект будет использовать динамическую память, +но не знаете, в каких именно своих методах он будет это делать. Такой +подход более практичен для долго живущих сущностей. -Преимущество "внедрения" аллокатора состоит не только в том, что -явно обозначено, что функция/объект будут использовать кучу, -но также и в том, что это предоставляет некоторую гибкость. -Дело в том, что `std.mem.Allocator` это *интерфейс* (а не конкретный аллокатор), -который предоставляет методы `alloc/free` и `create/destroy`, наряду с некоторыми другими. -До этого момента мы видели только `std.heap.GeneralPurposeAllocator`, -но в стандартной библиотеке имеются реализации и других аллокаторов. +Преимущество "внедрения" аллокатора состоит не только в том, что явно +обозначено, что функция/объект будут использовать кучу, но также и в том, +что это предоставляет некоторую гибкость. Дело в том, что +`std.mem.Allocator` это *интерфейс* (а не конкретный аллокатор), который +предоставляет методы `alloc/free` и `create/destroy`, наряду с некоторыми +другими. До этого момента мы видели только +`std.heap.GeneralPurposeAllocator`, но в стандартной библиотеке имеются +реализации и других аллокаторов. Если Вы разрабатываете библиотеку, лучше всего будет, если она будет -принимать аллокатор, это позволит пользователям Вашей библиотеки -сами выбирать, какой конкретно аллокатор будет использоваться. -В противном случае Вам будет нужно выбирать "правильный" аллокатор и, -как мы далее увидим, аллокаторы не являются взаимоисключающими - -программа может одновременно использовать несколько видов аллокаторов, -и на то могут быть свои причины. - - +принимать аллокатор, это позволит пользователям Вашей библиотеки сами +выбирать, какой конкретно аллокатор будет использоваться. В противном +случае Вам будет нужно выбирать "правильный" аллокатор и, как мы далее +увидим, аллокаторы не являются взаимоисключающими - программа может +одновременно использовать несколько видов аллокаторов, и на то могут быть +свои весьма веские причины. ## Аллокатор общего назначения -## `std.testing.allocator` +Как и подразумевает само название этого аллокатора +(`std.heap.GeneralPurposeAllocator`), это во всех отношениях аллокатор +общего назначения, в частности, он безопасен для использования в +многопоточных программах и может служить главным аллокатором в Ваших +программах. Для большинства программ он будет единственным аллокатором. +Аллокатор создаётся на старте программы и затем передаётся тем функциям, +которым он нужен. Вот небольшой пример: + +```zig +const std = @import("std"); +const httpz = @import("httpz"); + +pub fn main() !void { + // create our general purpose allocator + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + + // get an std.mem.Allocator from it + const allocator = gpa.allocator(); + + // pass our allocator to functions and libraries that require it + var server = try httpz.Server().init(allocator, .{.port = 5882}); + + var router = server.router(); + router.get("/api/user/:id", getUser); + + // blocks the current thread + try server.listen(); +} +``` + +Здесь мы создаём `GeneralPurposeAllocator`, получаем из него +`std.mem.Allocator` и заьем передаём его функции `init` структуры +`httpz.Server`. В более сложном проекте аллокатор, скорей всего, будет +передаваться во многие другие подсистемы, каждая из которых, в свою +очередь, будет передавать аллокатор дальше по цепочке для своих функций, +объектов и т.п. + +Наверное, Вы обратили внимание, что синтаксис создания аллокатора +какой-то странный. Что вообще такое `GeneralPurposeAllocator(.{}){}`? На +самом деле всё это уже нам знакомо, просто тут оно сразу всё в кучу. +`GeneralPurposeAllocator` это, очевидно, функция и, поскольку она +написана в `PascalCase`, она возвращает *тип* (мы будем говорить об +обощённых структурахданных в следующей главе). Зная, что она возвращает +тип, этот пример, где это явно обозначеноЮ будет легче "расшифровать": + +```zig +const T = std.heap.GeneralPurposeAllocator(.{}); +var gpa = T{}; + +// is the same as: + +var gpa = std.heap.GeneralPurposeAllocator(.{}){}; +``` + +Возможно, Вы всё ещё не уверены в смысле `.{}`. Это мы тоже встречали +раньше, это инициализатор структуры с неявным типом, тип будет выведен +компилятором. А что это за тип и какие у него поля? Это +`std.heap.general_purpose_allocator.Config`, хоть это явно у нас и не +обозначено. Никакие поля тут мы не инициализируем, потому что в `Config` +указаны значения по умолчанию, их мы и намеревались использовать. Это +общая практика при работе с конфигурациями/настройками. Несколькими +строчками ниже мы встречаем подобное ещё раз, при передаче `.{.port = +5882}` функции `Server.init`. В данном случае мы используем значения по +умолчанию для всех полей, кроме поля `port`. + +## Аллокатор для тестирования + +Автор выражает надежду, что Вы серьезно призадумались, когда мы обсуждали +утечки памяти и после заявления про то, что Zig тут может помочь, +захотели узнать, чем именно. Помощь нам тут может оказать +`std.testing.allocator`, который на данный момент реализовать на основе +`GeneralPurposeAllocator`, но с добавлением интеграции с системой тестов +Zig, но это детали реализации. Нам будет важно то, что если мы будем +использовать `std.testing.allocator` а наших тестах, он нам отловит +большинство утечек памяти, если таковые будут. + +Наверняка Вы уже знакомы с динамическими массивами, также часто называемых `ArrayList`. +Во многих языках с динамической типизацией вообще всё массивы динамические. +Динамические массивы могут менять свою длину в процессе работы программы. +В стандартной библиотеке Zig есть реализация обобщённого динамического массива, +но мы сейчас вручную сделаем реализацию специализированного (только для целых) +динамического массива и продемонстрируем детекцию утечки памяти: + +```zig +pub const IntList = struct { + pos: usize, + items: []i64, + allocator: Allocator, + + fn init(allocator: Allocator) !IntList { + return .{ + .pos = 0, + .allocator = allocator, + .items = try allocator.alloc(i64, 4), + }; + } + + fn deinit(self: IntList) void { + self.allocator.free(self.items); + } + + fn add(self: *IntList, value: i64) !void { + const pos = self.pos; + const len = self.items.len; + + if (pos == len) { + // we've run out of space + // create a new slice that's twice as large + var larger = try self.allocator.alloc(i64, len * 2); + + // copy the items we previously added to our new space + @memcpy(larger[0..len], self.items); + + self.items = larger; + } + + self.items[pos] = value; + self.pos = pos + 1; + } +}; +``` + +Нас тут будет интересовать та часть функции `add`, где проверяется, а не +переполнится ли наш массив при добавлении в него очередного элемента, +далее при необходимости длина массива увеличивается в два раза и только +после этого добавляется новый элемент. Мы можем использовать наш +`IntList` вот так: + +```zig +const std = @import("std"); +const Allocator = std.mem.Allocator; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + + var list = try IntList.init(allocator); + defer list.deinit(); + + for (0..10) |i| { + try list.add(@intCast(i)); + } + + std.debug.print("{any}\n", .{list.items[0..list.pos]}); +} +``` + +Код работает правильно и печатает то, что от него ожидается. Однако, +несмотря на то, что мы вызвали `deinit`, тут имеет место утечка. Если Вы +не заметили, где она происходит, ничего страшного, ведь сейчас мы напишем +тест и будем в нём использовать `std.testing.allocator`: + +```zig +const testing = std.testing; +test "IntList: add" { + // We're using testing.allocator here! + var list = try IntList.init(testing.allocator); + defer list.deinit(); + + for (0..5) |i| { + try list.add(@intCast(i+10)); + } + + try testing.expectEqual(@as(usize, 5), list.pos); + try testing.expectEqual(@as(i64, 10), list.items[0]); + try testing.expectEqual(@as(i64, 11), list.items[1]); + try testing.expectEqual(@as(i64, 12), list.items[2]); + try testing.expectEqual(@as(i64, 13), list.items[3]); + try testing.expectEqual(@as(i64, 14), list.items[4]); +} +``` + +Тесты в Zig обычно пишутся в том же файле, где реализуется то, +что мы хотим протестировать. Поместите 3 предыдущих отрывка кода в один файл +и запустите тест, используя команду `zig test src/ex-ch06-03.zig` +и наслаждайтесь результатом: + +``` +$ /opt/zig-0.11/zig test src/ex-ch06-03.zig +Test [1/1] test.IntList: add... [gpa] (err): memory address 0x7ff7897f4000 leaked: +/coding/zig-lang/learning-zig-rus/src/ex-ch06-03.zig:14:41: 0x2248be in init (test) + .items = try allocator.alloc(i64, 2), +... ^ + +[gpa] (err): memory address 0x7ff7897fe000 leaked: +/home/zed/2-coding/zig-lang/learning-zig-rus/src/ex-ch06-03.zig:29:50: 0x224cd0 in add (test) + var larger = try self.allocator.alloc(i64, len * 2); + ^ +... +All 1 tests passed. +2 errors were logged. +1 tests leaked memory. +``` + +У нас тут аж 2 утечки! К счастью, тестирующий аллокатор говорит нам в точности, +где была выделена память, указатели на которую мы не освободили. +Можете теперь отследить, где и как происходит утечка? Если нет, вспомните, +что каждому выделению памяти (`alloc`) должно соответствовать её освобождение (`free`). +Наш код вызывает `free` только один раз, в функции `deinit`, однако, `alloc` +вызывается более одного раза - первый раз при создании списка, то есть в функции `init` +и затем всякий раз, когда вызывается метод `add` и нам нужно расширить массив. +Поэтому всякий раз, когда мы перевыделяем память под массив, нам нужно +освободить память от предыдущего (более короткого) массива: + +```zig +// existing code +var larger = try self.allocator.alloc(i64, len * 2); +@memcpy(larger[0..len], self.items); + +// Added code +// free the previous allocation +self.allocator.free(self.items); +``` + +Добавление строчки `self.allocator.free(self.items);` после копирования массива +в новую область решает нашу проблему. Теперь тест завершается успешно. + +## Распределитель на основе регионов + +Распределитель памяти общего назначения, то есть `GeneralPurposeAllocator` это +вполне разумный выбор по умолчанию, поскольку он в среднем хорошо работает +для любых возможных сценариев и объёмов выделяемой,высвобождаемой памяти. +Однако, в какой-то конкретной программе Вы можете столкнуться с такой +схемой выделения/освобождения, для которой будет более выгодно использовать +специализированные аллокаторы. В качестве примера можно привести ситуацию, +когда Вам нужны какие-то относительно короткоживущие данные, +которые хотелось бы удалить, так сказать, одним махом. Часто под такие +требования подпадают парсеры. Вот скелет некой функции разбора каких-то данных: + +```zig +fn parse(allocator: Allocator, input: []const u8) !Something { + var state = State{ + .buf = try allocator.alloc(u8, 512), + .nesting = try allocator.alloc(NestType, 10), + }; + defer allocator.free(state.buf); + defer allocator.free(state.nesting); + + return parseInternal(allocator, state, input); +} +``` + +Хоть с этим и не особо сложно управляться, однако, функция +`parseInternal`, возможно, сама используется много сущностей с коротким +временем жизни, которые, разумеется, тоже надо удалять. И было бы +замечательно, если бы могли освободить память от множества объектов не +вызывая много раз `free` или `destroy`, а как бы за один присест. А тут +нам на помощь приходит `ArenaAllocator`: + +```zig +fn parse(allocator: Allocator, input: []const u8) !Something { + // create an ArenaAllocator from the supplied allocator + var arena = std.heap.ArenaAllocator.init(allocator); + + // this will free anything created from this arena + defer arena.deinit(); + + // create an std.mem.Allocator from the arena, this will be + // the allocator we'll use internally + const aa = arena.allocator(); + + var state = State{ + // we're using aa here! + .buf = try aa.alloc(u8, 512), + + // we're using aa here! + .nesting = try aa.alloc(NestType, 10), + }; + + // we're passing aa here, so any we're guaranteed that + // any other allocation will be in our arena + return parseInternal(aa, state, input); +} +``` + +Этот аллокатор принимает "дочерний" аллокатор, в данном случае это +аллокатор, который передаётся в функцию, которая создаёт другой +аллокатор, `ArenaAllocator`. При использовании этого аллокатора нам не +нужно чистить память индивидуально для каждой сущности, размещённой на +"арене": всё будет освобождено разом при вызове `arena.deinit`. Методы +`free` и `destroy` в этом аллокаторе всё же имеются, но они просто ничего +не делают. + +Аллокатор на основе регионов следует использовать с осторожностью. +Поскольку здесь нет никакого способа высвобождать память для каждой +сущности отдельно, нужно иметь уверенность, что `arena.deinit` будет +вызвана прежде, чем мы задействуем слишком много памяти. Интересно, что +это знание (когда нужно почистить) может содержаться как внутри, так и +снаружи функции/объекта? Например, в приведённом выше скелете +представляется разумным задействовать `ArenaAllocator` внутри `Parser`, +поскольку детали, касающиеся времён жизни - это сугубо "личное дело" +самого парсера. + +Однако, этого нельзя сказать про наш список `intList`. Он может +быть использован для хранения 10 чисел, а может быть использован +для хранения 10 миллионов чисел. Кроме того, список может быть нужен +на протяжении, скажем, 100 миллисекунд, а может и на протяжении недель. +То есть сам список никак не может знать, как аллокатор выгоднее использовать, +такое знанием в данном случае обладает код, который использует список. +Изначально мы управлялись (в плане аллокатора) с нашим списком вот так: + +```zig +var gpa = std.heap.GeneralPurposeAllocator(.{}){}; +const allocator = gpa.allocator(); + +var list = try IntList.init(allocator); +defer list.deinit(); +``` + +Возможно, нам по каким-то причинам захотелось использовать аллокатор на основе регионов: + +```zig +var gpa = std.heap.GeneralPurposeAllocator(.{}){}; +const allocator = gpa.allocator(); + +var arena = std.heap.ArenaAllocator.init(allocator); +defer arena.deinit(); +const aa = arena.allocator(); + +var list = try IntList.init(aa); + +// I'm honestly torn on whether or not we should call list.deinit. +// Technically, we don't have to since we call defer arena.deinit() above. +defer list.deinit(); +``` + +При этом нам не нужно вносить изменения в реализацию `intList`, он работает +только с `std.mem.Allocator`, то есть с интерфейсом, общим для всех аллокаторов. +И если бы где-то в `intList` создавался бы ещё один аллокатор с регионами, +то это тоже бы сработало, поскольку никто не запрещает иметь арену внутри арены. -## Arena... ## FixedBufferAllocator - - - diff --git a/src/ex-ch06-03.zig b/src/ex-ch06-03.zig new file mode 100644 index 0000000..560ac2a --- /dev/null +++ b/src/ex-ch06-03.zig @@ -0,0 +1,73 @@ + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +pub const IntList = struct { + pos: usize, + items: []i64, + allocator: Allocator, + + fn init(allocator: Allocator) !IntList { + return .{ + .pos = 0, + .allocator = allocator, + .items = try allocator.alloc(i64, 2), + }; + } + + fn deinit(self: IntList) void { + self.allocator.free(self.items); + } + + fn add(self: *IntList, value: i64) !void { + const pos = self.pos; + const len = self.items.len; + + if (pos == len) { + // we've run out of space + // create a new slice that's twice as large + var larger = try self.allocator.alloc(i64, len * 2); + + // copy the items we previously added to our new space + @memcpy(larger[0..len], self.items); + + self.items = larger; + } + + self.items[pos] = value; + self.pos = pos + 1; + } +}; + + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + + var list = try IntList.init(allocator); + defer list.deinit(); + + for (0..10) |i| { + try list.add(@intCast(i)); + } + + std.debug.print("{any}\n", .{list.items[0..list.pos]}); +} + +const testing = std.testing; +test "IntList: add" { + // We're using testing.allocator here! + var list = try IntList.init(testing.allocator); + defer list.deinit(); + + for (0..5) |i| { + try list.add(@intCast(i+10)); + } + + try testing.expectEqual(@as(usize, 5), list.pos); + try testing.expectEqual(@as(i64, 10), list.items[0]); + try testing.expectEqual(@as(i64, 11), list.items[1]); + try testing.expectEqual(@as(i64, 12), list.items[2]); + try testing.expectEqual(@as(i64, 13), list.items[3]); + try testing.expectEqual(@as(i64, 14), list.items[4]); +} diff --git a/src/ex-ch06-04.zig b/src/ex-ch06-04.zig new file mode 100644 index 0000000..fed6aaa --- /dev/null +++ b/src/ex-ch06-04.zig @@ -0,0 +1,73 @@ + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +pub const IntList = struct { + pos: usize, + items: []i64, + allocator: Allocator, + + fn init(allocator: Allocator) !IntList { + return .{ + .pos = 0, + .allocator = allocator, + .items = try allocator.alloc(i64, 2), + }; + } + + fn deinit(self: IntList) void { + self.allocator.free(self.items); + } + + fn add(self: *IntList, value: i64) !void { + const pos = self.pos; + const len = self.items.len; + + if (pos == len) { + // we've run out of space + // create a new slice that's twice as large + var larger = try self.allocator.alloc(i64, len * 2); + + // copy the items we previously added to our new space + @memcpy(larger[0..len], self.items); + self.allocator.free(self.items); + self.items = larger; + } + + self.items[pos] = value; + self.pos = pos + 1; + } +}; + + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + + var list = try IntList.init(allocator); + defer list.deinit(); + + for (0..10) |i| { + try list.add(@intCast(i)); + } + + std.debug.print("{any}\n", .{list.items[0..list.pos]}); +} + +const testing = std.testing; +test "IntList: add" { + // We're using testing.allocator here! + var list = try IntList.init(testing.allocator); + defer list.deinit(); + + for (0..5) |i| { + try list.add(@intCast(i+10)); + } + + try testing.expectEqual(@as(usize, 5), list.pos); + try testing.expectEqual(@as(i64, 10), list.items[0]); + try testing.expectEqual(@as(i64, 11), list.items[1]); + try testing.expectEqual(@as(i64, 12), list.items[2]); + try testing.expectEqual(@as(i64, 13), list.items[3]); + try testing.expectEqual(@as(i64, 14), list.items[4]); +}