Ad hoc polymorphism in Zig
Lets say we have a function such as the following.
fn dot(args: anytype) i32 {
// compute dot product...
}
We want this function to operate on two
or two Point2D
structs. But we do not want to expose 2 different functions for this, i.e something like the following.Point3D
pub fn dot2d(p1: Point2D, p2: Point2D) i32 { // ... }
pub fn dot3d(p1: Point3D, p2: Point3D) i32 { // ... }
Rather, we would like our function
to select the correct implementation based on the type and length of dot
. The more technical term for this is "Ad hoc polymorphism". Zig does not support ad hoc polymorphism, but using args
and compile time type reflection, we can sort of mimick this. What we essentially want is a function that takes two arguments, the arguments we are calling our function with, and a list of functions which are our different implementations of this method. The function signature should look like something like thiscomptime
fn adHocPoly(args: anytype, fns: anytype) fn () typed.ReturnType(fns[0])
typed.ReturnType
is a utility type part of a small library I made, I have provided the implementation below if you want to use it in your own project.pub inline fn ReturnType(comptime func: anytype) type {
comptime {
const type_info: Type = @typeInfo(@TypeOf(func));
return type_info.@"fn".return_type orelse {
@compileError("Function has no return type. This should not be possible but is possible due to the current Zig spec.");
};
}
}
pub fn adHocPoly(args: anytype, fns: anytype) fn () typed.ReturnType(fns[0]) {
const RT = typed.ReturnType(fns[0]);
for (fns) |func| {
if (typed.ReturnType(func) != RT) {
@compileError("all functions must have the same return type");
}
}
const args_ty = @typeInfo(@TypeOf(args));
switch (args_ty) {
inline .@"struct" => |s| {
const fields = s.fields;
const fns_ty = @typeInfo(@TypeOf(fns));
switch (fns_ty) {
inline .@"struct" => |fns_struc| {
for (fns_struc.fields, 0..) |func, fn_idx| {
const fn_ty = @typeInfo(func.type);
switch (fn_ty) {
inline .@"fn" => |fn_ty_info| {
if (fn_ty_info.params.len != fields.len) {
continue;
}
var found = true;
for (fn_ty_info.params, 0..) |param, i| {
if (param.type) |param_ty| {
if (param_ty != fields[i].type) {
found = false;
break;
}
} else {
@compileError("generic fn parameter of anytype found, can not deduce which fn to call");
}
}
if (found) {
return (struct {
fn e() RT {
return @call(.auto, fns[fn_idx], args);
}
}).e;
}
},
else => @compileError("functions params was not an array of functions, was " ++ @typeName(@Type(fn_ty))),
}
}
@compileError("no function which fulfills type signature of args was provided");
},
else => @compileError("functions was not a struct of fn, was " ++ @typeName(@Type(fns_ty))),
}
},
else => @compileError("expected struct for args, was " ++ @typeName(@Type(args))),
}
}
Now we can implement our ad hoc polymorphic
function.dot
const Point2D = struct {
x: i32,
y: i32,
};
fn dot2d(p1: Point2D, p2: Point2D) i32 {
return p1.x * p2.x + p1.y * p2.y;
}
const Point3D = struct {
x: i32,
y: i32,
z: i32,
};
fn dot3d(p1: Point3D, p2: Point3D) i32 {
return p1.x * p2.x + p1.y * p2.y + p1.z * p2.z;
}
fn dot(args: anytype) i32 {
return adHocPoly(
args,
.{ dot2d, dot3d },
)();
}
test "ad_hoc_polymorphism" {
try testing.expectEqual(11, dot(.{
Point2D{ .x = 1, .y = 2 },
Point2D{ .x = 3, .y = 4 },
}));
try testing.expectEqual(32, dot(.{
Point3D{ .x = 1, .y = 2, .z = 3 },
Point3D{ .x = 4, .y = 5, .z = 6 },
}));
}
Suppose we would like to also support multiple return types as well. To do that we would have to modify the API of our polymorphic function to take the expected return type as an argument.
pub fn adHocPoly(args: anytype, fns: anytype) fn () typed.ReturnType(fns[0]) {
pub fn adHocPoly(comptime T: type, args: anytype, fns: anytype) fn () T {
const RT = typed.ReturnType(fns[0]);
for (fns) |func| {
if (typed.ReturnType(func) != RT) {
@compileError("all functions must have the same return type");
}
}
const args_ty = @typeInfo(@TypeOf(args));
switch (args_ty) {
inline .@"struct" => |s| {
const fields = s.fields;
const fns_ty = @typeInfo(@TypeOf(fns));
switch (fns_ty) {
inline .@"struct" => |fns_struc| {
for (fns_struc.fields, 0..) |func, fn_idx| {
const fn_ty = @typeInfo(func.type);
switch (fn_ty) {
inline .@"fn" => |fn_ty_info| {
if (fn_ty_info.params.len != fields.len) {
continue;
}
if (fn_ty_info.return_type) |RT| {
if (RT != T) {
continue;
}
} else {
unreachable;
}
var found = true;
for (fn_ty_info.params, 0..) |param, i| {
if (param.type) |param_ty| {
if (param_ty != fields[i].type) {
found = false;
break;
}
} else {
@compileError("generic fn parameter of anytype found, can not deduce which fn to call");
}
}
if (found) {
return (struct {
fn e() RT {
return @call(.auto, fns[fn_idx], args);
}
}).e;
}
},
else => @compileError("functions params was not an array of functions, was " ++ @typeName(@Type(fn_ty))),
}
}
@compileError("no function which fulfills type signature of args was provided");
},
else => @compileError("functions was not a struct of fn, was " ++ @typeName(@Type(fns_ty))),
}
},
else => @compileError("expected struct for args, was " ++ @typeName(@Type(args))),
}
}
You can find this entire exercise on my github for reference.