Menu
The Missing Zig Polymorphism / Runtime Dispatch Reference
Feb 13, 2022
Polymorphism in Zig is not well explained, and there are no good references for it. It’s never explained why it’s like that. I decided to experiment my way there, and write about what I found.
Zig does not have a convenient polymorphism language feature (like interface
in C# or virtual methods
in C++ classes), and it’s not going to get one (you can dig into the references to that issue to try to understand the discussion).
So if you need it, you have to build your own Polymorphism out of existing language features. That may either make you feel excited or scared. I was apathetic and really not interested. But I really like Zig, and I had a strong need for it, so I bit the bullet.
(BTW, there’s also interface.zig, but I haven’t tried it, so I can’t vouch for it)
In Runtime Polymorphism, why, how? Alex Naskos explains different paradigms for dynamic dispatch in Zig.
It seems the Zig community converged on a fat pointer solution, where an object that supports dynamic dispatch contains two pointers: a vtable pointer and an object pointer. This is what is used in the standard library, e.g. in Allocator. Along the way here we’ll get into some details that I hope will illuminate why that decision makes sense (I end up in pretty much the same place).
Where I end is going to be very close to the 3rd option in Alex Naskos' talk (12:49). But his presentation is very condensed. It didn’t quite leave me in a place where I understood the details of what is going on. Perhaps it’s enough for you?
Goal
I want to write a simple expression calculator that represents the expression as a graph.
The expression might be (2+3)*(4*5)
. Here’s a visualization of the graph:
The result of the computation should be 100
.
I suggest that in order to do this, we need a polymorphic node
type. node
can have an arbitrary number of inputs, and it needs to have a Compute
function which must be runtime dispatched so we can ask any type of node to compute its result. You can see that we need 3 implementations of node, with different Compute
implementations for integer literal, operator plus and operator multiply.
Prerequisitess
In C++ it’s easy to extend a base class with additional fields and methods, just use :
to inherit from a base class in your derived class. Zig offers no mechanism to inherit from a type, so the idea of extending a class or overriding it’s virtual methods is not going to work. (There is an method for doing polymorphism by composition using @fieldParentPtr, but it is no longer the prefered method)
The fact that we can’t extend a struct by adding or overriding members means that we can’t just have a polymorphic implementation in one object. As far as I can tell, it is necessary to use a polymorphic “wrapper” object that references your implementation object.
One other thing before we start.
Below, you will see me use a pattern like this:
1
2
3
4
5
|
const SomeVar = struct {
fn SomeFunction(SomeArgs) void {
DoSomething().
}
}.SomeFunction
|
This is a workaround for Zigs lack of inline function declarations. You should think of the above as
1
2
3
|
const SomeVar = fn(SomeArgs) void {
DoSomething();
};
|
Proposal #1717 will address this eventually, but in the meantime this is required.
Function pointers
Starting from scratch - we know that dynamic dispatch is built using function pointers. So let’s try that for our wrapper polymorphic object.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
const node = struct {
Compute: fn(self: *node) i32,
pub fn make(Object: *literal_value) node {
return node {
.Compute = ???,
};
}
};
const literal_value = struct {
Value: i32 = undefined,
pub fn Compute(self: *literal_value) i32 {
return self.Value;
}
};
test "literal computes expected value" {
var LiteralThree = literal_value { .Value = 3 };
var Node = node.make(&LiteralThree);
try std.testing.expectEqual(@as(i32, 3), Node.Compute(&Node));
}
|
Here node
is our polymorphic wrapper and literal_value
the node implementation that represents a literal integer.
But how do we construct Compute
on line 6?
We can’t.
node
has no storage for the reference to the implementation object, so there is no way to connect Node
to LiteralThree
.
Detour: Closures
You might ask, couldn’t we just do something like this?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
const node = struct {
Compute: fn(self: *node) i32,
pub fn make(Object: *literal_value) node {
return node {
.Compute = struct {
fn Compute(self: *node) i32 {
_ = self;
return Object.Compute();
}
}.Compute,
};
}
};
|
The idea here is to capture Object
on line 4 in a closure, and call it directly on line 9. If you try this you will get
error: 'Object' not accessible from inner function
refering to line 9.
That is because Zig does not support closures.
Add Object Reference
Let’s add a reference to the object we’re making polymorphic.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
const node = struct {
Object: *literal_value = undefined,
Compute: fn(self: *node) i32,
pub fn make(Object: *literal_value) node {
return node {
.Object = Object,
.Compute = struct {
fn Compute(self: *node) i32 {
return self.Object.Compute();
}
}.Compute,
};
}
};
const literal_value = struct {
Value: i32 = undefined,
pub fn Compute(self: *literal_value) i32 {
return self.Value;
}
};
test "literal computes expected value" {
var LiteralThree = literal_value { .Value = 3 };
var Node = node.make(&LiteralThree);
try std.testing.expectEqual(@as(i32, 3), Node.Compute(&Node));
}
|
Here I’ve added an object pointer on line 2, and update make
so the node
Compute
method invokes Compute
on the referenced Object
.
This works! But one problem here is that we use the specialized literal_value
directly as the type of the wrapped object. Let’s try to make node
support more types.
Polymorphism
Instead of declaring Object
as *literal_node
, I’ll use an int pointer with no type information. I define the type int_ptr
to make this a little clearer. (You could also use anyopaque
but it introduces alignment problems that only complicates the solution.)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
const int_ptr = usize;
const node = struct {
Object: int_ptr = undefined,
Compute: fn(self: *node) i32,
pub fn make(Object: anytype) node {
const ObjectPtrType = @TypeOf(Object);
return node {
.Object = @ptrToInt(Object),
.Compute = struct {
fn Compute(self: *node) i32 {
const TypedObject = @intToPtr(ObjectPtrType, self.Object);
return TypedObject.Compute();
}
}.Compute,
};
}
};
const literal_value = struct {
Value: i32 = undefined,
pub fn Compute(self: *literal_value) i32 {
return self.Value;
}
};
const square = struct {
Input: *node = undefined,
pub fn Compute(self: *square) i32 {
const Val = self.Input.Compute(self.Input);
return Val*Val;
}
};
test "square computes expected value" {
var LiteralThree = literal_value { .Value = 3 };
var Node1 = node.make(&LiteralThree);
var Square = square { .Input = &Node1 };
var Node2 = node.make(&Square);
try std.testing.expectEqual(@as(i32, 9), Node2.Compute(&Node2));
}
|
This is essentially just the changes needed to change *literal_value
to int_ptr
in node
. Our solution is now polymorphic!
I also introduced the square
operator to make sure we had at least one node type that had an input, so we can demonstrate calling Compute
on its input, making the computation recursive.
Dot syntax
You may have noticed that when we invoke node.Compute
we have to supply the self
argument manually, which is surprising. You can see this on lines 33 and 45 in the example above.
This is because there is apparently a difference in Zig between
1
2
3
4
5
|
var SomeStruct = struct {
pub fn SomeFunction() void {
DoSomething();
}
};
|
and
1
2
3
|
var SomeStruct = struct {
SomeFunction: fn() void;
};
|
It seems the dot syntax which automatically applies self
as the first argument to the struct method only works in the first case! I spent some time researching this, but I could not figure out if this was expected.
Fortunately it is fixable with some additional typing:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
const int_ptr = usize;
const node = struct {
Object: int_ptr = undefined,
VirtualTable: virtual_table = undefined,
const virtual_table = struct {
Compute: fn(self: *node) i32,
};
pub fn Compute(self: *node) i32 {
return self.VirtualTable.Compute(self);
}
pub fn make(Object: anytype) node {
const ObjectPtrType = @TypeOf(Object);
return node {
.Object = @ptrToInt(Object),
.VirtualTable = . {
.Compute = struct {
fn Compute(self: *node) i32 {
const TypedObject = @intToPtr(ObjectPtrType, self.Object);
return TypedObject.Compute();
}
}.Compute,
}
};
}
};
const literal_value = struct {
Value: i32 = undefined,
pub fn Compute(self: *literal_value) i32 {
return self.Value;
}
};
const square = struct {
Input: *node = undefined,
pub fn Compute(self: *square) i32 {
const Val = self.Input.Compute();
return Val*Val;
}
};
test "literal computes expected value" {
var LiteralThree = literal_value { .Value = 3 };
var Node = node.make(&LiteralThree);
try std.testing.expectEqual(@as(i32, 3), Node.Compute());
}
test "square computes expected value" {
var LiteralThree = literal_value { .Value = 3 };
var Node1 = node.make(&LiteralThree);
var Square = square { .Input = &Node1 };
var Node2 = node.make(&Square);
try std.testing.expectEqual(@as(i32, 9), Node2.Compute());
}
|
This is a little bit verbose, but fixes the problem.
I also wrapped the the Compute
function pointer in node
inside virtual_table
. This avoids the naming conflict between the Compute
function pointer and the pass-through function we introduced to make sure dot notation worked as expected on node.Compute
. It also prepares us a little for the next step.
Fat pointer, but not too fat
Now, the problem with the above solution - what if the interface grows much larger? What if we have 10 or 20 function pointers? The object we’re passing around grows with every method we add to the virtual table. That is not great for performance, because it will consume cache and memory bandwidth. We can quickly verify the object size growth:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
const node = struct {
Object: int_ptr = undefined,
VirtualTable: virtual_table = undefined,
const virtual_table = struct {
Compute: fn(self: *node) i32,
AnotherFunction: fn(self: *node, i32) void,
OneMoreFunction: fn(self: *node) void,
};
pub fn Compute(self: *node) i32 { return self.VirtualTable.Compute(self); }
pub fn AnotherFunction(self: *node, Argument: i32) void { self.VirtualTable.AnotherFunction(self, Argument); }
pub fn OneMoreFunction(self: *node) void { self.VirtualTable.OneMoreFunction(self); }
pub fn make(Object: anytype) node {
...
}
};
test "node is size of four pointers - the object pointer and three function pointers" {
var LiteralThree = literal_value { .Value = 3 };
var Node = node.make(&LiteralThree);
try std.testing.expectEqual(@as(usize, 4*@sizeOf(int_ptr)), @sizeOf(@TypeOf(Node)));
}
|
We see that the size of node
is indeed 4 pointers, or 32 bytes. So the size of the wrapper can become large, and we would prefer this to be just two pointers.
This is straightforward to fix:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
const int_ptr = usize;
const node = struct {
Object: int_ptr = undefined,
VirtualTable: *const virtual_table = undefined,
const virtual_table = struct {
Compute: fn(self: *node) i32,
};
pub fn Compute(self: *node) i32 {
return self.VirtualTable.Compute(self);
}
pub fn make(Object: anytype) node {
const ObjectPtrType = @TypeOf(Object);
return node {
.Object = @ptrToInt(Object),
.VirtualTable = &.{
.Compute = struct {
fn Compute(self: *node) i32 {
const TypedObject = @intToPtr(ObjectPtrType, self.Object);
return TypedObject.Compute();
}
}.Compute,
}
};
}
};
|
You can run the same test as before to verify the size of this object will remain 16 bytes if you add additional functions.
This solution is now very close to the 3rd option presented in Alex Naskos' talk.
One difference I would like to point out here is that Alex uses the comptime
keyword when creating the pointer to the virtual table. As far as I could tell in my tests, omitting it did not make any difference, but I could not tell you why.
Now we can build our acceptance test:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
const operator_plus = struct {
Input1: *node = undefined,
Input2: *node = undefined,
pub fn Compute(self: *operator_plus) i32 {
return self.Input1.Compute() + self.Input2.Compute();
}
};
const operator_multiply = struct {
Input1: *node = undefined,
Input2: *node = undefined,
pub fn Compute(self: *operator_multiply) i32 {
return self.Input1.Compute() * self.Input2.Compute();
}
};
test "(2+3)*(4*5) == 100" {
var LiteralTwo = literal_value { .Value = 2 };
var LiteralThree = literal_value { .Value = 3 };
var LiteralFour = literal_value { .Value = 4 };
var LiteralFive = literal_value { .Value = 5 };
var NodeLiteralTwo = node.make(&LiteralTwo);
var NodeLiteralThree = node.make(&LiteralThree);
var NodeLiteralFour = node.make(&LiteralFour);
var NodeLiteralFive = node.make(&LiteralFive);
var Plus = operator_plus { .Input1 = &NodeLiteralTwo, .Input2 = &NodeLiteralThree };
var NodePlus = node.make(&Plus);
var Multiply = operator_multiply { .Input1 = &NodeLiteralFour, .Input2 = &NodeLiteralFive };
var NodeMultiply = node.make(&Multiply);
var Result = operator_multiply { .Input1 = &NodePlus, .Input2 = &NodeMultiply };
var NodeResult = node.make(&Result);
try std.testing.expectEqual(@as(i32, 100), NodeResult.Compute());
}
|
Thanks for reading
That’s it folks. I hope you have better starting point for writing polymorphic code in Zig than you did before.
Back to top