Idea for minimal variadic type parameters


#1

This is a proposal for Variadic type parameters, that is sufficient to make a clean API for the Fn* traits that we could stabilize. It does not create a complete system for general variadic arguments, although I believe it would be forwards compatible with such a system.

To give a concrete example first, the definition of the FnOnce trait would look like this:

pub trait FnOnce<Args: ...> {
  type Output;
  extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}

and an implementation of FnOnce might look like:

impl FnOnce<i32, String, bool> for MyCallable {
  type Output = Result<(), Error>;
  extern "rust-call" fn call_once(self, a: i32, s: String, b: bool) -> Self::Output {
    ...
  }
}

Details

The special token “…” will be allowed as a type bound on generic type parameter, such a type parameter will be referred to as a variadic type parameter. The “…” bound cannot be combined with other bounds with “+”. A generic item must have at most one variadic type parameter, and if it does have one, it must be the last type parameter.

If a type parameter T is “…”, then that type parameter is only allowed in the following positions:

  • The type of the last argument in a function, method, or closure definition
  • As the last type in a tuple type (ex: (i32, u32, T))
  • As the last type in the definition of a tuple like struct (ex: struct MyStruct<T: ...>(i32, u32, T))
  • As the last type parameter to another generic item which accepts a variadic type parameter

In particular, note that you cannot use a variadic type parameter as the type for a field or associated const, as an associated type, or as a return type, although you could use a tuple composed of the types in T in these places by using (T).

When a generic item with a variadic type parameter T is used, rather than having to use exactly one type to substitute into T, T can be replaced with zero or more type parameters separated by commas. Any place where the variadic type parameter was used then expands to those types. If implementing a generic trait, and the variadic type parameter is used as the last argument to a method, the implementation of that method must replace that argument with n arguments, where n is the number of types in T and the types of those arguments match the types that T is replaced with (in order).

If an argument whose type is a variadic type parameter is passed to a function (or function-like object) it is equivalent to passing a list of arguments of the types determined by the variadic type parameter.

Examples

struct TaggedTuple<T: ...>(u32, T);

fn test() {
  let x = TaggedTuple(0, "hello", 23, 5.4);
assert!(x.0 == 0);
assert!(x.1 == "hello");
assert!(x.2 == 23);
assert!(x.3 == 5.4);
}

trait Len {
  fn len() -> usize;
}

impl<Head, Tail: ...> Len for (Head, Tail) {
  fn len() -> usize {
    1 + (Tail)::len()
  }
}

impl Len for () {
  fn len() -> usize {
    0
  }
}

fn partial_apply<T, F, Args: ...>(f: F, first: T) -> impl FnOnce<Args> where F: FnOnce<T, Args>  {
  |args: Args| {
    f(first, args)
  }
}


Alternatives

  • The first pass could leave out allowing variadic type parameters in tuple and tuple-like-struct types. I think that allowing that adds quite a bit of value for not very much cost though.
  • Different syntax for specifying the variadic type parameter
  • wait for a more complete variadics solution
  • stabilize Fn* traits as is
  • https://github.com/rust-lang/rfcs/issues/376

Extensions

An important extension would be to allow combining the “…” contstraint with other type constraints (ex: allow something like ... + Clone to specify a variadic type argument where all types passed must implement Clone).

Another extension would be a way to deconstruct a variadic argument to a function. I’m not entirely sure what this would look like, but would probably involve some kind of compile time pattern matching, or maybe specialization.

Finally, eventually it may be desirable to have some kind of splat operation to pass a tuple as a variadic argument that is expanded.


#2

Some scattered thoughts:


As written, a DST can only ever appear as the last type in variadics, due to the selection of base cases (function args, tuples, tuple structs). (I’m not sure what this says about the proposal, I just find it a bit amusing!)


fn partial_apply<T, F, Args: ...>(f: F, first: T) -> impl FnOnce<Args> where F: FnOnce<T, Args>  {
  |args: Args| {
    f(first, args)
  }
}

Something just doesn’t feel right to me about the syntax. I’m picturing things like

if condition() {
    f(first, args)    // calling with 1 or more args
} else {
    f(first, second)  // calling with two args!
}

and it seems even weirder to me that something like &args would be an invalid expression. I feel like C++ got this somewhat right, by making variadic expressions look like the second class citizens that they really are:

if condition() {
    f(first, args...)
} else {
    f(first, second)
}

Actually, on that note, I’m not sure what the advantages are of this syntax over @eddyb’s syntax in the rfc you linked to (which fits perfectly with the existing pattern syntax for ignoring the rest of a tuple).


No mention is made in the alternatives of the other “obvious” suggestion, which is to change Fn/FnMut/FnOnce to use a cons-list encoding of the arguments instead of tuples.

