3  Memory and Allocators

In this chapter, we will talk about memory. How does Zig controls memory? What commom tools are used? Are there any important aspect that makes memory different/special in Zig? You will find the answers here.

Every computer needs memory. Is by having memory that computers can temporarily store the values/results of your calculations. Without memory, programming languages would never have concepts such as “variables”, or “objects”, to store the values that you generate.

3.1 Memory spaces

Every object that you create in your Zig source code needs to be stored somewhere, in your computer’s memory. Depending on where and how you define your object, Zig will use a different “memory space”, or a different type of memory to store this object.

Each type of memory normally serves for different purposes. In Zig, there are 3 types of memory (or 3 different memory spaces) that we care about. They are:

  • Global data register (or the “global data section”);
  • Stack;
  • Heap;

3.1.1 Compile-time known versus runtime known

One strategy that Zig uses to decide where it will store each object that you declare, is by looking at the value of this particular object. More specifically, by investigating if this value is known at “compile-time” or at “runtime”.

When you write a program in Zig, the values of some of the objects that you write in your program are known at compile time. Meaning that, when you compile your Zig source code, during the compilation process, the zig compiler can figure it out what is the exact value of a particular object that exists in your source code. Knowing the length (or the size) of each object is also important. So the length (or the size) of each object that you write in your program is, in some cases, known at compile time.

The zig compiler cares more about knowing the length (or the size) of a particular object , than to know it’s actual value. But, if the zig compiler knows the value of the object, then, it automatically knows the size of this object. Because it can simply calculate the size of the object by looking at the size of the value.

Therefore, the priority for the zig compiler is to discover the size of each object in your source code. If the value of the object in question is known at compile-time, then, the zig compiler automatically knows the size/length of this object. But if the value of this object is not known at compile-time, then, the size of this object is only known at compile-time if, and only if, the type of this object have a known fixed size.

In order to a type have a known fixed size, this type must have data members whose size is fixed. If this type includes, for example, a variable sized array in it, then, this type do not have a known fixed size. Because this array can have any size at runtime (i.e. it can be an array of 2 elements, or 50 elements, or 1 thousand elements, etc.).

For example, a string object, which internally is an array of constant u8 values ([]const u8) have a variable size. It can be a string object with 100 or 500 characters in it. If we do not know at compile-time, which exact string will be stored inside this string object, then, we cannot calculate the size of this string object at compile-time. So, any type, or any struct declaration that you make, that includes a string data member that do not have an explicit fixed size, makes this type, or this new struct that you are declaring, a type that do not have a known fixed size at compile-time.

In contrast, if the type or this struct that you are declaring, includes a data member that is an array, but this array have a known fixed size, like [60]u8 (which declares an array of 60 u8 values), then, this type, or, this struct that you are declaring, becomes a type with a known fixed size at compile-time. And because of that, in this case, the zig compiler do not need to known at compile-time the exact value of any object of this type. Since the compiler can find the necessary size to store this object by looking at the size of it’s type.

Let’s look at an example. In the source code below, we have two constant objects (name and array) declared. Because the values of these particular objects are written down, in the source code itself ("Pedro" and the number sequence from 1 to 4), the zig compiler can easily discover the values of these constant objects (name and array) during the compilation process. This is what “known at compile time” means. It refers to any object that you have in your Zig source code whose value can be identified at compile time.

const name = "Pedro";
const array = [_]u8{1, 2, 3, 4};
_ = name; _ = array;

fn input_length(input: []const u8) usize {
    const n = input.len;
    return n;
}

The other side of the spectrum are objects whose values are not known at compile time. Function arguments are a classic example of this. Because the value of each function argument depends on the value that you assign to this particular argument, when you call the function.

For example, the function input_length() contains an argument named input, which is an array of constant u8 integers ([]const u8). Is impossible to know at compile time the value of this particular argument. And it also is impossible to know the size/length of this particular argument. Because it is an array that do not have a fixed size specified explicitly in the argument type annotation.

So, we know that this input argument will be an array of u8 integers. But we do not know at compile-time, it’s value, and neither his size. This information is known only at runtime, which is the period of time when you program is executed. As a consequence, the value of the expression input.len is also known only at runtime. This is an intrinsic characteristic of any function. Just remember that the value of function arguments is usually not “compile-time known”.

