ztrait
ztrait copied to clipboard
A simple version of Rust style type traits in Zig
Zig Type Traits
Disclaimer: This was an exploratory project that I no longer believe should be used for practical purposes. Everything I leared in this project was refined into the zimpl library which I will continue to maintain.
An attempt at implementing something along the lines of Rust type traits in Zig. Using this library you can define traits and compile-time verify that types implement them.
I wrote
an article
about working with anytype
which reflects on
developing this library.
Related Links
Below are some related projects and Zig proposal threads that I read while implementing the library.
- Zig Compile-Time-Contracts
- Zig issues: #1268, #1669, #6615, #17198
I don't have any strong position on proposed changes to the Zig language regarding generics, and I respect the Zig team's reasoning for keeping the type system simple.
Basic use
A trait is simply a comptime function taking a type and returning a struct type containing only declarations. Each declaration of the returned struct is a required declaration that a type must have if it implements the trait.
Below is a trait that requires implementing types to define an integer
sub-type Count
, define an init
function, and define member functions
increment
and read
.
pub fn Incrementable(comptime Type: type) type {
return struct {
// below is the same as `pub const Count = type` except that during
// trait verification it requires that '@typeInfo(Type.Count) == .Int'
pub const Count = hasTypeId(.Int);
pub const init = fn () Type;
pub const increment = fn (*Type) void;
pub const read = fn (*const Type) Type.Count;
};
}
A type that implements the above Incrementable
trait is provided below.
const MyCounter = struct {
pub const Count = u32;
count: Count,
pub fn init() @This() {
return .{ .count = 0 };
}
pub fn increment(self: *@This()) void {
self.count += 1;
}
pub fn read(self: *const @This()) Count {
return self.count;
}
};
To require that a generic type parameter implements a given trait you simply need to add a comptime verification line at the start of your function.
pub fn countToTen(comptime Counter: type) void {
comptime where(Counter, implements(Incrementable));
var counter = Counter.init();
while (counter.read() < 10) {
counter.increment();
}
}
Note: If we don't specify that trait verification is comptime then verification might be evaluated later during compilation. This results in regular duck-typing errors rather than trait implementation errors.
If we define a type that fails to implement the Incrementable
trait and pass
it to countToTen
, then the call to where
will produce a compile error.
const MyCounterMissingDecl = struct {
pub const Count = u32;
count: Count,
pub fn init() @This() {
return .{ .count = 0 };
}
pub fn read(self: *const @This()) Count {
return self.count;
}
};
trait.zig:12:13: error: trait 'count.Incrementable(count.MyCounterMissingDecl)' failed: missing decl 'increment'
Combining traits
Multiple traits can be type checked with a single call.
pub fn HasDimensions(comptime _: type) type {
return struct {
pub const width = comptime_int;
pub const height = comptime_int;
};
}
pub fn computeAreaAndCount(comptime T: type) void {
comptime where(T, implements(.{ Incrementable, HasDimensions }));
var counter = T.init();
while (counter.read() < T.width * T.height) {
counter.increment();
}
}
Constraining sub-types
Traits can require types to declare sub-types that implement traits.
pub fn HasIncrementable(comptime _: type) type {
return struct {
pub const Counter = implements(Incrementable);
};
}
pub fn useHolderToCountToTen(comptime T: type) void {
comptime where(T, implements(HasIncrementable));
var counter = T.Counter.init();
while (counter.read() < 10) {
counter.increment();
}
}
pub const CounterHolder = struct {
pub const Counter = MyCounter;
};
pub const InvalidCounterHolder = struct {
pub const Counter = MyCounterMissingDecl;
};
trait.zig:12:13: error: trait 'count.HasIncrementable(count.InvalidCounterHolder)' failed: decl 'Counter': trait 'count.Incrementable(count.MyCounterMissingDecl)' failed: missing decl 'increment'
Declaring that a type implements a trait
Alongside enforcing trait implementation in generic functions, types themselves can declare that they implement a given trait.
const MyCounter = struct {
comptime { where(@This(), implements(Incrementable)); }
// ...
};
Then with testing.refAllDecls
you can run zig test
to automatically verify
that these traits are implemented.
test {
std.testing.refAllDecls(@This);
}
Credit to "NewbLuck" on the Zig Discord for pointing out this nice pattern.
Pointer types
Often one will want to pass a pointer type to a function taking
anytype
, and it turns out that it is still quite simple to do
trait checking.
Single item pointers allow automatic dereferencing in Zig, e.g.
ptr.decl
is ptr.*.decl
, so it makes sense to define that a pointer
type *T
implements a trait if T
implements the trait.
To
accomplish this, types are passed through an Unwrap
function before
trait checking occurs.
pub fn Unwrap(comptime Type: type) type {
return switch (@typeInfo(Type)) {
.Pointer => PointerChild(Type),
else => Type,
};
}
Therefore the following function will work just fine with pointers to
types that implement Incrementable
.
pub fn countToTen(counter: anytype) void {
comptime where(@TypeOf(counter), implements(Incrementable));
while (counter.read() < 10) {
counter.increment();
}
}
Slice types
For slice parameters we usually want the caller to be able to pass
both *[_]T
and []T
. The library provides the
SliceChild
helper function to verify that a type can coerce to a slice
andt extract its child type.
pub fn incrementAll(list: anytype) void {
comptime where(SliceChild(@TypeOf(list)), implements(Incrementable));
for (list) |*counter| {
counter.increment();
}
}
The above function works directly on parameters that can coerce to a slice, but if required we can force coercion to a slice type as shown below.
pub fn incrementAll(list: anytype) void {
comptime where(SliceChild(@TypeOf(list)), implements(Incrementable));
const slice: []SliceChild(@TypeOf(list)) = list;
for (slice) |*counter| {
counter.increment();
}
}
Interfaces: restricting access to declarations
Using where
and implements
we can require that types have
declaration satisfying trait requirements. We cannot, however,
prevent code from using declarations beyond the scope of the checked
traits. Thus it is on the developer to keep traits up to date with how
types are actually used.
Constructing interfaces within a function
The interface
function provides a method to formally restrict
traits to be both necessary and sufficient requirements for types.
Calling interface(Type, Trait)
will construct a comptime instance of a
generated struct type that contains a field for each declaration of
Type
that has a matching declaration in Trait
. The
fields of this interface struct should then be used in place of the
declarations of Type
.
pub fn countToTen(counter: anytype) void {
const ifc = interface(@TypeOf(counter), Incrementable);
while (ifc.read(counter) < 10) {
ifc.increment(counter);
}
}
Interface construction performs the same type checking as where
.
Flexible interface parameters
The type returned by interface(U, T)
is Interface(U, T)
, a
struct type containing one field for each declaration of
T
with default value equal to the corresponding declaration of U
(if it exists and has the correct type).
A more flexible way to work with interfaces is to
take an Interface
struct as an explicit comptime
parameter.
pub fn countToTen(counter: anytype, ifc: Interface(@TypeOf(Counter), Incrementable)) void {
while (ifc.read(counter) < 10) {
ifc.increment(counter);
}
}
This allows the caller to override declarations, or provide a custom interface for a type that doesn't have the required declarations.
Unfortunately, this style of interfaces does not allow
trait declarations to depend on one another.
A restricted version of the Incrementable
interface that will play
well with the interface parameter convention is provided below.
pub fn Incrementable(comptime Type: type) type {
return struct {
pub const increment = fn (*Type) void;
pub const read = fn (*const Type) usize;
};
}
An example of what is possible with this convention is shown below.
const USize = struct {
pub fn increment(i: *usize) void {
i.* += 1;
}
pub fn read(i: *const usize) usize {
return i.*;
}
};
var my_count: usize = 0;
countToTen(&my_count, .{ .increment = USize.increment, .read = USize.read });
Extending the library to support other use cases
Users can define their own helper functions as needed by wrapping and expanding the trait module.
// mytrait.zig
// expose all declaraions from the standard trait module
const zt = @import("ztrait");
pub usingnamespace zt;
// define your own convenience functions
pub fn BackingInteger(comptime Type: type) type {
comptime zt.where(Type, zt.isPackedContainer());
return switch (@typeInfo(Type)) {
inline .Struct, .Union => |info| info.backing_integer.?,
else => unreachable,
};
}
Returns syntax: traits in function definitions
Sometimes it can be useful to have type signatures directly in function
definitions. Zig currently does not support this, but there is a hacky
workaround using the fact that Zig can evaluate a comptime
function in
the return type location.
pub fn sumIntSlice(comptime I: type, list: []const I) Returns(I, .{
where(I, hasTypeId(.Int)),
}) {
var count: I = 0;
for (list) |elem| {
count += elem;
}
return count;
}
The first parameter of Returns
is the actual return type of the function,
while the second is an unreferenced anytype
parameter.
pub fn Returns(comptime ReturnType: type, comptime _: anytype) type {
return ReturnType;
}
Warning: Error messages can be less helpful when using Returns
because the compile error occurs while a function signature is being
generated. This can result in the line number of the original call
not be reported unless building with -freference-trace
(and even then
the call site may still be obscured in some degenerate cases).