Variadic generics design sketch

This should work, as long as the permissiveness stays behind an unstable feature flag; note that once we lift the requirement for a feature flag, we can't then add new restrictions, so my inclination is to be maximally permissive behind an unstable feature flag, and only release the minimum useful set from under the feature flag at a time.

2 Likes

Update: I've added TODO sections explaining limitations of the design, a few examples of error conditions, and reworked the documentation around "lists of types" (which are now called "generic argument packs").

1 Like

One thing that I'd love to see is the removal of the need for a trailing comma.

The spread operator allows the user to disambiguate the two cases (tuple vs grouped expression) and we could apply the fix over an edition change.

Before:

let x = (12 + 3); // expression 
let y = (12 + 3,); // 1-tuple

After:

let x = ...(12 + 3); // expression using spread operator
let x1 = 12 + 3; // expression by removing superfluous brackets 
let y = (12 + 3); // 1-tuple

Another worthwhile change over an edition is to fix function calls with regards to tuples. Given rust's heritage from the ML family, I never understood why rust deviated here.

fn foo(x: i32, y: i32);
// Should be the same as:
fn foo((x,y): (i32, i32));

Tuples are not the only kinds of splattable values.

struct Point(i32, i32);

impl Add for Point {
    type Output = Self;

    fn add(self, other: Self) -> Self {
        Self(self.0 + other.0, self.1 + other.1)
    }
}

fn main() {
    some_fn(...(Point(1, 3) + Point(2, 2)));
}

Your suggestion to get rid of the trailing comma would change the behavior of the above code, for no good reason that I can see.

I don't agree, that's not consistent with how Rust patterns work in Rust in general. However, I would be open to the below:

fn foo(x: i32, y: i32);
// Should be the same as:
fn foo(...(x,y): (i32, i32));

It is incompatible with my proposal in its current state, but I would definitely consider changing that given proper motivation.

2 Likes

I'm quite not understending how Variadic generics design sketch become to Python pack\unpack and variadic generics.

I don't like this syntax ... and I don't want to see and use it as programmer. In Rust we use patterns for this, not an expressions (note that with ... we can't say what we will have instead of pattern)

Note, that in some examples we have strange meta in function which is make understanding hard to achieve

fn id< ...Ts >( vs : (...Ts,) ) -> (...Ts,) {
    vs
}

I can't understand what function must produces This is obvious bad (at least for me)

I suggest next idea:

fn id<T as U[], U: UsefulTrait>(several: T) -> UsefulValue {
    // Since several is kind of array, we can iterate over it with standart trait Iterator

    let mut consumer = UsefulValue::new();
    for value in several {
       consumer.use_as_usefultrait(value);
    }
    consumer
}

Example with match:

fn reverse_tuple<...Ts: Debug>(ts: (...Ts,)) -> (...Reverse<...Ts>) {
    match ts {
        () => (),
        (head, ...tail) => {
           (...reverse_tuple(tail), head)
        }
    }
}

Becomes to...

// For now it's required to pass value by reference (according to analogue for [T])
fn reverse_tuple<'s, T as U[], U: Debug>(several: &'s T) -> impl Iterator<Item = &'s dyn Debug>{
    // But here we can use standard rev method over non usual collection
    some.iter().map(|i| i as &dyn Debug).rev().collect::<Vec<_>>().into_iter()
}

The idea is less changes per feature is better. Moreover, ... have collisions with (a, b): (A, B) pattern and that is unlikely

P.S. I starting to think that this is better to left this functionality for a while P.P.S. I will continue to read your discussion, so any feedback or critics is good

Yep, that works.

Regarding the tuple struct, I find this to be surprising. I'd prefer that this design would be limited to structural types only (tuples and arrays). Nominal types need a separate discussion IMO. I'm not sure that ...Point(1,3) should be accepted.

To be fair, I've just talked at a high level so there may be other examples where my suggestion breaks, but nevertheless, I still believe that there should be a way to remove the (x,) hack from Rust and that this is a worthwhile goal.

2 Likes

I like the proposal a lot, it's the most rustic-looking take on variadic generics I have seen so far.

One part that seemed a bit unnecessary to me was the reinvention of parameter pack binding in static for, resulting in some inconsistencies.

let t: (usize, i32, bool) = static for <type T, const N: T> in <<usize, 3>, <i32, -10>, <bool, true>> {
    N + T::default()
};

vs

fn foo<T, const N: T>() {
    N + T::default()
};

