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
Problems with Current Solutions
There are two main ways to do static dispatching in Zig:
somevar: anytype
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.