Use case for tuples?

Hello there! After experimenting a bit with the language through my noize project, I arrived to a point where using tuples as “heterogenous arrays” is really convenient (combining eval functions that take a tuple of inputs and return a tuple of outputs). I find myself doing the following a lot (checked boxes mean “there is a simple way to do it with current language syntax”):

  • accessing by index: myTuple[idx]
  • concatenating tuples to build a bigger one: tup1 ++ tup2
  • copying content: @memcpy does not support tuples, need a helper function with an inline for to iterate over fields
  • extracting a part of a tuple: myTuple[start..end] is not supported, need extra helper function to build the type of the subtuple and then copy the content

Does it make sense to use tuples that way? Should the language evolve a bit to better support this use case, basically mirroring the array/slice API, turning tuples into the missing link between arrays and structs?

In zig 12.x or latter, The destructuring syntax is supported.

Retuining tuple type from method led to simulate return-multiple-value feture.

Are you using these checkboxes to indicate things working/not working?
(I was a bit surprised by these checkboxes and accidentially toggled it off and back again, in case you wonder about the edit, I didn’t know this checkbox feature)

Do you mean copying where the element type is a tuple with @memcpy does not work?
I think it actually does work, it just isn’t useful because the tuple literal is a comptime type which is a zero size type at runtime, thus resulting in a noop:

const std = @import("std");

pub fn show(elem: anytype) void {
    if (@TypeOf(elem) == u32) {
        std.debug.print("{} ", .{elem});
    } else {
        std.debug.print("{s} ", .{elem});
    }
}

pub fn printArray(array_ptr: anytype) void {
    for (array_ptr.*) |tuple| {
        inline for (tuple) |e| show(e);
        std.debug.print("| ", .{});
    }
    std.debug.print("\n", .{});
}

pub fn main() !void {
    const a = .{ @as(u32, 5), @as([]const u8, "hello") };
    const b = .{ @as(u32, 42), @as([]const u8, "world") };

    const TupleA = @TypeOf(a);
    const TupleB = @TypeOf(b);

    std.debug.print("TupleA {} size {}\n", .{ TupleA, @sizeOf(TupleA) });
    std.debug.print("TupleB {} size {}\n", .{ TupleB, @sizeOf(TupleB) });
    std.debug.print("TupleA == TupleB {}\n", .{TupleA == TupleB});

    var array = [_]TupleA{ a, b }; // maybe this should be an error passing TupleB to an array of TupleA's similar to the error below?
    printArray(&array);

    // Technically you can, but this does nothing?! (because it is a zero sized type)
    @memcpy(@as([]TupleA, &array)[0..1], @as([]TupleA, &array)[1..2]);
    printArray(&array);

    printArray(@as(*[2]TupleB, &array));

    // const array_err = [_]TupleA{ a, .{ @as(u32, 5), @as([]const u8, "world") } };
    // printArray(&array_err);
    // this gives us an error
    // error: value stored in comptime field does not match the default value of the field
}

Output:

TupleA struct{comptime u32 = 5, comptime []const u8 = "hello"} size 0
TupleB struct{comptime u32 = 42, comptime []const u8 = "world"} size 0
TupleA == TupleB false
5 hello | 5 hello | 
5 hello | 5 hello | 
42 world | 42 world |

I don’t quite understand why we sometimes can use TupleB where a TupleA is expected without @ptrCast, whether these are missing type checks / a bug in the compiler or there is something special about zero sized things.

You can look at std.ChildProcess.run method, which shows a great way of using tuples

first std.ChildProcess.run signature is

fn run(args: struct {
    allocator: mem.Allocator,
    argv: []const []const u8,
    cwd: ?[]const u8 = null,
    cwd_dir: ?fs.Dir = null,
    env_map: ?*const EnvMap = null,
    max_output_bytes: usize = 50 * 1024,
    expand_arg0: Arg0Expand = .no_expand,
}) RunError!RunResult

then typical usage is

std.ChildProcess.run(.{
    .allocator = allocator,
    .argv = &[_][]const u8 {"uname", "-a"},
});

So essentially using anonymous & tuple together, we can

  1. given function args default values and names when define functions
  2. pasing keyword based params when calling it (very similar to what I like in python, but zig is native!)