However, as I mentioned earlier, what really matters to the compiler is to know the size of the object at compile-time, and not necessarily it’s value. So, although we don’t know the value of the object n, which is the result of the expression input.len, at compile-time, we do know it’s size. Because the expression input.len always return a value of type usize, and the type usize have a known fixed size.

3.1.2 Global data register

The global data register is a specific section of the executable of your Zig program, that is responsible for storing any value that is known at compile time.

Every constant object whose value is known at compile time that you declare in your source code, is stored in the global data register. Also, every literal value that you write in your source code, such as the string "this is a string", or the integer 10, or a boolean value such as true, is also stored in the global data register.

Honestly, you don’t need to care much about this memory space. Because you can’t control it, you can’t deliberately access it or use it for your own purposes. Also, this memory space does not affect the logic of your program. It simply exists in your program.

3.1.3 Stack vs Heap

If you are familiar with system’s programming, or just low-level programming in general, you probably have heard of the “duel” between Stack vs Heap. These are two different types of memory, or different memory spaces, which are both available in Zig.

These two types of memory don’t actually duel with each other. This is a commom mistake that beginners have, when seeing “x vs y” styles of tabloid headlines. These two types of memory are actually complementary to each other. So, in almost every Zig program that you ever write, you will likely use a combination of both. I will describe each memory space in detail over the next sections. But for now, I just want to stablish the main difference between these two types of memory.

In essence, the stack memory is normally used to store values whose length is fixed and known at compile time. In contrast, the heap memory is a dynamic type of memory space, meaning that, it is used to store values whose length might grow during the execution (runtime) of your program (Chen and Guo 2022).

Lengths that grow during runtime are intrinsically associated with “runtime known” type of values. In other words, if you have an object whose length might grow during runtime, then, the length of this object becomes not known at compile time. If the length is not known at compile-time, the value of this object also becomes not known at compile-time. These types of objects should be stored in the heap memory space, which is a dynamic memory space, which can grow or shrink to fit the size of your objects.

3.1.4 Stack

The stack is a type of memory that uses the power of the stack data structure, hence the name. A “stack” is a type of data structure that uses a “last in, first out” (LIFO) mechanism to store the values you give it to. I imagine you are familiar with this data structure. But, if you are not, the Wikipedia page1 , or, the Geeks For Geeks page2 are both excellent and easy resources to fully understand how this data structure works.

So, the stack memory space is a type of memory that stores values using a stack data structure. It adds and removes values from the memory by following a “last in, first out” (LIFO) principle.

Every time you make a function call in Zig, an amount of space in the stack is reserved for this particular function call (Chen and Guo 2022; Zig Software Foundation 2024). The value of each function argument given to the function in this function call is stored in this stack space. Also, every local object that you declare inside the function scope is usually stored in this same stack space.

Looking at the example below, the object result is a local object declared inside the scope of the add() function. Because of that, this object is stored inside the stack space reserved for the add() function. The r object (which is declared outside of the add() function scope) is also stored in the stack. But since it is declared in the “outer” scope, this object is stored in the stack space that belongs to this outer scope.

const r = add(5, 27);
_ = r;

fn add(x: u8, y: u8) u8 {
    const result = x + y;
    return result;
}

So, any object that you declare inside the scope of a function is always stored inside the space that was reserved for that particular function in the stack memory. This also counts for any object declared inside the scope of your main() function for example. As you would expect, in this case, they are stored inside the stack space reserved for the main() function.

One very important detail about the stack memory is that it frees itself automatically. This is very important, remember that. When objects are stored in the stack memory, you don’t have the work (or the responsibility) of freeing/destroying these objects. Because they will be automatically destroyed once the stack space is freed at the end of the function scope.

So, once the function call returns (or ends, if you prefer to call it this way) the space that was reserved in the stack is destroyed, and all of the objects that were in that space goes away with it. This mechanism exists because this space, and the objects within it, are not necessary anymore, since the function “finished it’s business”. Using the add() function that we exposed above as an example, it means that the object result is automatically destroyed once the function returns.

Important

Local objects that are stored in the stack space of a function are automatically freed/destroyed at the end of the function scope.

