introduction
In Java and Go, behavior based abstractions can be defined using "interface" - a set of methods (or method set). Normally interfaces hold so-called vtable for dynamic dispatching. Zig allows declaring functions and methods in struct, enum, union and opaque, although zig does not yet support interface as a language feature. Zig standard libraries apply a few code idioms or patterns to achieve similar effects.
Similar to interfaces in other languages, zig code idiom and patterns enable:
- type checking instance/object methods against interface types at compile time,
- dynamic dispatching at runtime.
There are some notable differences:
- Go's interfaces are independent from the types/instances they abstract over. New interfaces can be added at any time when common patterns of api/methods are observed across diverse types. There is no need going back to change types for implementing new interfaces, that is required for Java.
- Go's interfaces contain only vtab for dynamic dispatching and small method-set/vtable are preferred, eg. io.Reader and io.Writer with single method. Common utilities such as io.Copy, CopyN, ReadFull, ReadAtLeast are provided as package functions using those small interfaces. Zig's interfaces, such as std.mem.Allocator, typically contains both vtable and common utilities as methods; so they normally have many methods.
The following are study notes of zig's code idioms/patterns for dynamic dispatching, with code extracts from zig standard libraries and recoded as simple examples. To focus on vtab/dynamic dispatching, utility methods are removed and code are modified a bit to fit Go's model of small interfaces independent from concrete types.
Full code is located in this repo and you can run it with "zig test interfaces.zig".
set up
Let's use the classical OOP example, create a few shapes: Point, Box and Circle.
const Point = struct {
x: i32 = 0,
y: i32 = 0,
pub fn move(self: *Point, dx: i32, dy: i32) void {
self.x += dx;
self.y += dy;
}
pub fn draw(self: *Point) void {
print("point@<{d},{d}>\n", .{ self.x, self.y });
}
};
const Box = struct {
p1: Point,
p2: Point,
pub fn init(p1: Point, p2: Point) Box {
return .{ .p1 = p1, .p2 = p2 };
}
pub fn move(self: *Box, dx: i32, dy: i32) void {
......
}
pub fn draw(self: *Box) void {
......
}
};
const Circle = struct {
center: Point,
radius: i32,
pub fn init(c: Point, r: i32) Circle {
return .{ .center = c, .radius = r };
}
pub fn move(self: *Circle, dx: i32, dy: i32) void {
self.center.move(dx, dy);
}
pub fn draw(self: *Circle) void {
......
}
};
//create a set of "shapes" for test
fn init_data() struct { point: Point, box: Box, circle: Circle } {
return .{
.point = Point{},
.box = Box.init(Point{}, Point{ .x = 2, .y = 3 }),
.circle = Circle.init(Point{}, 5),
};
}
interface 1: enum tagged union
Using enum tagged union for interfaces is introduced by Loris Cro "Easy Interfaces with zig 0.10.0". This is the simplest solution, although you have to explicitly list, in the union, all the variant types which "implement" the interface.
const Shape1 = union(enum) {
point: *Point,
box: *Box,
circle: *Circle,
pub fn move(self: Shape1, dx: i32, dy: i32) void {
switch (self) {
inline else => |s| s.move(dx, dy),
}
}
pub fn draw(self: Shape1) void {
switch (self) {
inline else => |s| s.draw(),
}
}
};
We can test it as following:
test "union_as_intf" {
var data = init_data();
var shapes = [_]Shape1{
.{ .point = &data.point },
.{ .box = &data.box },
.{ .circle = &data.circle },
};
for (shapes) |s| {
s.move(11, 22);
s.draw();
}
}
interface 2: 1st implementation of vtable and dynamic disptaching
Zig has switched from original dynamic dispatching based on embedded vtab and #fieldParentPtr(), to the following pattern based on "fat pointer" interface; please go to this article for more details "Allocgate is coming in Zig 0.9,...".
Interface std.mem.Allocator uses this pattern, and all standard allocators, std.heap.[ArenaAllocator, GeneralPurposeAllocator, ...] have a method "allocator() Allocator" to expose this interface. The following code changed a bit to douple the interface from implementations.
const Shape2 = struct {
// define interface fields: ptr,vtab
ptr: *anyopaque, //ptr to instance
vtab: *const VTab, //ptr to vtab
const VTab = struct {
draw: *const fn (ptr: *anyopaque) void,
move: *const fn (ptr: *anyopaque, dx: i32, dy: i32) void,
};
// define interface methods wrapping vtable calls
pub fn draw(self: Shape2) void {
self.vtab.draw(self.ptr);
}
pub fn move(self: Shape2, dx: i32, dy: i32) void {
self.vtab.move(self.ptr, dx, dy);
}
// cast concrete implementation types/objs to interface
pub fn init(obj: anytype) Shape2 {
const Ptr = @TypeOf(obj);
const PtrInfo = @typeInfo(Ptr);
assert(PtrInfo == .Pointer); // Must be a pointer
assert(PtrInfo.Pointer.size == .One); // Must be a single-item pointer
assert(@typeInfo(PtrInfo.Pointer.child) == .Struct); // Must point to a struct
const alignment = PtrInfo.Pointer.alignment;
const impl = struct {
fn draw(ptr: *anyopaque) void {
const self = @ptrCast(Ptr, @alignCast(alignment, ptr));
self.draw();
}
fn move(ptr: *anyopaque, dx: i32, dy: i32) void {
const self = @ptrCast(Ptr, @alignCast(alignment, ptr));
self.move(dx, dy);
}
};
return .{
.ptr = obj,
.vtab = &.{
.draw = impl.draw,
.move = impl.move,
},
};
}
};
We can test it as following:
test "vtab1_as_intf" {
var data = init_data();
var shapes = [_]Shape2{
Shape2.init(&data.point),
Shape2.init(&data.box),
Shape2.init(&data.circle),
};
for (shapes) |s| {
s.move(11, 22);
s.draw();
}
}
interface 3: 2nd implementation of vtab and dynamic dispatch
In above 1st implementation, when "casting" a Box into interface Shape2 thru Shape2.init(), the box instance is type-checked for implementing the methods of Shape2 (matching signatures including names). There are two changes in the 2nd implementation:
- the vtable is inlined in the interface struct (possible minus point, interface size increased).
- methods to be type checked against interface are explicitly passed in as function pointers, that possiblely enable the use case of passing in different methods, as long as they have same arguments/return types. For examples, if Box has extra methods, stopAt(i32,i32) or even scale(i32,i32), we can pass them in place of move().
Interface std.rand.Random and all std.rand.[Pcg, Sfc64, ...] use this pattern.
const Shape3 = struct {
// define interface fields: ptr,vtab
// ptr to instance
ptr: *anyopaque,
// inline vtable
drawFnPtr: *const fn (ptr: *anyopaque) void,
moveFnPtr: *const fn (ptr: *anyopaque, dx: i32, dy: i32) void,
pub fn init(
obj: anytype,
comptime drawFn: fn (ptr: @TypeOf(obj)) void,
comptime moveFn: fn (ptr: @TypeOf(obj), dx: i32, dy: i32) void,
) Shape3 {
const Ptr = @TypeOf(obj);
assert(@typeInfo(Ptr) == .Pointer); // Must be a pointer
assert(@typeInfo(Ptr).Pointer.size == .One); // Must be a single-item pointer
assert(@typeInfo(@typeInfo(Ptr).Pointer.child) == .Struct); // Must point to a struct
const alignment = @typeInfo(Ptr).Pointer.alignment;
const impl = struct {
fn draw(ptr: *anyopaque) void {
const self = @ptrCast(Ptr, @alignCast(alignment, ptr));
drawFn(self);
}
fn move(ptr: *anyopaque, dx: i32, dy: i32) void {
const self = @ptrCast(Ptr, @alignCast(alignment, ptr));
moveFn(self, dx, dy);
}
};
return .{
.ptr = obj,
.drawFnPtr = impl.draw,
.moveFnPtr = impl.move,
};
}
// define interface methods wrapping vtable func-ptrs
pub fn draw(self: Shape3) void {
self.drawFnPtr(self.ptr);
}
pub fn move(self: Shape3, dx: i32, dy: i32) void {
self.moveFnPtr(self.ptr, dx, dy);
}
};
We can test it as following:
test "vtab2_as_intf" {
var data = init_data();
var shapes = [_]Shape3{
Shape3.init(&data.point, Point.draw, Point.move),
Shape3.init(&data.box, Box.draw, Box.move),
Shape3.init(&data.circle, Circle.draw, Circle.move),
};
for (shapes) |s| {
s.move(11, 22);
s.draw();
}
}
interface 4: original dynamic dispatch using embedded vtab and @fieldParentPtr()
Interface std.build.Step and all build steps std.build.[RunStep, FmtStep, ...] still use this pattern.
// define interface/vtab
const Shape4 = struct {
drawFn: *const fn (ptr: *Shape4) void,
moveFn: *const fn (ptr: *Shape4, dx: i32, dy: i32) void,
// define interface methods wrapping vtab funcs
pub fn draw(self: *Shape4) void {
self.drawFn(self);
}
pub fn move(self: *Shape4, dx: i32, dy: i32) void {
self.moveFn(self, dx, dy);
}
};
// embed vtab and define vtab funcs as wrappers over methods
const Circle4 = struct {
center: Point,
radius: i32,
shape: Shape4, //embed vtab
pub fn init(c: Point, r: i32) Circle4 {
// define interface wrapper funcs
const impl = struct {
pub fn draw(ptr: *Shape4) void {
const self = @fieldParentPtr(Circle4, "shape", ptr);
self.draw();
}
pub fn move(ptr: *Shape4, dx: i32, dy: i32) void {
const self = @fieldParentPtr(Circle4, "shape", ptr);
self.move(dx, dy);
}
};
return .{
.center = c,
.radius = r,
.shape = .{ .moveFn = impl.move, .drawFn = impl.draw },
};
}
// the following are methods
pub fn move(self: *Circle4, dx: i32, dy: i32) void {
self.center.move(dx, dy);
}
pub fn draw(self: *Circle4) void {
print("circle@<{d},{d}>radius:{d}\n", .{ self.center.x, self.center.y, self.radius });
}
};
// embed vtab and define vtab funcs on struct directly
const Box4 = struct {
p1: Point,
p2: Point,
shape: Shape4, //embed vtab
pub fn init(p1: Point, p2: Point) Box4 {
return .{
.p1 = p1,
.p2 = p2,
.shape = .{ .moveFn = move, .drawFn = draw },
};
}
//the following are vtab funcs, not methods
pub fn move(ptr: *Shape4, dx: i32, dy: i32) void {
const self = @fieldParentPtr(Box4, "shape", ptr);
self.p1.move(dx, dy);
self.p2.move(dx, dy);
}
pub fn draw(ptr: *Shape4) void {
const self = @fieldParentPtr(Box4, "shape", ptr);
print("box@<{d},{d}>-<{d},{d}>\n", .{ self.p1.x, self.p1.y, self.p2.x, self.p2.y });
}
};
We can test it as following:
test "vtab3_embedded_in_struct" {
var box = Box4.init(Point{}, Point{ .x = 2, .y = 3 });
var circle = Circle4.init(Point{}, 5);
var shapes = [_]*Shape4{
&box.shape,
&circle.shape,
};
for (shapes) |s| {
s.move(11, 22);
s.draw();
}
}
interface 5: generic interface at compile time
All above interfaces focus on vtab and dynamic dispatching: the interface values will hide the types of concrete values it holds. So you can put these interfaces values into an array and handle them uniformly.
With zig's compile-time computation, you can define generic algorithms which can work with any type which provides the methods or operators required by the code in function body. For example, we can define a generic algorithm:
fn update_graphics(shape: anytype, dx: i32, dy: i32) void {
shape.move(dx, dy);
shape.draw();
}
As shown above, "shape" can be anytype as long as it provides move() and draw() methods. All type checking happen at comptime and no dynamic dispatching.
As following, we can define a generic interface which capture the methods required by generic algorithms; and we can use it to adapt some types/instances with different method names into the required api.
Interface std.io.[Reader, Writer] and std.fifo and std.fs.File use this pattern.
Since these generic interfaces do not erase the type info of the values it hold, they are different types. Thus you cannot put them into an array for handling uniformally.
pub fn Shape5(
comptime Pointer: type,
comptime drawFn: *const fn (ptr: Pointer) void,
comptime moveFn: *const fn (ptr: Pointer, dx: i32, dy: i32) void,
) type {
return struct {
ptr: Pointer,
const Self = @This();
pub fn init(p: Pointer) Self {
return .{ .ptr = p };
}
// interface methods wrapping passed-in funcs/methods
pub fn draw(self: Self) void {
drawFn(self.ptr);
}
pub fn move(self: Self, dx: i32, dy: i32) void {
moveFn(self.ptr, dx, dy);
}
};
}
//a generic algorithms use duck-typing/static dispatch.
//note: shape can be "anytype" which provides move()/draw()
fn update_graphics(shape: anytype, dx: i32, dy: i32) void {
shape.move(dx, dy);
shape.draw();
}
//define a TextArea with similar but diff methods
const TextArea = struct {
position: Point,
text: []const u8,
pub fn init(pos: Point, txt: []const u8) TextArea {
return .{ .position = pos, .text = txt };
}
pub fn relocate(self: *TextArea, dx: i32, dy: i32) void {
self.position.move(dx, dy);
}
pub fn display(self: *TextArea) void {
print("text@<{d},{d}>:{s}\n", .{ self.position.x, self.position.y, self.text });
}
};
We can test it as following:
test "generic_interface" {
var box = Box.init(Point{}, Point{ .x = 2, .y = 3 });
//apply generic algorithms to matching types directly
update_graphics(&box, 11, 22);
var textarea = TextArea.init(Point{}, "hello zig!");
//use generic interface to adapt non-matching types
var drawText = Shape5(*TextArea, TextArea.display, TextArea.relocate).init(&textarea);
update_graphics(drawText, 4, 5);
}
Latest comments (9)
Why not use this:
I'm completely new to Zig and also C like languages and still learning. This is a grate summary of Interface pattern, thanks!
The other day I tried myself a probably naive implementation of the Interface pattern but it seems to work well. At least in the context I want to use it. The structs used are empty structs except the interface struct that defines the functions.
Example:
This prints the following as expected:
info: Implementation1 fn1 called
info: Implementation1 fn2 called
info: Implementation1 fn3 called 123
info: Implementation2 fn1 called
info: Implementation2 fn2 called
info: Implementation2 fn3 called 123
info: Implementation2 fn1 called
info: Implementation2 fn2 called
info: Implementation2 fn3 called 456
But maybe on other context this will not work as expected!?
The only "caveat" with this implementation seems to be that, if you want to pass an instance of
Interface
around, you're only getting away with it if you do, for instance:It should be possible to pass the Interface since you have a referenced the interface struct and not the implementation, just like so:
I tried this and it seems to work.
An even simpler implementation would also work without generics:
Then you can create and pass around this with:
Which version of zig are you on? This is what I get with
0.11.0
and0.12.0-dev+2059
-- edit --
Oh, looks like it works if I do
fn receiveOne(x: Iface()) void {
, so I assume that's what you meant. Cool!Ou yes, forgot that in this example. I've corrected it, thanks!
This post is more than an introduction to Zig, but a summary of varied implementation of polymophism. I really appreciate Zig, as a lower level language, for leaving the choices to users.
Learned a lot, thanks!
This is a great recap of the commonly used patterns.
Thank you for sharing!