We Have Traits at Home - Blog Post

We Have Traits at Home

I was noodling for a while with some functional programming languages, and in the process I stumbled onto a rather nice way to handle comptime abstraction. After using it in a few places, I thought I would type up a blog post and share.

The gist: you can imitate OCaml modules or Rust traits at comptime, and it can be a nice way to make your function interfaces more explicit without involving VTable machinery.

My blog is fairly young, and I’m thinking of writing more articles in this direction, so any feedback is very much appreciated. Let me know what you think, and if you would be interested in more articles that expand on this idea.

26 Likes

Very cool from a technical perspective, I probably won’t use it in practice though. I can imagine that if I did, whoever has to read my code would have a hard time. I’ll just try to forget about traits until they become a language feature

Lovely read! Had to stop at Trait Functors since I’m way too sleepy to continue, but you really made me think hard about interfaces. I’ll resume asap :3 but I got some thoughts to share.


Personally, I don’t like Polymorphism. Working in the game industry I have seen all and every sin you can conceive. From the legendary MasterMainObjectClass, parent of many many many grandchildren; to hundreds of single function interfaces painstakingly inherited, one by one, by tens of victims; or finding the answer to the question “How many MonoBehaviours can a GameObject hold?”.

Yet, I think Polymorphism is undeniably useful and maybe even necessary the moment you implement generics in your language.

It is my opinion that Zig should’t reach 1.0 without some sort of idiomatic way of doing interfaces. Not idiomatic in the sense of “we all agree to write all this boilerplate” but an actual elegant way of solving Polymorphism.

Some might disagree, but zig zen got my back on this one:

* Only one obvious way to do things.

Right now it’s all but obvious to me. I can count three methods now but there might be more for all I know, which is not much.

I really like your approach btw! It’s the most obvious one to me and if ZLS is not complaining, I’m not complaining.

But in the long run I’d be inclined to go for VTables because that’s what I see each time I inspect the standard library, thus I asume that’s the idiomatic way, and maybe even the only way that survives the Zen doctrine whenever the team starts applying it.

Future is uncertain, as always, and I do trust the community and the team to find the most elegant performant solution of all.

7 Likes

It was an interesting read.(It reminded me a little of C++ template metaprogramming :wink:)

I don’t think it’ll be much of an issue, but since Zig’s behavior can change significantly between versions, I thought it might be helpful to mention which version you have in mind.

One thing I want to point out is, instead of having a separate builtinNum (and similar) functions for “trait” implementations, you could just put that a declaration on the “trait”:

mobius(f32, builtinNum(f32), x, a, b, c, d); → mobious(f32, .builtin_num, x, a, b, c, d);

2 Likes

I don’t think it’ll be much of an issue, but since Zig’s behavior can change significantly between versions, I thought it might be helpful to mention which version you have in mind.

Good call. I added a note (Zig 0.17).

I know that type captures may change significantly in the future, but I was pleased to find a way to carry on with the current syntax.

One thing I want to point out is, instead of having a separate builtinNum (and similar) functions for “trait” implementations, you could just put that a declaration on the “trait”:

mobius(f32, builtinNum(f32), x, a, b, c, d); → mobious(f32, .builtin_num, x, a, b, c, d);

Yep! There are a lot of things like that in the example code, because I felt it was important to show how new functions could build on existing traits. In practice, if you are authoring both the trait and the consumer, you can pack almost everything into the trait definition.

I had to cut this article down quite a lot - previous drafts were 3x as long. It was really hard to figure out what to keep.

1 Like

Minor aside: [*c]T should never appear outside of auto-translated C code. When writing code that needs to conform to the C ABI, you should make the choice between [*]T and ?[*]T. See #2984 and #36082 for context.

2 Likes

Interesting read. I find myself wondering why i’d ever use this. The mobius example seemed a bit contrived or abstract. I’m wondering how it might look in more of a concrete use case.

Maybe something like this search suggestion (in the most popular language of the week :joy:)?

EDIT: anyone mind showing how would this look in zig with a runnable test?

<?php
trait Loggable
{
    private function log(string $msg): void
    {
    }
}

trait Notifiable
{
    public function notify(string $subject, string $body): void
    {
    }
}

class User
{
    use Loggable, Notifiable;
    private string $email;
    private string $name;

    public function __construct(string $name, string $email)
    {
        $this->log("Created user {$this->name}");
    }

    public function changeEmail(string $newEmail): void
    {
        $this->log("Email changed from $old to $newEmail");
        $this->notify(
            "Email updated",
            "Hi {$this->name}, your email was changed to $newEmail."
        );
    }
}
fn mobius(T: type, num: Num(T), x: T, a: T, b: T, c: T, d: T) T

Ah, it’s interesting how num here is an implicitly comptime parameter (so, passing the same T but different num will monomrphize, and you can’t select a num at runtime). That’s because having a function body as a field makes a type comptime-only:

    return struct {
        empty: T,
        combine: fn (T, T) T,
    }

This is something I haven’t quite realized before, thanks!

1 Like

Nice writeup and very thorough.

Traits are a sometimes treat.

That is a very important piece of advice, I wish I had known back when I programmed more in Rust.

I think this is for things like the "{f}" and "{qf}" format specifiers in std.Io.Writer.

The way it works now: you add a format method to your type, e.g.

    const Vec2 = struct {
        const SelfType = @This();
        x: f32,
        y: f32,

        pub fn format(self: SelfType, w: *Writer) Error!void {
            return w.print("({d:.3},{d:.3})", .{ self.x, self.y });
        }
    };

Then when you use std.Io.Writer.print(.{"{f}", .{ val }), it will be resolved at comptime, eventually leading to the line:

pub fn printValue(
    w: *Writer,
    comptime fmt: []const u8,
    options: std.fmt.Options,
    value: anytype,
    max_depth: usize,
) Error!void {
    ...
    'f' => return value.format(w),
    ...
}

Very handy. I’m interested in what it would look like with “Traits at home”, but not interested enough to do it myself. :face_with_tongue:


This seems to be a pattern - when a language has generic programming features, you are recommended to not use them unless you have to:

  • C - macros, void*.
  • C++ - macros, templates, polymorphism.
  • Zig - comptime, anytype.

When all you have is a Hammerable, you can only hope it’s a nail.

2 Likes

Awesome post!

I had recently tried another form of alternative to compile-time duck typing and VTables… wondering how people would react to it?

My idea was to define an “interface” as a (unused/abstract) struct Interface with some fields and methods, and then to offer a comptime assertImplements(Interface, @TypeOf(someAnytypeArg)) do some compile-time enforcement of duck typing. I am not sure if that’s totally redundant with Zig’s compiler, but it could serve as a form of documentation and point out the missing or incorrect parts of the interface. Of course, in my implementation of this idea I found it hard to do a perfect job of that and had to cut some corners, but I was able to handle a lot of cases.

My understanding of traits is sort of limited, so I am not sure about how much this interface idea overlaps with the kinds of problems traits solve, but maybe it is at least enough not to be off-topic in this thread :).

In any case, even coming from Python I was missing some form of safety/documentation/tooling around type annotations of duck-typed arguments, so this post was great to read!! Thank you again and I hope it catches on!

1 Like