This same logic applies to any other special structure in Zig that have it’s own scope by surrounding it with curly braces ({}). For loops, while loops, if else statements, etc. For example, if you declare any local object in the scope of a for loop, this local object is accessible only within the scope of this particular for loop. Because once the scope of this for loop ends, the space in the stack reserved for this for loop is freed. The example below demonstrates this idea.

// This does not compile succesfully!
const a = [_]u8{0, 1, 2, 3, 4};
for (0..a.len) |i| {
    const index = i;
    _ = index;
}
// Trying to use an object that was
// declared in the for loop scope,
// and that does not exist anymore.
std.debug.print("{d}\n", index);

One important consequence of this mechanism is that, once the function returns, you can no longer access any memory address that was inside the space in the stack reserved for this particular function. Because this space was destroyed. This means that, if this local object is stored in the stack, you cannot make a function that returns a pointer to this object.

Think about that for a second. If all local objects in the stack are destroyed at the end of the function scope, why would you even consider returning a pointer to one of these objects? This pointer is at best, invalid, or, more likely, “undefined”.

Conclusion, is totally fine to write a function that returns the local object itself as result, because then, you return the value of that object as the result. But, if this local object is stored in the stack, you should never write a function that returns a pointer to this local object. Because the memory address pointed by the pointer no longer exists.

So, using again the add() function as an example, if you rewrite this function so that it returns a pointer to the local object result, the zig compiler will actually compile you program, with no warnings or erros. At first glance, it looks that this is good code that works as expected. But this is a lie!

If you try to take a look at the value inside of the r object, or, if you try to use this r object in another expression or function call, then, you would have undefined behaviour, and major bugs in your program (Zig Software Foundation 2024, see “Lifetime and Ownership”3 and “Undefined Behaviour”4 sections).

// This code compiles succesfully. But it has
// undefined behaviour. Never do this!!!

// The `r` object is undefined!
const r = add(5, 27);
_ = r;

fn add(x: u8, y: u8) *const u8 {
    const result = x + y;
    return &result;
}

This “invalid pointer to stack variable” problem is very known across many programming language communities. If you try to do the same thing, for example, in a C or C++ program (i.e. returning an address to a local object stored in the stack), you would also get undefined behaviour in the program.

Important

If a local object in your function is stored in the stack, you should never return a pointer to this local object from the function. Because this pointer will always become undefined after the function returns, since the stack space of the function is destroyed at the end of it’s scope.

But what if you really need to use this local object in some way after your function returns? How can you do this? The answer is: “in the same you would do if this was a C or C++ program. By returning an address to an object stored in the heap”. The heap memory have a much more flexible lifecycle, and allows you to get a valid pointer to a local object of a function that already returned from it’s scope.

3.1.5 Heap

One important limitation of the stack, is that, only objects whose length/size is known at compile-time can be stored in it. In contrast, the heap is a much more dynamic (and flexible) type of memory. It is the perfect type of memory to use on objects whose size/length might grow during the execution of your program.

Virtually any application that behaves as a server is a classic use case of the heap. A HTTP server, a SSH server, a DNS server, a LSP server, … any type of server. In summary, a server is a type of application that runs for long periods of time, and that serves (or “deals with”) any incoming request that reaches this particular server.

The heap is a good choice for this type of system, mainly because the server does not know upfront how many requests it will receive from users, while it is active. It could be one single request, or, 5 thousand requests, or, it could also be zero requests. The server needs to have the ability to allocate and manage it’s memory according to how many requests it receives.

Another key difference between the stack and the heap, is that the heap is a type of memory that you, the programmer, have complete control over. This makes the heap a more flexible type of memory, but it also makes it harder to work with it. Because you, the programmer, is responsible for managing everything related to it. Including where the memory is allocated, how much memory is allocated, and where this memory is freed.

Unlike stack memory, heap memory is allocated explicitly by programmers and it won’t be deallocated until it is explicitly freed (Chen and Guo 2022).

To store an object in the heap, you, the programmer, needs to explicitly tells Zig to do so, by using an allocator to allocate some space in the heap. At Section 3.2, I will present how you can use allocators to allocate memory in Zig.

Important

Every memory you allocate in the heap needs to be explicitly freed by you, the programmer.

