pixqc

Zig Functions Want to Allocate in Our Memory

"It is against conventions in Zig to allocate memory inside a module without accepting an Allocator parameter." -- andrewrk (source).

An allocator's purpose is to place data in memory in an orderly manner. Orderly placement means: data is aligned correctly, new allocation doesn't overwrite old ones, it can be freed/destroyed later on.

Function that takes allocator as param executes alloc, free, and resize on our behalf. This is why we need to pass allocators around when writing Zig.

Here's a simplified example:

const std = @import("std");

fn allocHello(allocator: std.mem.Allocator) !void {
    const str = "hello world\n";
    const ptr = try allocator.alloc(u8, str.len);
    @memcpy(ptr, str);
}

pub fn main() !void {
    var stack_buf: [1000]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&stack_buf);
    try allocHello(fba.allocator()); // data lives in stack

    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    try allocHello(arena.allocator()); // data lives in heap
}

allocHello() doesn't care where the data is allocated. We're free to use whatever allocator that suits our needs.

· · ·

Allocators aren't strictly necessary in Zig:

test "look ma, no allocator" {
    var stack_buf: [1024]u8 = undefined;
    const file = try std.fs.cwd().openFile("/tmp/hi.txt", .{});
    defer file.close();

    const bytes_read = try file.readAll(&stack_buf);
    const content = stack_buf[0..bytes_read];
    const another = stack_buf[bytes_read .. bytes_read + 4];
    @memcpy(another, "hi!!");

    try std.testing.expectEqualStrings(content, "henlo from file!!\n");
    try std.testing.expect(another.ptr == content.ptr + content.len);
}

The downside is we'd have to do pointer arithmetic and manual memory management.

Zig allocators abstract all that away from us.

test "read data to with an allocator" {
    // use std.heap.GeneralPurposeAllocator to use heap instead of stack
    var stack_buf: [1024]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&stack_buf);
    const allocator = fba.allocator();

    const file = try std.fs.cwd().openFile("/tmp/hi.txt", .{});
    defer file.close();
    const content = try file.readToEndAlloc(allocator, 32);
    defer allocator.free(content);
    const another = try allocator.alloc(u8, 4);
    @memcpy(another, "hi!!");

    try std.testing.expectEqualStrings(content, "henlo from file!!\n");
    try std.testing.expect(content.ptr + content.len == another.ptr);
}

Let's examine this line:

const content = try file.readToEndAlloc(allocator, 20);

What does readToEndAlloc() do with our allocator? If we go def, we'll reach readToEndAllocOptions().

pub fn readToEndAllocOptions(
    self: File,
    allocator: Allocator,
    max_bytes: usize,
    size_hint: ?usize,
    comptime alignment: u29,
    comptime optional_sentinel: ?u8,
) !(if (optional_sentinel) |s| [:s]align(alignment) u8 else []align(alignment) u8) {
    const size = size_hint orelse 0;
    const initial_cap = (if (size > 0) size else 1024) + @intFromBool(optional_sentinel != null);
    var array_list = try std.ArrayListAligned(u8, alignment).initCapacity(allocator, initial_cap);
    defer array_list.deinit();

    self.reader().readAllArrayListAligned(alignment, &array_list, max_bytes) catch |err| switch (err) {
        error.StreamTooLong => return error.FileTooBig,
        else => |e| return e,
    };
    if (optional_sentinel) |sentinel| {
        return try array_list.toOwnedSliceSentinel(sentinel);
    } else {
        return try array_list.toOwnedSlice();
    }
}

It uses our allocator to create an arraylist, places the file content inside, then returns the pointer.

Let's examine this line:

var array_list = try std.ArrayListAligned(u8, alignment).initCapacity(allocator, initial_cap);

What does std.ArrayListAligned do with our allocator?

pub fn init(allocator: Allocator) Self {
    return Self{
        .items = &[_]T{},
        .capacity = 0,
        .allocator = allocator,
    };
}

pub fn initCapacity(allocator: Allocator, num: usize) Allocator.Error!Self {
    var self = Self.init(allocator);
    try self.ensureTotalCapacityPrecise(num);
    return self;
}

It initializes an arraylist. Let's go def and see what ensureTotalCapacityPrecise() does:

pub fn ensureTotalCapacityPrecise(self: *Self, new_capacity: usize) Allocator.Error!void {
    if (@sizeOf(T) == 0) {
        self.capacity = math.maxInt(usize);
        return;
    }

    if (self.capacity >= new_capacity) return;
    if (self.allocator.resize(old_memory, new_capacity)) {
        self.capacity = new_capacity;
    } else {
        const new_memory = try self.allocator.alignedAlloc(T, alignment, new_capacity);
        @memcpy(new_memory[0..self.items.len], self.items);
        self.allocator.free(old_memory);
        self.items.ptr = new_memory.ptr;
        self.capacity = new_memory.len;
    }
}

It calls alignedAlloc() and free() on our behalf.

Recap: we created stack memory with size 1024 called stack_buf. We pass it to FixedBufferAllocator, which implements the Allocator interface. The Allocator interface provides functions for allocating, freeing, and resizing memory. readToEndAlloc() and ensureTotalCapacityPrecise() use this allocator to allocate and free memory on our stack_buf.

Let's examine stack_buf on our debugger:

memory read -size 1 -format x -count 32 `&stack_buf`

0x16fdfe710: 0x68 0x65 0x6e 0x6c 0x6f 0x20 0x66 0x72
0x16fdfe718: 0x6f 0x6d 0x20 0x66 0x69 0x6c 0x65 0x21
0x16fdfe720: 0x21 0x0a 0xaa 0xaa 0xaa 0xaa 0xaa 0xaa
0x16fdfe728: 0xaa 0xaa 0xaa 0xaa 0xaa 0xaa 0xaa 0xaa

Which reads: "henlo from file!!"

These Zig functions... they just want to allocate in our memory.

· · ·

In the spirit of "what I cannot build I do not understand," I've implemented Zig's allocators in C. Here's the code: zalloc.