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:
App
must be initialized afterRender
andInput
.Render
andInput
must be initialized afterWindow
.- I never initialized
Window
. This produces a runtime error!
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;
}