The majority of allocators in Zig do allocate memory on the heap. But some exceptions to this rule are ArenaAllocator() and FixedBufferAllocator(). The ArenaAllocator() is a special type of allocator that works in conjunction with a second type of allocator. On the other side, the FixedBufferAllocator() is an allocator that works based on buffer objects created on the stack. This means that the FixedBufferAllocator() makes allocations only on the stack.

3.1.6 Summary

After discussing all of these boring details, we can quickly recap what we learned. In summary, the Zig compiler will use the following rules to decide where each object you declare is stored:

  1. every literal value (such as "this is string", 10, or true) is stored in the global data section.

  2. every constant object (const) whose value is known at compile-time is also stored in the global data section.

  3. every object (constant or not) whose length/size is known at compile time is stored in the stack space for the current scope.

  4. if an object is created with the method alloc() or create() of an allocator object, this object is stored in the memory space used by this particular allocator object. Most of allocators available in Zig use the heap memory, so, this object is likely stored in the heap (FixedBufferAllocator() is an exception to that).

  5. the heap can only be accessed through allocators. If your object was not created through the alloc() or create() methods of an allocator object, then, he is most certainly not an object stored in the heap.

3.2 Allocators

One key aspect about Zig, is that there are “no hidden-memory allocations” in Zig. What that really means, is that “no allocations happen behind your back in the standard library” (Sobeston 2024).

This is a known problem, specially in C++. Because in C++, there are some operators that do allocate memory behind the scene, and there is no way for you to known that, until you actually read the source code of these operators, and find the memory allocation calls. Many programmers find this behaviour annoying and hard to keep track of.

But, in Zig, if a function, an operator, or anything from the standard library needs to allocate some memory during it’s execution, then, this function/operator needs to receive (as input) an allocator provided by the user, to actually be able to allocate the memory it needs.

This creates a clear distinction between functions that “do not” from those that “actually do” allocate memory. Just look at the arguments of this function. If a function, or operator, have an allocator object as one of it’s inputs/arguments, then, you know for sure that this function/operator will allocate some memory during it’s execution.

An example is the allocPrint() function from the Zig standard library. With this function, you can write a new string using format specifiers. So, this function is, for example, very similar to the function sprintf() in C. In order to write such new string, the allocPrint() function needs to allocate some memory to store the output string.

That is why, the first argument of this function is an allocator object that you, the user/programmer, gives as input to the function. In the example below, I am using the GeneralPurposeAllocator() as my allocator object. But I could easily use any other type of allocator object from the Zig standard library.

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
const name = "Pedro";
const output = try std.fmt.allocPrint(
    allocator,
    "Hello {s}!!!",
    .{name}
);
try stdout.print("{s}\n", .{output});
Hello Pedro!!!

You get a lot of control over where and how much memory this function can allocate. Because it is you, the user/programmer, that provides the allocator for the function to use. This makes “total control” over memory management easier to achieve in Zig.

3.2.1 What are allocators?

Allocators in Zig are objects that you can use to allocate memory for your program. They are similar to the memory allocating functions in C, like malloc() and calloc(). So, if you need to use more memory than you initially have, during the execution of your program, you can simply ask for more memory using an allocator.

Zig offers different types of allocators, and they are usually available through the std.heap module of the standard library. So, just import the Zig standard library into your Zig module (with @import("std")), and you can start using these allocators in your code.

Furthermore, every allocator object is built on top of the Allocator interface in Zig. This means that, every allocator object you find in Zig must have the methods alloc(), create(), free() and destroy(). So, you can change the type of allocator you are using, but you don’t need to change the function calls to the methods that do the memory allocation (and the free memory operations) for your program.

3.2.2 Why you need an allocator?

As we described at Section 3.1.4, everytime you make a function call in Zig, a space in the stack is reserved for this function call. But the stack have a key limitation which is: every object stored in the stack have a known fixed length.

But in reality, there are two very commom instances where this “fixed length limitation” of the stack is a deal braker:

  1. the objects that you create inside your function might grow in size during the execution of the function.

  2. sometimes, it is impossible to know upfront how many inputs you will receive, or how big this input will be.

