Memory management in Zig is handled manually, similar to C and C++. By default, variables are allocated on the stack. However, should we need some dynamic memory, we can use allocators to allocate memory on the heap for use.
Allocation is handled by the std.mem.Allocatorstruct, which defines methods to allocate and free memory based on some underlying allocation strategy. The Zig standard library provides several different allocators with different strategies.
Allocators
Let's first go through a simple example using the general-purpose allocator.
General-Purpose Allocator
The general purpose allocator in Zig can be used for most purposes. Here we use the allocfunction of the allocator to allocate memory for 16 u8s (16 bytes of memory).
// We first create the general-purpose allocator.
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
// Then we get the general `std.mem.Allocator` struct from it.
// This is what we'll call to (de)allocate memory.
const allocator = gpa.allocator();
// Let's allocate 16 bytes of memory.
const some_bytes: []u8 = try allocator.alloc(u8, 16);
// Maybe put a string into it.
std.mem.copyForwards(u8, some_bytes, "Hello, my world!");
// What's in the memory?
std.debug.print("{s}\n", .{some_bytes});
// Wait, don't we need to free the memory???
defer
Here's a brief digression to introduce the deferkeyword. This keyword can be used to execute an expression at the end of the current scope. If there are multiple defers in the same scope, they wil be executed in the reverse order from which they were introduced.
The following statements will produce the following console output.
normal 1
normal 2
normal 3
defer 3
defer 2
defer 1
Checking for leaks
The example we gave earlier wasn't complete. We didn't actually free the memory we allocated. Should the program have been more long-running (e.g., web server), we would have leaked memory. Luckily, the general-purpose allocator comes with a built-in way to check for leaks, which composes nicely with the defer keyword that we just learnt.
// We first create the general-purpose allocator.
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
// Then we get the general `std.mem.Allocator` struct from it.
// This is what we'll call to (de)allocate memory.
const allocator = gpa.allocator();
// Let's allocate 16 bytes of memory.
const some_bytes: []u8 = try allocator.alloc(u8, 16);
// Maybe put a string into it.
std.mem.copyForwards(u8, some_bytes, "Hello, my world!");
// What's in the memory?
std.debug.print("{s}\n", .{some_bytes});
// LEAKKKKKKKK
Now, the program should crash indicating where the memory leak took place. To fix this, we can use defer once more to free the memory at the end of the scope.
// We first create the general-purpose allocator.
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
// Then we get the general `std.mem.Allocator` struct from it.
// This is what we'll call to (de)allocate memory.
const allocator = gpa.allocator();
// Let's allocate 16 bytes of memory.
const some_bytes: []u8 = try allocator.alloc(u8, 16);
defer allocator.free(some_bytes);
// Maybe put a string into it.
std.mem.copyForwards(u8, some_bytes, "Hello, my world!");
// What's in the memory?
std.debug.print("{s}\n", .{some_bytes});
// Phew, no more leaks!
Fixed Buffer Allocator
This allocator takes in a slice of bytes and performs allocations on it. The example should make this clear.
var buf: [16]u8 = undefined;
// We first create the fixed buffer allocator.
var fba = std.heap.FixedBufferAllocator.init(&buf);
// Then we get the general `std.mem.Allocator` struct from it.
// This is what we'll call to (de)allocate memory.
const allocator = fba.allocator();
// Let's allocate 16 bytes of memory.
const some_bytes: []u8 = try allocator.alloc(u8, 16);
defer allocator.free(some_bytes);
// Maybe put a string into it.
std.mem.copyForwards(u8, some_bytes, "Hello, my world!");
// What's in the memory?
std.debug.print("{s}\n", .{some_bytes});
Arena Allocator
The arena allocator wraps an existing allocator, using it the perform allocations. However, it doesn't perform any frees, instead free-ing all the memory it allocated at once upon deinit.
page_allocator
This is the most basic allocator. When you make an allocation, it will ask the OS for an entire page of memory, which makes this extremely space inefficient, and also not performant.
ArrayList
Let's explore memory management further by looking at a common data structure used in Zig programs: the humble ArrayList. This is Zig's implementation of a dynamically-sized array in the standard library.
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Initialise the ArrayList, passing in the allocator it will use to dynamically
// allocate memory for its items.
var some_array_list = std.ArrayList(i32).init(allocator);
defer some_array_list.deinit(); // REMEMBER TO DEINIT WHAT YOU INIT!!
// Append some items to the list. Notice how we need to use `try` here, since the
// memory allocation can fail.
try some_array_list.append(3);
try some_array_list.append(8);
try some_array_list.append(4);
try some_array_list.append(39);
// Remove some items in the list. Notice how we don't allocate memory here, so we
// don't need to use `try`. But we need to assign the result to something.
_ = some_array_list.orderedRemove(1);
// Iterate through the array list.
for (some_array_list.items) |item| {
std.debug.print("array list item: {}\n", .{item});
}
Notice how we pass allocator into the constructor of the ArrayList. It will store the allocator and use it whenever it needs to allocate memory internally. This is quite different from C, where the allocator is assumed to be a global construct (e.g., mallocand free). In this way, and since std.mem.Allocator represents any allocator, we can separate the concerns of how to allocate memory from how to implement a dynamic list.
However, storing the allocator means that the ArrayListstruct takes up more space. There is another version called ArrayListUnmanagedthat doesn't require passing the allocator in the constructor. Instead, you pass the allocator each time you need to allocate memory.
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Initialise the ArrayListUnmanaged. Notice we don't need to pass any allocator here,
// instead we just need to pass it in `deinit`.
var some_array_list = std.ArrayListUnmanaged(i32){};
defer some_array_list.deinit(allocator); // REMEMBER TO DEINIT WHAT YOU INIT!!
// Append some items to the list. Notice how we need to pass the allocator here, and also
// use `try` here, since the memory allocation can fail.
try some_array_list.append(allocator, 3);
try some_array_list.append(allocator, 8);
try some_array_list.append(allocator, 4);
try some_array_list.append(allocator, 39);
// Remove some items in the list. Notice how we don't allocate memory here, so we
// don't need to use `try` or pass any allocator. But we need to assign the result
// to something.
_ = some_array_list.orderedRemove(1);
// Iterate through the array list.
for (some_array_list.items) |item| {
std.debug.print("array list item: {}\n", .{item});
}
And this highlight a common choice in Zig, whether to store the allocator or pass it in each memory-allocating operation. There isn't a correct answer, and really depends on the use-case. Hash maps in Zig also follow a similar principle, with the standard library providing both managed and unmanaged versions.