Peeking Behind Zig Interfaces by Creating a Dummy std.Random Implementation
Jan 15, 2025
Zig doesn't have an interface
keyword or some simple way to create interfaces. Nonetheless, types like std.mem.Allocator
and std.Random
are often cited as examples that, with a bit of elbow grease, Zig offers every thing needed to create them.
We've looked at Zig Interfaces in the past. If you want to understand how they work and how to write your own, I recommend you read that post first. But I recently needed to create a dummy std.Random
implementation (for testing purposes) and felt that the experience was a nice refresher.
When I think about an interface, I think about a contract with no implementation. But if we look at most types in Zig's standard library which people call an "interface", it's usually something different. Interfaces in Zig have a tendency to expose their own behavior which enhance an underlying implementation's algorithm. For example, the std.io.Reader
"interface" has an readAtLeast
method. readAtLeast
is implemented directly in std.io.Reader
. It uses the underlying implementation's read
method as part of its implementation. (that underlying implementation could be a file, or a socket, etc).
std.Random
is no different and methods like intRangeAtMost
are implemented within the std.Random
type itself. These method utilize behavior for the underlying implementation. In order to write our own [mock] implementation, we need to know what method(s) std.Random
needs us to implement. If you're already comfortable in Zig, you can probably look at the documentation for std.Random
and figure it out, although it isn't explicitly stated. You'd see that it has two fields:
ptr: *anyopaque
,
fillFn: *const fn (ptr: *anyopaque, buf: []u8) void
and realize that this interface requires a single fill
function.
Another way to try to divine the requirements would be to look at an existing implementation. For example, if we look at std.Random.DefaultPrng
, we'll be brought to the std.Random.Xoshiro256
type, where we can find the random
method. This is the method we call on an implementation to get the an std.Random
interface. Just like you call allocator
on a GPA to get an std.mem.Allocator
. The implementation of random
is:
pub fn random(self: *Xoshiro256) std.Random {
return std.Random.init(self, fill);
}
This tells us that, if we want to create an std.Random
, we can use its init
function. std.Random.init
has the following signature:
pub fn init(
pointer: anytype,
comptime fillFn: fn (ptr: @TypeOf(pointer), buf: []u8
) void) Random
Thus, init
expects a pointer of any type as well as a function pointer. Knowing this, we can take a stab at writing our dummy implementation:
const std = @import("std");
pub fn main() !void {
var dr = DummyRandom{};
var random = dr.random();
std.debug.print("{d}\n", .{random.int(u8)});
}
const DummyRandom = struct {
pub fn fill(_: *DummyRandom, buf: []u8) void {
@memset(buf, 0);
}
pub fn random(self: *DummyRandom) std.Random {
return std.Random.init(self, fill);
}
};
This code works, but can we make it less verbose? In normal cases, such as with the real Xoshiro256
implementation, the underlying instance exists because it maintains some state (such as a seed). That's why std.Random
maintains a pointer
to the instance and then passes back to the given fill
function. Our implementation is dumb though. Do we really need the DummyRandom
structure and an instance of it?
If we try to pass void
as our type, and use an anonymous struct, we can tighten up the code:
const std = @import("std");
pub fn main() !void {
var random = std.Random.init({}, struct {
pub fn fill(_: void, buf: []u8) void {
@memset(buf, 0);
}
}.fill);
std.debug.print("{d}\n", .{random.int(u8)});
}
But it won't compile. We get the following error: access of union field 'pointer' while field 'void' is active. Looking at the implementation of std.Random.init
we see all of these compile-time check:
pub fn init(pointer: anytype, comptime fillFn: fn (ptr: @TypeOf(pointer), buf: []u8) void) Random {
const Ptr = @TypeOf(pointer);
assert(@typeInfo(Ptr) == .pointer);
assert(@typeInfo(Ptr).pointer.size == .One);
assert(@typeInfo(@typeInfo(Ptr).pointer.child) == .@"struct");
Essentially, we must pass a pointer to a structure, e.g. a pointer to a Xoshiro256
or DummyRandom
or whatever. From what I can tell, there's no good reason for this restriction. std.Random
only uses the provided pointer
to pass it back to the provided fill
function - it shouldn't care if it's a struct, an integer, or void.
To get around this, we'll need to circumvent init
and set the fields directly:
const std = @import("std");
pub fn main() !void {
var random = std.Random{
.ptr = {},
.fillFn = struct {
pub fn fill(_: *anyopaque, buf: []u8) void {
@memset(buf, 0);
}
}.fill,
};
std.debug.print("{d}\n", .{random.int(u8)});
}
This also gives us an error: expected type '*anyopaque', found 'void'. That seems right to me. The ptr
field is of type *anyopaque
, and we're trying to assign void
. We can't just @ptrCast({})
, because @ptrCast
expects a pointer, but what if we try @ptrCast(&{})
?
const std = @import("std");
pub fn main() !void {
var random = std.Random{
.ptr = @ptrCast(&{}),
.fillFn = struct {
pub fn fill(_: *anyopaque, buf: []u8) void {
@memset(buf, 0);
}
}.fill,
};
std.debug.print("{d}\n", .{random.int(u8)});
}
We get a different error: @ptrCast discards const qualifier. So now our problem is that our void pointer, &{}
is a const
, but the ptr
field is an *anyopaque
not an *const anyopque
.
Since we're already using @ptrCast
, which is always questionable, why not add an even more questionable @constCast
?:
const std = @import("std");
pub fn main() !void {
var random = std.Random{
.ptr = @constCast(@ptrCast(&{})),
.fillFn = struct {
pub fn fill(_: *anyopaque, buf: []u8) void {
@memset(buf, 0);
}
}.fill,
};
std.debug.print("{d}\n", .{random.int(u8)});
}
This code works. It's safe because our fill
implementation never uses it and thus the invalid const
discard is never a factor. But it's unsafe because, in theory, std.Random
could one day change and use self.ptr
itself or assume that it's a pointer to a struct - which is what its init
function enforces.
Creating our DummyRandom
and going through std.Random.init
is safer and the right way. But, creating std.Random
directly is more fun.