Thoughts on tuples

Hello, after using rust for quite some time, I've realized that I missed some features related to tuples.

I've implemented some of the features I miss here, but there are few things that I wanted to do that probably need some new language features.

Below some pseudo code to illustrate what I still miss.

Ability to bind by pattern a subset of a tuple:

let (a, b @ .., c) = ("some", "tuple", "stuff", 1);

assert_eq!(b, ("tuple", "stuff"));

const loops/loop unrolling with tuples (not sure if it's the correct wording here):


let mut tupl = (1, 2, 3, 4);

const while let Some((head, tail)) = tupl.uncons_opt() {
    if do_something(head) {
        tupl = tail;
    } else {
        tupl = ();
    }
}

// which would be expanded to

let (a, b, c, d) = (1, 2, 3, 4);
if do_something(a) {
    if do_something(b) {
        if do_something(c) {
            do_something(d);
        }
    }
}

Constraints on tuple members


trait SomeTrait {}

struct Foo;

impl SomeTrait for Foo {}

struct Bar;

impl SomeTrait for Bar {}

struct Baz;

fn do_something(t: impl TupleOf<SomeTrait>) {}

do_something((Foo, Bar)); // compiles
do_something((Foo, Bar, Baz)); // compile error

With all of those combined I think that this would be mostly equivalent to variadic generics.

I was wondering if this is worth a discussion since I have not seen one (sorry if that's not the case, it slipped under my radar).

EDIT: I've added more examples here

6 Likes

Variadics have been discussed many times before, most recently in the context of A Mirror for Rust: Compile-Time Reflection Report - Shepherd's Oasis. IMO Analysising variadics, and how to add them to Rust · PoignardAzur is a good overview of the design space.

9 Likes

thanks for your answer. I've read the posts you linked.

When I said I was wondering if this is worth a discussion since I have not seen one I was refering to using tuples directly not having variadic generics that can use tuples.

after some search and readings:

It seems that there is no definitive consensus on this, but similar ideas have already emerged.

I'm wondering if this is the right time to bring back this topic and if I should comment in the open draft rfc on github.

The internals forum seemed the right place to me but I don't know if I'm mistaken (should I create an account on zulip ?, ask specific persons ?). I can contribute to the effort if I'm allowed to.

I have no knowledge related to the compiler internals so I've no idea on the feasibility of what I'm suggesting.

I think the things you propose are so close to variadics, that it doesn't make sense to consider them in isolation. The most natural context where stuff like tuple subslicing would be important is exactly variadic tuple context. For concrete tuples, you can always write out this transformation manually, even if it may be slightly verbose. And stuff like

fn do_something(t: impl TupleOf<SomeTrait>) {}

is basically variadics with a different syntax.

If you decide to pursue variadics, I would suggest leaving things like const while let out of your proposals. This is an orthogonal feature, with applications which go far beyond variadic tuples (even if those give a natural use case), and likely with very complex design and implementation of its own. Trying to pull in some sort of Zig-like or D-like comptime into Rust is a huge endeavor which is likely to tank any proposal it gets attached to.

4 Likes

Indeed, my first post is not a proposal but a list of things I miss and couldn't implement directly. Those 3 things are separate but the combo is something I want to have (if possible ofc).

I can write an RFC Covering tuples patterns/variadic tuples if it does not collude with work already in progress.

Tuple subslicing is relatively accessible to today's rust, that could be an RFC. The other stuff sounds very far off and related to variadic generics, as mentioned.

... Actually tuple subslicing has a problem, which I'm sure would come up in the RFC: you can't ref-bind a tuple subslice because the fields of a tuple don't have to be stored in order.

4 Likes

Interesting,

... Actually tuple subslicing has a problem, which I'm sure would come up in the RFC: you can't ref-bind a tuple subslice because the fields of a tuple don't have to be stored in order.

This seems weird to me since patterns already work on individual fields, but it makes sense depending on how the compiler works internally, could you give me more insight ?

did you mean the following don't work because b may be reordered to (3, 2) instead of (2, 3) ?

let (a, ref mut b @ .., c) = (1, 2, 3, 4);
let (d, e) = b;

It's not that the value of b can be (3, 2), that would clearly be bad. But references have to point to a certain location in memory, and if it is a tuple then the layout has to match that of all other tuples with those types since you could call a function expecting a &(T, U) and it won't "remember" that this particular (T, U) has a weird layout.

To give a more concrete example, consider the type (u8, u16, u8, u16). The compiler is incentivized to lay out this type in memory with the two u8's at the beginning so that the whole struct only uses 6 bytes instead of 8. But then if I match it as let (_, _, ref mut x @ ..) = (0u8, 0u16, 0u8, 0u16), x will be of type &(u8, u16) but with its fields in the wrong place (since there is a u16 between the two fields). If I passed it to fn foo(_: &(u8, u16)) {} the function foo would expect the two fields to be next to each other, which is not the case here.

6 Likes

ok, so if I understand correctly tuple patterns could cause de-optimization of code by forcing a memory layout and/or force the use of multiple pointers and this behavior should be specified by an eventual rfc.

Naively it seem acceptable that

let (_, _, ref mut a @ ..) : (u8, u16, u8, u16) = ...;

would be equivalent to

let (_, _, ref mut a, ref mut b) = ...;

Now I'm curious in which case wasting few bytes might be preferable.

How are the size decided ? is there a pass that is able to determine if a specific instance of a tuple is only present in a stack frame vs stored somewhere like in a collection ?

No, what I said above means that by-ref tuple patterns are unimplementable, this is not about performance. If you by-ref capture something there is the expectation that you have a reference to the original thing, not a copy of it; this is observable in the case of ref mut and interior mutability.

It's obviously not equivalent, since in one case you get one binding and in the other case you get two. A &mut (u8, u16) is not two pointers, it is one pointer to two things, and those two things have to be situated in a certain way with respect to each other and the pointer.

The layout algorithm is deliberately unspecified, but all tuples of the same shape have to have the same layout, so you can't use things like "it is only present in a stack frame" to influence the layout because it has to agree with other runs of the compiler on other crates. You can't layout "a specific instance of a tuple" differently from the tuple itself. (You can optimize tuples away completely with enough analysis, but this is an optimization which means that you can't base a language feature like by-ref tuple pattern bindings on it.)

If the tuple fields in question are Copy then I think Rust should let you pull them out into a new (non-referenced) tuple.

// At least one of these should be syntactically valid and assign 
// `a = (2, 3)` by copying elements in the RHS into a new tuple
let (_, &a @ .., _) = &(1, 2, 3, 4); 
let (_, a @ *.., _) = &(1, 2, 3, 4);
2 Likes
  1. Some simple pattern binding is possible. If you don't make a reference
  2. For the rest of yous thoughts "homogeneous tuples" [( .. T)] are needed:
let mut tupl : [(.. i32)] = [(1, 2, 3, 4)];

const while let (head, tail ^^ 1.. ) = tupl {
    if do_something(head) {
        tupl = tail;
    } else {
        tupl = [()];
    }
}
//

trait SomeTrait {}

struct Foo;
impl SomeTrait for Foo {}

struct Bar;
impl SomeTrait for Bar {}

struct Baz;

fn do_something(t: [( ... impl SomeTrait )] ) {}

let fb :  [( ... impl SomeTrait )] = [(Foo, Bar)];
do_something(fb); // compiles
let fb :  [( ... impl SomeTrait )] = [(Foo, Bar, Baz)]; // compile error
  1. Unfortunately you need for that UnSized Types or their's awful Rust representatives, like a List (and homogeneous tuples are Lists)
1 Like

Thx for your explanatios & patience I think I understand: by ref patterns on tuples should only be allowed if they are either destructured later in the code or used to create a new tuple with refs inside aka: late use as individual members and not as a single tuple. (if the info is accessible by the compiler, otherwise not allowed at all).

Based on my usage of by-ref patterns and the usefulness of pattern in tuples to me it's worth an rfc (except if there are some unresolvable gotchas).

I'm not aware of how much rust users would be interested in this feature.

To me these seems like very individual features.

Tuple slicing

To properly support tuple subslicing from scratch(though i'm not sure if it's worth it at this point), i think it's best to mimick array vs slice's examples, and introduce a tuple slice type.

fn foo(_: &(|i32, u32|)) {}

let [_, a @ .., _] = &(true, -2, 3, "str");
foo(a);

which roughly corresponds to

fn foo(_: &[i32]) {}

let [_, a @ .., _] = &[1, 2, 3, 4];
foo(a);

Here (|i32, u32|) is an unsized tuple slice type(syntax same as tuple except adding two bars inside the parenthesis), whose pointee metadata contains information about its origin(element offsets, etc).

const loops on tuple elements

I haven't thought a lot about the syntax, but to me it's best desugared to something like this:

trait TupleVisit {
    fn visit_element<T>(&mut self, element: &(|T,|));
}

trait TupleVisitMut {
    fn visit_element_mut<T>(&mut self, element: &mut (|T,|));
}

trait TupleVisitOnce {
    fn visit_element_once<T>(&mut self, element: T);
}

This will allow one thing to "receive" the elements in a tuple one-by-one.

Constraints on tuple members

I think this one needs a more accurate use case... What would one use this for?

1 Like

Some form of heterogenous tuples is already possible today, my current workaround is to have an enum with a variant for every different member in the tuple then convert the tuple to an array (ie: tuplify/hlist.rs at main · cantina-space/tuplify · GitHub)

The same trick can work for function parameters by converting a tuple to an array of enum and then taking the array/iterator/slice as parameter, not the best solution but it works. Most of the time I create ad-hoc enums when I need to do this (and hope the compiler is clever enough to optimize but this is not really reliable depending on contexts).

To properly support tuple subslicing from scratch(though i'm not sure if it's worth it at this point), i think it's best to mimick array vs slice's examples, and introduce a tuple slice type.

After looking at my code (probably not representative of all rust users), the places where I want to use tuple tuple subslicing/patterns are almost always in the same function and I rarely need to pass them to function parametes.

for this use case I think that the following would be acceptable without introducing a new user type (not sure if possible with the compiler).

fn foo(_: (&u32, &i32)) {}

let (_, ref tupl @ ..) = (1, 2, 3, 4);
foo((..tupl)); //ok
let (a, b) = tupl; // ok
foo(tupl); // not ok

I think this one needs a more accurate use case... What would one use this for?

This alone is not very usefull on it's own (without being able to iterate on tuples), the only places where I would use that currently is when I implement some features on tuples/hlists without having to know the common restriction in advance, this would reduce boilerplate.

Think of this kind of code (currently doable but today intermediate traits are needed):

trait Action {
    fn do_something(&self) -> Result<(), ()>;
}

struct AnyOf<T: TupleOf<Action>>(pub T); // or whatever syntax to have this restriction

impl<T> Action for AnyOf<T> {
    // ...
}

struct OneOf<T: TupleOf<Action>>(pub T);

impl<T> Action for OneOf<T> {
    // ...
}

Seems coverable with a single pattern(needs some way to specify the reduce fn after the automatic map part, of course):

#[derive_structural_propagation]
//^ imaginary attribute that generate all tuple and maybe array impls.
trait Action {
     fn do_something(&self) -> Result<(), ()>;
}

Is there more use cases than this?

This is completely doable with proc macro (but maybe overkill depending on use cases), I currently implement this using macros rules.

Is there more use cases than this?

Not that much, only few similar cases. This is not really useful on it's own except saving ~10 loc per implementation (much more with variadics and loops if they existed).

Why not make ref tuple @ .. desugar to a normal tuple of references? So

let (_, ref rest @ .., _) = (1, 3u8, 3, 7);
// rest: (&3u8, &3i32)

This seems like the simplest and most consistent solution.

1 Like

Homogeneous tuples would be useless because then they'd be just arrays. Being heterogeneous is the only advantage tuples have over arrays.

It would be nice to have heterogeneous tuples with a trait bound though, and being able to filter/map/fold them using that trait.

2 Likes