where F: FnOnce(A, B) -> R
// ...would be sugar for...
where F: FnOnce<Cons<A, Cons<B, NoArgs>>, Output = R>

// what this proposal writes as
fn foo<F, T, B, Args: ...>(f: F) where F: FnOnce(T, Args) -> B {}
// would instead be
fn foo<F, T, Rest>(f: F) where F: FnOnce<Cons<T, Rest>> {}

Which I am a fan of because it requires no additions to the language to be usable (other than allowing Fn to use the same <> syntax as everything else), and only a small number of library additions to be made reasonably ergonomic.


#3

As written, a DST can only ever appear as the last type in variadics, due to the selection of base cases

I’m unfamiliar with DST (I’m guessing that means “dynamic sized type”). Link to documentation or RFC?

I’m not sure what the advantages are of this syntax over @eddyb’s syntax in the rfc you linked to

I would be fine with using ...T instead of T: ..., ...arg for using the argument etc. The biggest syntax advantage I see is syntax for defining methods in concrete implementations of traits that use variadic types (ex call_once), where the args are defined directly instead of having to destructure a tuple. Semantically it has some other advantages, such as the fact that it isn’t directly tied to tuples, which can avoid some layout problems (https://github.com/rust-lang/rfcs/issues/376#issuecomment-213692855), although it probably is a fixable problem with @eddyb’s proposal. The fact that it isn’t tied to tuples also makes it possible to use it in tuple-like structs as well as tuples.

No mention is made in the alternatives of the other “obvious” suggestion, which is to change Fn/FnMut/FnOnce to use a cons-list encoding of the arguments instead of tuples.

That’s true. That’s another alternative. It has the same problem as stabilizing the Fn* traits as is, namely that if we ever add variadic type parameters, the Fn* traits will be stuck using the cons-list, unless the generics system is designed to seamlessly interoperate with cons lists.

it requires no additions to the language to be usable (other than allowing Fn to use the same <> syntax as everything else), and only a small number of library additions to be made reasonably ergonomic.

With macros I suppose we cold get something reasonable: https://play.rust-lang.org/?gist=425865521280bb564b836d5c65f0e8b2&version=stable&mode=debug

But then, in order to call partial_apply you would still need to do something like partial_apply(f, arg_list!(arg1, arg2, arg3)) which is a little unwieldy. I guess what I’m saying is that the cons-list approach doesn’t seem very future-compatible with actual variadic types.


#4

In D language the items of type tuples are accessed with the regular array syntax, that also allows to slice them. This, combined with few other features like static foreach, simplifies hugely the mess that you see in modern C++ to do the same things.


#5

I don’t think that would work in rust without adding a lot more compile time expressiveness (compile time if statements, for loops, etc. Based on type parameters). I don’t see that ever happening. Also, in rust type checking is done before monomorphisation. So, the comiler has know way of knowing during type checking if the type has enough components.


#6

This is done very ad-hoc in D (in fact, C++ could’ve done it too, much easier than Rust can).

It’s not impossible for Rust to support it, but it requires far more advanced typesystem features other than what amounts to little more than syntactic expansion in C++/D.

Specifically, it needs impls like these:

impl<A, B> Index<0usize> for (A, B) {
    type Output = A;
    fn index(&self, _: 0usize) -> &A { &self.0 }
}
impl<A, B> Index<1usize> for (A, B) {
    type Output = B;
    fn index(&self, _: 1usize) -> &B { &self.1 }
}

Where 0usize is what is usually known as “refinement type”, and in this particular case, I’m referring to the type usize, restricted to exactly one of its values, 0.

That would allow value-based dispatch (so (a, b)[0] and (a, b)[1] would work, but not (a, b)[runtime_index]).

for loops could still not work, because for x in tuple would require x to take different types on different iterations, which is better suited for generic closures and internal iteration, unless someone comes up with a solution involving dependent (associated) types or something.


#7

You need something else for slicing too of type tuples.


#8

Just a small orthographic nit: The one-element tuple type is (T,), not (T). Mind the comma.


#9

Parameter packs in C++ are nifty, granted, but unlike C++ Rust has tuples as first-class citizens. Adding parameter packs in addition to tuples would add confusion, IMHO.

Personally, I’d much prefer a “parameters as tuples” approach, where one could treat the parameters passed to a function as a tuple, and conversely apply a tuple to pass to a function as individual parameters.

The TaggedTuple example could be implemented with a type-level concatenation operator, e.g.:

type TaggedTuple<T: Tuple> = (u32,) ++ T;

Also, len should IMHO really be a compiler-provided associated const variable (or const fn) of such a Tuple trait.