Also, there is another instance where you might want to use an allocator, which is when you want to write a function that returns a pointer to a local object. As I described at Section 3.1.4, you cannot do that if this local object is stored in the stack. However, if this object is stored in the heap, then, you can return a pointer to this object at the end of the function. Because you (the programmer) control the lyfetime of any heap memory that you allocate. You decide when this memory get’s destroyed/freed.

These are commom situations where the stack is not good for. That is why you need a different memory management strategy to store these objects inside your function. You need to use a memory type that can grow together with your objects, or that you can control the lyfetime of this memory. The heap fit this description.

Allocating memory on the heap is commonly known as dynamic memory management. As the objects you create grow in size during the execution of your program, you grow the amount of memory you have by allocating more memory in the heap to store these objects. And you that in Zig, by using an allocator object.

3.2.3 The different types of allocators

At the moment of the writing of this book, in Zig, we have 6 different allocators available in the standard library:

  • GeneralPurposeAllocator().
  • page_allocator().
  • FixedBufferAllocator() and ThreadSafeFixedBufferAllocator().
  • ArenaAllocator().
  • c_allocator() (requires you to link to libc).

Each allocator have it’s own perks and limitations. All allocators, except FixedBufferAllocator() and ArenaAllocator(), are allocators that use the heap memory. So any memory that you allocate with these allocators, will be placed in the heap.

3.2.4 General-purpose allocators

The GeneralPurposeAllocator(), as the name suggests, is a “general purpose” allocator. You can use it for every type of task. In the example below, I’m allocating enough space to store a single integer in the object some_number.

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    const some_number = try allocator.create(u32);
    defer allocator.destroy(some_number);

    some_number.* = @as(u32, 45);
}

While useful, you might want to use the c_allocator(), which is a alias to the C standard allocator malloc(). So, yes, you can use malloc() in Zig if you want to. Just use the c_allocator() from the Zig standard library. However, if you do use c_allocator(), you must link to Libc when compiling your source code with the zig compiler, by including the flag -lc in your compilation process. If you do not link your source code to Libc, Zig will not be able to find the malloc() implementation in your system.

3.2.5 Page allocator

The page_allocator() is an allocator that allocates full pages of memory in the heap. In other words, every time you allocate memory with page_allocator(), a full page of memory in the heap is allocated, instead of just a small piece of it.

The size of this page depends on the system you are using. Most systems use a page size of 4KB in the heap, so, that is the amount of memory that is normally allocated in each call by page_allocator(). That is why, page_allocator() is considered a fast, but also “wasteful” allocator in Zig. Because it allocates a big amount of memory in each call, and you most likely will not need that much memory in your program.

3.2.6 Buffer allocators

The FixedBufferAllocator() and ThreadSafeFixedBufferAllocator() are allocator objects that work with a fixed sized buffer that is stored in the stack. So these two allocators only allocates memory in the stack. This also means that, in order to use these allocators, you must first create a buffer object, and then, give this buffer as an input to these allocators.

In the example below, I am creating a buffer object that is 10 elements long. Notice that I give this buffer object to the FixedBufferAllocator() constructor. Now, because this buffer object is 10 elements long, this means that I am limited to this space. I cannot allocate more than 10 elements with this allocator object. If I try to allocate more than that, the alloc() method will return an OutOfMemory error value.

var buffer: [10]u8 = undefined;
for (0..buffer.len) |i| {
    buffer[i] = 0; // Initialize to zero
}

var fba = std.heap.FixedBufferAllocator.init(&buffer);
const allocator = fba.allocator();
const input = try allocator.alloc(u8, 5);
defer allocator.free(input);

3.2.7 Arena allocator

The ArenaAllocator() is an allocator object that takes a child allocator as input. The idea behind the ArenaAllocator() in Zig is similar to the concept of “arenas” in the programming language Go5. It is an allocator object that allows you to allocate memory as many times you want, but free all memory only once. In other words, if you have, for example, called 5 times the method alloc() of an ArenaAllocator() object, you can free all the memory you allocated over these 5 calls at once, by simply calling the deinit() method of the same ArenaAllocator() object.

