Index


Manual Initialization Order in Zig

I want to isolate subsystems in my code to, hopefully, make sure changes to one subsystem won't break things elsewhere. In Zig, the straightforward approach is to move the systems out into their own modules:

const App = @import("App.zig");
const Input = @import("Input.zig");
const Render = @import("Render.zig");

pub fn main() void {
    App.init();
    Input.init();
    Render.init();
}

But, inevitably, there will be cross-cutting concerns. Some subsystems will use others so - by definition - modifying a dependency might break something in the dependent. Then Suddenly the dependents need to ensure their dependencies have valid state to avoid runtime errors. In Zig we can

Initialization Order

The most obvious cross-cutting concern in the example above is initializing the modules correctly. Some constraints:

I could fix these by just being careful: list the modules in the right order and don't forget any transitive dependencies. But, if I do it wrong, any errors will be runtime errors. It is safer to resolve these constraints programmatically, in comptime.

I'm aiming for some usage like:

const App = @import("App.zig");

const modules = resolve(.{App});

pub fn main() void {
    inline for (modules) |module|
        module.init();
}

Ultimately, this is a task scheduling problem: initialize every module after all its dependencies. This is Topological Sort.

Resolving Dependencies

The easiest algorithm for Topological Sort is straightforward: it is Depth First Search over the dependency graph with a check to detect cycles.

fn visit(output, node) {
    if node is "done"
        return

    if node is "marked"
        error. cycle detected

    "mark" the node

    for each dependency of the node
        visit(dependency)

    append the node to result
}

fn resolve(graph) {
    for each node in the graph
        visit(node)

    return result
}

The first step to make this possible is to list each module's dependencies. I'll replace the @import structs with simple struct {} for brevity:

const Input = struct {
    pub const depends = .{Window};
};

const Render = struct {
    pub const depends = .{Window};
};

const App = struct {
    pub const depends = .{Render, Input};
};

const Window = struct {
    pub const depends = .{};
};

To start, let's skip cycle detection; just write plain Depth First Search on the dependencies. A slice of modules has type []const type. Since the parameter list includes type (or anytype), we don't need explicit comptime var.

fn visit(result: *[]const type, node: type) void {
    if (std.mem.indexOfScalar(type, result, node) != null)
        return;

    inline for (node.depends) |dep|
        visit(result, dep);

    result.* = result.* ++ .{node};
}

fn resolve(roots: anytype) []const type {
    var result: []const type = &.{};

    inline for (roots) |root|
        visit(&result, root);

    return result;
}

We can use the same pattern for cycle detection. Mark nodes by appending them to a slice and check with std.mem.indexOfScalar.

fn visit(
    result: *[]const type,
    marked: *[]const type,
    node: type,
) void {
    if (std.mem.indexOfScalar(type, result, node) != null)
        return;

    if (std.mem.indexOfScalar(type, markde, node) != null)
        @compileError("Cyclic Dependency");

    marked.* = marked.* ++ .{node};

    inline for (node.depends) |dep|
        visit(result, marked, dep);

    result.* = result.* ++ .{node};
}

fn resolve(roots: anytype) []const type {
    var result: []const type = &.{};
    var marked: []const type = &.{};

    inline for (roots) |root|
        visit(&result, &marked, root);

    return result;
}

test {
    const A = struct { const depends = .{}; };
    const B = struct { const depends = .{A}; };
    const C = struct { const depends = .{B, A}; };

    const sorted = resolve(&.{C});
    const expected = &.{A, B, C};
    try std.testing.expect(comptime std.mem.eql(type, sorted, expected));
}

Initialization

We can use inline for to invoke init on each module in the computed order. Using a std.mem.reverseIterator we can even invoke deinit in reversed order! Just make sure to use comptime var for the iterator.

const modules: []const type = resolve(.{App});

pub fn main() void {
    inline for (modules) |module|
        module.init();

    defer {
        comptime var rev = std.mem.reverseIterator(modules);
        inline while(rev.next()) |module|
            module.deinit();
    };
}
debug: Window init
debug: Render init
debug: Input init
debug: App init
debug: App deinit
debug: Input deinit
debug: Render deinit
debug: Window deinit

Now, when writing App, I'm guaranteed that Render was already initialized. I can freely call the Render API from App.init and App.deinit.

// App.zig

const Render = @import("Render.zig");

pub const depends = .{
    Render,
};

var cube: Render.Mesh = undefined;

pub fn init() void {
    cube = Render.Cube.create(.{});
}

pub fn deinit() void {
    cube.destroy();
}

Safely adding dependencies is now trivial:

  const Render = @import("Render.zig");
+ const Input = @import("Input.zig");

  pub const depends = .{
      Render,
+     Input,
  };

Generalized Hooks

Let's revisit main.

const modules: []const type = resolve(.{App});

pub fn main() void {
    inline for (modules) |module|
        module.init();

    defer {
        comptime var rev = std.mem.reverseIterator(modules);
        inline while(rev.next()) |module|
            module.deinit();
    };
}

Those loops, and especially the defer block, are a bit messy. I'll create helper functions invoke and rinvoke to clean this up. I'll also check @hasDecl and skip any trivial modules that don't have init or deinit.

