Pre-RFC: #[repr(rust)] variadic functions

I have heard that there is a general sentiment that rust doesn't need or shouldn't have variadic functions because the builder pattern is so good. While I must admit that the builder pattern is very good, I still think that there are some use cases for variadic functions.


General Syntax:

function declarations:

fn sum_all(start: usize, items: ...usize) -> usize;

use of functions:

let rest = [4, 6, 9];
sum_all(0, 1, 2, rest...) // == 21

Meaning:

A variadic function can be called with 0 or more arguments. The type is some type that implements Iterator<item = T> for a declaration ...T.

A combination of individual and slice arguments are allowed. They will be iterated over in left to right order for each item. Effectively like iter::once().concat(iter::once()).concat(slice.into_iter()).

Traits:

Such functions would be allowed in traits but would not have to be specified as a type parameter.

Alternatives:

  1. Do nothing, we can currently specify something very close to this in another way: ie the builder pattern.
  2. Do not allow the joining of both individual items and other types of iterators. This is a smaller feature but I think it doesn't have enough weight behind it to be worth the effort.

Variadic functions in a single type is not the big issue. We can model that with slices, or if we need owned values, we can use iterators. If const generics lands, we could also use arrays.

Its generic variadics that needs solving. Where each element in the variadic can be a different type.

10 Likes

Do you mean generic over a Trait or literally any type?

I'm not sure what you're getting at with this question, but I guess the answer is "both"? AFAIK all useful variadic generic code would eventually need a trait bound to show up somewhere, but for the feature to scale to non-trivial examples there's also plenty of intermediate steps that would need to work on literally any types, like turning T1, T2, ... into an N-tuple and then later taking the Xth element of that tuple.

1 Like

Any type, not quite known at compile time. Things like printf. Args for "%d %f %s" are all different, so ...usize or ...T doesn't cut it.

3 Likes

I would think the "printf" type of problem is best/easily solved by using a FormatMe trait and passing an array/slice/etc dyn FormatMe items. Something like:

fn printf_like_function( formatString: &str, args : (dyn FormatMe)[] ) { ... }

Or perhaps it would need to be Box<FormatMe>[] or some-such. Is there something besides this sort of thing where something else would be needed/appropriate?

EDIT: Changed hypothetical trait name to ensure it is understood as not something that already exists necessarily.

EDIT 2: The reason I believe something like this is sufficient, is that, it is highly run-time dependent and so, I would think, that allocation overhead is probably not high on the list due to the inherent inefficiency.

I guess if, you wanted compile-time expansion and dispatch based on the compile-time types, then you'd be looking at something like the Generic/Variadic Tuples proposal RFC

Note: this is exactly how the write macro works (as well as all macros like it, ex. println)

