[Analysis / Pre-RFC] Variadic generics in Rust

I've written a detailed analysis of variadic generics as they relate to Rust. It's not exactly a RFC or even a "pre-RFC", so much as survey of existing proposals and my two cents on how variadics should be implemented.

Quick intro: Variadic generics are a way to have a template take an arbitrary number of types; it's basically the template equivalent of C's printf. So in rust that might look like:

fn make_tuple_sing<...T: Sing>(t: (...T)) {
    for member in ...t {
        member.sing();
    }
}

let kpop_band = (KPopStar::new(), KPopStar::new());
let rock_band = (RockStar::new(), RockStar::new(), RockStar::new(), RockStar::new());
let mixed_band = (KPopStar::new(), RockStar::new(), KPopStar::new());

make_tuple_sing(kpop_band);
make_tuple_sing(rock_band);
make_tuple_sing(mixed_band);

The analysis is here: variadics_rfc.md Ā· GitHub

Feel free to comment here or on the reddit thread.

13 Likes

If you look for another usecase for such a feature: diesel would greatly benefit from having variadic generics in some form. We do currently implement a bunch of traites for various tuple sizes. As this has a quite serve impact on compile times (see here, or here or here) we need to limit the number of tuple elements via feature flags. I think something like variadic generics would greatly improve the compile time situation for large tuple sizes, as we could just skip all those expanding and generating large amounts of code.

4 Likes

Why did you choose ... over ..? Rust seems to favor double dots over triple dots (struct update syntax, ranges).

2 Likes

for consistency?

3 Likes

Related:

Given that ..x already has a meaning (RangeTo { end: x }) it makes sense to me that this would use a different syntax. So picking something like the C++ one seems fine (especially for initial explorations).

4 Likes

It's worth noting that ..x isn't ever IntoIterator, so for _ in ..x never will work. This mitigates the "is it .. or ..." problem somewhat (as when using ... for inclusive ranges).

Sure. But it's all the other positions that are more interesting to me. For example, a fold expression like return (start * ... * ts); can compile today with .., as return start * (..*ts), with an appropriate (albeit contrived) Mul implementation. Or, simpler, argument splatting like foo(...ts) could be a function that takes RangeTo, if it had only two dots.

So I think "most of the time it won't compile anyway" is a good argument for not being worried about the similarity between .. and ..., but is not sufficient to say that it could reuse the same syntax.

2 Likes

Using ... in patterns would be a breaking change though, since ... is equivalent to ..= in patterns. It's deprecated but still works.

Yup, but because it's deprecated we can make it not work in a future edition, then use it for variadics in an edition after that.

9 Likes

Do we need an extra edition? If itā€™s already deprecated, and has a clear replacement (so the code can be migrated), couldnā€™t we rebrand ... in Rust 2021 to mean "variadic generics", or do we need to wait Rust 2024 and have ... in Rust 2021 a compile error?

No reason whatsoever.

I just needed something to write in my examples and picked a syntax at random.

That's cool. I'm wondering if I should have ended the post with a call for people to submit crates they're working on that would seriously benefit from variadics, because a lot of reactions were from people wondering if variadics were even useful.

While this is technically not something language design should be concerned about, there's also the fact that this mixes multiple stages of parsing/validating in a way that the compiler currently can't handle.

For instance,

let my_struct = MyStruct { a, b, ..the_rest };

currently compiles, but the following:

let my_tuple = ( a, b, ..the_rest );

doesn't, because the compiler assumes you're trying to build a 3-elements tuple where the third is RangeTo{ end: the_rest }.

Whether or not this would be a problem in practice would be very implementation-dependent.

Speaking of syntax changes and editions, I'm thinking of making a variadic_generics crate, to serve as a POC. I'm thinking once I get a 0.1 out, I should probably make a RFC to add some syntax-only variadic constructs (eg ...Ts, for _ ...in _, etc), the way dtolnay did for unsafe mod.

