Polymorphism is hated as much as it’s loved, but one thing is for sure: it’s not going anywhere, and if zig wants to spread, it should probably be possible in some way. However, polymorphism almost always adds implicit behavior and “magic”, which is strictly against the zig philosophy. For this reason, polymorphism in zig relies almost entirely on the anytype keyword, which does not provide any way to explicitly define what members the type is required to include. Here’s an example:
const print = @import("std").debug.print;
pub fn main() !void {
const dog: Dog = .{ .age = 4 };
const cat: Cat = .{ .age = 5 };
const brick: Brick = .{ .mass = 200, .pain = 1_000_000_000 };
feed(dog);
feed(cat);
feed(brick);
}
fn feed(animal: anytype) void {
animal.eat();
}
const Dog = struct {
age: u8,
pub fn eat(self: Dog) void {
_ = self;
print("Dog ate!\n", .{});
}
};
const Cat = struct {
age: u8,
pub fn eat(self: Cat) void {
_ = self;
print("Cat ate!\n", .{});
}
};
const Brick = struct {
mass: u8,
pain: u64,
};
After zig build run, we get an error that looks like this:
src/main.zig:13:11: error: no field or member function named 'eat' in 'main.Brick'.
This is great and all, but it’s not quite as clear as it could be, and, more importantly, it’s not very easy to tell what members the animal: anytype parameter require unless you were to read the body of the function itself, which is not ideal. To fix this, we can do something like:
fn feed(animal: anytype) void {
if (comptime !@hasDecl(@TypeOf(animal), "eat")) {
@compileError("Type '" ++ @typeName(@TypeOf(animal)) ++ "' does not satisfy the requirements for 'feed()': no member function named 'eat'");
}
animal.eat();
}
Which gives us this error instead:
src/main.zig:14:9: error: Type 'main.Brick' does not satisfy the requirements for 'feed()': no member function named 'eat'.
This is quite verbose, and repeating it for every function that accepts anytype makes for a lot of boilerplate. At the very least, now the requirements for the parameter are near the top of the function and easier to read, and the error message is a bit clearer. However, one issue still remains: tooling.
Simply put, a static analysis is unable to tell whether or not a type passed to feed() meets the requirements or not, which means the programmer will have to either hunt down the function definition, or find out the hard way when the code doesn’t compile. Whether you like it or not, good tooling can make-or-break a language, which is why I think this problem is an important one to solve; therefore, my idea: the satisfies keyword.
The intent of the satisfies keyword is not to add an interface system or any new runtime functionality; it only exists as an easy-to-parse shorthand for the comptime checks shown above. Here’s an example:
fn feed(animal: anytype satisfies { fn eat void }) void {
animal.eat();
}
This example is functionally equivalent to:
fn feed(animal: anytype) void {
if (comptime !@hasDecl(@TypeOf(animal), "eat")) {
@compileError("Type '" ++ @typeName(@TypeOf(animal)) ++ "' does not satisfy the requirements for 'feed()': no member function named 'eat'");
}
animal.eat();
}
The only differences are that it’s easier to read and can be statically analyzed. I would put this in the same category as the for loop: while the same functionality can be recreated entirely with a while loop and an iterator, the use-case is so common that it became its own keyword. In the same spirit, the satisfies keyword adds nothing that couldn’t be achieved before, and (I think) it maintains the simplicity that the zig language is trying to achieve. In short: it’s syntactic sugar, and nothing more.
The following are a few more ideas I have for this keyword:
Multiple requirements (this one’s a no-brainer)
// Now *both* the 'eat' function *and* the 'age' field are required.
fn feed(animal: anytype satisfies { fn eat void, age: u8 }) void {
animal.eat();
}
Splitting declaration and usage (great for reusing long lists of requirements)
// This is **not** a type and cannot be used anywhere other than function declarations.
const Animal = satisfies {
fn eat void,
age: u8,
};
// 'Animal' effectively just gets replaced with '{ fn eat void, age: u8 }' during compilation.
fn feed(animal: anytype satisfies Animal) void {
animal.eat();
}
The satisfies keyword has plenty of potential for more advanced features such as nesting and being able to specify function parameters instead of just the name and return type, but that goes beyond the scope of this post and potentially makes the feature too complex to realistically fit the language anymore.
I understand that language proposals are not welcome at this time, so the intent of this post was not to get a feature added but instead to have an interesting discussion and get some feedback on this idea. To that end, please feel free to critique my work and/or suggest improvements. I had a lot of fun writing this, and while I think it would be very cool to see this (or something similar) as a real feature someday, I have no intention of pushing for it or harassing the core maintainers about it, so please keep it civil and let’s have an interesting thought experiment together.