What is algebraic dispatch?
Well, it's a simple design to provide (1) interface schema and (2) dispatch/dependency flexibility in Zig. As it turns out, these two components are sufficient to solve The Expression Problem in a static language.
First of all, we have the concept of an operation set. This is just a bag of functions (and possibly constants and types, as well). Depending on the language you're looking at, this might be called a .
const ArithmeticOps(T) = struct {
add: fn(T, T) T,
sub: fn(T, T) T,
mul: fn(T, T) T,
div: fn(T, T) T,
}
An operation set is an interface.
An instance of an interface is we'll call this a comptime dispatch table. Okay, that's all well and good, but doesn't interfaces.zig already provide this?
Yes, it does but such interfaces are clunky and inconsistent with Zig's closed-by-default duck-typed dispatch mechanisms.
This proposal is to re-use Zig's existing schema functionality (struct field definitions) and its comptime support to extend object flexibility in two dimensions:
- Provide a simple mechanism to make dispatch open
- Encourage the use of separate, documented interfaces that imply a certain set of semantics
const Vector4 = struct(T: type) {
contents: [4]T
} ++ ArithmeticOps(T) {
.add = fn(x: T, x: T) T { code... }
.sub = fn(x: T, x: T) T { code... }
.mul = fn(x: T, x: T) T { code... }
.div = fn(x: T, x: T) T { code... }
};
Syntax wrinkle: the parameters of struct(...) functions last until the end of the statement.
One of the key limitations of interfaces.zig is... what? That it's disconnected from zig's usual dispatch?
Well, interfaces.zig also fundamentally depends on duck-typing it seems. That's the real flaw! It's not open.
makeVTable is
Notice that this is less restrictive than Rust's traits since we have no orphan rule, and there's no trait solver to contend with.
Key Open: any T and duck-typing
How do I avoid the problem of namespace conflicts between a type's "anonymous" namespace and its named namespaces?
Ideally, I could have a way to say that I want just the interfaces. In theory this would be just changing T ++ ArithmeticOps(T) to ArithmeticOps(T) but this is WEIRD because it's possible to have the type of x completely unspecified:
const Interface = ArithmeticOps(u32);
// I feel so _uncomfy_ not knowing the type of `x`
fn foo(x: Interface) {
}
Wait, can we just re-use erased? Almost!
erased T ++ ArithmeticOps(any T) is a totally generic interface that works with any type. However, this is really ABI ambiguous (it suggests that I'm passing a dyn Trait or similar)...
We also have to deal with the fact this requires assembling temporary objects whose methods cannot be resolved. "anonymous" traits are really the worst :(
Wait, is there a problem?
When I define the type it's actually a <anonymous representation> ++ <anonymous comptime namespace>, so the comptime namespace is dropped.
The main challenge then, is providing a method to drop the anonymous namespace when I want to create a compatible x.
This could be done by a @rebind() built-in (which requires a complete list of interfaces).
var x = SomeStruct {
.x = 15
};
x = @rebind(x, .{M, N, O}); // This drops the "anonymous" object
Main problem: How do I rebind without dropping the anonymous methods? Maybe just add a parameter to support keeping them, provided that they do not conflict.
Yeah, that's reasonable, so the next step is elaborating the UFCS and translation patterns.
const MagicInt = struct { a: T, b: T, c: T, } ++ .{ .read = fn(x: T, y: T) T { // impl here }, .write = fn(x: *T, y: T) T { // impl here }, };
It would be nice to have a way to pass around objects with ambiguous methods and to provide a means to distinguish these at the call-site...
What if we allowed the scoping and the object-association to mix!?
So you have T ++ .{} ++ MagicOps(T), which by default only lets you access unambiguous methods.
To use:
usingnamespace @extract(x, MagicOps(T))
The problem is that usingnamespace is purely additive...