The thing that inspired this post:
I remember reading a blog post from matklad (IIRC that is, I can’t find it unfortunately), which introduced me to the concept of using arena v.s. gpa to manage the lifetime of internal allocations of functions.
Consider the following function:
pub fn doWork(...) struct {
foo: []const u8,
bar: *Thing, // might even contain references to other allocated data :O
} {
...
}
how would you manage the lifetime of .foo and .bar? In rust, doWork might receive a 'a lifetime and declare .foo and .bar to be under that lifetime, that is an okay solution, but there is an alternative in zig which I really like, by simply changing the signature of doWork to:
pub fn doWork(arena: Allocator, gpa: Allocator, ...) ...
where the returned .foo and .bar would be allocated under the arena, separated from other internal allocations under gpa.
What I really like about this approach is that, conventionally a gpa is an allocator capable of individual frees whereas an arena is not. By naming (we’ll get to that in a sec) the arguments this way, the function expresses that it will use the gpa to allocate data that are used internally, guaranteeing them to all be freed, whereas the arena will be used to allocate data that it is not responsible of freeing. This way the caller can confortably do something like:
// var arena_instance: std.heap.ArenaAllocator
// const arena = arena_instance.allocator()
{
const foo, const bar = doWork(arena, gpa, ...);
defer arena_instance.reset(...);
// use foo and bar to do intersting stuff
...
}
// foo and bar and other inter-referenced memory are now freed
// carry on with other works
...
The issue:
Now despite how I love this pattern, an obvious point of awkwardness is that the differences between the arena and the gpa is only conveyed via their names. Imagine if the following is what you are presented with instead, it’d be not obvious at all how each allocators are expected to be used:
pub fn doWork(allocator_a: Allocator, allocator_b: Allocator, ...) ...
// or
pub const doWork: fn(Allocator, Allocator, ...) ... = ...;
obviously one should avoid irresponsible naming scheme like this, but on the other hand, I often find the zig’s standard library consisting of design choices that naturally encourage good programming patterns, maybe we can do better here as well.
Essentially what we want is a way to represent the capability of free isolated from allocate, via the type system.
Idea:
Before I stumbled uppon zig I was learning rust, and one idea I still really missed from the language is trait. Now I don’t intend to dive into the good and bad of how they implemented the system and whether should or shouldnot we add something simialr to zig, but I do like the abstract idea of separating each atomic properties of a type into a separate trait (e.g. Read + Write + Seek instead of a bigFile class).
Adapting that idea to std.mem.Allocator, what if instead of a single:
// std.mem
pub const Allocator = struct {
ptr: *anyopaque,
vtable: *const VTable,
const VTable = struct {
alloc: *const fn (...) ...,
free: *const fn (...) ...,
...
};
};
we split it up into:
// std.mem
pub const Allocate = struct {
vfunc: *const fn (...) ...,
pub fn create ...
pub fn alloc ...
...
};
pub const Free = struct {
vfunc: *const fn (...) ...,
pub fn destroy ...
pub fn free ...
};
// other capatibilities like remap and resize
...
Therefore, the doWork example function signature can be changed to something like:
pub fn doWork(
// btw: I assume we should use intrusive interface
// in this case, but I could be wrong.
arena: *mem.Allocate,
gpa: struct { *mem.Allocate, *mem.Free },
...,
) ...
Just by looking at the signature, you can tell that doWork cannot free using the arena, and doWork will use the gpa to free!
Using it from the caller might look something like:
_ = doWork(
&arena.interface.allocate,
.{ &gpa.interface.allocate, &gpa.interface.free },
...,
);
or maybe if you are into something fancier
_ = doWork(
arena.interface(.allocate),
gpa.interface(.{ .allocate, .free }),
...,
);
We can extend the idea further, for example:
// std.array_list.Aligned
pub fn ensureTotalCapacity(
self: *Self,
gpa: struct {
in_place: ?*mem.resize.Expand, // optional
relocating: *mem.Allocate,
},
new_capacity: usize
) ...
Notice the mem.resize.Expand. We could do mem.Resize, but I feel like there might be allocator implementations where an in-place shrink can be easier than an in-place expand, or vice versa.
Some potential issues/caveates:
- With
gpa: struct { *mem.Allocate, *mem.Free }it is totally valid to pass the two interfaces from two different allocators. Tho is this really a problem that concerns us? Since if a free frees a memory region not allocated by the allocator in debug/ReleaseSafe build, I would assume it’ll typically lead to a panic. - An allocator instance might have to effectively store multiple
*const fn, as opposed to constructing astruct { ptr: *anyopaque, vtable: *const VTable }. - function signature and caller syntax can become more verbose