Pre-RFC: variadic tuple

Hi, I wrote a pre-RFC about variadic tuple, a solution for variadic generics that I think more suitable for Rust. This is based on previous work made on the variadic generics.

All feedbacks are appreciated, so feel free to comment the PR here: https://github.com/fredpointzero/rfcs/pull/1

2 Likes

I'm glad to see a proposal that isn't just cons-lists again.

My thoughts:

  • Why are there different syntax forms for declaration and usage?

  • There's no reason to keep using italics throughout the whole thing. Italics are for emphasis or phrases borrowed from other languages.

  • The section on "Multiple identifier expansion forms" makes it feel like something is missing from the type signature. While there are exceptions, it is uncommon for something in rust to produce errors at monomorphization time.

    // These look identical from the outside, but have different
    // restrictions on Bar and Baz.
    struct Foo<(..#Bar), (..#Baz)>((Bar#..), (Baz#..));
    struct Foo<(..#Bar), (..#Baz)>(((Bar, Baz)#..));
    

    What about something like (..#(Bar, Baz)) to say that these sequences are equal length? (so that the type of the MegaMap example in the RFC is MegaMap<((Key1, Value1), (Key2, Value2), (Key3, Value3))>

  • let ({ref T}#..) = values;
    

    I think this should be

    let ((ref T)#..) = values;
    

    because {pat} is not a pattern, while (pat) is.

  • In that same section, most of the headings there are not useful.

    • "struct member declaration", "function arguments", "function output", "impl block type" are all showing a single example: a tuple type.
    • "Function body" shows two examples: A tuple pattern, and a tuple expression.
    • "where clause" is something legitimately different. Now that's worth mentioning!
    • Are other patterns, expressions, and syntax forms possible?
      • Can you splat into a function call? (func(Tuple#..))
      • Can you generate a list of statements? (maybe not? I can't picture a good syntax for it)
  • Is it possible to use this to write a variadic Enum? (i.e. is it possible to write an enum type as a type with a tuple type argument (A, B, C, ...)?) It seems like it would be a shame to only cover product types without sum types.

4 Likes

One note I want to add is that recursive variadic tuples should be explicitly allowed or disallowed. With the current given syntax, I think that'd be <(..#(..#T))>.

I'll take a deeper look later, but on the surface this looks promising. I just need to think through the implications of allowing #.. basically anywhere.

To me this looks like macro_rules, but without a macro. For example, the new {…}# syntax in function bodies seems to be similar to $(…)+. Could they be somehow unified?

Or perhaps instead of a new syntax, could there be something more conventional, like std::tuple::for_each/map macros?

    fn hash<S: Hasher>(&self, state: &mut S) {
        std::tuple::for_each! {self, |t| t.hash(state)}
    }
6 Likes

@kornel they wouldn't be normal macros, but I think that is fine, as the correspondence to normal macros will make this easier to learn.

1 Like

Hi, thanks for your comments, I'll answer as I can point by point:

  • Why are there different syntax forms for declaration and usage?

I find it confusing if they both share the same syntax. By using different syntax, you know exactly what you are looking at.

  • There's no reason to keep using italics throughout the whole thing. Italics are for emphasis or phrases borrowed from other languages.

Noted, I'll update the RFC accordingly.

  • The section on "Multiple identifier expansion forms" makes it feel like something is missing from the type signature. While there are exceptions, it is uncommon for something in rust to produce errors at monomorphization time.

I like the idea you are suggesting, embedding the length constraint in the syntax itself is nice. Although it may collide with the point made by CAD97 about recursive variadic tuple, but it is worth thinking about it.

  • let ({ref T}#..) = values;

Noted, I wasn't sure about this, I'll update the RFC, it will make more sense for Rust users.

  • In that same section, most of the headings there are not useful.

Several points here. First of all, the section was intended to show the location where the variadic tuple can be used, not use cases of the variadic tuple. Maybe I should rephrase the section then.

Can you splat into a function call? (func(Tuple#..))

No, you will use a variadic tuple as a single argument.

Can you generate a list of statements? (maybe not? I can't picture a good syntax for it)

No, the goal is to manipulate tuples.

Although you can do something similar:

let values = (({let mut v = T::VALUE; v *= 2; v})#..);

will expand to

let values = ({let mut v = T1::VALUE; v *= 2; v}, {let mut v = T2::VALUE; v *= 2; v}, {let mut v = T3::VALUE; v *= 2; v});

for a variadic tuple instance (T1, T2, T3).

  • Is it possible to use this to write a variadic Enum?

No, this is intended to be for tuples only. Handling variadic enum is, in my opinion, quite different and will have way more implications that variadic tuples. So if variadic enum needs to be implemented, I think it should be in an another RFC.

Hi @CAD97, this is interesting.

For this RFC, recursive variadic tuples will not be allowed, however, this can be added in a further RFC. The only restriction I want to keep, is that the syntax chosen in this RFC will not prevent a recursive variadic tuple RFC later.

Hi @kornel.

I don't think such a macro like syntax is viable, because:

  • I don't think about a way of declaring self as a tuple.
  • You will expect that this macro will follow the macro hygiene and you don't want the hygiene here.
  • I think it is weird to have a macro syntax at some locations, like where bounds

Hmmm, this I just find hard to agree with. I had a difficult time writing those examples because the difference feels arbitrary.

We don't have special declaration syntax for type parameters or lifetime parameters. (just the regular syntax, with the optional addition of bounds). const generics have different syntax for declaration and usage, but that serves a number of purposes (specifying the type of the const; and making parsing tractable when types appear in expressions), and they mirror items and expressions so they are easy to remember.

It looks to me precisely like you are listing use cases. Expressions, patterns, and types are fundamental parts of the language. Function argument types, return types, struct member types etc. are all just specific instances of types with the same grammar.

I think it'd be easier to list the places where they cannot be used... if there even are any!

On seeing this I'd just like to clarify: when I suggested (ref T)#.. over {ref T}#.., that was specifically for patterns and types. { }# (creating a block scope for each value) makes perfect sense for expressions.

1 Like

You mention C++ parameter packs in your RFC, and I feel like they should be strongly comparable to what you're suggesting, but I'm failing to make the comparison. Could you explain how your idea is similar or completely different from C++ parameter packs (in particular, their first-class form in C++17).

2 Likes

I updated the RFC accordingly to your feedbacks:

  1. Both the expansion and declaration form have the same syntax. Let's see if this is less confusing that way.
  2. You can define multiple variadic tuple in the same declaration with (..#(T1, T2)), in that case all variadic tuple will have the same arity. This syntax avoids to handle invalid arity error by the compiler.
  3. The expansion form can be enclosed by either braces or parenthesis (or nothing, if it is simple).

I will add later an error concerning expansion errors that uses multiple variadic tuple identifiers that were not declared together.

I am not an expert concerning C++ parameter packs, but the design around it inspired this RFC and the previous one concerning the variadic generics.

Basicaly, variadic tuples and parameters packs are quite similar. In a few word, variadics tuple are parameters packs enclosed by parenthesis.

So the major difference is that you will always manipulate a tuple with variadic tuple, while you manipulate a set of types with parameter packs. If you look at the RFC, any declaration form or expansion form will result in a tuple.

This makes possible to use multiple variadic tuple in a declaration and easily "join" them. (See the MegaMap example).

Finally, one of the goal of this RFC is also to empower the usage of tuple in Rust (and that is specific to the way Rust implemented tuples). So this is another reason to focus more on tuples here.

I think it would help if you could find some usages of C++ parameter packs and contrast them to your proposal. In C++, a parameter pack is nothing more than a tuple of types; std::tuple<> is really "let's make a struct that has a parameter pack as a member variable". I agree we should focus on built-in tuples, since we already have them, and it allows for things C++ doesn't easily allow, such as multiple parameter packs in a definition. However, I think it would be useful if you could find some C++ examples and express something equivalent to them in this proposal. For example, how would I write

// C++ for trait Named { const NAME: &str; }, roughly
template <typename T>
concept Named = requires(T) {
  { T::kName } -> std::string_view;
}

// Returns a vector of the names of the types |Ts|.
template <typename... Ts>
  requires Named<Ts>...
std::vector<std::string_view> Names() {
  return std::vector{Ts::kName...};
}

Relatedly, can you explain what (..#T) is, syntactically? It looks like you use it as a type parameter, a pattern, and an expression in different places. What are the allowed forms of T in each? I get the impression that as a type parameter, you're allowed to write (..#(T, U)). Might it make sense to also allow writing struct Foo<(T, U)>, as a sort of "type-level pattern matching"?

Can I write something like

type T = (int, int);
struct K<(..#T)> { .. };
type L = K<T>;

Also, can you address the case of single-element tuples? If I have struct K<(..#T)>, can I write K<(int)>, or must I write K<(int,)>?

This would be K<(int,)> to stay consistent with current behavior. (int) is always the same as int today, and that should not change due to this proposal.

1 Like

If you can provide some example like you did, I will appreciate. I can add then in an appendix in the end for C++ users to have a reference to compare to. (My C++ experience is old so I will need quite some time to catch up and find appropriate syntax examples otherwise

)

So I guess that variadic tuples can be compared to C++'s std::tuple<>.

Syntactically, (..#T) is different depending on the usage location. Inside generic parameter groups (declaration form):

  • For this syntax: (..#T), then ..#T defines a list of types identified by T and (..#T) is a tuple type made with the list of type ..#T.
  • For this syntax: (..#(A, B)), ..#(A, B) is a list of 2-tuple types and (..#(A, B)) is a tuple type made with the list of 2-tuple type ..#(A, B)

Note 1: (..#(A, B)) can be considered as a type-level pattern matching a tuple type with only 2-tuple members.

Note 2: This kind of type-level pattern matching is only works for tuple, ie (..#(A, Vec<B>)) is not valid.

Note 3: For the syntax (..#(A, B)), ..#(A, B) is a list of 2-tuple types, ..#A is a list of type and ..#B is a list of type as well.

At any other location (expansion form): Let's consider the MegaMap example:

struct MegaMap<(..#(K, V))> {
    maps: (..#HashMap<K, V>),
}

HashMap<K, V> is an expression using identifiers K and V. I am not sure about (..#HashMap<K, V>) though, but I'd say it is an expression as well.

Note: I will update the RFC to clarify the syntax role of each element, and the type pattern matching in declarative form

A good overview of C++ parameter packs is at https://en.cppreference.com/w/cpp/language/parameter_pack

In C++ there are type parameter packs, which are almost the same as yours, and function parameter packs, which are missing from your proposal AFAICT (FWIW there are also template template parameter packs, and non-type template parameter packs). Parameter packs are "collected" (declared) with a prefix ..., and expanded with postfix ....

Crucially, I think this

fn double<(..#T)>(input: (..#T)) -> (..#T)
	where ..#(T: Add), {
    (..#(T + T))
}

would not really work, unless I'm misunderstanding something. First,

fn double<(..#T)>(input: (..#T)) -> (..#T)

AFAICT should really be (?):

fn double<(..#T)>(input: (..#T)) -> (T#..)

but especially:

 (..#(T + T))

doesn't make sense to me: T is/are type(s), not values, and thus can't be added.

For reference, the C++ equivalent would be (off the top of my head):

template<typename ...Ts>                         // type parameter pack
std::tuple<Ts...>                                // expand in return type
tupledouble(Ts ... input) {                      // function parameter pack
    return std::make_tuple((input + input)...);  // expand parameter pack
}

Note C++ also has neat fold expressions, e.g., to sum all values:

return input + ...;       // right fold, i.e. input_1 + input_2 + ... + input_n
return ... + input;       // left fold
return input + ... + 0;   // right fold with initial value
return 0 + ... + input;   // left fold with initial value

Thanks for the information I appreciate!

Actually, the original design used (..#T) as declaration form and (T#..) as expansion form, but some people were confused by this syntax, so I kept the same syntax for both form.

For me, I prefer to have different syntax as it sounds clearer to me, but this is not the case in a lot of Rust syntax (for instance tuple declaration and destructuring tuples have extremely close syntax).

That is indeed a major issue. So I think it can be fixed the same way C++ does:

Instead of

fn double<(..#T)>(input: (..#T)) -> (..#T)
	where ..#(T: Add), {
    (..#(T + T))
}

We can use

fn double<(..#T)>(input: (..#T)) -> (..#T)
	where ..#(T: Add), {
    (..#(input + input))
}

I think that can be tuple expansion rule that happens at compile time. This can be done for any tuple (variadic or fixed).

let my_tuple = (1, "Hello World!", true);
let my_string_tuple = (..#{ format!("{}", my_tuple) });
// let my_string_tuple = ({ format!("{}", my_tuple.0) }, { format!("{}", my_tuple.1) }, { format!("{}", my_tuple.2) });

Concerning the syntax, then (..#T) is a variadic tuple type, and index in index: (..#T) is a variadic tuple.

What do you think about this?

Obviously, but I mention it because it looks rather ugly, doesn't it?

1 Like

TBH, input: (..#T) reads to me as a single parameter input that captures a single tuple of arbitrary length, i.e., not a parameter pack.

The C++ syntax doesn't translate very well (FWIW, despite all the ugly syntax in C++, variadic templates and fold expressions are one of the neater syntaxes, IMHO). I think you'd need an outer parameter that captures the whole tuple (input in our example), and a new syntax to capture individual elements, e.g., input: (i: ..#T), then expand with ..#(i + i).

On the plus side, this retains the ability to have multiple parameter packs in a call/expression, something that C++ can't do, and allow you to address the whole tuple via input, too, which may come in handy.

1 Like

I am not fan of input: (i: ..#T) because it is using two identifiers for the same variable.

I would say that both of these syntaxes are possible: input: (..#T) and (..#input): (..#T). You will have to choose which one is more convenient for the implementation.

Some examples:

fn double<(..#T)>((..#input): (..#T)) -> (..#T)
	where ..#(T: Add), {
    (..#(input + input))
}

fn only_tuple_to_string<(..#T)>(input: (..#T)) -> String {
   format!("{}", input)
}

fn recursive<Head, (..#T)>((h, ..#tail): (..#T)) -> (..#T) {
    println!("{}", h);
    recursive((..#tail));
}
fn recursive<()>((): ()) { }
1 Like