I’m attempting to set up a large operation graph at compile time to later be executed at runtime. The goal is to statically embed this graph into the executable, however, I’ve encountered challenges with Zig’s comptime restrictions, particularly around mutable memory and pointer constness, which complicate creating a mutable runtime state.
While there are other ways of solving the problem and trying to do this at comptime may be considered abusive, but I am curious if it is possible… So is there a Zig-idiomatic way to achieve mutable runtime states from comptime configurations, or any advice on managing mutable memory initialized at comptime for runtime use?
I am led to believe the answer is no based on my limited understanding as well as the following posts:
- Comptime-Mutable Memory Changes
- Comptime Garbage Collection - #3 by mlugg
- Container-level comptime vars - #8 by mlugg
Here is some code that sketches out the rough concept, this is the closest I’ve gotten (the only thing I think I achieved is working around an alpha-stage compiler). This will yield a bus error.
const std = @import("std");
const Value = struct {
data: i32,
};
pub fn Operation(comptime T: type) type {
return struct {
const Self = @This();
runFn: *const fn (a: T, b: T) T,
inputs: [2]*const T,
output: *T,
pub fn run(self: Self) void {
self.output.* = self.runFn(self.inputs[0].*, self.inputs[1].*);
}
};
}
pub fn Add(comptime T: type) type {
return struct {
const Self = @This();
// cannot pass a reference to a mutable output container type at comptime
// but, why cant output be an address to some memory baked in the executable that is mutable at runtime?
pub fn init(a: *const T, b: *const T) Operation(T) {
return Operation(T){
.runFn = &run,
.inputs = .{ a, b },
.output = @constCast(&std.mem.zeroes(T)), // try to make an empty container as a workaround
};
}
pub fn run(a: T, b: T) T {
return T{ .data = a.data + b.data };
}
};
}
pub fn OperationGraph(comptime T: type, comptime n: u32) type {
return struct {
const Self = @This();
operations: [n]Operation(T),
pub fn execute(self: Self) void {
for (self.operations) |op| {
op.run();
}
}
};
}
pub fn main() void {
// build the op graph at compile time, making space for inputs and outputs
const input1 = Value{ .data = 1 };
const input2 = Value{ .data = 2 };
const GraphT = OperationGraph(Value, 2);
const addOp = comptime Add(Value);
const op1 = comptime addOp.init(&input1, &input2);
const op2 = comptime addOp.init(&input1, op1.output); // using reference to some op output
const g = comptime &GraphT{
.operations = .{op1, op2},
};
// execute at runtime, mutating existing memory
g.execute();
std.debug.print("{}", .{op2.output});
}