Zig Interfaces Tutorial

Compile-Time, Runtime, and Tagged Union Approaches (Using Shapes)

This tutorial demonstrates Zig's three approaches to polymorphism using a single example domain: - Rectangle - Square - Circle - Triangle

Each shape must implement: - area()- perimeter()

We will cover: 1. Compile-time interface (generics / duck typing) 2. Runtime interface (vtable pattern like std.mem.Allocator) 3. Tagged union (closed set polymorphism)

For each approach we'll also show: - How to create arrays of shapes - Memory management patterns - Gotchas and best practices


1. Compile-Time Interface (Generic / Duck Typing)

Concept

Zig does not have a trait or interface keyword.

Instead, a function can accept anytype, and the compiler verifies that the required methods exist at compile time.

There is: - Zero runtime overhead - No dynamic dispatch - One compiled version per concrete type


Shape Implementations

const std = @import("std");

const Rectangle = struct {
    width: f64,
    height: f64,

    pub fn area(self: Rectangle) f64 {
        return self.width * self.height;
    }

    pub fn perimeter(self: Rectangle) f64 {
        return 2.0 * (self.width + self.height);
    }
};

const Square = struct {
    side: f64,

    pub fn area(self: Square) f64 {
        return self.side * self.side;
    }

    pub fn perimeter(self: Square) f64 {
        return 4.0 * self.side;
    }
};

const Circle = struct {
    radius: f64,

    pub fn area(self: Circle) f64 {
        return std.math.pi * self.radius * self.radius;
    }

    pub fn perimeter(self: Circle) f64 {
        return 2.0 * std.math.pi * self.radius;
    }
};

const Triangle = struct {
    a: f64,
    b: f64,
    c: f64,

    pub fn perimeter(self: Triangle) f64 {
        return self.a + self.b + self.c;
    }

    pub fn area(self: Triangle) f64 {
        const s = self.perimeter() / 2.0;
        return std.math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c));
    }
};

Generic "Interface" Function

fn printShapeInfo(shape: anytype) void {
    std.debug.print("Area: {d}\n", .{shape.area()});
    std.debug.print("Perimeter: {d}\n\n", .{shape.perimeter()});
}

If a type does not implement area() and perimeter(), compilation fails.


Restricting the Interface Explicitly

fn printShapeInfoStrict(shape: anytype) void {
    const T = @TypeOf(shape);

    if (!@hasDecl(T, "area"))
        @compileError("Type must implement area()");
    if (!@hasDecl(T, "perimeter"))
        @compileError("Type must implement perimeter()");

    std.debug.print("Area: {d}\n", .{shape.area()});
}

Arrays in Compile-Time Approach

You cannot store mixed shapes in one array:

var arr = [_]Rectangle{ ... }; // OK

But this does NOT work:

// Cannot mix types
var arr = [_]{ Rectangle{...}, Circle{...} };

Because there is no shared runtime type.

Pattern: Separate Arrays Per Type

var rectangles = [_]Rectangle{
    .{ .width = 4, .height = 3 },
    .{ .width = 2, .height = 5 },
};

Memory Management

There are no special memory concerns here because: - Shapes are stored by value - No pointers required - No heap needed

This is the safest approach.


When to Use Compile-Time


2. Runtime Interface (VTable Pattern)

Concept

This is how std.mem.Allocator works.

We create a Shape struct that contains: - ctx: *anyopaque- vtable: *const VTable

This allows heterogeneous collections like []Shape.


Define Runtime Interface

const Shape = struct {
    ctx: *anyopaque,
    vtable: *const VTable,

    const VTable = struct {
        area: *const fn (ctx: *anyopaque) f64,
        perimeter: *const fn (ctx: *anyopaque) f64,
    };

    pub fn area(self: Shape) f64 {
        return self.vtable.area(self.ctx);
    }

    pub fn perimeter(self: Shape) f64 {
        return self.vtable.perimeter(self.ctx);
    }
};

Implement Rectangle for Runtime Interface

