Variadic generics design sketch

First: I'm a member of T-opsem and as such know a significant amount about the semantics of Rust and how those impact what transformations/optimizations the compiler is allowed to do without deriving further proofs about the code. For this purpose, though, I am speaking exclusively for myself and do not carry any T-opsem influence, and just offer my understanding, which will never be absolutely flawless.

I was also a member of wg-grammar while that was a thing. It effectively isn't anymore, but it's still structurally present.

Ah, must've missed that when I scanned through.

While this kind of shuffling does get optimized out, given that unsized_fn_params needs a different solution and that "iterate pack by reference" would rather be spelled static for item in &pack (which creates a reference to the pack) than static for ref item in pack (which doesn't, but would also be a consuming iteration by analogy to regular for), I'd lean towards uniformly always treating parameter packs differently from plain tuples. The existence of the reference to a pack-tuple can still be fairly easily optimized out if the reference never escapes the local function, but given the compiler needs to treat packs specially roughly always and the language needs to treat packs specially when they potentially contain unsized params, it feels more consistent to consistently treat parameter packs as "tuple-like" but a distinct concept from tuples.

There's also lifetime packs, which clearly need to always be their own thing. Variadic syntax should work for tuples and borrow from tuple vocabulary, but I think it's fair to admit that packs are always a distinct concept from tuples, with tuples being more restricted but thus offering more options. An interesting way of looking at it would be to say (A, B, C) is #[repr(Rust)] (A, B, C) but variadic packs are #[repr(RustVariadic)] (A, B, C). This mostly communicates how they're similar but distinct types (like extern "Rust" fn vs extern "C" fn), but is still an insufficient lie — packs shouldn't be described as a singular object that has a consistent layout — because of the following point.

Additionally, it's a subtle pitfall, but it's not sufficient to only prove absence of taking the address of the entire pack to justify eliding data shuffling into tuple layout. It's also required to prove absence of taking the address of any item in the pack. This is the case even with strict subobject provenance slicing, because since the items in the tuple are all part of the same Rust Allocated Object, operations which care about the containing Rust Allocated Object require that (stack) allocation to exist with the correct layout and for any subobject with its reference taken to be at the correct location in that tuple object. (Notable examples of such operations include pointer arithmetic and comparison.) If we want variadics to be a properly "zero overhead abstraction" (i.e. that going without the abstraction doesn't get you better codegen), we need these to optimize to identical code: [godbolt] (ignoring variadic typeck implications for the example)

#![allow(improper_ctypes)]

// pass-by-ref ABI
pub struct Data([u64; 8]);

extern "Rust" {
    fn sink(
        a: &Data,
        b: &Data,
        c: &Data,
        d: &Data,
    );
}

// variadic form
pub unsafe fn f1(...data: (...Data; 4)) {
    sink(...&data,)
}

// monomorphizes to
pub unsafe fn f1(
    data_0: Data,
    data_1: Data,
    data_2: Data,
    data_3: Data,
) {
    let data = (data_0, data_1, data_2, data_3);
    sink(&data.0, &data.1, &data.2, &data.3);
}
// -> a bunch of stack shuffling and then a call

// manually monomorphized
pub unsafe fn f2(
    data_0: Data,
    data_1: Data,
    data_2: Data,
    data_3: Data,
) {
    sink(&data_0, &data_1, &data_2, &data_3);
}
// -> just a tail call (jmp)

Unless packs somehow are distinct from tuples, this does mean that packs including unsized parameters are extremely difficult to work with, because you'd be limited to only ever passing them by value; any attempt to use a reference to the value could potentially the creation (since the use of the reference, if not inlined, can't be proven not to observe the address and do the problematic pointer things to the reference).

Plus, presuming that static for gets evaluates-to-tuple semantics, converting from pack to tuple isn't all that syntactically involved (static for item in pack { item }) and clearly communicates to the reader that there's a potential address impacting data shuffling cost here. (But further down I do express trepidation for that behavior.)

The nuclear option for the compiler would be to attempt to treat packs differently than tuples until the whole pack is used as a tuple to avoid this subtle pessimization.


I've had a bit more time to look a bit closer, so here are some specific notes I didn't see addressed:

...

