Are Tuples Structs?

I kinda think they aren’t! I think they’re a clean different thing, which is overloading the struct keyword, and maybe that part should change.

  • A file is “just a struct”
    • But a file can’t be a tuple
      • But it can’t be an extern or packed either
      • But tuples don’t take a modifier!
  • We have @Struct coming up, do we use it to make a tuple?
    • Nope! We use @Tuple. So why is its @typeInfo a .@"struct"? Of type Struct?
  • Tuples are structurally typed, every .{ bool, bool } is the same type.
    • Structs are very much not structurally typed, Foo{ .a: bool, .b: bool } is not at all a Bar{ .a: bool, .b: bool }.
  • We can destructure a tuple, but not a struct, them we just construct.
  • Tuples have a .len “field”, structs… I mean they can but, mostly, no.

OK, but we still need to define tuple types, somehow.

If not with struct, how? Keywords are expensive.

How about:

fn tupleator() .{ []const u8, []const u8 } {
    return .{ "why", "not?" };
}

This is not ungrammatical, but currently gives the error:

error: type ‘type’ does not support array initialization syntax

Well.

What if it did?

9 Likes

That might be too galaxy-brained, “a tuple of types defines the type of a tuple of those types”. IDK maybe it’s perfect, but to illustrate my point:

const WhatAmI = .{ bool, bool };
// Currently: 
// @TypeOf(WhatAmI) == struct { bool, bool };
// 
// Becomes:
// @TypeOf(WhatAmI) == type;

Is this the good kind of punning? Do we ever need a tuple of types which isn’t a tuple type? Problem is we can make a tuple of anything now, so, losing that is probably bad. I think just handwaving it off and letting a type which is also a tuple do “tuple business” like .@"0" and .len is uh. That sounds, irregular.

But!

@Tuple(.{ bool, bool})

That creates a tuple already on master branch[1]. This is how we make @Vectors, why not tuples?

At that point why not just @Tuple(bool, bool) though. We have @TypeOf and @compileLog as variadics already, there’s precedent.


  1. @Tuple(&.{bool, bool}), I believe. Surely we can drop the & though… ↩︎

I’m pretty sure the reason for not making @Tuple accepting variadic arguments is because you can’t construct variadic arguments dynamically, which throws out the value proposition of a function call instead of just writing struct {bool, bool}.

That is a credible reason not to ditch the inner .{}, good point.

Extremely cursed counterpoint:

@call(.auto, @Tuple, dynamic_tuple_types);

@call cannot invoke builtin functions

Oh ye of little faith

Oh, I completely forgot one of the most compelling arguments: tuples are not containers.

const Nope = struct {
    bool,
    bool,

    pub fn uh_no(no: Nope) bool {
        return no.@"0";
    }
};

Gives:

error: tuple declarations cannot contain declarations

Not much of a struct!

1 Like

I wouldn’t mind having them completely separated from structs, by introducing the keyword tuple.

const Tuple = tuple { bool, u8 };
const Struct = struct { boolean: bool, byte: u8 };
9 Likes

That would be acceptable, sure. An obvious choice. But they’re weird, tuples. They’re not as much of a type as a control primitive imho, especially for duck-looking comptime mojo and multiple return values.

I think “you make a tuple with .{ true, false } and a tuple type with @Tuple(bool, bool) or @Tuple(.{ bool, bool }) or (under duress) @Tuple(&.{ bool, bool })” is fairly credible as an alternative, one which doesn’t create a whole type category. For that matter I kind of think keyword { ... } should mean a container or scope, which it mostly does.

It’s how we do vectors already and that works out fine. Since you can’t put declarations in there, tuple { bool, bool } and @Tuple(.{ bool, bool}) have identical expressive power, except for when building up types at comptime, where you need @Tuple anyhow.

2 Likes

I suspected that type equality is also different, and it is:

pub fn main() void {
    const S1 = struct { x: usize };
    const S2 = struct { x: usize };
    const T1 = struct { usize };
    const T2 = struct { usize };
    std.debug.print("struct types are equal: {}\n", .{S1 == S2});
    std.debug.print("tuple types are equal: {}\n", .{T1 == T2});
}

output:
struct types are equal: false
tuple types are equal: true

This lack of distinctness is a nice feature of tuples, I think, but is another reason that the struct keyword may cause confusion.

P.S. Note that this post doesn’t really add anything beyond the OP, since the OP does say that tuples are structural types and structs are not:

6 Likes

Main point of tuples is for function to return two arguments, so you can unpack them at the call site.

Conversely it means the most common place to find tuple types is in function signatures. So a shorthand syntax in that context would help making function signature to be more readable.

7 Likes

I very much think of regular structs as just tuples with syntactic sugar (as far as the language not the implementation is concerned).

The only difference is that instead of indexes into it, you have names.

So imo struct { x: i32, y: i32 } is the same as struct{ i32, i32 }, with the minor difference that the first one is nicer to use because you have names (which are hopefully at least half-self-descriptive) instead of 0 and 1.