const Rectangle = struct {
    width: f64,
    height: f64,

    fn vArea(ctx: *anyopaque) f64 {
        const self: *Rectangle = @ptrCast(@alignCast(ctx));
        return self.width * self.height;
    }

    fn vPerimeter(ctx: *anyopaque) f64 {
        const self: *Rectangle = @ptrCast(@alignCast(ctx));
        return 2.0 * (self.width + self.height);
    }

    const vtable = Shape.VTable{
        .area = vArea,
        .perimeter = vPerimeter,
    };

    pub fn asShape(self: *Rectangle) Shape {
        return .{
            .ctx = self,
            .vtable = &vtable,
        };
    }
};

Other shapes follow the same pattern.


Array of Runtime Shapes

var shapes = std.ArrayList(Shape).init(allocator);
try shapes.append(rect.asShape());
try shapes.append(circle.asShape());

Now we can mix types freely.


Memory Gotcha

This is WRONG:

try shapes.append(Rectangle{ .width = 4, .height = 3 }.asShape());

Because the temporary Rectangle is destroyed immediately.

The Shape stores a pointer to invalid memory.


Correct Lifetime Pattern

Concrete shapes must outlive the Shape handles.

Stack Allocation

var r = Rectangle{ .width = 4, .height = 3 };
try shapes.append(r.asShape());

Heap Allocation

const r = try allocator.create(Rectangle);
r.* = .{ .width = 4, .height = 3 };
try shapes.append(r.asShape());

Remember to free heap allocations later.


Best Practices (Runtime Interface)


3. Tagged Union (Closed Set Polymorphism)

Concept

When the set of possible types is known, use a tagged union.

This is the most idiomatic Zig solution for AST-style problems.


Define Tagged Union

const Shape = union(enum) {
    rectangle: Rectangle,
    square: Square,
    circle: Circle,
    triangle: Triangle,

    pub fn area(self: Shape) f64 {
        return switch (self) {
            .rectangle => |r| r.width * r.height,
            .square => |s| s.side * s.side,
            .circle => |c| std.math.pi * c.radius * c.radius,
            .triangle => |t| blk: {
                const s = (t.a + t.b + t.c) / 2.0;
                break :blk std.math.sqrt(s * (s - t.a) * (s - t.b) * (s - t.c));
            },
        };
    }

    pub fn perimeter(self: Shape) f64 {
        return switch (self) {
            .rectangle => |r| 2.0 * (r.width + r.height),
            .square => |s| 4.0 * s.side,
            .circle => |c| 2.0 * std.math.pi * c.radius,
            .triangle => |t| t.a + t.b + t.c,
        };
    }
};

Array of Tagged Union Shapes

var shapes = [_]Shape{
    .{ .rectangle = .{ .width = 4, .height = 3 } },
    .{ .circle = .{ .radius = 2 } },
    .{ .square = .{ .side = 5 } },
};

This works because all elements are the same type: Shape.


Why Tagged Union Is Powerful


Memory Behavior

Tagged union stores: - The largest variant - Plus a small tag

Everything is stored inline.

No lifetime issues.


Comparison Summary

| Feature | Compile-Time | Runtime | Tagged Union | |---|---|---|---| | Heterogeneous array | No | Yes | Yes | | Runtime cost | None | Function pointer call | None | | Requires pointers | No | Yes | No | | Safe lifetimes | Yes | Must manage | Yes | | Open for extension | Yes | Yes | No | | Compiler exhaustiveness | No | No | Yes | | Idiomatic Zig choice | Often | When needed | Very common |


Practical Guidance

Use Compile-Time When:- Performance matters - Types known at compile time - No mixed collections needed

Use Tagged Union When:- Finite set of variants - AST nodes - Game entity types - Parsers

Use Runtime Interface When:- Need plugin-style extensibility - Types not known in advance - Allocators, writers, loggers


Final Takeaway

Zig gives you three tools: 1. Compile-time polymorphism (fastest) 2. Tagged unions (most idiomatic for closed sets) 3. Runtime interfaces (most flexible)

In real Zig projects: - AST - tagged union - Allocator - runtime interface - Math utilities - compile-time - Game entities - tagged union

Mastering when to choose each is a core Zig skill.