We already have a syntax for binding generic parameter (pack)s to variables, that's generic parameters which are already defined and extended in the document. No need to invent another syntax for it in the context of static for. Rather, I would suggest putting any type-level arguments in a static for in <>, hence static for <T> in Ts {} instead of static for type T in Ts {}.

The static for syntax is one of the things I am least satisfied with, and open to changing. That being said, I don't totally agree with this framing. Consider today's humble for loop:

//  ╭─ "One part that seems a bit unnecessary to me
//  │  is the reinvention of parameter binding in `for`, resulting in some inconsistencies.
//  │  We already have syntax for binding value parameters to variables,
//  │  it's `(irrefuatable_pattern: Type)`. No need to invent another syntax for it
//  │  in the context of `for`."
// ╭┴────╮
for value in [1, 2, 3] {
    dbg!(value);
}

It's symmetry with standard for, which doesn't enclose its bindings with delimiters, that motivated my choice of syntax.

I don't agree with the claimed inconsistency. We do have one syntax for binding values, that's the pat syntax and you can use it anywhere a new name is introduced: let bindings, for bindings, function parameters, match, etc. The : type part is not part of that syntax (at least for now), and different lexical constructs decide how or whether to allow a type ascription separately.

The main reason static for is complicated here is because it can bind both types and values, but (assuming this is a requirement and you can't just split these into two parts) you can use some local disambiguation for that specific to static for and use pat for values and generic_params for types, just like everywhere else in the grammar.

I've reworked the design to get rid of the "generic argument pack" concept, and just use tuples for everything.

1 Like

I've added support for homogeneously-typed varargs to the design (for example, variadic std::cmp::max).

1 Like

If a static for returns a () for each iteration but doesn’t use break, then the expression will have some tuple type instead of (). For instance,

static for i in (2, "hi", 5.0) {
    v.push(Box::new(i));
}

would have a type of ((), (), ()) instead of (). This will cause a type error if you wrap it in an if-condition:

if a == 2 {
    static for i in (2, "hi", 5.0) {
        v.push(Box::new(i));
    } // expected `()`, found `((), (), ())`
}
1 Like

I didn't read the whole discussion, but just a quick question: why can't we have an inductive definition for tuples?

That is, make two types Cons and Nil so that (a, b, c) is desugared to Cons(a, Cons(b, Nil)).

(Actually Nil may not needed, because it can be just the () type)

Then, treat this like frunk's HList. In particular, to write a trait impl for all tuples, you just need to implement for Nil and Cons.

The downside of the frunk approach is that it leads to worse layout/excess padding compared to just a flat field list. So to keep tuples and variadics as a zero-cost abstraction, we must do things differently.

1 Like

Oh, I agree that a regression on tuple size would be very bad.

But that only happens if you consider the tuple -> cons cell desugaring with cons being a vanilla struct. The main difficulty here is that in order to get a &Cons we need to align the Cons to a specific alignment that may be too large (and which if it were a vanilla struct, it would be the max alignment of all its fields), and also have all its fields contiguous, so (a, b, c) would necessitate to have the fields in the same order as declaration. Rust wants to reorder fields and wants to align them more smartly, to reduce padding.

But there's already attributes like #[repr(packed)] that controls how much padding Rust is allowed to make. With the tuple -> cons desugaring one would need to introduce a new repr, #[repr(tuple)], that can only be applied to Cons and no other type. This would make Cons a weird type indeed.

Okay if this sound too handwavy, well indeed it is :slight_smile: but, it seems to me it may be possible to shift the complexity into a perma-stable (i mean, perma-unstable) repr that is completely internal to the compiler, while never exposing to the user any new complexity specific to variadics. That way, variadic generics on tuples is just regular generics!

By the way, when you linked to haskell as prior art, it's actually the same approach as I'm describing, but in that example instead of defining tuples recursively it relies on the fact that in Haskell functions are curried (in rust terms, an Fn(a, b) -> c would be the same as an Fn(a) -> T where T: Fn(b) -> c). Rust isn't like this, but Rust could add a CurriedFn trait that enabled writing an impl for a N-ary function just looking at the first argument (and processing the other arguments in a later step). This kind of reasoning is what makes it possible to write a variadic function as a plain generic function in Haskell.

All I'm saying is that Rust already has a very powerful trait resolution machinery and it could be leveraged for variadic generics, rather than introducing an entirely new generics syntax.

You're in the process of reinventing CDR coding. That link makes it sound like something very specific to the cons-cell construction of Lisp lists, but it's not; it's fundamentally the insight that if you have this pair of structures

struct A { /* fields ... */ }
struct B {
  /* fields ... */
  a: Box<A>
}

then the box is unnecessary; the memory representation can be the same as

struct BA {
   /* B fields ... */
   /* A fields ... */
}

Lisp Machines added a bunch of tagged-pointer cleverness on top of that to allow for the fundamental dynamism of Lisp. None of it would be necessary for Rust's compile-time-constant-length tuples, and the "fold box into parent structure" layout optimization would also be useful for other things, e.g. Strings whose size is statically known never to change.

1 Like

I think, continue for static for must be called something like static continue to avoid mixing with normal runtime continue.

E.g.:

'runtime: for _ in my_iter {
    let gg: (bool, i32) = static for t in <bool, i32>{
         if check::<t>(){
             // Prevent mixing with 'runtime.
             static continue;
         }
         expensive_compute::<t>()
   };
}

