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});
}
};
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(),
}
}
};
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(),
}
}
};
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;
}
After comptime, the program basically becomes:
const nums = [_]usize {1, 2, 3};
var accumulator: usize = 0;
accumulator += 1;
accumulator += 2;
accumulator += 3;
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(),
}
}
};
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)
Spoke with Spex and Andrew and they confirmed that doing
switch (self.*)
does not create a copy, so you can do this:Diff:
*Cat
, not a*Animal
|*case|
and calling the function normallyThis would not work if
self.*
created a copy, but since it never does, it's fine.If you have *Cat, why would you need
self.cat.hp += 1;
and not:
self.hp += 1;
oh, I forgot to update that line from the original code by Zhora.
I've fixed it now, thank you.
Wouldn't it be a bit more readable like this?
I'm afraid not, this required changes to language syntax, which is not a must.
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;)
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.
Don't get me wrong, it's not a reproach, but on the contrary, having explanations with examples is crucial and I like that.
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.
This will surely come in handy. I will replace my parser
Node
parent class - sub class with this approachI think maybe best for zig would be to avoid adding new language syntax.. just my 2 cents
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.