So variadics would still be rejected after the semantic pass (no variadic MIR), but attribute macros could generate variadic functions from the variadic DSL, Rust parsers would be able to read the functions and do syntax highlighting on them, and the language wouldn't actually commit to anything that couldn't be canceled later.

1 Like

actix-web would also benefit from variadic generics. In actix-web, a handler is a function with up to 10 arguments that implement FromRequest. The limit is 10 because the trait implementations are generated by macros. This also means that error messages are very cryptic when registering an invalid handler function.

This is "repurposing" syntax, so based on https://rust-lang.github.io/rfcs/2052-epochs.html#example-repurposing-syntax, it should wait until the previous syntax "is obscure". I suppose that technically doesn't require another addition, but I suspect it does require a year at least, and probably more -- it's not like ... in matches was a niche feature.

3 Likes

It's not clear to me that a separate C++-like parameter pack support and syntax is needed. Why not operate on the passed-in tuple directly, instead?

ISTM that by enhancing meta-programming capabilities in general, but esp. around tuples and arrays*), with individually useful additions, the same end-result could be achieved, and be more flexible in the end.

*) FWIW, I think that for some of the listed use cases arrays would be a better fit than tuples.

1 Like

Aren't these two the same?

  • C++-like parameter pack syntax
  • enhanced meta-programming capabilities around tuples

My reading was

  • ...t was probably a tuple of values
  • ...T was definitely a tuple of types

One could argue for t : ...T syntax instead.
t : ...T is surely cleaner
However isn't ...t much easier to read?
Especially in the example above?

That makes ... into a kind of sigil. Theoretically that is an ugly design. In practice it may prove pretty nice..

here's a strawman example: Instead of the above suggestion

We could instead do something like:

fn make_tuple_sing<T> (t: (T)) 
    where T: Sing
{
    for member in t.iter() {
        member.sing();
    }
}

where the form (T) denotes that t is a tuple (that's probably the only syntax that's currently missing in Rust, a tuple type constructor), and the tuple type provides a regular Rust idiom - a const iterator.

As an aside, a better tuple type constructor is also needed to remove the wart of the postfix comma for 1-tuples in Rust. so that we could have:

 i32 // scalar type
(i32) // 1-tuple of i32
(i32, i64) // 2-tuple of i32, i64
((i32)) // 1-tuple of 1-tuple of i32

Isn't this extremely similar to what was proposed before? Just with a different syntax?

It's a little less clear here that T is a tuple of types so I would consider this an improvement:

fn make_tuple_sing<(T)> (t: (T)) where T:Sing {
     for member in t ...

at which point it becomes even more similar to the above just bikeshedding (T) vs ...T and t vs ...t

Update: or did you mean that fn make_tuple_sing<T> (t: (T)) takes a "homogenously-typed" tuple, e.g. a tuple of values of the same type? Then fn make_tuple_sing<(T)> (t: (T)) or fn make_tuple_sing<...T> (...t: ...T) is a big improvement as either implies a tuple of heterogeneous types.

The idea is that a n-tuple should be a regular type that implements standard traits (like Iter) instead of adding special unpack expression syntax.

This makes the language more regular, more predictable, and easier to learn for new users. Unlike C++ which has a very bad track record in this regard. (e.g co_return for a special case of return from a coroutine)

There is a major benefit in having consistent language idioms applied uniformly throughout.

Problem: in the auto-generated implementation of std::iter::Iterator for your tuple, what is the Iterator::Item associated type?

A standard iterator works if all the items of your collection are of the same type (or a dyn Trait type). You can't have a for loop that iterates over different types with existing semantics. If you're proposing to change for loop semantics, well, that's essentially variadics under another name.

4 Likes

Well, I meant to use a tuple type constructor where each member is a type that implements the Sing trait. There should be distinction between a constraint on each member and a constraint on the tuple as a whole. So for the homogenous case we could use perhaps the following constraint: where (T) : (Sing) ?

Also, as @lordan mentioned and I agree, the homogenous case should be represented by arrays/slices instead of tuples and that could be another (better) alternative to differentiate the two cases.