Traits for Static Dispatch

I decided to write up a quick proposal for static-dispatched traits, I think there are several problems with anytype and I’m hoping that this explains some of my problems with it as well as how they can possibly be solved.

I’ll apologize ahead of time for the 80-character linebreaks which might be a bit frustrating to read on web, I had written this in a text editor ahead-of-time :sweat_smile:

Problems with Current Solutions

There are two main ways to do static dispatching in Zig:

  1. somevar: anytype
  2. fn myFn(comptime T: type, somevar: T)

Both of these suffer from the same issue: there is no named type
associated with the identifier being defined.

Because there is no named type constraint for the identifier,
a beginner would never know what to pass in to writer: anytype,
but if it could be like writer: std.io.Writer, it’s much more clear (as well
as there now being documentation for what std.io.Writer is). This also
helps in large codebases where there may be an important difference between
different kinds of writers, and there’s no way to separate
special_writer: anytype and io_writer: anytype (where with traits,
this could be writer: mylib.Writer and writer: std.io.Writer).

In addition to poor documentation, anytype also runs into the issue of having
pretty awful error messages. Accidentally passing a BufferedWriter
or File to a library which takes a writer: anytype
may give an error message that relates to a deeply-nested file within
the library’s code. It would be great to have a solution which tells the caller
that they are simply calling the function incorrectly.

Static Dispatch: Trait Types

The goal of Trait Types is to allow an interface-like type which
can have reusable types, documentation, and better error messages.

Trait types may only be used in the same places as anytype.
For instance, fn (x: SomeTrait) void is legal, but
const x: SomeTrait = 5 is not.

A type is assignable to a given trait type
if it contains the same fields and public declarations that the trait has.

Defining and using Traits

For instance, we could have a std.io.Writer interface defined as:

/// A standard writer interface which allows for statically-dispatched writers.
/// See `GenericWriter` for a way to implement this trait using only a `write` function.
const Writer = trait {
    pub const Context;
    pub const WriteError;
    pub fn write(self: Context, buf: []const u8) WriteError!usize;
    pub fn writeAll(self: Context, buf: []const u8) WriteError!void;
    // ...
};

(I’m aware that std.io.Writer is going to be entirely
reworked for std.Io, but I’m using it as an example since
it’s one of the most widely used cases for static dispatch anytype)

Aside - I have also taken a liking to naming this anytype
(ie: const Writer = anytype { pub const ... }).
This makes it much more clear that this is essentially just a
restricted form of anytype (and the feature could
be called “anytype constraints” instead of “traits”)

Implementing this trait would then look like:

// this is implementing `Writer` from scratch; in reality you'd use `GenericWriter`.
const MyThingWriter = struct {
    pub const Context = *MyThingWriter;
    pub const WriteError = error{ SomeError };
    pub fn write(self: *MyThingWriter, buf: []const u8) WriteError!usize {
        // ...
    }
    pub fn writeAll(self: *MyThingWriter, buf: []const u8) WriteError!void {
        // ...
    }
};

// and GenericWriter could look like:
pub fn GenericWriter(
    comptime ContextT: type,
    comptime WriteErrorT: type,
    comptime writeFn: fn (context: Context, bytes: []const u8) WriteError!usize,
) type {
    return struct {
        pub const Context = ContextT;
        pub const WriteError = WriteErrorT;

        pub inline fn write(context: Context, bytes: []const u8) WriteError!usize {
            return writeFn(self.context, bytes);
        }
        // ...
    };
}

This makes it so that now functions which used to be defined
as fn writeMyThing(myThing: MyThing, writer: anytype) !void can become
fn writeMyThing(myThing: MyThing, writer: std.io.Writer) !void,
and it would work the exact same.

This would also restrict type bounds at the call-site of writeMything,
rather than inside of a deeply-nested file in library code.

One thing to take note of is that traits are implicitly implemented
(really, traits aren’t “implemented” at all - they’re
just type-bounds for functions). However, it may be worth adding a
way to explicitly implement an interface with a function like
try std.testing.expectImplements(MyType, SomeTrait), or even
an explicit implementing keyword on struct definitions.

2 Likes

Have you seen any of the GitHub issue discussions related to this? The four big threads I remember are 1268, 1669, 6615, and 17198, but there were more.

2 Likes

I believe that there is real benefit to the traits as defined, for documentation and editors code completion. A similar design might omit the self arguments in traits.

Andrew response to a similar proposal for static dispatch interfaces was: comptime interfaces · Issue #1268 · ziglang/zig · GitHub

I’m not saying there won’t be interfaces of any kind, ever, but there are no current plans for adding them, …
I suggest if you want to use zig, you accept the fact that it may never gain an interface-like feature, and also trust the core team to add it in a satisfactory manner if it does get added at all.

I think one can accept that a change might never happen while still lobbying for that change. I think OP is 100% correct that anytype is too generic and having a way to put constraints on the type would be a great improvement for the readability of the language.