Working with Zig's anytype

November 1, 2023  |  zig  |  GitHub

Generics in Zig often get a bad wrap, particularly the anytype keyword. Personally, while I enjoy working with the robust type systems found in languages like Haskell and Rust, I have come to really appreciate Zig’s “type as a first class type” approach to generics.

This article will cover common patterns for working with anytype parameters and document my adventure implementing a generic trait library.

A situation

Suppose that we are writing a networking library and have created a Server struct that will accept and manage TCP connections. We would like Server to exposes a poll function that will poll for events on the Server’s internal collection of sockets.

There are three possible events that we want to handle: client connection, message received, and client disconnection.

pub const Server = struct {
    // ... 

    pub const Handle = u32;

    const Event = union(enum) {
        open: Handle,
        msg: struct { handle: Handle, bytes: []const u8, },
        close: Handle,
    };

    fn pollSockets(self: *Self) !void {
        // poll tracked sockets for any events
    }

    fn getEvent(self: *Self) ?Event {
        // get the next available event from the last call to pollSockets
    }

    pub fn listen(self: *Self, port: u16) !void {
        // begin listening for connections
    }

    // ...
}

Below I’ll go over a few different ways that we could write a Server.poll function that accepts a generic handler parameter to respond to reported events. There are other ways to tackle the problem, but for this article I am only interested in static dispatch with compile time generics.

Option 1: use duck typing

Zig allows functions to take anytype parameters. A concrete version of the function will be generated for each different parameter type that the generic function is called with.

At compile time, duck typing is used to verify that each parameter type has the required fields and declarations1. It is not usually good practice to directly access fields on generic parameters, so for the purposes of this article we will consider it forbidden to do so.

A poll function for our Server struct using anytype is provided below.

pub const Server = struct {
    // ... 

    pub fn poll(self: *Self, handler: anytype) !void {
        try self.pollSockets();
        while (self.getEvent()) |evt| {
            switch (evt) {
                .open => |handle| handler.onOpen(handle),
                .msg => |msg| handler.onMessage(msg.handle, msg.bytes),
                .close => |handle| handler.onClose(handle),
            }
        }
    }

    // ...
}

The biggest downside with duck typing is that it will not be immediately clear from the function signature of poll what is allowed to be passed in for the handler parameter. Someone using our server library would need to read the function body of poll, track down the return type of Server.getEvent, and then find the definition of Server.Event.

Below is a simple example server using a handler to log events.

const std = @import("std");
const log = std.log.scoped(.log_server);

const my_server_lib = @import("my_server_lib");
const Server = my_server_lib.Server;
const Handle = Server.Handle;

pub const LogHandler = struct {
    count: usize = 0,

    pub fn onOpen(_: *Self, handle: Handle) void {
        log.info("connection {} opened", .{ handle });
    }

    pub fn onMessage(self: *Self, handle: Handle, msg: []const u8) void {
        log.info("{d}: client {d} sent '{s}'", .{ self.count, handle, msg });
        self.count += 1;
    }

    pub fn onClose(_: *Self, handle: Handle) void {
        log.info("connection {} closed", .{ handle });
    }
};

pub fn main() void {
    var server = Server{};
    var handler = LogHandler{};
    try server.listen(port);
    while (true) {
        try server.poll(&handler);
    }
}

Option 2: use explicit function parameters

A convention that will always provide clear requirements for anytype parameters is to simply never rely on duck typing. Everywhere that you would usually rely on the existence of a type declaration, instead require the caller to pass an additional comptime parameter.

// ...

pub fn poll(
    self: *Self,
    handler: anytype, 
    comptime onOpen: fn (@TypeOf(handler), Handle) void,
    comptime onMessage: fn (@TypeOf(handler), Handle, Message) void,
    comptime onClose: fn (@TypeOf(handler), Handle) void
) void {
    try self.pollSockets();
    while (self.getEvent()) |evt| {
        switch (evt) {
            .open => |handle| onOpen(handler, handle),
            .msg => |msg| onMessage(handler, msg.handle, msg.bytes),
            .close => |handle| onClose(handler, handle),
        }
    }
}

// ...

Unfortunately, while this does eliminate the issues with duck typing, it also makes calling Server.poll quite a bit more verbose.

// ...

var server = Server{};
var handler = LogHandler{};
try server.listen(port);
while (true) {
    try server.poll(
        &handler,
        LogHandler.onOpen,
        LogHandler.onMessage,
        LogHandler.onClose
    );
}

// ...

However, a second massive upside to passing each function explicitly is that they can be defined separately from the type of the parameter. This provides a lot of flexibility, and even allows for types that don’t support declarations, e.g. numeric types.

Option 3: use traits for custom type checking

I created the ztrait library to explore whether it was possible to implement Rust-style type traits in Zig. Let’s revisit the duck typing example in Option 1, but this time add our own explicit type checking using traits. I’ll provide an explanation for each part of ztrait that we make use of, but you may still want skim the ztrait readme.

In ztrait a trait is function that takes a type and returns a struct containing only type valued declarations2. A type Type implements a trait Trait if for each declaration Trait(Type).decl, Type.decl exists and @TypeOf(Type.decl) equals Trait(Type).decl.

We can define a Handler trait for the type of our handler parameter as follows.