fn invoke(
    name: comptime: []const u8,
    args: anytype,
) void {
    inline for(modules) |module|
        if (@hasDecl(module, name))
            @call(.auto, @field(module, name), args);
} 

fn rinvoke(
    name: comptime: []const u8,
    args: anytype,
) void {
    comptime var rev = std.mem.reverseIterator(modules);
    inline while(rev.next()) |module|
        if (@hasDecl(module, name))
            @call(.auto, @field(module, name), args);
} 

pub fn main() void {
    invoke("init", .{});
    defer rinvoke("deinit", .{});
}

Much nicer. This API is interesting, too: I can create arbitrary "hooks" to call code from any module that implements it. All this is resolved at comptime. I use hooks begin_frame and end_frame to let modules run code in the main loop, and to manage an arena allocator fast temporary data.

pub fn main() void {
    invoke("init", .{});
    defer rinvoke("deinit", .{});

    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const alloc = arena.allocator();

    while (!Window.should_close()) {
        alloc.reset();
        invoke("begin_frame", .{alloc});
        rinvoke("end_frame", .{alloc});
    }
}

Render.begin_frame sets up Vulkan command queues, and any modules that depend on Render (e.g. PBR, ImGui) can freely submit Vulkan commands in their begin_frame. Render.end_frame submits and presents those to the screen.

We could even make invoke and rinvoke public, so modules could declare their own hooks. Name collisions may be a problem, though. How can I ensure that no module misuses a hook declared in another? What if, in the general case, there's a hook that should be accessible by other modules? And in some sense, module.depends is also a kind of hook. What if we want to generalize that?

I'm curious about mechanisms to ensure uniqueness of hooks and avoid collisions, but I don't have a good solution yet. I want to avoid too much boilerplate about declaring hooks. I expect new hooks to be rare anyway. I'll just keep invoke and rinvoke public until I have a better sense on what real usage looks like.

Error Handling

One thing I have already encountered is: what if a hook returns an error? So far it's enough to implement try_invoke and try_rinvoke:

fn try_invoke(
    name: comptime: []const u8,
    args: anytype,
) !void {
    inline for(modules) |module|
        if (@hasDecl(module, name))
            try @call(.auto, @field(module, name), args);
} 

fn try_rinvoke(
    name: comptime: []const u8,
    args: anytype,
) !void {
    comptime var rev = std.mem.reverseIterator(modules);
    inline while(rev.next()) |module|
        if (@hasDecl(module, name))
            try @call(.auto, @field(module, name), args);
} 

There is a catch: if a hook returns an error, the loop breaks and no other hooks are called. There is no way to do error-handling only on the hooks already done. For example, init and deinit come in pairs; if some init returned an error there is no way to run deinit only on the hooks that succeeded.

I considered letting invoke return an array of error unions so every error could be handled explicitly, but properly handling those errors is not easy. For now I don't support error unions from hooks to avoid the issue. I may add abstractions here in the future after I know what errors I might want in practice.

All Together

main.zig

To put everything together, I moved Topological Sort and invoke functions into a new file Modules.zig. I kept modules in main.zig, but I renamed it root_modules.

const Modules = @import("Modules.zig");
const App = @import("App.zig");

pub const root_modules = .{App};

pub fn main() void {
    Modules.invoke("init", .{});
    defer Modules.rinvoke("deinit", .{});

    // main loop here
}

App.zig

Any dependencies I declare in pub depends are safe to call from init and deinit.

const Render = @import("Render.zig");
const Input = @import("Input.zig");

pub const depends = .{Render, Input};

var cube: Cube = undefined;

pub fn init() void {
    cube = Render.Cube.create(.{});
}

pub fn deinit() void {
    cube.destroy();
}

Modules.zig

Since I left root_modules in main.zig. I can access it with @import("root"), the same way that std_options works. This is also how I set some module options like initial window size, certain render pipeline settings, etc.

const App = @import("App.zig");
const root = @import("root");

pub const resolved = resolve(root.root_modules);

pub fn invoke(
    name: comptime: []const u8,
    args: anytype,
) void {
    inline for(resolved) |module|
        if (@hasDecl(module, name))
            @call(.auto, @field(module, name), args);
}

pub fn rinvoke(
    name: comptime: []const u8,
    args: anytype,
) void {
    comptime var rev = std.mem.reverseIterator(resolved);
    inline while(rev.next()) |module|
        if (@hasDecl(module, name))
            @call(.auto, @field(module, name), args);
} 

fn visit(
    result: *[]const type,
    marked: *[]const type,
    node: type,
) void {
    if (std.mem.indexOfScalar(type, result, node) != null)
        return;

    if (std.mem.indexOfScalar(type, markde, node) != null)
        @compileError("Cyclic Dependency");

    marked.* = marked.* ++ .{node};

    inline for (node.depends) |dep|
        visit(result, marked, dep);

    result.* = result.* ++ .{node};
}

fn resolve(roots: anytype) []const type {
    var result: []const type = &.{};
    var marked: []const type = &.{};

    inline for (roots) |root|
        visit(&result, &marked, root);

    return result;
}