If you give, for example, a GeneralPurposeAllocator() object as input to the ArenaAllocator() constructor, like in the example below, then, the allocations you perform with alloc() will actually be made with the underlying object GeneralPurposeAllocator() that was passed. So, with an arena allocator, any new memory you ask for is allocated by the child allocator. The only thing that an arena allocator really do is helping you to free all the memory you allocated multiple times with just a single command. In the example below, I called alloc() 3 times. So, if I did not used an arena allocator, then, I would need to call free() 3 times to free all the allocated memory.

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
var aa = std.heap.ArenaAllocator.init(gpa.allocator());
defer aa.deinit();
const allocator = aa.allocator();

const in1 = allocator.alloc(u8, 5);
const in2 = allocator.alloc(u8, 10);
const in3 = allocator.alloc(u8, 15);
_ = in1; _ = in2; _ = in3;

3.2.8 The alloc() and free() methods

In the code example below, we are accessing the stdin, which is the standard input channel, to receive an input from the user. We read the input given by the user with the readUntilDelimiterOrEof() method.

Now, after reading the input of the user, we need to store this input somewhere in our program. That is why I use an allocator in this example. I use it to allocate some amount of memory to store this input given by the user. More specifically, the method alloc() of the allocator object is used to allocate an array capable of storing 50 u8 values.

Notice that this alloc() method receives two inputs. The first one, is a type. This defines what type of values the allocated array will store. In the example below, we are allocating an array of unsigned 8-bit integers (u8). But you can create an array to store any type of value you want. Next, on the second argument, we define the size of the allocated array, by specifying how much elements this array will contain. In the case below, we are allocating an array of 50 elements.

At Section 1.8 we described that strings in Zig are simply arrays of characters. Each character is represented by an u8 value. So, this means that the array that was allocated in the object input is capable of storing a string that is 50-characters long.

So, in essence, the expression var input: [50]u8 = undefined would create an array for 50 u8 values in the stack of the current scope. But, you can allocate the same array in the heap by using the expression var input = try allocator.alloc(u8, 50).

const std = @import("std");
const stdin = std.io.getStdIn();

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    var input = try allocator.alloc(u8, 50);
    defer allocator.free(input);
    for (0..input.len) |i| {
        input[i] = 0; // initialize all fields to zero.
    }
    // read user input
    const input_reader = stdin.reader();
    _ = try input_reader.readUntilDelimiterOrEof(
        input,
        '\n'
    );
    std.debug.print("{s}\n", .{input});
}

Also, notice that in this example, we use the defer keyword (which I described at Section 2.1.3) to run a small piece of code at the end of the current scope, which is the expression allocator.free(input). When you execute this expression, the allocator will free the memory that it allocated for the input object.

We have talked about this at Section 3.1.5. You should always explicitly free any memory that you allocate using an allocator! You do that by using the free() method of the same allocator object you used to allocate this memory. The defer keyword is used in this example only to help us execute this free operation at the end of the current scope.

3.2.9 The create() and destroy() methods

With the alloc() and free() methods, you can allocate memory to store multiple elements at once. In other words, with these methods, we always allocate an array to store multiple elements at once. But what if you need enough space to store just a single item? Should you allocate an array of a single element through alloc()?

The answer is no! In this case, you should use the create() method of the allocator object. Every allocator object offers the create() and destroy() methods, which are used to allocate and free memory for a single item, respectively.

So, in essence, if you want to allocate memory to store an array of elements, you should use alloc() and free(). But if you need to store just a single item, then, the create() and destroy() methods are ideal for you.

In the example below, I’m defining a struct to represent an user of some sort. It could be an user for a game, or a software to manage resources, it doesn’t mater. Notice that I use the create() method this time, to store a single User object in the program. Also notice that I use the destroy() method to free the memory used by this object at the end of the scope.

const std = @import("std");
const User = struct {
    id: usize,
    name: []const u8,

    pub fn init(id: usize, name: []const u8) User {
        return .{ .id = id, .name = name };
    }
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    const user = try allocator.create(User);
    defer allocator.destroy(user);

    user.* = User.init(0, "Pedro");
}

  1. https://en.wikipedia.org/wiki/Stack_(abstract_data_type)↩︎

  2. https://www.geeksforgeeks.org/stack-data-structure/↩︎

  3. https://ziglang.org/documentation/master/#Lifetime-and-Ownership↩︎

  4. https://ziglang.org/documentation/master/#Undefined-Behavior↩︎

  5. https://go.dev/src/arena/arena.go↩︎