In C and other low level, non-garbage-collected languages, there’s a question that often comes to mind when writing functions that need to return data of a size not known at compile time:
“Should I ask the caller to provide the buffer, or should I just malloc?”
Each approach has its tradeoffs. Use the stack and force the caller to manage memory? Or use the heap and introduce allocation complexity and fragmentation? It’s not always a trivial decision, and if you’re like me, these are the kind of things that can totally get you out of flow while you consider the implications.
One of the selling points of Zig is making Allocator (written in uppercase as it’s a type) a first class citizen. On first sight it may look like a feature for high-performance finetuning, and it is, but it’s even more powerful than that. It’s a way to avoid having to choose between heap and stack altogether.
Let’s see an example. Here’s an excerpt from a project (saga) I’m working on:
const std = @import("std");
const Environ = std.process.Environ;
pub const Paths = struct {
cfg_dir: []const u8,
data_dir: []const u8,
const Err = error{
HomeNotDefined, // custom error
};
pub fn init(env: *Environ.Map) Err!@This() {
return .{
.cfg_dir = xdgGet(".config", "XDG_CONFIG_HOME", env),
.data_dir = xdgGet(".local/share", "XDG_DATA_HOME", env),
};
}
fn xdgGet(comptime default: []const u8,
envvar: []const u8, env: *Environ.Map) Err![]u8 {
const home = env.get("HOME") orelse return Err.HomeNotDefined;
return if (env.get(envvar)) |xdgval|
// Problem: This requires a place to store the formatted string:
// xdgval ++ "/saga"
unreachable
else
// Problem: This requires a place to store the formatted string:
// "$HOME/" ++ default ++ "/saga"
unreachable
}
};
This is the definition of a Paths struct, which is intended to store the paths to the project’s directories (config + data), e.g.:
.cfg_dir = "/home/user/.config/saga".data_dir = "/home/user/.local/share/saga"
The fields themselves are string pointers, which in Zig means “slice of constant chars” ([]const u8). The idea is to play nice and follow the
standards, trying to get them
from the environment or falling back to their default values. In either case, the strings’ size
won’t be known at compile time. So the code is missing the most important part: actually formatting
the strings somewhere. What are our options?
Asking the caller Link to heading
The init function could ask for two buffers from the caller:
pub const Paths = struct {
// ...
const Err = error{
NoSpaceLeft, // buffer is too small
HomeNotDefined,
};
pub fn init(cfg_buf: []u8, data_buf: []u8, env: *Environ.Map) Err!@This() {
return .{
.cfg_dir = try xdgGet(".config", cfg_buf, "XDG_CONFIG_HOME", env),
.data_dir = try xdgGet(".local/share", data_buf, "XDG_DATA_HOME", env),
};
}
fn xdgGet(comptime default: []const u8, buf: []u8,
envvar: []const u8, env: *Environ.Map) Err![]const u8 {
const home = env.get("HOME") orelse return Err.HomeNotDefined;
// bufPrint is like sprintf(3) in C, formatting a string into a buffer
// (it's safe, as the slice already carries its length).
//
// Can throw NoSpaceLeft errors if the buffer is too small; the 'try'
// will propagate it through this function.
return if (env.get(envvar)) |xdgval|
try std.fmt.bufPrint(buf, "{s}/saga", .{xdgval})
else
// Notice the compile time join of the formatting string. Groovy.
try std.fmt.bufPrint(buf, "{s}/" ++ default ++ "/saga", .{home});
}
};
Here’s how we would call it:
pub fn main(init: std.process.Init) !void {
// This syntax allocates the buffers without initializing them
var cfg_buf: [256]u8 = undefined;
var data_buf: [256]u8 = undefined;
const paths = try Paths.init(&cfg_buf, &data_buf, init.environ_map);
std.log.debug("Config dir: {s}", .{paths.cfg_dir});
std.log.debug("Data dir: {s}", .{paths.data_dir});
}
The downsides of this approach are evident: hardcoded sizes, wasted space, requiring one buffer per field… but on the other hand, It Just Works (TM), it’s dead simple, and we are even enforcing an upper bound to runtime memory requirements.
Allocating memory on the heap Link to heading
Good old malloc(3). Or, almost. In C, the “malloc & company” family of functions is provided by
the libc. In Zig, Allocator is an
interface and the standard library offers several
implementations, so before even trying to
allocate memory, you need to chose one. Functions needing to allocate memory can simply receive an
Allocator and ask it for memory, not needing to know the particular implementation.
Let’s see how the code would look like using this pattern:
pub const Paths = struct {
// ...
const Err = error{
OutOfMemory, // allocator out of memory
HomeNotDefined,
};
pub fn init(alloc: std.mem.Allocator, env: *Environ.Map) Err!@This() {
return .{
.cfg_dir = try xdgGet(".config", alloc, "XDG_CONFIG_HOME", env),
.data_dir = try xdgGet(".local/share", alloc, "XDG_DATA_HOME", env),
};
}
pub fn deinit(this: @This(), alloc: std.mem.Allocator) void {
alloc.free(this.cfg_dir);
alloc.free(this.data_dir);
}
fn xdgGet(comptime default: []const u8, alloc: std.mem.Allocator,
envvar: []const u8, env: *Environ.Map) Err![]u8 {
const home = env.get("HOME") orelse return Err.HomeNotDefined;
// Now instead of bufPrint we use allocPrint (asprintf(3) in C), which
// internally allocates memory as required from alloc, writes the string
// there, and returns a pointer.
//
// Can throw OutOfMemory errors.
return if (env.get(envvar)) |xdgval|
try std.fmt.allocPrint(alloc, "{s}/saga", .{xdgval})
else
try std.fmt.allocPrint(alloc, "{s}/" ++ default ++ "/saga", .{home});
}
};
pub fn main(init: std.process.Init) !void {
// A default General Purpose Allocator is provided here by Zig for convenience
const gpa = init.gpa;
const paths = try Paths.init(gpa, init.environ_map);
defer paths.deinit(gpa); // Free memory used by `paths` when exiting this scope
std.log.debug("Config dir: {s}", .{paths.cfg_dir});
std.log.debug("Data dir: {s}", .{paths.data_dir});
}
Here the memory is not being explicitly requested by us; it’s done by the
allocPrint library function.
So, the allocator is passed through init -> xdgGet -> allocPrint. And now we need to expose a
deinit function to free the buffers.
This is probably the more standard approach: the caller does not need to worry about buffer sizes, although the caller does need to remember to deinit every Paths struct to prevent memory leakage.
The plot twist Link to heading
Now in the last example, the used allocator was the GPA “General Purpose Allocator”, which works just fine most of the time and has nice properties during development, such as detecting memory leaks. But the caller still has the option to choose a different one, such as the raw page_allocator or converting the GPA to an Arena for higher performance.
The key here is that an Allocator implementation does not even need to use the heap. An Allocator can be any anything that implements its vtable: alloc, resize, remap, and free. And indeed there’s one implementation in the standard library that uses any provided buffer as memory pool, including one residing in the stack: the FixedBufferAllocator:
pub fn main(init: std.process.Init) !void {
var buf: [256]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buf);
const alloc = fba.allocator();
const paths = try Paths.init(alloc, init.environ_map);
// Notice we don't need to call `paths.deinit` anymore!
std.log.debug("Config dir: {s}", .{paths.cfg_dir});
std.log.debug("Data dir: {s}", .{paths.data_dir});
}
And here we are again, with a stack allocated buffer as in the first example, but with the extra convenience of not having to allocate individual buffers for each field, calling the same init function that we used when we were using the heap, without having to change its signature.
That is what using Allocator means: not needing to worry again about finding out the best place to request memory from; the caller will provide it.
Noice.