Implementing Generic Concepts on Function Declarations

Resurrecting this to see what people think of the generic interface convention in my tiny Zimpl library. It achieves a very limited version of “generic concepts on function declarations.”

The idea is to provide clarity and avoid relying on duck typing by taking a separate parameter that contains all the necessary “member functions” and type data. Part of the std.io.Reader interface translated to this style is below as an example.

pub fn Reader(comptime Type: type) type {
    return struct {
        ReadError: type = error{},
        read: fn (reader_ctx: Type, buffer: []u8) anyerror!usize,
    };
}

pub inline fn read(
    reader_ctx: anytype,
    reader_impl: Reader(@TypeOf(reader_ctx)),
    buffer: []u8,
) reader_impl.ReadError!usize {
    return @errorCast(reader_impl.read(reader_ctx, buffer));
}

pub inline fn readAll(
    reader_ctx: anytype,
    reader_impl: Reader(@TypeOf(reader_ctx)),
    buffer: []u8,
) reader_impl.ReadError!usize {
    return readAtLeast(reader_ctx, reader_impl, buffer, buffer.len);
}

pub inline fn readAtLeast(
    reader_ctx: anytype,
    reader_impl: Reader(@TypeOf(reader_ctx)),
    buffer: []u8,
    len: usize,
) reader_impl.ReadError!usize {
    assert(len <= buffer.len);
    var index: usize = 0;
    while (index < len) {
        const amt = try read(reader_ctx, reader_impl, buffer[index..]);
        if (amt == 0) break;
        index += amt;
    }
    return index;
}

The issue now is that calling such functions is verbose and clunky, even in cases like Reader where the interface only has two fields.

test {
    var buffer: [19]u8 = undefined;
    var file = try std.fs.cwd().openFile("my_file.txt", .{});
    try io.readAll(
        file,
        .{
            .read = std.fs.File.read,
            .ReadError = std.fs.File.ReadError,
        },
        &buffer
    );

    try std.testing.expectEqualStrings("Hello, I am a file!", &buffer);
}

My attempt at a solution is the Impl function: Impl(Type, Reader) is a struct with the same fields as Reader(Type) but with the default value of each field set to be the declaration of Type of the same name, if such a declaration exists[1].

Replacing Reader(@TypeOf(reader_ctx)) with Impl(@TypeOf(reader_ctx), Reader) everywhere in the above example lets us default construct the reader_impl parameter for std.fs.File.

test {
    var buffer: [19]u8 = undefined;
    var file = try std.fs.cwd().openFile("my_file.txt", .{});
    try io.readAll(file, .{}, &buffer);

    try std.testing.expectEqualStrings("Hello, I am a file!", &buffer);
}

I’ve been enjoying this style because it provides type requirements for anytype parameters in function signatures while remaining simple and feeling similar to other Zig patterns. It has very limited power and thus encourages simple uses of generics, which also feels in line with Zig.


  1. Technically, it “unwraps” Type first so that pointer/optional types will work too, see the readme. ↩︎

3 Likes