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.

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.

Discussion (10)

Collapse
theo profile image
Theofanis Despoudis

Nice. Maybe next time show an example of How Zig handles Composition.

Collapse
tensorush profile image
Zhora 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 Author • Edited on

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
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 Author

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
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
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