Also, I think, we shouldn't use static keyword here to prevent piling of different meanings of word static like in C++. Maybe something like generic for?

I don’t see why continue couldn’t continue on the innermost loop. If you wanted to continue on the outer loop in your example, then you could just use continue 'runtime.

The main reason would be consistency; "static break" can't really exist, so does continue target the static for and break just fails to compile?

The conservative answer would be to have static for serve as a barrier for continue/break and not permit them to work, requiring static continue to signal the somewhat different behavior for static for or continue 'runtime to target the outer standard loop.

But on the other hand, this also feels like a case of "Stroustrup's Rule;" a desire for syntax to do new, less familiar things to be more verbose, whereas once it's more familiar a more concise syntax is generally preferable. In that vein, I do think that continue/break always hitting the closest loop, not requiring static continue is the preferable semantics.

While this is a different meaning than for static items or the 'static lifetime, it's not a new meaning of the word "static" for Rust. We refer to the type system as being a static type system, or as #[cfg] as being static configuration. If you're going to suggest a different keyword, the best fit would probably be macro for, since the point is, similar to macros, to syntactically paste the same code across multiple variables.

There's also a need for a static if, though, and while it's doing "the same" thing as static for, none of the "better" descriptors for static for also capture what static if is doing. (Monomorphize one or the other branch based on a const evaluated condition, to avoid monomorphization errors from the other arm, but still typecheck both arms pre-mono.)


@Jules-Bertholet: it's not super important to semantics, but what's the expected default (extern "Rust") ABI for Rust variadics? I.e., is it passing the parameter pack as a tuple, or passing them individually as parameters?

If the intent is to treat tuples and parameter packs similarly, I think it'd want to have a tuple's ABI. Especially if taking a reference to the pack is permitted, which would force the pack to be rearranged back into tuple layout in memory. Unfortunately, I think this would result in a lot of shuffling for the same reason just using inductive definitions over tuples isn't satisfactory. It would also prevent the Fn traits from using variadics, since they need to match the non-variadic ABI.

If parameter packs are sufficiently distinct from tuples, then them having a distinct ABI (i.e. as separate parameters) is probably preferable. This makes variadics more easily interchangeable with non-variadics, and minimizes the shuffling required for inductive recursive implementations. The main reason not to do so is if parameter packs implicitly act like tuples, since reifying a parameter bundle to a tuple would then require the shuffling we're trying to avoid by not using a tuple ABI.

While static for should work on tuples and it should be possible to turn tuples into parameter packs, I think it's preferable for parameter packs to not be tuples. Otherwise, they're a lot of extra language semantics just to avoid the minor syntactic overhead of building tuples at call sites, but still with all the other downsides of working with tuples instead of first class variadics. If tuple semantics are desirable, you can just use (or convert to) the tuple instead of a parameter pack.

Personally, I think static for is useful enough to stand on its own merit even without variadics to back it up. Obviously variadics should be kept in mind when designing static for, but static for would still be massively useful even without full support for variadics.

2 Likes

The latter; the HackMD specifies this (Ctrl-F for "calling convention"). Because of this, there are extra restrictions on unsized varargs, because without unsized_locals you can't reconstruct the tuple. In general, I'm relying on optimizations to ensure that the tuple isn't reconstructed unless it needs to be (its address is taken, or it is passed as an argument to a function call). For the unsized_fn_params case, those optimizations would have to become guarantees.

1 Like