Comptime, flow, and exhaustiveness
2024-12-17
Busy working on the ADC lately, but I just happened upon this kind-of-follow-up to the non-intrusive vtable.
I have methods implemented by only some “subtypes”, and usually “do nothing” is
the correct default, or perhaps “return null
”. They end up looking like this:
pub fn handleMouseDown(
self: Control,
b: SDL.MouseButton,
clicks: u8,
cm: bool,
) Allocator.Error!?Control {
switch (self) {
inline else => |c| if (@hasDecl(@TypeOf(c.*), "handleMouseDown")) {
return c.handleMouseDown(b, clicks, cm);
},
}
return null;
}
fn handleMouseDrag(self: Control, b: SDL.MouseButton) !void {
switch (self) {
inline else => |c| if (@hasDecl(@TypeOf(c.*), "handleMouseDrag")) {
try c.handleMouseDrag(b);
},
}
}
’tis a fine barn, but sure ’tis no pool.
What I would keep encountering was that I’d write an implementation for a
“““““subtype”””””, and then run the program and wonder why the behaviour seemed
unchanged. It’s amazing how many times you can encounter the exact same problem
— in this case, a missing pub
qualifier on the implementations, meaning
they’re invisible to other files.
What if we made the default behaviour opt-in, instead of implicit? Here’s an example:
fn parent(self: Control) ?Control {
switch (self) {
inline else => |c| if (@hasDecl(@TypeOf(c.*), "parent")) {
return c.parent();
} else if (@hasField(@TypeOf(c.*), "orphan") and c.orphan) {
return null;
},
}
}
We check if there’s a parent
decl, and call it if so. If not, we check for an
orphan
field, and if it exists and is true, do our default action. Note that
we don’t assert this as the only other alternative. Let’s see what happens if
we compile an existing control that doesn’t supply either:
src/Imtui.zig:63:30: error: function with non-void return type '?Imtui.Control' implicitly returns
fn parent(self: Control) ?Control {
^~~~~~~~
src/Imtui.zig:71:5: note: control flow reaches end of body here
}
^
referenced by:
focus__anon_8053: src/Imtui.zig:352:35
accelerate: src/controls/DialogButton.zig:67:29
The function implicitly returns! Both conditions evaluate to false at comptime,
so the body of the method ends up being totally empty. (Alternatively, if
you use return switch
, you’ll see a message about error: expected type 'whatever', found 'void'
.)
It’s not very helpful, because the reference trace refers to the point at which this function gets called. We don’t actually know which is the missing implementation, just that it exists.
But that’s okay, we can add that ourselves!
fn parent(self: Control) ?Control {
switch (self) {
inline else => |c| if (@hasDecl(@TypeOf(c.*), "parent")) {
return c.parent();
} else if (@hasField(@TypeOf(c.*), "orphan") and c.orphan) {
return null;
} else {
@compileError(@typeName(@TypeOf(c.*)) ++ " doesn't implement parent or set orphan");
},
}
}
Ja nii:
src/Imtui.zig:70:17: error: controls.Dialog.Impl doesn't implement parent or set orphan
@compileError(@typeName(@TypeOf(c.*)) ++ " doesn't implement parent or set orphan");
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
referenced by:
focus__anon_8053: src/Imtui.zig:354:35
accelerate: src/controls/DialogButton.zig:67:29
Finally, note how a field used this way must be: comptime
!
comptime orphan: bool = true,
If not, its value won’t be available at comptime, and codegen will need to
produce a runtime condition for the c.orphan
check, meaning the possibility of
false is always entertained and the @compileError
will fire.
Things to consider:
- It might be worth asserting in the first branch that
orphan
isn’t set to true, to avoid any confusion about behaviour when both are set. - We only got the exhaustiveness thing because this example returns a value.
With
void
returns, the@compileError
isn’t optional if you want to know if you forgot. - Did you buy the graphite tube?
- Try
comptime opaque: void
to avoid the need for@hasDecl()
and the boolean check! Does it work? Almost like little tags, attributes, hmmmmmm.