pub fn Handler(comptime Type: type) type {
    return struct {
        pub const onOpen = fn (*Type, Handle) void;
        pub const onMessage = fn (*Type, Handle, []const u8) void;
        pub const onClose = fn (*Type, Handle) void;
    };
}

Then we can simply add a trait verification line at the top of Server.poll.

// ...

pub fn poll(self: *Self, handler: anytype) void {
    comptime where(PointerChild(@TypeOf(handler)), implements(Handler));

    try self.pollSockets();
    while (self.getEvent()) |evt| {
        switch (evt) {
            .open => |handle| handler.onOpen(handle),
            .msg => |msg| handler.onMessage(msg.handle, msg.bytes),
            .close => |handle| handler.onClose(handle),
        }
    }
}

// ...

If the reader is familiar with the trait convention, the type requirements are now immediately clear: the type of handler must be a single item pointer to a type that has the declarations defined by the Handler trait3.

The type requirements are still not in the function signature itself, or easily accessible from an LSP, but traits provide clear and formal type documentation.

Option 4: use a comptime constructed interface

Trait verification relies on the library writer to ensure that the traits are up to date with how generic parameters are actually used. The ztrait library exposes the interface function to generate an interface that only provides access to the declarations of a type that match a given trait.

A version of Server.poll that constructs an interface for the handler parameter is provided below.

// ...

pub fn poll(self: *Self, handler: anytype) void {
    const handler_ifc = interface(PointerChild(@TypeOf(handler)), Handler);

    try self.pollSockets();
    while (self.getEvent()) |evt| {
        switch (evt) {
            .open => |handle| handler_ifc.onOpen(handler, handle),
            .msg => |msg| handler_ifc.onMessage(handler, msg.handle, msg.bytes),
            .close => |handle| handler_ifc.onClose(handler, handle),
        }
    }
}

// ...

So long as the handler parameter is only used with the functions of handler_ifc, the Handler trait is guaranteed to define “necessary and sufficient” conditions for the type of handler.

Option 5: take an interface as a separate parameter

Another way to use interfaces is take an interface struct as an explicit parameter along the lines of Option 2. To understand how this works, we’ll first need to take a look inside how the interface function works4.

pub fn interface(
    comptime Type: type,
    comptime Trait: fn (type) type
) Interface(Type, Trait) {
    comptime where(Type, implements(Trait));
    return .{};
}

A call to interface(Type, Trait) returns a default constructed struct of type Interface(Type, Trait). The struct type contains one field for each declaration of Trait(Type) with default value equal to the corresponding declaration of Type if it exists and matches the type signature. The call to where ensures that each field has a default value, and thus a default instance can be constructed and returned.

We can instead decide to use Interface(Type, Trait) directly as the type of a parameter5.

// ...

pub fn poll(
    self: *Self,
    handler: anytype,
    handler_ifc: Interface(PointerChild(@TypeOf(handler)), Handler)
) void {
    try self.pollSockets();
    while (self.getEvent()) |evt| {
        switch (evt) {
            .open => |handle| handler_ifc.onOpen(handler, handle),
            .msg => |msg| handler_ifc.onMessage(handler, msg.handle, msg.bytes),
            .close => |handle| handler_ifc.onClose(handler, handle),
        }
    }
}

// ...

Calling Server.poll with our defined LogHandler type would now look like the code below.

// ...

var server = Server{};
var handler = LogHandler{};
try server.listen(port);
while (true) {
    try server.poll(&handler, .{});
}

// ...

In this way we allow the caller the to define a custom interface at each call site, but keep calls concise when implementations can be inferred from declarations of the parameter type.

server.poll(&handler, .{ onOpen = otherOnOpen });

We also now have type requirements directly in the function signature.

Conclusion: zimpl is better

I believe that Option 2 and Option 5 are by far the best conventions to use in most situations.

  • Both are easy to implement and understand (see zimpl below for a simple version of Option 5).
  • Both clearly define type requirements directly in the function signature.
  • Both closely resemble other common Zig patterns.
  • Both let the caller define a specific implementation at each call site.
  • Both generate errors at call sites, not in function signatures. As of zig-0.12-dev error messages originating from a generic function signature are usually poor.

For all the above reasons, I decided to make zimpl. The zimple library is a tiny subset of ztrait containing ~20 lines of code and the one public declaration zimpl.Impl that is a simplified ztrait.Interface.

A version of Server.poll using zimpl would go as follows.

pub fn Handler(comptime Type: type) type {
    return struct {
        onOpen: fn (Type, Handle) void,
        onMessage: fn (Type, Handle, []const u8) void,
        onClose: fn (Type, Handle) void,
    };
}

pub fn poll(self: *Self, ctx: anytype, impl: Impl(Handler, @TypeOf(ctx))) void {
    try self.pollSockets();
    while (self.getEvent()) |evt| {
        switch (evt) {
            .open => |handle| impl.onOpen(ctx, handle),
            .msg => |msg| impl.onMessage(ctx, msg.handle, msg.bytes),
            .close => |handle| impl.onClose(ctx, handle),
        }
    }
}

The above version of poll is called in the same way as the version of poll we wrote for Option 5.

Source code

A full working implementation of every example found in the article is available on GitHub.