Zig seems like a great language and has good ideas for how to improve on a systems level language without sacrificing compatibility with C. I haven’t used it in any projects, but I love reading about it whenever I come across it on Hacker News or the like. One of the design choices that I’ve been intrigued with is the use of allocator structs in functions that are expected to allocate memory. There are some good examples here on their website:
const std = @import("std");
const expect = std.testing.expect;
test "allocation" {
const allocator = std.heap.page_allocator;
const memory = try allocator.alloc(u8, 100);
defer allocator.free(memory);
try expect(memory.len == 100);
try expect(@TypeOf(memory) == []u8);
}The design choice of passing around allocation functions is language agnostic, so I implemented the same kind of thing in my project, otter. The full header file can be found here, but I’ll write out the most interesting bits inline.
typedef struct otter_allocator otter_allocator;
typedef struct otter_allocator_vtable {
void (*free_allocator)(struct otter_allocator *);
void *(*malloc)(struct otter_allocator *, size_t size);
void *(*realloc)(struct otter_allocator *, void *ptr, size_t size);
void (*free)(struct otter_allocator *, void *);
} otter_allocator_vtable;
typedef struct otter_allocator {
otter_allocator_vtable *vtable;
} otter_allocator;The otter_allocator struct contains a pointer to a vtable of function pointers. Right now, the vtable supports malloc, realloc, free (the obvious three), and a free_allocator function for when the allocator itself is freed/released. As an aside, this pointer to vtable layout is very much how a C++ class with virtual functions works under the hood. I’ve also created some convenience functions to make calling these function pointers a bit easier. If you are curious about OTTER_DEFINE_TRIVIAL_CLEANUP_FUNC, check out my other post on it from a couple of weeks ago.
static inline void otter_allocator_free(otter_allocator *allocator) {
allocator->vtable->free_allocator(allocator);
}
OTTER_DEFINE_TRIVIAL_CLEANUP_FUNC(otter_allocator *, otter_allocator_free);
static inline void *otter_realloc(otter_allocator *allocator, void *p,
size_t size) {
return allocator->vtable->realloc(allocator, p, size);
}
static inline void *otter_malloc(otter_allocator *allocator, size_t size) {
return allocator->vtable->malloc(allocator, size);
}
static inline void otter_free(otter_allocator *allocator, void *p) {
allocator->vtable->free(allocator, p);
}So if a function needs to allocate memory, it could take an otter_allocator as a parameter and use these convenience functions as opposed to calling the C standard library’s allocation functions directly. Here’s an example from otter’s lexer code.
otter_lexer *otter_lexer_create(otter_allocator *allocator,
const char *source) {
if (allocator == NULL) {
return NULL;
}
if (source == NULL) {
return NULL;
}
otter_lexer *lexer = otter_malloc(allocator, sizeof(*lexer));
if (lexer == NULL) {
return NULL;
}
lexer->allocator = allocator;
lexer->index = 0;
lexer->source_length = strlen(source);
lexer->source = source;
lexer->line = OTTER_LEXER_LINE_ZERO;
lexer->column = OTTER_LEXER_COLUMN_ZERO;
return lexer;
}
This design allows for custom allocators to be provided by the caller (e.g., bump, arena, etc.). It also makes it clear that a function intends to allocate some memory, which is not always obvious without reading documentation or implementation code of functions that aren’t using this dependency injection pattern. This also allows me to mock allocations. Here’s a simple test that ensures otter_lexer_create behaves properly if otter_malloc returns NULL.
static void *null_malloc(otter_allocator *, size_t) { return NULL; }
OTTER_TEST(lexer_create_allocator_returns_null) {
otter_allocator_vtable vtable = {
.malloc = null_malloc,
.realloc = NULL,
.free = NULL,
};
otter_allocator allocator = {
.vtable = &vtable,
};
otter_lexer *lexer = otter_lexer_create(&allocator, "source");
OTTER_ASSERT(lexer == NULL);
OTTER_TEST_END();
}
Most code in otter relies on being given an allocator to do anything. And while I haven’t implemented any custom allocation patterns (the default vtable just forwards to your traditional malloc, realloc, free), the option is now there for me to easily do it. I also want to explore making mocks easier to write to allow for better test coverage. The example shown is a very simple mock and it still takes quite a bit of boilerplate. I’m keeping an eye out for more cool things to take inspiration from in Zig as well!
Leave a Reply