Isn’t this just an anonymous struct (for some reason, why not just define a type)?
Tuples are structs without named fields, like struct {i32, i32} with literals like .{10, 20}.

If what I’m assuming is correct, I would strongly advocate for rethinking your design.

I’ve worked around systems that try to make heavy use of tuples (a type that doesn’t require one to explicitly name fields) and they’ve always turned out to be a mess later on… especially if you’re using them for struct replacement.

You have to do a lot of type-forensics to understand what a tuple is and what it has… this is fine at compile time but can be severely restricting at runtime.

Since this is a brainstorming post, I’d like to know a few things:

  • What makes using tuples appealing in this case?

  • What hurdles are you facing if you try to move away from tuples?

  • Can you share code examples so we can see what you’re trying to achieve?

I use tuples a lot, but they have defined uses and expectations surrounding them. For instance, in one system I am working on, I know that tuples are always linked to a function that uses them. Because of that, the tuple is just a way to delay execution of functions by storing their arguments. So there’s always a restriction in my case - they have to mirror arguments to explicitly defined functions.

4 Likes

(checked boxes meant “zig has syntax support to do that easily”, clarified in my post)

Sorry for the delay, here is an example of how I use those tuples.

I have “nodes” defined by a set of inputs, a set of outputs and an eval function inputs -> outputs (sometimes there is also an internal state but let’s leave that out for now).

I have a set of basic nodes, and a some operators that allow me to combine nodes in various dispositions (sequence, parallel, fan in, fan out, recursive …).

Here I defined two nodes : Add adds a fixed value to its input, and Par joins two nodes in parallel.

const std = @import("std");
const Tuple = std.meta.Tuple;

pub fn Add(comptime T: type, comptime add: T) type {
    return struct {
        pub const Input = [1]type{T};
        pub const Output = [1]type{T};

        fn eval(self: *@This(), input: Tuple(&Input)) Tuple(&Output) {
            _ = self;
            return .{input[0] + add};
        }
    };
}

/// 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) {
            var input_a: Tuple(&A.Input) = undefined;
            var input_b: Tuple(&B.Input) = undefined;
            inline for (input, 0..) |v, i| {
                if (i < input_a.len) {
                    input_a[i] = v;
                } else {
                    input_b[i - input_a.len] = v;
                }
            }
            return self.a.eval(input_a) ++ self.b.eval(input_b);
        }
    };
}

pub fn main() void {
    const T = Par(
        Add(u8, 1),
        Add(f32, 10),
    );
    var t = T{};
    const output = t.eval(.{ 0, 0 });
    std.debug.print("output: {}\n", .{output});
}

To me it makes total sense to use tuples here, because:

  • each node has arbitrary and heterogenous inputs and outputs
  • operators derive their own inputs and outputs from the inputs and outputs of combined nodes

Par.eval is effectively splitting the input tuple into two, in order to feed Par.a.eval and Par.b.eval. Here the code is rather trivial, but with more complicated operators it can quickly turn into error prone code.

This is where I think it would be nice to be able to do something like this:

        pub inline fn eval(self: *@This(), input: Tuple(&Input)) Tuple(&Output) {
            return self.a.eval(input[0..A.Input.len]) ++ self.b.eval(input[A.Input.len..]);
        }

Recently I’ve tried to define helper functions to extract a subpart of a tuple:

fn SubTuple(comptime T: type, comptime low: usize, comptime high: usize) type {
    const info = @typeInfo(T);
    const fields = std.meta.fields(T)[low..high];
    return @Type(.{
        .Struct = .{
            .layout = info.Struct.layout,
            .fields = fields,
            .decls = info.Struct.decls,
            .is_tuple = true,
        },
    });
}

fn extract(tuple: anytype, comptime low: usize, comptime high: usize) SubTuple(@TypeOf(tuple), low, high) {
    var out: SubTuple(@TypeOf(tuple), low, high) = undefined;
    inline for (low..high, 0..) |i, o| {
        out[o] = tuple[i];
    }
    return out;
}

EDIT: as for the design, one would think that all this could be a tree of function calls, but that approach fails as soon as you need to define a loop somewhere. This is what I find appealing in Faust’s approach.

2 Likes

