Use case for tuples?

SubTuple does not work if low is greater than 0, because then the resulting type doesn’t have field names starting with 0 and thus isn’t a valid tuple, you need to reindex the fields similar to what extract does:

fn SubTuple(comptime T: type, comptime low: usize, comptime high: usize) type {
    const info = @typeInfo(T);
    const old_fields = std.meta.fields(T)[low..high];
    var new_fields: [old_fields.len]std.builtin.Type.StructField = undefined;
    for (old_fields, 0..) |old, i| {
        new_fields[i] = .{
            .name = std.fmt.comptimePrint("{d}", .{i}),
            .type = old.type,
            .default_value = old.default_value,
            .alignment = old.alignment,
            .is_comptime = old.is_comptime,
        };
    }
    return @Type(.{
        .Struct = .{
            .layout = info.Struct.layout,
            .fields = &new_fields,
            .decls = &.{},
            .is_tuple = true,
        },
    });
}

Now extract works and we can do something fun creating tuples, using tuples for multiple return values, resulting in this function:

fn Split(comptime T: type, comptime pivot: usize) type {
    const fields = std.meta.fields(T);
    return std.meta.Tuple(&[_]type{
        SubTuple(T, 0, pivot),
        SubTuple(T, pivot, fields.len),
    });
}

fn split(tuple: anytype, comptime pivot: usize) Split(@TypeOf(tuple), pivot) {
    const fields = std.meta.fields(@TypeOf(tuple));
    return .{
        extract(tuple, 0, pivot),
        extract(tuple, pivot, fields.len),
    };
}

Which means you can write Par like this:

/// combine two nodes in parallel
pub fn Par(comptime A: type, comptime B: type) type {
    return struct {
        pub const Input = A.Input ++ B.Input;
        pub const Output = A.Output ++ B.Output;
        a: A = A{},
        b: B = B{},

        pub inline fn eval(self: *@This(), input: Tuple(&Input)) Tuple(&Output) {
            const input_a, const input_b = split(input, A.Input.len);
            return self.a.eval(input_a) ++ self.b.eval(input_b);
        }
    };
}

I understand @AndrewCodeDev’s concerns and I think they are valuable to keep in mind and explore in detail, for example it would make sense to compare the resulting code with and without the inline, in terms of performance, code size and actual assembly.

However, this seems like a good use of tuples for me, you essentially found a way to build computation graphs at comptime, the types of things like pub const Input = A.Input ++ B.Input; are still easy to understand and predict and you avoid having to manually type out a lot of boring code, by being able to build primitives and combine them together. Reminds me a bit of parser combinators, probably also has similar pros and cons to those.

Another thing, I think this is actually quite good for another reason, if you build up a bunch of combinations and you notice that some deep tree of combinations has some performance problem, than you can use @TypeOf on that tree to find out the exact input and output type and just write a manual function that does everything in that subtree in one hand crafted function, allowing you to optimize on demand.

2 Likes