Do we actually need tuples? What purpose do they serve that couldn’t been accomplished with anonymous struct literals?

Having destructuring is not compelling to me. I think I have personally used destructuring exactly once, and only because it was a std lib api.

Edit: I guess you need them for @call.

I will say that tuples are one of the last things I have learned about zig, and they were just confusing, maybe I will find a cool use for them eventually and I’m not galaxy brained enough.

I think it also comes from zigs functions using positional args. In python I typically always use named arguments syntax when calling a function (foo(my_param=1)) because its more refactoring proof and easier to know what arguments are doing a the callsite.

2 Likes

There are a bunch of other differences listed in the OP, and type distinctness is another one.

1 Like

I think it kind of makes sense that the tuple type would be just the tuple of the element’s types.

With @TypeOf(.{ true, false }), the result would be .{ bool, bool } and if you apply @TypeOf again type or should it be .{ type, type }?.

With struct-tuples you immediately get @TypeOf(@TypeOf(.{ true, false })) == type

That brings up a question for me, when/how/why should the result eventually become type? Basically what are the intermediary types from an arbitrary tuple-instance to the type-root?

As far as I can tell @TypeOf(@TypeOf(...)) == type holds for everything you can put in @TypeOf, or expressed in another way, I think Zig doesn’t have higher kinded types.

With something like:

const t = .{ lst, true, [5]u32 };
std.debug.print("t: {}\n", .{t});
std.debug.print("@TypeOf(t): {}\n", .{@TypeOf(t)});
std.debug.print("@TypeOf(@TypeOf(t)): {}\n", .{@TypeOf(@TypeOf(t))});
std.debug.print("\n", .{});

you get:

t: .{ .{ .items = {  }, .capacity = 0 }, true, [5]u32 }
@TypeOf(t): struct { comptime array_list.Aligned(u32,null) = .{ .items = &.{}[0..0], .capacity = 0 }, comptime bool = true, comptime type = [5]u32 }
@TypeOf(@TypeOf(t)): type

The first is a tuple-instance, the second is a tuple-type, which then just is of general type.

Translated I guess this would have to become something like this?:

t: .{ .{ .items = {  }, .capacity = 0 }, true, [5]u32 }
@TypeOf(t): .{ comptime array_list.Aligned(u32,null) = .{ .items = &.{}[0..0], .capacity = 0 }, comptime bool = true, comptime type = [5]u32 }
@TypeOf(@TypeOf(t)): type

I think the comptime fields basically serve as a way to distinguish between tuple-instances and tuple-types, so maybe the comptime fields would become something that is only for tuples?

I think without a way to clearly distinguish between types and instances comptime might get too confusing?

Currently you can build a tuple that contains types at comptime, but this tuple can’t be used as a type directly you have to pass it to std.meta.Tuple first (or @Tuple).
I think there might be some edge-cases that are quite complicated.

2 Likes

but probably the first thing you used, by passing a tuple to a print function :slight_smile:

13 Likes

Tuples allow some wild stuff, for example I have

pub fn insertTextual(ps: *PaddedHlString, comptime T: type, ix: usize, args: anytype) !void {
    if (ix > ps.chunks.items.len) {
        return error.IndexOutOfBounds;
    }
    var value: T = args[0];

    switch (T) {
        t.TextChunk => {
            value.visible = if (args.len > 1) args[1] else true;
            try ps.chunks.insert(ps.alc, ix, .{ .text = value });
        },

        []const u8,
        []const t.HlChar,
        t.HlString,
        t.Chars,
        => {
            const hl: t.Highlight = if (args.len > 1) args[1] else .normal;
            const chunk = try t.Chunk.init(ps.alc, T, .{ value, hl });
            try ps.chunks.insert(ps.alc, ix, chunk);
        },

        else => @compileError("invalid type: " ++ @typeName(T)),
    }
}

Not saying it’s good code, I will probably refactor it to make it less ‘guess what happens there’, but right now this can be called with different kinds of types in the args tuple (which is some kind of variadic argument), depending on the main type T.

So basically you can have args: anytype which is a tuple of some kind (even different kinds, then you switch on their type), then implement the logic depending on what you expect to be in there.

Again, not saying it’s good practice, just that it’s possible.

1 Like

As somebody else already said: passing the arguments to a formatting function (like a print function).

In general you want tuples when you want to have a function which receives variadic arguments and type safety at the same time. Formatting functions are just the (probably) best known example for that.

1 Like

For me, the conciseness brought by destructuring is very important, as it can indirectly reduce dangling pointers.

If a function returns a structure that contains pointer members, accessing the members through the structure is always somewhat cumbersome. From an ergonomic perspective, many people create a new symbol for the specific member returned by the structure. Doing this increases the risk of using dangling pointers.

If we obtain pointers through a tuple structure in multiple return values, no one would want to add an extra reference for a flat symbol, which in turn reduces the risk.

2 Likes
const std = @import("std");

pub fn main() !void {
    std.debug.print("Hello {[name]s}\n", .{.name = "jeff"});
}

Tuples are not strictly required for formatting APIs.