Zig NEWS

Cover image for Easy Interfaces with Zig 0.10.0
Loris Cro
Loris Cro

Posted on • Updated on

Easy Interfaces with Zig 0.10.0

This release of Zig introduces a new language feature that makes creating interface types much more ergonomic.

EDIT: make sure to read the comments below for more information about enabling mutability through the interface.

About interfaces

Zig doesn't have a built-in interface type, like higher-level languages do. One of the reasons for this choice is the fact that people who use interfaces most of the time care about modeling the program in a way that they like, but usually don't care much for how the system works behind the scenes.

In Zig we always care about what the machine ends up doing and, when it comes to interfaces, there are multiple approaches with different tradeoffs, each equally valid and with its own preferred use cases.

EDIT since people on social media really both hate reading and love jumping to conclusions, I'll repeat: this article is going to present a language feature that helps with tagged union based interfaces. In Zig you can also have "open interfaces" (eg vtables). That's how std.io.Reader works, for example. The link above talks about all these possibilities.

Interfaces based on tagged unions

The most straight-forward way of creating an interface type is by creating a tagged union of its possible concrete implementations. It's not the right choice in all cases, but it's usually good enough for simple programs.

Let's say that we have a Cat and a Dog and we want to be able to use them through a common interface.

const Cat = struct {
   anger_level: usize

   pub fn talk(self: Cat) void {
      std.debug.print("Cat: meow! (anger lvl {})", .{self.anger_level});
   }
};

const Dog = struct { 
   name: []const u8

   pub fn talk(self: Dog) void {
      std.debug.print("{s} the dog: bark!", .{self.name});
  }
};
Enter fullscreen mode Exit fullscreen mode

Before, you had to do this in Zig:

const Animal = union(enum){
   cat: Cat,
   dog: Dog,

   pub fn talk(self: Animal) void {
      switch (self) {
         .cat => |cat| cat.talk(),
         .dog => |dog| dog.talk(),
      }
   }
};
Enter fullscreen mode Exit fullscreen mode

As you can see, the point where static dispatch connects with dynamic dispatch is explicitly marked in the implementation of Animal.talk. In that function (which can be statically dispatched when called on an instance of Animal) you can see how switching on the active case calls the right implementation, based on the value of the tag (runtime-known, thus dynamic).

This is very nice, but it has the downside of being a bit too verbose. Imagine an interface with 100 concrete types and 10 methods part of the interface. That's a lot of redundancy!

Thankfully, starting from Zig 0.10.0 you can do this:

const Animal = union(enum){
   cat: Cat,
   dog: Dog,

   pub fn talk(self: Animal) void {
      switch (self) {
         inline else => |case| case.talk(),
      }
   }
};
Enter fullscreen mode Exit fullscreen mode

What's happening here is that inline else inside a switch behaves in a similar way to inline for or inline while.

In an inlined loop, the loop itself is unrolled at comptime and replaced with the result, like so:

const nums = [_]usize {1, 2, 3};

var accumulator: usize = 0;
inline for (nums) |n| {
   accumulator += n;
}
Enter fullscreen mode Exit fullscreen mode

After comptime, the program basically becomes:

const nums = [_]usize {1, 2, 3};

var accumulator: usize = 0;
accumulator += 1;
accumulator += 2;
accumulator += 3;
Enter fullscreen mode Exit fullscreen mode

Inside a switch, inline else produces many branches, each based on a possible value of the enum tag, effectively acting as a shortcut to produce the original code, where each tag value had its own case.

This works because in an inline else we're able to use comptime ducktyping to call talk() on each concrete implementation. It obviously wouldn't work as seamlessly if instead the implementations had methods like .meow() and .bark().

That said, if you happen to have an odd implementation that you want to handle manually, you can still add a dedicated case:

const Animal = union(enum){
   cat: Cat,
   dog: Dog,
   snake: Snake,

   pub fn talk(self: Animal) void {
      switch (self) {
         .snake => std.debug.print("Ssss~~~", .{}),
         inline else => |case| case.talk(),
      }
   }
};
Enter fullscreen mode Exit fullscreen mode

Wait shouldn't Cat and Dog inherit from Animal???

Oh no! Did I just use an OOP example to show interfaces? Have I learned nothing from my university Java classes?