I’m not the type to discourage language exploration, so I understand the appeal and the use of your idea. You’re basically building a set of tuple utilities that combine and slice tuples. That’s cool - and I encourage you to explore the idea more :slight_smile:

Going back to what I was referring to before, I use aligned byte buffers to store tuple data and callbacks that know how to cast back to the tuple type in question - that way you can store them in a homogenous array and use them later. For my use case, that works totally fine. That said, it’s still compile-time heavy.

When I’m referring to a use case, what I mean is what you’re attempting to use this for - a larger system where the use case isn’t about tuples, but where tuples are used to solve some other problem. What you’ve posted here is tuple-based utilities. That a self-fulfilling requirement though - the use case itself is tuples.

I think what we’re looking for is a use case that’s closer to system that has use outside of the context of utilities. Can you give us an example of an application where this would be a fundamental part of the backend that you think would not be solved better by types with explicitly named fields?

1 Like

The reason I’m bringing this up is due to functions like the following…

We have an inline function that has an inline loop, two additional calls at the return, and also does a type concatenation. In a large application, with all this inlined code, you could have a massive expansion in the output binary size (and inlining doesn’t always effect performance positively).

Meanwhile, we’re also creating many nameless types that use pointers to static data (Tuple(&Input) for example). So this is a lot of machinery to achieve tuple combinations at compile time. I’m trying to imagine where I would reach for something like this because the benefits outweigh the costs.

I’m not saying you’re wrong or that you aren’t onto something here, I would just like to hear more about what situation you’re trying to avoid via a practical example.

yes you are right. my mistake :slight_smile: that’s anony struct.

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

Computation graphs is one possibility - back tracking tends to have a lot of delayed calls that represent derivatives, so that’s pretty cool use case. @Sze (if it’s not too off topic) what are some of the pros and cons for the parser combinators you’re referring to as it relates to this?

From what I have heard they often struggle with giving good error messages, also you end up creating a dsl that may reinvent things that already exist in the host language in a similar or different way, creating multiple ways to do things.

I think it is going towards being off topic, but still somewhat related to making heavy use of some construct to build up something dsl like, so I will put my answer in a “Hide Details” block.

dsl like code

Essentially you end up building something that starts to look like its own language, eventually you write combinations of things that aren’t optimal, but convenient to write, because there are existing combinators for it, then eventually you begin wondering, what if I wrote some code that inspects subtrees/subparts of combinators and finds ways to change it, towards different or more specialized combinators, to end up with better performance, without the user needing to know, how to apply these more arcane looking specialized combinators (that also may make building the combination of it more complex).

Basically you potentially end up with writing a term rewriting compiler inside a dsl, in racket there is a project, that created a macro for flow graphs and they have gone down that route, personally I think it is probably better to keep that out of the direct implementation (make it an optional step), so that the combinators still can be used like a thing that has simple direct mechanistic semantics without unexpected cleverness which might have subtle semantic changes if there is a bug in the rewriting.

Now with error messages, you likely would need to be able to capture the nesting context of the different combinators, to print good error messages (at least in runtime error cases).

I think one way to avoid needing this might be to carefully limit what the combinators are allowed to do, but even with this, I think some divide by zero within a deeply nested thing, where there are many different uses of a thing, that divides by some dynamic input, could be difficult to find.

An alternative might be to require any building block that has some possibility to fail in some way to have an explicit unique label/name so you can use that to quickly find the problem.

1 Like

Hey @AndrewCodeDev, @Sze, thanks for your precious insights!

Honestly I’m not sure I can give an example :slight_smile:

I just found myself in a situation where I started using tuples as one would use arrays, because they offered me the possibility to have different types at different indexes while providing a good part of the “indexable API” (access by index, len, iterate over) to make my life easier.

I found it really convenient and wondered if such a use case would be common enough to push for the addition of the range operator on tuples, turning them into some kind of polymorphic arrays.

As for the inlines, I haven’t experimented enough to see if they make a big difference. The idea was that when eval(input) is called on the root node, eval gets called on each node of the tree - so I thought maybe inlining eval functions would turn the whole tree in one big function call, but I’m already out of my depth here!

@Sze thanks for pointing out my error with SubTuple ! About the computation graph approach, credit goes to the FAUST team really, such an interesting language.

2 Likes