... is already used for C variadics (RFC #2137). This should be unambiguous, but should be kept in mind.

... is deprecated but still allowed in pattern position for inclusive ranges for edition2015 and edition2018. Edition2021 makes it a hard error semantically but it's still allowed grammatically (e.g. under #[cfg(false)] or an unused macro $:pat capture). Range-to patterns with ... are a syntax error in all editions, so there's again no strict conflict, but the potential developer impact still should be kept in mind.

However, it also needs to be noted that exclusive range patterns ..x are already stably permitted syntactically and unstably permitted syntactically. This is why "rest" patterns need to be bound with ident @ ... ... has been deprecated and removed as an exclusive range pattern because of the confusability with the two-dot exclusive range, so making it such that .. and ... are ever valid in the same syntactical position is a notable confusion risk. To be completely fair, ranges and variadic packs are very different and unlikely to both be valid patterns for the same scrutinee type[0], but such a small visual difference between two valid bits of syntax is usually a poor idea.

I don't have a different proposal, though.

[0]: The one case I can think of is when matching against one member of an array/tuple pattern, with ...X as the pattern and some const X item in scope. As a pack pattern this would shadow the constant item, but at least still generate a warning for the nonstandard style, which could also point out the X in scope and ask if ..X was meant instead.

ref ...head

By analogy to ref head @ .., this should probably be spelled ...ref head instead. (The pattern binding mode decorates the binding, not the pattern.)

It should probably be explicitly noted somewhere whether unpacking patterns can be arbitrary patterns (e.g. ...Foo(a, b, c) for an unpack of Foos) or if they're restricted to being simple binding patterns. Arbitrary patterns should be allowed imho (and that allows ...ref head just like any other pattern rather than handling it as a special case).

For arrays, ... patterns serve as an alternative to ident @ ... [...] ... patterns also work with tuples.

Obviously, pattern matching ...rest on a tuple is producing a tuple. Tuple structs also produce tuples for a ...rest pattern. Arrays are the odd one out in that ...rest produces an array. Well, and slices would produce a slice, if permitted there, which has been left as an unresolved todo for now.

I will admit that matching kind is almost certainly preferable than rest @ .. making an array but rest @ ...

[static for] is unrolled at compile-time.

It should be noted in the eventual documentation that this is macro-style expansion (so I'd word it as "expanded" instead of "unrolled"). Notably to call out that this means the syntax is the same across each member but name resolution can give different results.

For the concrete case, anyway. Along with that it should be noted that static for name resolution for generic parameter packs is still done with the generic bounds only.

static for loops evaluate to a tuple.

I think it was already noted upthread, but if we want to not require every (almost: see next point) static for to have a semicolon, we'll have to adjust the semicolon elision rules. The current one is that expressions with blocks[2] are syntactically statements when in statement position[1] and are semantically rejected if their evaluated type is anything other than (). For (probably just static for) we'd need to also accept (...(); N).

We probably wont ever change nonstatic for to be able to produce a nonunit value, so I'm actually slightly preferable to leaving static for as always being a ()-valued statement expression rather than evaluating to a tuple. (That makes the pre-section-break about easy conversion to a tuple weaker, though.) Getting a tuple out could then be done the same way is, by creating the output place first and assigning to it. This also makes continue/break more properly consistent with their use in nonstatic for.

[1]: Certain following tokens like . can make them be expressions, but thankfully this is a purely syntactical decision. Also thankfully, loop { break 1 } $op 1 parses the same with either + or - (a statement and a prefix operator expression), despite prefix + not being valid.

[2]: Expressions are classified as block expressions not just for having a trailing block, but also need to be able to evaluate to (); this is why async {} isn't a block statement, for example. It would be very annoying if it were, since to return async {} as a tail expression you'd need to wrap it in (); if it were considered a expression-with-block, it would be parsed as a statement when in plain "tail expression" position.

[static for expansion semantics]

If static for creates "place aliases" instead of a fresh place, some things become a lot easier. To make the comparison clearer, consider this example:

static for i in (a, b) {
    Some(i)
}

There's two ways of translating this; either as a fresh binding/place, roughly:

(match a { i => {
    Some(i)
}, match b { i => {
    Some(i)
})

(using match instead of let because of temporary lifetime implications) or as a place alias, roughly:

({
    Some(a)
}, {
    Some(b)
})

The semantic difference becomes more obvious if we use a more interesting example, e.g. a body of Some(&i) instead:

// fresh place; value does not live long enough
(match a { i => {
    Some(&i)
}, match b { i => {
    Some(&i)
})

// place alias; references each item in the pack
({
    Some(&a)
}, {
    Some(&b)
})

A pattern of ref i would get the same behavior from both expansion forms, but the "place alias" form has a more "do what I mean" behavior. Support for the sort of analogous fine subplace "by use capture" already exists for edition2021+ closures, so it's not unprecedented, and getting "place alias" semantics for static for wouldn't be an outsized amount of additional work compared to using fresh bindings. It would be nonzero, though, since named places aren't a thing in the frontend yet. But "autoref bindings" have been discussed as desirable for a while, at least, and have very similar implications on the frontend as place aliases.

The place alias semantics also make assigning to a tuple without evaluate-to-tuple semantics a lot easier, since it could allow you to static for over uninitialized places:

// variadic
let out: (...,);
static for place, item in out, pack {
    place = Some(item);
}
let tuple = (...out);

// expanded
let (out_0, out_1);
let _: ((), ()) = ({
    out_0 = Some(pack.0);
}, {
    out_1 = Some(pack.1);
});
let tuple = (out_0, out_1);

// Yes, Rust allows deferred initialization of let bindings.

It's less convenient than just evaluating to the tuple, but it does directly mirror what you would do with nonstatic for (if you aren't using iterator combinators).

You can static for over multiple unpackable values at once, as long as they are the same length.

I'm not super fond of static for a, b in as, bs as the syntax for zipping unpackables, since it's so close to a pattern of (a, b), which would unpack each item in a single unpackable. That said, , is in the follow set of both patterns and expressions, so a grammar of static for $($pat:pat),+$(,)? in $($expr:expr),+$(,)? $block:block works without any problems and there's no real alternative I can offer.

error: unexpected `,` in pattern
  -->
   |
LL |     for a, b in iter {}
   |          ^
   |
help: try adding parentheses to match on a tuple
   |
LL |     for (a, b) in iter {}
   |         +    +

This proposal uses square brackets [for lifetime packs]

Square brackets are []. <> are angle brackets, when refered to as brackets.

In terms of syntax, parenthesized list ('a, 'b, ...) is out, as () would be ambiguous in generic argument lists—is it an empty set of lifetimes, or is that set elided and it’s actually a type?

It's almost certainly not happening for edition2024, but I'm actually in favor of entirely deprecating lifetime elision in generic lists, and requiring the use of '_ to explicitly use an inferred/elided lifetime. The main benefit is that doing so would unlock the ability to have defaulted lifetime parameters instead of leaving them to inference, sidestepping the difficulties of making defaults affect inference. The main difficulty is (lack of interest and) that an explicitly empty generics list (e.g. ::<>) is (almost) equivalent to the lack of a generics list; it doesn't specify the lack of any generic arguments (and thus the use of the defaults), it just leaves them as inferred. E.g. you can use Vec::<>::new() just fine, despite Vec<> being an error (struct takes at least 1 generic argument but 0 generic arguments were supplied) as a type annotation.

But even if this were made to be the case and, due to the nonelision of lifetimes, () wouldn't be ambiguous anymore, old editions would still be unable to specify an explicitly empty lifetime variadic. Such an outcome of edition differences is considered at best undesirable.

Without #![feature(unsized_fn_params)], you can’t produce a value of a mutiply-unsized tuple at all.

You need to add "safely" to this assertion; it's possible to create compound structs with an unsized tail on stable Rust, but it's unsafe and very manual. Run this through Miri to get some confidence that it's not doing anything UB (like e.g. creating an allocation with incorrect layout). [playground]

We use usize to store arity, so with this proposal it’s impossible to have a tuple or tuple struct with more than usize::MAX members. Such code would be incredibly degenerate anyway, so I doubt this restriction will be a problem. But it is, in theory, a potential source of post-monomorphization errors.

More than half of the pack items would need to be zero-sized for this to become the issue. If that isn't the case, you'll run into "values of type are too big for the current architecture" errors far sooner, which are also post-mono and (currently) don't get emitted from statically unreachable code.

Type-level for-in

Your markup breaks here.

.. inferred type parameter lists

Why isn't this ...? .. is a rest pattern, but given the pattern name @ .. can be equivalently and more succinctly written as ...name with these language extensions, I'd generally expect to see ... significantly more than .. not meaning a range.

Functions with variadic generic parameters

...ah, apparently since I had first read this I had forgotten that you're (seemingly?) requiring variadics calls to be syntactically provided with a tuple. (Or is that just for explicitness in the examples? If so, an early example should call out that either tupled or not are both considered valid call syntaxes.) This seems a bit inconsistent between the generic parameters (don't use tupling) and the value parameters (do).

Coming into this apparently more fresh than I thought, I'd expect fn drop_all<...Ts>(ts: Ts) to be variadic and accept any number of parameters, and for fn drop_tup<...Ts>(ts: (...Ts,)) to take a tuple argument of any arity. (The former would have separate function argument ABI; the latter would have an ABI taking a single tuple. Both don't tuple their generic arguments.) Instead, if I read correctly, those should be spelled fn drop_all<...Ts>(...ts: ...Ts) and fn drop_tup<...Ts>(ts: Ts).

Tupling at the call site also has implications similar to why place colocation for function parameters can be surprisingly difficult. Like in that case it's not necessarily a deal breaker given careful semantics, it's still potentially surprising to ever permit observably overlapping live places. (This can matter to unsafe even if no provenance valid pointers alias.)

... patterns in parameter lists also work with arrays.

Do they produce array bindings here, like when in local pattern bindings, or do they produce tuples?

Combining function parameter ... and variadic generics gives us varargs.

So I got ahead of myself a bit in the section about just variadic generics but not arguments.

If variadics are tuples, then it makes sense that ts: Ts and ts: (...Ts,) would be equivalent. However, I remain (for now, at least) of the thought that "just" doing so is still overly restrictive on the compiler if we want to do so without significant semantic cleverness allowing variadics to behave not like tuples (i.e. places within the pack being distinct objects rather than subobjects of a tuple). fn variadic(...args: ...Args) is certainly enough to signal that things are different, but saying that args "is" a tuple implies that it has all the semantics of being a tuple place, even if the ABI splats the components.

Given that packs want to be treated differently than tuples by the compiler, I maintain that it makes sense to expose this semantic difference to the developer. To that end, I would currently lean towards defining the variadic pieces roughly along the lines of:

  • In fn<...Ts>, Ts is a variadic type.
  • ts: Ts is never valid; a typical pattern cannot be of variadic type.
  • (...Ts,) reifies the variadic type into a tuple type.
  • ...ts is a variadic pattern.
  • A variadic pattern can be of variadic type, e.g. ...ts: Ts is valid.
  • (...ts,) is a tuple pattern which creates a variadic binding.
  • fn drop_all<...Ts>(...ts: Ts) defines a variadic function taking any number of arguments of any type.
  • fn drop_tup<...Ts>(ts: (...Ts,)) defines a unary function taking any tuple as an argument.
  • fn drop_tup<...Ts>((...ts,): (...Ts)) has the same public signature as the previous definition, but binds the tuple argument to a a variadic binding. As with any other argument pattern, it's as if the function starts by creating a binding like let (...ts,) = _arg; where _arg represents the value passed to the function call[1].
  • Invalid spellings include fn drop_ts<...Ts>(ts: Ts), fn drop_ts<...Ts>(ts: ...Ts), fn drop_ts<...Ts>(...ts: ...Ts), fn drop_ts<...Ts>(...ts: (...Ts,)), and fn drop_ts<...Ts>((...ts,): ...Ts). These are all errors.
  • As a variadic binding, ts cannot be used without either being unpacked (e.g. into a tuple or function arguments) or expanded over with static for.
  • A variadic pattern cannot be assigned from a tuple, e.g. let ...ts = (a, b, c); is invalid.
  • A variadic binding in a tuple pattern can be assigned from a tuple, e.g. let (...ts,) = (a, b, c); is valid. A variadic binding cannot be directly assigned to a tuple binding, even if its statically known, e.g. let (a, b, c) = ts; is invalid.
  • To unpack a variadic binding, you can reify it into a tuple, e.g. let (a, b, c) = (...ts,); is valid.
  • The ...ts and ...Ts variadic syntaxes are generally only ever valid as part of a parenthesized comma-delimited list.
  • (I don't know about let [...ts] = array, though if supported, it would create a parameter pack, not an array.)
  • (This sacrifices the ability to use tuple/array affordances to get heterogeneous/homogeneous packs, as well as being able to simply reuse the T; N notation for known size packs.)
  • (Inferring that packs need to be the same size if they're unpacked together in the signature seems nice, but a bit more implicit than typical for Rust signatures, and doesn't really[2] handle when the zip only happens in the body.)

I haven't spent the effort to go through the entire ripple effect of these initial choices. I know there would be multiple resulting impacts to other choices.

I am at least somewhat familiar with C++ template parameter packs, so I am at least somewhat influenced by them. I chose to keep the ... consistently on the left not not because how C++'s syntax trends to an "is it on the left or the right" ambiguity. IIRC, I originally learned C++ variadics first formatting as typename ...Ts and Ts ...ts rather than typename... Ts and Ts... ts.


Modulo the notes above — almost all of which are nits, honestly — this is a really strong foundation. I've some experience authoring RFCs (though none that have actually gotten officially addressed, because prioritization, and more that never actually got posted officially for similar reasons), and would be willing to help coauthor a proper RFC series for this if we can recruit a T-lang liason/sponsor.

T-opsem (of which I'm a member) is a subteam of T-lang, but I/we don't have any real pull on T-lang agenda (yet?), and definitely shouldn't for something like this which is outside T-opsem's domain.

This is a big enough addition that it should probably get an "intent to experiment in tree" (MCP? eRFC? Initiative?) and a proof-of-concept partial/incomplete implementation before full RFC. Based on (vibes of) current T-lang initiatives, I think the earliest I'd dare hope to see any real team acknowledgement of the "variadics initiative" to be after edition2024 ships (so about 1½ years). Out of tree experiments (e.g. like ThePhD/PhantomDerp/JeanHeyd was doing with uwuflection) would of course still be possible in the meantime.

FWIW, the large chunks I see would be:

  • Pattern un/packing of tuples/arrays,
  • static for expansion over tuples/arrays, and
  • Variadic generics and arguments,

and I see those three as likely three separate RFCs in a series. I can't quite decide what would be the ideal cadence to RFC them, though; simultaneously is bound to get comments on the final about the earlier and run into issues with the groundwork getting rewritten out from under it, but waiting until the earlier are FCPd to post the latter could easily lose context from the earlier about why some specific choices are made to assist later additions.

Just splitting into two (combining un/packing and static for into one) is also a reasonable division imho, and might be able to motivate the non-variadic part more strongly independently of variadics. But the two are mostly disjoint functionality only really tied together in how they are used to interact with variadics.


  1. Fun trivia: let _ = place; doesn't move from the place. Any temporaries are dropped, but if the assignment rhs is a place rather than a temporary value, it doesn't get dropped. Consistent with this, _ bindings for function arguments do not drop the argument at the start of the function! If not moved from, arguments are dropped in reverse order (the last declared is the first dropped) after any locals have been dropped. ↩︎

  2. The same stupid workaround as currently used for feature(generic_const_exprs) of an empty trait bound would work to add such a constraint to the signature, e.g. (...(As, Bs)): /*nothing*/. ↩︎

3 Likes

Good catch, I hadn't considered this case. Maybe the guaranteed-optimization path could be expanded to references that are immediately splatted (with static for or ...)?

In general, my thinking here is that unsized_fn_params is a niche feature, it's not even clear when or if it will ever be stabilized. I would rather keep the common case simple (just tuples, no "parameter packs"), and reserve more complex rules for the uncommon case.

Maybe, but remember that by-ref tuple bindings are special, in that you get a tuple of references instead of a reference to a tuple. I feel that ref ...head better reflects this, perhaps there should be different syntax for arrays vs tuples?

My thinking here is "probay not in the MVP, maybe later." I would want to see concrete use-cases first.

Yeah, this was noted before, I am not sure of the best solution. Would it work to just add a coercion from ((), (), ...) to () I wonder? But even so, the fact that adding a break changes the type of the loop expression is quite ugly, bad for readability. Perhaps there should be a different keyword for value-retuning loops?

(I presume you mean place = Some(item)) This is either brilliant or madness, I will have to sleep on it before deciding.

Thanks for pointing out this typo, will fix.

I am worried about issues with early vs late bound lifetime params here. Especially as those rules aren't set in stone.

Multiply-unsized—ie (str, str), not (u8, str).

Yes, for consistency with existing rest patterns.

I am not convinced, my instinct is that this complexity is incidental and I see no good reason to burden the programmer with it.

I chose ... for two reasons: consistency with FRU, and so it's the first things your eyes see when reading the code (which IMO it should be, in the context of a parenthesized list).

1 Like

I really hope you do find a T-lang sponsor (though I'm not feeling super optimistic). The lack of enthusiasm from the lang team in the past has been the major reason I've stopped pushing for variadic generics.

1 Like

Having slept on it, I think I am against it. for mut foo in [1, 2] { foo = 3; } works today, it would be too confusing if static for had different semantics here.

The HackMD has undergone another major revision. I've eliminated the (...Ts) generic parameter syntax in favor of a Tuple trait, re-organized some of the sections, and made various minor changes.

(I did mean to write place = Some(item).)

To be clear, the objection follows the line that for $pat in $iter iterates $iter by value, and the bound pattern is independent of the iterated items, so making static for iterate "place alias" would diverge the behavior from for in a surprising manner.

Like how the for desugar temporary works, the expansion of static for should thus behave like

// variadic
static for ref item in (a, b) {
    process(item)
}

// expanded
match (a, b) { tmp => (
    match tmp.0 { ref item => {
        process(item)
    }},
    match tmp.1 { ref item => {
        process(item)
    }},
)}

(Temporary lifetimes are annoying :upside_down_face:)

My initial intuition is that if ...head is (A, B, C), ref ...head would be ref (A, B, C), i.e. &(A, B, C) and not (&A, &B, &C). ...ref head feels more "inside" the pack to me, and that each item gets ref.

Either way could theoretically work and will catch some people by surprise. It definitely needs to do "the same thing" for both tuples and arrays, even if the pack kind is different.

Unfortunately I think this is a case of either allow pack patterns to nest a pattern (e.g. ...Foo(a, _)) or restrict them to simple bindings forever. Again, either is a functional choice, but they have an impact on how pattern decorations like ref work; the former naturally allows ...ref head (because ref head is a pattern), whereas the latter lends itself more to ref ...head (since ...head is a special form of ident pattern).

...$pat has a simple definition, FWIW: any bindings within $pat are pack bindings populated by repeatedly matching the pattern against the relevant scrutinees.

Yes with an asterisk; adding new coercions is always worrying because of inference implications. The intersection between coercions and inference is a big reason why feature(never_type) has been stuck in unstable limbo for so long.

It's annoyingly noisy syntax to require and a divergence from usual expression orientation, but requiring the use of static continue to get a tuple result would work. (continue and break would treat it like any other for.)

An annoyingly subtle option would be to distinguish between having a tail expression or not, with a tail expression in the loop body getting tuple translation, and a tail statement in the loop body getting unit translation. But that's already the difference between blocks returning T or (), so…. As long as it doesn't impact parsing, it's not egregiously bad, because the compiler can catch typos.

A legitimate option, even though it feels like a cop-out, is just to not address it and require a semicolon for use as a statement. Probably also forbid break for the MVP in that case. It'd be a bit of an odd syntax quirk, but a minor one, and one the compiler can easily notice and fix.

A proper cop-out would be to just stuff it behind macros, e.g. tuple_for! and static_for!.

The aren't any clearly good choices. Even completely ignoring unsized parameters, between the options of

  1. Always treat packs as tuples, requiring stack inefficiency when referencing pack items in order to provide correct place semantics;
  2. Pretend packs are just tuples, but actually give them different semantics when referencing pack items to avoid needing to reify the tuple layout;
  3. Treat packs as a distinct thing from tuples, but with a by-value coercion to tuple available; or
  4. Treat packs as a district thing from tuples, keeping conversion to tuples reasonably simple;

I will fight against (2) for being unnecessarily antagonistic towards unsafe authors trying to understand the semantics of Rust pointers, and be forever disappointed at (1) for preventing packs/variadics from being a zero overhead abstraction.

(1) is especially annoying since Rust already has a reputation for lending itself to being stack inefficient and doing more copies than would be done in similar code in other systems programming languages; we'd be introducing a feature knowing it's going to unavoidably make the issue worse.

(2) is extra bad since we'd be claiming let tuple = make_tuple(); and let (...pack,) = make_tuple(); are equivalent, but actually treating them differently in an edge case people are historically really bad at keeping straight. It's significantly worse for two things to be almost identical than to be more obviously different, because this makes it easy to assume they're actually identical and cause problems when you eventually hit the edge case. And the edge case here is only hittable with unsafe, where the compiler can't help in any way.

That leaves (3) and (4) as options. (3) preserves most of "can be used as a tuple;" I'd expect it to be implemented as the pack eagerly coercing to a tuple whenever it gets used as a value, and being invalid used as a place (except as a static for scrutinee or an unpack). I have a weak preference for (4) for being less involved since it doesn't have the coercion, but am fine with (3); the restrictions from unsized pack items is the same as restrictions on unsized parameters.

std::marker::Tuple

1 Like

Ok, having re-read this I think I understand the issue now. Is there any documentation about these rules I can read up on?

There's not really a good place for discovering "high level" implications like this; they're derived knowledge from smaller, individually obvious properties. In this case the main property is "comparing the address of two fields of a type will always give the same result," but also that operations like pointer::add talk about the bounds of the allocation.

One of the goals of T-opsem is to have documentation that can be used to answer questions like this. Eventually it might fall to the specification project to house such. The current draft home of precise answers is MiniRust.

The general guideline is that if there's any possible way that a program could observe some fact not being true (e.g. that a vararg pack is a tuple, by observing the relative address of the args compared to a "plain" tuple), then that fact must be maintained by the compiler. This is the "as-if" rule; only if the compiler can prove with absolute certainty that some fact is not observed can it violate it.

The burden of a systems language is that the behavior of the machine is highly observable. Inlining is the magical optimization enabling the optimization of high-level code in systems languages, because with increased code vision the compiler can see you aren't doing low level tricks.

Address stability, uniqueness, and disjointness are the biggest restrictions to optimization of a language which makes addresses observable (beyond object identity).

It's always a tradeoff. The more regular an abstraction is, the more overhead it has in order to provide that regularity when the compiler can't completely cut out the abstraction via inlining. This is often a reasonable choice to make for libraries, but the language has a higher standard for minimizing incidental overhead.

2 Likes

What if ...&tuple and static for reference in &tuple resulted in references that are considered to point to distinct allocated objects? Would that make sense/recover optimization potential?

That could work, but either:

  • It logically moves out of the tuple to the fresh places instead of iterating by reference (i.e. static for ref item in tuple);
  • You get case (2) where packs have different semantics than proper tuples; or
  • Through complicated spec trickery you say it also does "place splitting" for tuples, but
    • in practice, the address of proper tuple members won't change, so it'll still effectively look like (2),
    • this makes it a semantically meaningful pessimization to extract a temporary variable (not unprecedented but still undesirable),
    • allowing colocated objects in the opsem is complicated, especially if writable.

It sounds somewhat reasonable on the surface, and it might be possible to make work, but the implementation in the compiler is almost certainly still going to need to make packs their own type kind to get the desired semantics (with just assigning collected varargs to a tuple, there's no way to tell LLVM it's allowed to split the alloca into multiple; we need to do that on our side). I'm also not certain how you'd even spec it operationally to work for mutable[1] references (incl. shared mutable).

The only way I can think of to get the requested behavior would be to specifically say something like "for the purpose of operations that care about the bounds of an allocation, the bounds accessible by this pointer are constrained to this single field" (very roughly, the maximally strict subobject provenance) but there are issues with taking this restrictive approach instead of a constructive/operational approach. Not the least being that this by itself isn't enough — the addresses of the tuple fields still have a fixed and observable offset between them[2] — and I'm not fully confident that there aren't other ways to rely on the container layout for the absence of UB.

I do think the more honest option is to surface packs being a different thing from tuples. But importantly, they're not a type! (Types have consistent layout, packs deliberately shouldn't.) Rather, they're a different kind of binding, binding a single name to (potentially) many values. We can still talk about the pack binding as "having" a tuple type (i.e. for the purpose of explaining what you can do with it and ascribing a type to the binding), but packs being a binding of one name to many values is imho an approachable and understandable reason as to why it wouldn't be able to be manipulated like regular bindings.

At some rough high level it kinda feels conceptually similar to references. C++ references aren't proper types the way Rust references are. IIUC, they were originally conceptualized as what I'm calling "place aliases" and as a decorator on the binding rather than the type (e.g. Type &name versus Type& name). In C++, you can't have a reference reference (T&& is something different, and constructing T& & with templates immediately normalizes to just T&) and oftentimes templates just don't work with references (e.g. std::optional<T&> = delete); using std::reference_wrapper is required to get regular type semantics for references.

Rust absolutely enjoys benefits from references being regular types. It'd certainly be nice if packs could be regular as well. It's frustrating that they almost can be but for a few known obstacles. If "packs are just tuples" worked more generally, treating them regularly like other types would certainly be preferable. But given there needs to be special handling at least for packs of lifetimes, it seems reasonable to let packs be different rather than try to force them into being regular. Something being almost but not quite regular in some cases (e.g. C++ references, though packs would be less irregular than them) is worse than being clearly irregular from early on.

Btw, if you want to talk about this with a bit lower latency, ping me on the Discord (project or community) or the Zulip; I'll show up as CAD97 there as well. You could also post in the T-lang channel desire to create a variadics project at some point if you're interested in making the (slow, extended) push towards getting a T-lang liaison/sponsor and eventually seeing this in the compiler.


A couple more minor things that popped into my head, and one major one at the end:

core::marker::Tuple

If it's a pure marker trait (i.e. has no members and never will), core::marker is a reasonable home for it. If it has associated members, though, the core::primitive module feels like it might be a better fit[3]. For: tuples are a primitive types, and documented as such. Against: compound primitive types generic over / including user types don't feel as primitive, and core::primitive's current primary missive is for primitive types with shadowable names rather than types with syntax (e.g. there's no type Ref<'a, T> = &'a T).

<Ts as Tuple>::ARITY

"Arity" is the formal name, but Rust generally just calls it the tuple length. E.g. from the primitive tuple docs:

Tuples are finite. In other words, a tuple has a length. Here’s a tuple of length 3:

("hello", 5, 'c');

‘Length’ is also sometimes called ‘arity’ here; each tuple of a different length is a different, distinct type.

ref ...pack vs ...ref pack

Since you're using ...&pack as the expression to expand to a reference (individually) to each item in the pack/tuple, the pattern dual to that is ...ref pack, causing each item to be referenced (individually) by the bound pack.

Expressions and patterns aren't perfect duals, but we should avoid diverging them more than required. We should either

  • use ...ref pack for patterns and ...&pack for expressions, or
  • use ref ...pack for patterns and &...pack for expressions,

in order to maintain that dual.

Using the latter could actually potentially help somewhat in the "let packs be just tuples" problem, since now you aren't syntactically creating a reference to a tuple. (Though tbf when you have a tuple reference, &...*tuple is awkward when we have pattern binding modes and autoderef trying to remove this kind of "reference coercion" noise.)

It means you can have a simple syntactical rule that if a pack is ever used except directly in an unpack, it becomes a tuple, and it stays as separate places if it's only used directly by unpacks. I don't like bypassing expression composition to put that behavior onto ...&pack, but I'd be able to accept this as workable. static for still uses &pack and not &...pack so it's still not great, but it can be workable. But it'd still remain that packs are observably different from tuples; it'd just be that packs easily decay/infer to be tuples by some easily predictable rule.

Even though I agree that expansion expressions more complicated than simple list expansion should use a loop styled expression, (...pack,) to unpack into a tuple is very reasonable. If converting a pack to a tuple can be as simple as let tuple = (...pack,); (or let array = [...pack,]; for uniformly typed packs), automatic conversion isn't saving that much developer effort. The compiler should absolutely be able to see the use of a pack outside an unpack context and give a useful error suggesting to unpack it either inline or at the time of binding.

"place alias" static for semantics

I'm admitting it's an unlikely long shot. That it's potentially quite surprising. But it would solve some problems (e.g. can iterate by value over a varargs pack including unsized types), and perhaps calling it macro for would help conceptualize that given macro for place in pack, mentioning place is equivalent to mentioning pack.{N}.

Combined with spelling referencing unpack expressions as &...pack, this would allow elimination of the need to syntactically take a reference to a pack when references to its members are what's desired. It doesn't itself solve the "containing object" observability, but it manages to constrain the required scope of "not actually a tuple" manipulation to a more intuitive minimum.

Highly tangential: macro let

I've argued a couple times to add some sort of $:place and/or $:value and/or $:param matcher for macro_rules!, to make it easier to get function call-ish semantics for macros, with arguments evaluated exactly once and temporary lifetimes having the correct extent. $:value/$:param would be intended to behave exactly like a function argument and take ownership of the value, but $:place would behave more like $:expr and only capture the value as necessary (e.g. println! only uses its arguments by reference, not moving from them, despite using by-value syntax), just now with single upfront evaluation semantics instead of being reevaluated at each position the binder is used. I believe $:param can be accurately emulated with match $param { param => { /* body */ } }, but the extra code does come with a compilation time penalty, and macro authors need to both know and remember to use this trick.

Along similar lines, there's been some vague talk around some kind of k#autoref binding mode that would permit code (mostly macros) to extract names for partial expressions but while preserving autoref behavior (i.e. using a place by-value, by-ref, or by-mut) determined by usage of the named binding.

A somewhat interesting alternative popped into my head: macro let. The point being that macro already carries the copy/paste semantic connotation, so when writing something like macro let name = some.place[expr]; it shouldn't be too surprising for name to maintain autoref semantics for the assigned expression. It would ofc still maintain that the place is evaluated once at the let; syntactic repetition of the expression can and should still use macro_rules!. It's not fully general


impl<T: Tuple>

While I favor this simplification for non-varargs generics, to point out an obvious consequence: they're almost certainly no longer going to be usable with multiply-unsized varargs. Because for a tuple type to exist (and thus to have a layout which can be queried by offset_of!) it can only have a single tail unsized member.

This is honestly fine; it's an extreme edge case where this matters and using vararg generics instead would be ambiguous. In the case where it's absolutely required for some reason, a dummy generic of a different kind (i.e. const generic) can be used to split two vararg generics; we no longer require a strict {lifetime, type, const} ordering between generic kinds.

Yes, tuple WF could be loosened to to allow multiply unsized tuples to exist at a type level, or we could "just" have different WF rules for unpacked tuples than proper tuple types[4], but this is a very annoying thing to have leak to the rest of the language.

where Ts::ARITY == Us::ARITY

Some sort of additional notation is generally considered to be necessary to add const bounds to where clauses, to switch from type context to expression context. The current nightly hack would usually be Bool<{ Ts::ARITY == Us::ARITY }>: True (but others are possible), and allowing const { Ts::ARITY == Us::ARITY } has been mentioned.

I think I'd stick to having for<T, U in Ts, Us> in the signature implying matching arity for now, I think, to avoid getting into the weeds of the problems with const bounds (in short, SAT solving), meaning you can just remove the where clause entirety from the two examples using const == bounds in where.

(I think this implication is roughly comparable in impact to implied lifetime bounds (e.g. &'a T implies T: 'a, and for<'a> Ty<'a> implying bounds on 'a), so should hopefully be considered acceptable.)

...pack

What exactly is allowed for pack in an unpack expression? Is it any tuple-valued expression?

// e.g.:
call(...make_tuple(dispatch),);

// if so:
call(...static for item in make_tuple(dispatch) { item });

// potential misinterpretation:
call(...static for dispatch in dispatch { make_tuple(dispatch) });

Is it restricted to exactly just syntactically ...name, ...&name, and ...&mut name? What about iterating name: Box<(A, B)> by value with ...*name? More layers of (de)referencing?

The examples use ...(tup, ple), so literal syntax for unpackable types are allowed, it seems. Do these allow arbitrary nested syntax or is it restricted somehow? Perhaps to the "pattern or expression" expression syntax allowed in pattern assignment? Or related to the "pure initializer" rules used for static promotion? If I static for _ in (call(), call()), do all iterated values get evaluated at once up front, or interleaved with the loop body evaluation? If up front, how can I get the interleaved seq-macro semantics?

If it's stricter than "any expression," is this a syntactic restriction or is it a semantic one? (I.e. does it apply when hidden under #[cfg(FALSE)] or not?)

How strict are the type requirements? Is it restricted to just tuples, tuple structs, (packs,) and arrays? Do we allow references (single-level) of them? Does autoderef coercion get applied?

for loops use the IntoIterator trait to consume their iterable. Should / why shouldn't static for use some Unpack trait? The output type is statically known and variadics allow blanket implementing it for all tuples, after all. And would allow custom types to opt into being unpackable rather than automatically just being or not.

Can I have a return type generic over arity which isn't constrained to match an input?

These all have simple answers available but aren't entirely self-evident, so need to have those answers given. "Here's a bunch of examples" is good to explain how to use a thing, but the edge cases are just as important if not more for a good proposal.


  1. In order to support copy elision of abi-by-ref syntax-by-value parameters, we're eventually going to have to solve a similar spec issue to permit live objects to alias in this case. But this is a much simpler case; while the parent place is (potentially) still live, it's protected (UB to read/write from) for the call scope and has been uninitialized (so observing the value in the place after the call is UB). ↩︎

  2. It would actually be marginally easier to do for Abstract C++, because C++ makes (nonequality) comparison of pointers to different allocated objects UB, and leaves casts between pointers and integers entirely implementation defined. Thus in C++ there'd be no spec-compliant way to determine the layout offset between two items. Rust is much more permissive here, allowing comparison between arbitrary pointers (by address, allowing you to wrapping_offset to an equivalent address), and guaranteeing that casting to/from usize doesn't change the address offset between pointers. (See provenance rules for pointer validity implications of doing such tricks.) This makes address stability more observable (thus restrictive) in Rust than Abstract C++. (Strictly speaking, I believe a C++ implementation could be within rights to relocate allocations to a different address so long as it can fix up any and all pointers into the allocation to point to the relocated one, at least until PNVI provenance rules in the spec make tracking such impractical; our hypothetical implementation sidesteps that being a problem by ptr/int casts always producing 1.) ↩︎

  3. Especially with my pre-RFC that would also add traits there. ↩︎

  4. An interesting tidbit: for similar reasons as type aliases ignoring trait bounds, they also ignore WF, e.g. you can define a type alias to (str, str) or [[u8]] today, and it only errors when you try to use it. ↩︎

1 Like

On pack vs tuple varargs: one thing to keep in mind is that the proposal as written also sometimes binds varargs to arrays (for example, see the cmp::max example).

That's reasonable, I'll rename it.

Arguably Sized conceptually sort of has an associated member already, just spelled core::mem::size_of<T>() instead of <T as Sized>::SIZE. I'll add a bikeshed TODO.

Interesting idea. The interaction with static for is not great (as you note), but if we can get around that maybe this is the way to go. Perhaps combined with ... performing autoderef, to get &...ref_to_tup instead of &...*ref_to_tup?

Subtle nit: it's not "in the signature," it's "as part of a where clause" (as opposed to "as part of a type").

//fn not_legal<Ts: Tuple, Us: Tuple>foo(_: for<T, U in Ts, Us> (T, U)) {}

fn legal<Ts: Tuple, Us: Tuple>foo(_: for<T, U in Ts, Us> (T, U))
where
    for<T, U in Ts, Us> ():,
{}

But maybe that should be changed.

At the value level, ... is an eager, prefix, unary operator, like ! (not), - (negate), * (deref), &, &mut… It can be applied to any expression that evaluates to an acceptable type: tuple, tuple struct, array, or &/&mut of the preceding. In addition, if unpacking a (reference to a) tuple struct, all the fields of the struct must be visible to you. This list is documented in the very first section of the doc. (TODO: should raw pointers also be allowed, with an implicit wrapping_add?)

At the type level, ... applies to a type. Only tuple and array types are allowed.

Yes.

I'm not sure what you are asking?

We should not have such a trait, as trait impls don't (at present) have privacy, but you can only unpack a tuple struct if all its fields are visible to you. Also, IDK what the use-case for such a thing would be; all unpackable types can be trivially converted to tuples, so in maximally generic contexts, you should just use tuples.

WDYM by "input"?

If we are to go with (3), the question becomes—how to distinguish them? No matter what, these "packs" are meant to serve a tuple-like (or array-like, for homogeneous varargs) purpose, so they will naturally look a lot like these. If it is to quack like a duck, it will probably be awfully duck-like!

You never used it to pass tuple structs into variadic functions in any examples. Is that use case rejected?

    assert_eq!(id_at_least_one(3), (3,));

This doesn't make sense, because the type signature specified a tuple but 3 isn't a tuple.

Do you mean something like this?

struct Foo(i32, i32);
let foo = Foo(1, 2);
let m = std::cmp::max(...foo);
assert_eq!(m, 2);

It's allowed, yes.

(3,) (note the comma) is a one-element tuple.

I meant the argument, not the return value. It should be passing in (3,), but instead passes in 3.

1 Like

Ah, my bad, it's fixed.

I've made two minor tweaks:

  • Type-level for-in "pre-splats." So type OptionTuple<Ts: Tuple> = for<T in Ts> Option<T>; must now be written as type OptionTuple<Ts: Tuple> = (for<T in Ts> Option<T>);, and type OptionTupleI32<Ts: Tuple> = (...for<T in Ts> Option<T>, i32) becomes (for<T in Ts> Option<T>, i32).

  • All varargs now go through tuples, never arrays. (This may be changed to a new "pack" concept in the future, depending on how the opsem/SROA issues are resolved.)

Using ... in patterns is still all right, but is it possible to abandon the idea of ​​using ... in function and structure signatures?

The for-in design is fine, but the ... in function signatures makes them unreadable. This is the worst design that comes to mind. Can't we just use for-in?

As for for-in itself, it might be better if instead of for<T, U in Ts, Us> we write for<T in Ts, U in Us>. So it will be:

  1. Easier to read, especially with a large number of generics.

  2. Easier to format, because you can split T in Ts and U in Us to different lines without losing the meaning. This is especially true if generic names are not just letters.

This is just an assertion without any provided reasoning. Pure aesthetic choices are prone to such (it doesn't matter what color the bikeshed is, but everyone has an opinion), but it's not helpful without some attempted explanation for why you would prefer a different choice. Or at the very minimum, actually providing an example of what "just use for-in" actually looks like.

Personally, I find the ... splat a bit easier to read for the simple, no-manipulation cases, e.g.

// trait/fn
trait FnOnce<...Args>
where
    (...Args,): ?Sized,
{
    type Output;
    fn call_once(self, ...args: ...Args);
}

// instead of
trait FnOnce<...Args>
where
    (for<Arg in Args> Arg,): ?Sized,
{
    type Output;
    fn call_once(self, for<Arg in Args> arg: Arg);
}

// or perhaps
trait FnOnce<...Args>
where
    (for<Arg in Args> Arg,): ?Sized,
{
    type Output;
    fn call_once(self, ...arg: for<Arg in Args> Arg);
}
// struct
struct Tuple<...Args>(...Args);
struct Record<...Args> {
    ...field: ...Args,
}

// instead of
struct Tuple<...Args>(for<Arg in Args> Arg);
struct Record<...Args> {
    for<Arg in Args>
    field: Arg,
}

// or perhaps
struct Tuple<...Args>(for<Arg in Args> Arg);
struct Record<...Args> {
    ...field: for<Arg in Args> Arg,
}

That said, however, at least in my mental model for how splat places should work, for<Arg in Args> Arg and ...Args should have identical semantics and work in the same places, the latter basically being sugar for the no-op "identity" case of the former.

I think the former was chosen for three primary reasons:

  • for<T, U in Ts, Us> is very different from for<T in Ts> for<U in Us>.
  • It's closer to the runtime iterator equivalent, for (a, b) in zip(as, bs).
  • It makes the "same arity" requirement from zipping the two unpacks more apparent.

That said, I do agree that the latter is likely preferable when the generic/pack types are more interestingly named. (And I'm generally an advocate for naming generics more meaningfully than 'a, T when semantic names are available.) However, this comes with one major conditional: the change should be applied to static for as well. To illustrate, here's the advanced examples from the design doc in closer to your suggested bikeshed color:

/// Pair of tuples → tuple of pairs
pub fn zip<Ts: Tuple, Us: Tuple>(
    (ts, us): (Ts, Us),
) -> (for<T in Ts, U in Us> (T, U)) {
    let ...pack = static for
        t in ts,
        u in us,
    {
        (t, u)
    };
    (...pack)
}

// or formatted as a one-liner:
//     (static for t in ts, u in us { (t, u) })

/// Tuple of pairs → pair of tuples
pub fn unzip<Ts: Tuple, Us: Tuple>(
    zipped: (for<T in Ts, U in Us> (T, U)),
) -> (Ts, Us) {
    // This is a bit dense, tbh
    let (...(ts, us)) = zipped;
    ((...ts), (...us))
}

// Transpose is a pain as you need to cross pack layers
// But with a pivot table you can accomplish anything

/// NxM → MxN transformation, generalized un/zip
pub fn transpose<NxM: Tuple, const M: usize>(
    nxm: NxM,
) -> _
    // I cannot figure this return type out;
    // it requires transposing pack layers
where
    for<Ts in Tss> Ts: Tuple<const LEN = M>,
{
    const N = NxM::LEN;
    // This is quite annoyingly involved
    let ...mxn = static for _ in [(); M] {
        (static for _ in [(); N] { None })
    };
    static for
        xm in nxm,
        const n in array::from_fn(identity),
    {
        static for
            x in xm,
            xn in &mut ...mxn,
        {
            xn.(n) = Some(x);
        }
    }
    (static for xn in ...mxn {
        (static for x in xn { x.unwrap() })
    })
}
1 Like

What specifically do you believe the issue is? "It's unreadable" isn't actionable without reasoning behind it; I don't want to change it to something even worse!

That is the current state of things, yes.

I hadn't thought of this at all, but I think I like it. The big issue is how to support the transpose examples.

3 Likes