Variadic generics are a long-desired feature in Rust, but the syntax and semantics are not easy to get right. I've sketched out a possible design in a HackMD:
I would appreciate feedback, especially on marked TODOs. Feel free to respond either here, or in comments on the HackMD.
Cool design. I especially like the type-level for<T in Ts> syntax. Overall it seems like a good exhaustive list of the things you might want from generic variadics.
That said, I think it's maybe trying to do too much at once? Like, it's trying to do variadic types and tuple spreading and varargs in functions. It seems a bit too big for an RFC. For a design sketch, though, it gives a decent goal to navigate towards, so idk.
Something I never see in any of these proposals that I wish people did more is to consider what error messages would look like. What would be forbidden, what would be allowed, how would the lines be drawn and what would be the corner cases. This is especially important if you're introducing compile-time tuple spreading, because that has a lot of potential for post-monomorphization errors.
I'd suggest to add to the list of prior work the Circle lang. It is an extension of c++20 with a lot of design effort put into its metaprogramming and it leverages variadics extensively. A lot of good ideas there that we could learn from.
One thing that I'd like to see is making types eventually first class entities of the language. This would actually simplify greatly this proposal.
Firstly, I really don't like the description of static for loops as "iteration, but guaranteed to be unrolled at compile time"; that implies that it's possible to iterate over the expression after the in at runtime. Rather, I'd prefer to see static for initially explained as syntactic sugar for writing out repetitive blocks of code; in this model, you get:
let _: (Option<u32>, Option<bool>) = static for i in (42, false) {
if !pre_check() {
continue None;
}
Some(expensive_computation(i))
};
// desugars to
let _: (Option<u32>, Option<bool>) = {
{
if !pre_check() {
continue None;
}
Some(expensive_computation(42))
},
{
if !pre_check() {
continue None;
}
Some(expensive_computation(false))
}
};
This makes it very obvious that while we're copying the forms used for iteration, it's not "iteration, but unrolled at compile time" (and thus you can't expect to use a static for over an ExactSizeIterator or a TrustedLen iterator, even though in principle, you might expect to be able to fully unroll those loops at compile time).
There is a problem here desugaring static for loops that contain a break, because you need some way to jump to the end of the loop, but it heads off my objection to iterating over a heterogenous container (since it's not iteration, it's just a neat way to write repetitive code).
Secondly, I'd like to see more thought about the "weird" combinations. What happens when I have two variadic generics in a single function type? What happens when I try to put a non-variadic argument after a variadic one?
Thirdly, you introduce <...Is: Iterator> without explaining how I'm supposed to interpret it - there's only one obvious interpretation, but I'd like to see you call that out explicitly, in case someone else comes up with a silly interpretation.
Finally, I've spotted a few typos/thinkos, which I've commented on in the HackMD.
The proposed syntax is interesting. It reminds me of argument capture in Rust's declarative macros. I suspect it would have the same cognitive load in non-trivial type expressions.
There is one doesnt_work example, I will try to add more. The intention is to only reject combinations that are ambiguous, so for example putting a variadic argument in front of a non-variadic one is allowed (though I don't feel stongly about it). There aren't intended to be any post-monomorphization errors.
Thank you for the reference! I've added it to the doc, will try to review it in more detail.
Part of me also wishes we could have something like Zig comptime in Rust, but there are difficulties. Notably, lifetime-dependent specialization must remain impossible.
Scroll up, the syntax is explained in the fn default example.
As an editorial note, I expected that blocks without explanatory text between them were all examples of the same thing (since they were earlier in the document, with static for).
Having a single sentence telling me what the next block is about would make it much easier to follow - so something like "You can put bounds on variadic generics; these are applied to every element of the variadic" would catch my eye and stop me scrolling straight past the explanation I was looking for.
These two are vicious in different ways, since they are potentially ambiguous, and my bias is to say that they're both errors, since there are clearer ways to write them.
confused is one that is unambiguous if either all types in ...Ts or all types in ...Us do not implement Iterator. For example, confused(vec![1,2,3].into_iter()) is unambiguous, since ...Ts and ...Us are empty, as is confused(0u32, 1.0f64, vec![1,2,3].into_iter(), 2.0f32, 3i8), since none of the numeric types implement Iterator. But historically, trying to reason about negative trait implementations has led to soundness issues, and I'd prefer confused to be always an error to avoid that risk.
evil is straight up nasty. As long as neither ...Ts nor ...Us contains usize, you can determine unambiguously what evil's type is from its arguments, so in that sense it's not ambiguous. But, unlike confused, there's no way to use the turbofish syntax to explicitly label the type of a monomorphization of evil; the only way to be explicit is to write out the full function signature, including i: usize at the appropriate point.
And you can improve both of them by using grouping in the generic parameters:
These are still unpleasant, but it's at least always possible to set their types via a turbofish.
The other thing I see (but have put zero thought into) is having this interact with APIT and RPIT. Is there useful meaning to be given to the ...impl in the following two signatures:
Relying on negative reasoning like that is a serious semver hazard; confused is definitely illegal, as is evil.
I will have to think about less_confused and less_evil. Arguably the idiomatic solution is to tuple-wrap the value parameters, but should that be required? My inclination is to be maximally permissive at first; restrictions can be added later based on feedback from implementation or usage.
Your apit and rpit examples are not correct by the design. ... operates on a "parameter pack"[1]. The only ways of producing such a pack are:
...Ident generic parameter
Comma separated list of types enclosed by brackets: <usize, &str>
for<_ in _> _ mapping over a pack
Clearly this needs to be explained better in the doc. One source for confusion here is that at the value level, ... is strongly associated with tuples, as any collection of values can be expressed as a tuple[2]. In contrast, type-level ... is not particularly linked to tuples[3], because it needs to support lifetime and const generics, and e.g. ('a, 'b) isn't a valid type. There may be a better design here, though.
fn apit(...is: ...impl Iterator); is interesting, currently the proposal doesn't handle it, but arguably it should be the same as fn apit<...Is: Iterator>(...is: Is);?
For fn rpit:
...&str is not valid, because type-level ... only works on packs. There's no way to express "homogeneous tuple of arbitrary length" with the current state of this proposal; arguably that's what arrays are for.
...impl std::net::ToSocketAddrs is not valid either, as again ... only works on packs.
What you could do, however, is this:
fn rpit<...<'as, Names: AsRef<str>>>(...names: for<<'a, Name> in <'as, Names>> &'a Name) -> (...for<_ in Names> impl std::net::ToSocketAddrs,);
(Which reminds me, I need to consider lifetime elision...)
I've added a few TODO sections to the end of the doc, noting limitations of the design.
APIT is never required - fn foo(f: impl Trait) can always be rewritten as fn<T: Trait>(f: T), and thus I think your suggested interpretations is sensible, but it's also completely reasonable to say that there's no APIT with variadics.
I do think you should think about what variadic RPIT should look like - the problem with your suggestion is that (if I'm reading it right, which is never guaranteed), there have to be as many values in the return as in one of the arguments - but it would be useful to be able to express "the return type is a pack of unknown size, all elements of the pack are known to implement a given trait" somehow. For example, if fn rpit filtered the supplied names by some sort of validity criteria, and then returned impl ToSocketAddrs for each of the valid names, how would I write that return type? Similarly, what would I write to make fn rpit(names: &[&str]) -> ...impl ToSocketAddrs workable, where I return a pack based on at most N resolvable names from the input slice?
The only use case I can think of, and it's a stretch, is the case where I have multiple input packs of different sizes, and I want the output pack to be a different size where the compile-time size of the input packs determines the size of the output pack. The two useful cases are summing up the size of two input packs (equivalent to iteration's chain() method), and taking the shortest of two packs (equivalent to zip()).
That said, this needs more than just RPIT - it also needs some way to do the chain or zip operation on inputs at compile time, and I would expect that the syntax that lets you do that at the value level could be reused to let you build the output pack in the way you suggested for fn rpit.