If you had that reaction, you might want to take some time to explicitly free your thought process from OOP-isms. OOP is an approach to modeling solutions that relies on dynamic dispatch. That's why sometimes inheritance overlaps with interfaces.

In Zig it's not idiomatic to go bonkers with interfaces just to conform to a solution modeling approach. If you're using interfaces it should be because you need dynamic dispatch and the truth is that, a lot of the time, you just don't need it.

I personally think it's fine to think in OOP terms when using a language (and ecosystem) that models things that way. But I also think that it's a mistake to do so in a language like Zig where the priorities are different. Same with trying to shoehorn functional programming in Zig.

Top comments (15)

Collapse
 
tensorush profile image
Jora Trush

Thanks for the post! Found a way to have a mutable version, too:

const Cat = struct {
    hp: usize,

    pub fn eat(self: *Animal) void {
        self.cat.hp += 1;
    }
};

const Animal = union(enum) {
    cat: Cat,
    dog: Dog,

    pub fn eat(self: *Animal) void {
        switch (self.*) {
            inline else => |case| @TypeOf(case).eat(self),
        }
    }
};
Enter fullscreen mode Exit fullscreen mode
Collapse
 
kristoff profile image
Loris Cro • Edited

Spoke with Spex and Andrew and they confirmed that doing switch (self.*) does not create a copy, so you can do this:


const Cat = struct {
    hp: usize,

    pub fn eat(self: *Cat) void {
        self.hp += 1;
    }
};

const Animal = union(enum) {
    cat: Cat,
    dog: Dog,

    pub fn eat(self: *Animal) void {
        switch (self.*) {
            inline else => |*case| case.eat(),
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

Diff:

  1. eat now accepts a *Cat, not a *Animal
  2. the switch case is now capturing |*case| and calling the function normally

This would not work if self.* created a copy, but since it never does, it's fine.

Collapse
 
kfird214 profile image
kfir • Edited

If you have *Cat, why would you need
self.cat.hp += 1;
and not:
self.hp += 1;

Thread Thread
 
kristoff profile image
Loris Cro

oh, I forgot to update that line from the original code by Zhora.

I've fixed it now, thank you.

Collapse
 
tensorush profile image
Jora Trush

That's neat! I'll try it, thanks ;)

Collapse
 
kristoff profile image
Loris Cro • Edited

Oof, this is very annoying and error prone, I tried to think about it for a moment but I don't see a better way of doing that. Thank you for bringing that usecase to my attention.

Collapse
 
yuyoyuppe profile image
Andrey Nekrasov

Wouldn't it be a bit more readable like this?

   pub fn talk(self: Animal) void {
      switch (self) {
         inline default => |case| case.talk(),
      }
   }
Enter fullscreen mode Exit fullscreen mode
Collapse
 
jiacai2050 profile image
Jiacai Liu

I'm afraid not, this required changes to language syntax, which is not a must.

Collapse
 
jpl profile image
Jean-Pierre

Hello, we need more articles like this (I know there is yuotube) but I don't speak English and my friend Google is translating me and it makes me feel good to read, it allows me to think about my speed;)

Collapse
 
kristoff profile image
Loris Cro

Can you specify what you mean with "like this"? More articles about interfaces?

zig.news/kilianvounckx/zig-interfa...

zig.news/david_vanderson/interface...

zig.news/david_vanderson/faster-in...

zig.news/kristoff/how-to-add-buffe...

These are all the posts that talk about interfaces that I can think of.

Collapse
 
jpl profile image
Jean-Pierre

Don't get me wrong, it's not a reproach, but on the contrary, having explanations with examples is crucial and I like that.

Collapse
 
yasammez profile image
Liv Fischer • Edited

I seem to run into that specific problem all the time in Rust. I pinned the problem down to "enum variants aren't proper types in their own right and thus not first class citizens". There has been an RFC about this a while ago but it got postponed. I mean, even venerable old Java does this better with sealed interfaces.

This is really nice. I would love an addition where you can tell it to automatically delegate the calls, so that you can just write "animal.talk()" without the three leftover lines of boilerplate, which really add up fast if you have lots of methods.

Collapse
 
himujjal profile image
Himujjal Upadhyaya

This will surely come in handy. I will replace my parser Node parent class - sub class with this approach

Collapse
 
untitled0001 profile image
Michael

I think maybe best for zig would be to avoid adding new language syntax.. just my 2 cents