How would the compiler be able to check the format string at compile time if it was a regular function argument (as opposed to the current situation, which is that it's passed to a compiler-defined macro)?

I see. So a lot of work would need to be done to be able to support this. Since it isn't a good idea to just box the trait object.

Would an intermediate solutions using only concrete types be possible as an in between step? That way we can implement some now and the rest later.

Users could then use boxing if they want or something like the auto_enums.

What does “using only concrete types” mean in this context? Any use of generics (variadic or otherwise) must provide concrete types somehow.

Note that we’ve always had variadic macros like vec! and print!, so it’s hard to see what subset of variadic generics could provide any new functionality. In fact, AFAIK the only significant benefit of “proper” variadic generics over macros is additional type safety, so there’s not even much design space to look for subsets in.

Also, the moment you do any kind of runtime indirection like boxing, you’re erasing types and are back to things we already have today like arrays/slices of trait objects.

I mean that the type in the function signature would only be able to be a concrete type.

What I meant by boxing was that even with the above restriction, it would still be possible to use type objects using Box<dyn T>.

I guess there thus another possible alternative: a macro variadic!. Which concerts its arguments into a chain of iterators with the ... meaning use "iter" instead of "once" .

The "uniform variadic" case is handled almost entirely by for<N: usize> [T; N]. (Sure, const generics are a ways off, but so would a variadics implementation.) For a literal list of arguments, this is pretty much how the variadic would be implemented.

The part that isn't covered by taking an array is the spread operator. And those are served by taking a impl [Into]Iterator<Item = T> with a chain! or list! macro to sugar the chaining.

(I assume the presence of [T; N]: IntoIterator<Item=T>.)

Here's a draft of the two macros for sugaring calls to a function taking impl [Into]Iterator:

macro_rules! ichain {
    ($front:expr $(,)?) => { IntoIterator::into_iter($front) };
    ($front:expr $(, $($rest:expr),* $(,)?)?) => {
        itertools::chain($front, ichain!($($($rest),*)?))
    };
}

macro_rules! isplat {
    (...$front:expr $(,)?) => { IntoIterator::into_iter($front) };
    (...$front:expr, $($rest:tt)*) => {
        itertools::chain($front, isplat!($($rest)*))
    };
    ($front:expr $(,)?) => { IntoIterator::into_iter($front) };
    ($front:expr, $($rest:tt)*) => {
        itertools::chain(Some($front), isplat!($($rest)*))
    }
}

ichain at least could probably be added to itertools.

2 Likes

I think that the iterator based solution is better since it would support both the spread operator and using multiple spread operators.

It does mean that direct indexing would not be supported. But I think that usefulness of joining multiple sources outweighs that.

In all cases where a variadic function goes into what is basically an iterator there is no need to make this part of the function call, just a syntactic sugar for constructing another iterator

    iter![1,2,3,rest...]
   // where rest is IntoIterator perhaps.
   // or even
   iter![1,2,3,it1...,12,13,it2...,somevar]

I realize creating a macro that works over the second one would have a fair few challenges.

I guess at the defining end

    fn dothing(a:i32, b:String...){}

Could sugar to

    fn dothing<T:IntoIterator<item=String>(a:i32,b:T){}

It's just sugar, but IntoIterator is pretty core to Rust anyhow.

OTOH:

If the arguments are of different types Then the only way to handle that typesafe would be default value args

Yeah.

Also, there's a lot of things you can do in eg D that don't work with a simple trait solver. Eg writing react-style DOM components for a web framework, with the components represented as structs:

struct Button {
  DomNode!"div" button;
  Action click;
  @prop string innerText = "Click me!";
  @callback void onClick(MouseEvent event) {
    this.emit(click);
  }
}

struct App {
  DomNode!Button button;
  @connect!"button.click" void click() {
    printfln( "Hello world!" );
  }
}

mixin MyWasmDomFramework!App;

I'm not saying the pseudocode above is good or desirable.

But it's expressive, in a way that can't be done with typeless macros and trait arithmetic alone.

EDIT: Actually, I'm realizing the Yew framework does exactly that, so this is a bad example. Not sure what a better example of first-class types used for actual production work would look like.

1 Like

While de sugaring to Iterators makes sense in principle, using iterators often enough involves heap allocation, and it would be a real waste to have to allocate just to use variadic arguments.

1 Like

So is the suggestion that "rest" from the original post would have to be fixed size? to be able to use it, and the receiver would get a slice of that?

In every version of variadics I've come across before, it basically gets thrown into an array/vec of some kind, and the function only exists once.

This version seems to be Generic, and would exist for every number of args it is called with?

I'm not sure this idea would ultimately work, but since the number of variadic items and their types are known at compile time, then in principle they can be allocated much like any other argument to regular fns. One place where that gets complicated is that there are multiple calling conventions, and any fn variadics feature that gets implemented has to work with all of them.

What do you mean by "iterators often enough uses heap allocation". I don't see any Box<T> in any of std::iter::Once, std::iter::Empty, std::iter::Chain, or the IntoIterator implementations for []. Since, I think that these would be the most common building steps I don't see where you claim comes from.

2 Likes

It is sort of generic, but the benefit of using iterators over a slice/vec solution is that there is no need to copy the elements to the vec just for the function (which it probably worse than a heap allocation anyway).