homedark

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:

  1. ptr: *anyopaque,
  2. 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); // Must be a pointer
    assert(@typeInfo(Ptr).pointer.size == .One); // Must be a single-item pointer
    assert(@typeInfo(@typeInfo(Ptr).pointer.child) == .@"struct"); // Must point to a 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{
        // added @ptrCast and switch {} to &{}
        .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{
        // added @constCast
        .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.