Zig NEWS

Yigong Liu
Yigong Liu

Posted on

Code study: interface idioms/patterns in zig standard libraries

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),
    };
}
Enter fullscreen mode Exit fullscreen mode

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(),
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
            },
        };
    }
};
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
};
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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 });
    }
};
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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 });
    }
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Latest comments (9)

Collapse
 
fwx5618177 profile image
wenxuan feng

Why not use this:


// shared v-table
const staticVTab = &VTab {
    .draw = impl.draw,
    .move = impl.move,
};

return .{
    .ptr = obj,
    .vtab = staticVTab,
};
Enter fullscreen mode Exit fullscreen mode
Collapse
 
andreashefti profile image
Andreas Hefti • Edited

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:

fn Interface(comptime T: type) type {
    return struct {
        const Self = @This();

        fn1: *const fn () T = undefined,
        fn2: *const fn () void = undefined,
        fn3: *const fn (T) T = undefined,

        pub fn init(initImpl: *const fn (interface: *Interface(T)) void) Self {
            var self = Self{};
            _ = initImpl(&self);
            return self;
        }

        pub fn fn1(self: Self) T {
            return self.fn1();
        }

        pub fn fn2(self: Self) void {
            self.fn2();
        }

        pub fn fn3(self: Self, t: T) T {
            return self.fn3(t);
        }
    };
}

const Implementation1 = struct {
    pub fn initImpl(interface: *Interface(u8)) void {
        interface.fn1 = fn1;
        interface.fn2 = fn2;
        interface.fn3 = fn3;
    }

    pub fn fn1() u8 {
        std.log.info("Implementation1 fn1 called ", .{});
        return 1;
    }

    pub fn fn2() void {
        std.log.info("Implementation1 fn2 called ", .{});
    }

    pub fn fn3(b: u8) u8 {
        std.log.info("Implementation1 fn3 called {any}", .{b});
        return 1;
    }
};

const Implementation2 = struct {
    pub fn initImpl(interface: *Interface([]const u8)) void {
        interface.fn1 = fn1;
        interface.fn2 = fn2;
        interface.fn3 = fn3;
    }

    pub fn fn1() []const u8 {
        std.log.info("Implementation2 fn1 called ", .{});
        return "1";
    }

    pub fn fn2() void {
        std.log.info("Implementation2 fn2 called ", .{});
    }

    pub fn fn3(b: []const u8) []const u8 {
        std.log.info("Implementation2 fn3 called {s}", .{b});
        return "1";
    }
};

pub fn testInterface() void {
    var interface1 = Interface(u8).init(Implementation1.initImpl);
    _ = interface1.fn1();
    interface1.fn2();
    _ = interface1.fn3(123);

    var interface2 = Interface([]const u8).init(Implementation2.initImpl);
    _ = interface2.fn1();
    interface2.fn2();
    _ = interface2.fn3("123");

    var interfac2Copy = interface2;
    _ = interfac2Copy.fn1();
    interfac2Copy.fn2();
    _ = interfac2Copy.fn3("456");
}
Enter fullscreen mode Exit fullscreen mode

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!?

Collapse
 
iiian profile image
Ian Ray

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:

fn receieveOne(x: anytype) void {
  _ = x.fn1();
  x.fn2();
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
andreashefti profile image
Andreas Hefti • Edited

It should be possible to pass the Interface since you have a referenced the interface struct and not the implementation, just like so:

fn receieveOne(x: Interface(T)) void {
  _ = x.fn1();
  x.fn2();
}
Enter fullscreen mode Exit fullscreen mode

I tried this and it seems to work.

An even simpler implementation would also work without generics:

fn Iface() type {
    return struct {
        const Self = @This();
        fn1: *const fn (a: usize) void = undefined,

        pub fn init(initImpl: *const fn (i: *Iface()) void) Self {
            var self = Self{};
            _ = initImpl(&self);
            return self;
        }

        pub fn fn1(self: Self, a: usize) void {
            self.fn1(a);
        }
    };
}

const Impl1 = struct {
    pub fn initImpl(interface: *Iface()) void {
        interface.fn1 = fn1;
    }

    pub fn fn1(a: usize) void {
        std.log.info("Impl1 fn1 called {d}", .{a});
    }
};

const Impl2 = struct {
    pub fn initImpl(interface: *Iface()) void {
        interface.fn1 = fn1;
    }

    pub fn fn1(a: usize) void {
        std.log.info("Impl2 fn1 called {d}", .{a});
    }
};
Enter fullscreen mode Exit fullscreen mode

Then you can create and pass around this with:

const iface1 = Iface().init(Impl1.initImpl);
receiveOne(iface1);

const iface2 = Iface().init(Impl2.initImpl);
receiveOne(iface2);

fn receiveOne(x: Iface()) void {
  x.fn1(2);
}
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
iiian profile image
Ian Ray • Edited
   └─ zig build-exe zig Debug native 1 errors
main.zig:67:15: error: expected type 'type', found 'fn() type'
fn receiveOne(x: Iface) void {
Enter fullscreen mode Exit fullscreen mode

Which version of zig are you on? This is what I get with 0.11.0 and 0.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!

Thread Thread
 
andreashefti profile image
Andreas Hefti

Ou yes, forgot that in this example. I've corrected it, thanks!

Collapse
 
voilaneighbor profile image
Fifnmar

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.

Collapse
 
jackji profile image
jack

Learned a lot, thanks!

Collapse
 
kristoff profile image
Loris Cro

This is a great recap of the commonly used patterns.

Thank you for sharing!