[Pre-pre-RFC] "splatting" for named arguments and function overloading

While discussing C++ overloading on Zulip, a bunch of us (re?)discovered a pretty cute idea, which I'm taking the liberty to flesh out here.

This RFC comes from the realization that "we have named arguments at home" and "we have function overloading at home":

  • Named arguments at home:
struct MethodArgs {
    a: u32,
    b: Option<String> = None, // note the default field value here
    c: bool,
}
impl Bar {
    fn method(&self, args: MethodArgs) { /* whatever */ }
}

bar.method(MethodArgs { a: 42, c: true, .. }); // using the `..` default field value syntax
  • Function overloading at home:
trait MethodArgs: std::marker::Tuple {
    fn call_method(self, this: &Foo);
}
impl MethodArgs for (i32, String) {
    fn call_method(self, this: &Foo) { /* actual code */ }
}
impl MethodArgs for (i32,) {
    fn call_method(self, this: &Foo) { /* actual code */ }
}

impl Foo {
    fn method(&self, args: impl MethodArgs) { args.call_method(self) }
}

foo.method((42,));
foo.method((42, "asdf".to_owned()));

Proposed feature

This RFC proposes a .. syntax when declaring function arguments, which we can apply to our examples above as follows:

impl Bar {
    fn method(&self, ..args: MethodArgs) { ... }
}

bar.method(a: 42, c: true, ..);

impl Foo {
    fn method(&self, ..args: impl MethodArgs) { args.call_method(self) }
}

foo.method(42);
foo.method(42, "asdf".to_owned());

The syntax is ..ident: Type in function arguments, and is called "splatting". It is only allowed on the last argument of a function. It is allowed if Type is a struct or if it implements std::marker::Tuple. This then changes the call syntax of the function.

Struct case

struct Args { /* whatever */ }
fn foo(x: T, y: U, ..args: Args) { /* whatever */ }

foo(<expr1>, <expr2>, x: <expr3>, y: <expr4>)
// actually means, in the normal call syntax:
foo(<expr1>, <expr2>, Args { x: <expr3>, y: <expr4> })

foo(<expr1>, <expr2>, x: <expr3>, y: <expr4>, ..)
// actually means, in the normal call syntax:
foo(<expr1>, <expr2>, Args { x: <expr3>, y: <expr4>, .. })

Tuple case

trait FooArgs: Tuple { /* whatever */ }
fn foo(x: T, y: U, ..args: impl FooArgs) { /* whatever */ }

foo(<expr1>, <expr2>)
// actually means, in the normal call syntax:
foo(<expr1>, <expr2>, ())

foo(<expr1>, <expr2>, <expr3>)
// actually means, in the normal call syntax:
foo(<expr1>, <expr2>, (<expr3>,))

foo(<expr1>, <expr2>, <expr3>, <expr4>)
// actually means, in the normal call syntax:
foo(<expr1>, <expr2>, (<expr3>, <expr4>))

Related work

There are a large number of named arguments proposals on this forum. The one that looks closest to this one is Struct sugar .This is also quite related to Pre-RFC v2 - Static Function Argument Unpacking . Haven't looked into function overloading.

Possible extension

I propose that, if a function uses splatting, then foo(<expr1>, <expr2>, ..<expr3>) is allowed if expr3 has exactly the expected type, and skips the splatting. E.g. with my two running examples above, we could also write this:

let args = MethodArgs { a: 42, c: true, .. };
bar.method(..args); // passes the struct directly

let args = (42, "asdf".to_owned());
foo.method(..args); // passes the tuple directly

I do not propose that this syntax should be allowed on functions that don't use splatting, but I know such a thing has been proposed before (e.g. Pre-RFC v2 - Static Function Argument Unpacking).

Actually ..expr is already a half-open range. We should probably use ... instead...

9 Likes

We also have splatting at home:

fn method(MethodArgs {a, b, c}: MethodArgs)

So this only needs something at call site

6 Likes

Indeed, this is purely for the call site. Your example makes me realize that this should allow more than just idents after .. (and thus ..expr is not at all ok because it's a valid pattern, so gotta switch to ...):

fn method(...MethodArgs {a, b, c}: MethodArgs)

On second thought the syntax could be pat: ...Type actually (instead of ...pat: Type)

1 Like

Really hope there is a way to do .. because it has nice parallels to struct initialization and it is unfortunate the number of slightly related syntax

  • -> vs =>
  • assigning with : (struct field) vs = (variable)

I feel like there are others but can't think of them.

5 Likes

Not really: the tuple trait is nightly only, and doesn't even have a tracking issue. So this only works inside std.

That doesn't work on stable as far as I know? Or did derive of Default become implicit recently? It would be helpful if you included the required #[feature] directives in your examples.

I fear this will encourage awful APIs with dozens of arguments, as seen in C++ and Python (and maybe other languages, but those are the ones I'm most familar with that suffers from this).

Named arguments (struct case) are slightly better, but still have you seen some of the insane examples in popular python libraries? You tend to end up with lots of similar operations being stuffed into one function using 16 different optional arguments, like the proverbial square peg in the round hole.

I'm slightly opposed to this for named arguments, but strongly opposed to this for positional arguments. Especially since nothing seems to prevent you from accepting both (String, i8) and (i8, String) which leads to the full madness of C++ overloading.

That will be abused and lead to worse errors. We already see this with traits that are implemented for several different tuple lengths, like in axum. Rustc will list a number of "did you mean" but it is all useless (T), (T, U) etc, rather than what you were actually looking for.

3 Likes

you can already write code that accepts (i8, String) or (String, i8), yet that doesn't seem to cause issues for the Rust ecosystem:

pub trait MyArgs {
    fn f(self);
}

impl MyArgs for (i8, String) {
    fn f(self) {
        todo!()
    }
}

impl MyArgs for (String, i8) {
    fn f(self) {
        todo!()
    }
}

pub fn f<T, U>(t: T, u: U)
where
    (T, U): MyArgs,
{
    (t, u).f()
}

True, but the call site there looks like (t, u).f(), which hopefully looks sufficiently alien that people don't abuse it. I feel the situation would be very different if it looked like f(t, u).

1 Like

but it can look like f(t, u), that's what the wrapper pub fn f does. this is the exact same way that splatting could simulate overloading.

Ah, you are right. Yeah it is unfortunate that we have that in the language. It is fairly bulky though to set up, and so far people don't seem to abuse that this exists in the language.

That said, I'm not convinced that making it more convenient would be a good idea. Currently it is obscure and clunky to define on the callee side, which discourages it. The generated docs in rustdoc would also be bad.

It also doesn't currently support a variable number of arguments with the wrapper (good), which this pre-RFC would (bad).

1 Like

I'd argue a variable number of arguments is a less problematic form of overloading since it's much easier to see the difference between f(a, b, c) and f() than it is between f(a, b, c) and f(x, y, z) (which can only differ in argument types).

Plus, we already have a bunch of functions that would be really nice to have them become variadic, e.g. min/max and iter::chain/zip.

Another use case I have that isn't nearly as compelling of a use case is that I have a library that is manipulating types at runtime for a domain-specifc language embedded in Rust and it needs some syntax for a runtime equivalent of supplying generic arguments such as T, U, and V for a generic type MyStruct, like the Rust syntax MyStruct<T, U, V>, to generate a runtime type. Currently I'm using the Rust expression MyStruct[T][U][V] since that allows a variable number of generic arguments to be supplied which is needed for handling defaults (e.g. if V is omitted, it could use the default type for that generic parameter instead). It would be waay nicer to be able to write MyStruct(T, U, V) since that returns by value and doesn't need a bunch of intermediate types with Index impls and doesn't need a final step to fill in default values.

2 Likes

This is true, to some extent. See e.g. Erlang and Prolog (though they have both forms of overloading really: functions pattern match their arguments, and this can be used for overloading).

The problem really is that language design informs and shapes the language culture. The path of least resistance is by and large followed. I expanded on why I didn't want overloading in the zulip thread:

But the TLDR is that the devex is worse in languages like C++ that have this feature (C# also according to @scottmcm):

  • Error messages end up with "no overloading matching ..., did you mean <list of 17 different things>"
  • Code review becomes harder (no type info in GitHub, Gitlab etc)
  • Navigating code in your editor becomes harder: clang and RA will often not know where to go when you use macros (Rust and C++) or templates (C++, duck typed) or generics (Rust, currently OK but RA would be confused if I tried to ctrl click inside f() above to go to the inner function). If you are working on a large code base with other people this quickly becomes a problem. Having unique names make things easier to find.
  • Writing code but becomes worse: the IDE will give you less good completions when you are filling in arguments to an overloaded function, and often it will second guess which one you are using and start completing for a different variant. In Rust you select up front by writing different function names.

So, I just don't think it is worth the cost or risk to support function overloading on Rust in a "native feeling" way.

Named arguments is less risky, as the tooling doesn't tend to get as confused. The main risk there is that it encourages overly broad APIs.

5 Likes

I think having to use splatting and trait dispatch will make Rust be capable of overloading but be annoying enough to implement that it will discourage gratuitously overloading everything like in C++ and limit overloading to where there's a major ergonomic improvement e.g. chain/zip. If you think that's insufficient we could use syntax like:

#[discouraged_overloading] // required
pub fn f<Args: MyTrait>(args: ...Args) -> Ret {
    todo!()
}

Maybe also have some other measures to encourage FFI crates like cxx to prefer renaming C++ functions rather than generating overloaded Rust functions

3 Likes

A good principal of design is "there's only one way to do this". This eliminates unnecessary stylistic debates that could go on forever without any gains in expressivity. The above suggestions clearly violate this principle.

You've already demonstrated how this can be implemented already in current Rust and this can be leveraged by tooling such as code generators for FFI bindings. There's is zero need to add syntax sugar for this (especially since the goal is strictly to support FFI to C++). It doesn't matter if it's verbose and salty if it's generated by a tool!

3 Likes

It doesn't matter if it's verbose and salty if it's generated by a tool!

You might be misperceiving the benefits of this: the function definitions would indeed likely be generated by a tool; the point of this feature is to make the call-sites nice, and these are to be written by humans.

Many proposals for variadic generics include something like this, including my own from a while ago (Variadic generics design sketch). Pre-RFC v2 - Static Function Argument Unpacking also made this connection. Ideally, the syntax for unpacking should be consistent with what the rest of variadics would use.

7 Likes

The feature was explicitly presented as syntactic sugar. Your counterargument boils down to "we shouldn't do this because it's just syntactic sugar". By this argument we should remove most of the syntactic sugar from Rust, including for loops.

Clearly there is such a thing as too much syntactic sugar (as one of my professors put it, "syntactic sugar causes semantic cancer"), but at the same time also clearly some syntactic sugar is worth having. To evaluate this proposal one has to judge its benefits against its downsides; just saying "we shouldn't have any syntactic sugar" doesn't really make progress on that evaluation.

10 Likes

However, do keep in mind that one of the defining principles that has shaped Rust's syntax thus far, and kept it from going too far in the direction of Java, is "The syntax should not assume the presence of an IDE".

I can quite comfortably write Rust using nothing but the syntax highlighting in a tool like Vim or, if they add Rust as an option, SciTE and I would be very wary of any feature that pushes the language and its ecosystem away from that dynamic.

(In fact, given that I keep putting off getting the hang of coc.nvim's UI for assists and familiarizing myself with the available set of predefined code snippets, I do write Rust with nothing but syntax highlighting, on-save cargo check/cargo clippy, the occasional code snippet I wrote myself, and occasionally the identifier renaming assist when I remember that this isn't Python and I can trust it to work.)

1 Like

Personally, I think it'd be better to implement inferred struct literal types, so it becomes possible to write something like this:

bar.method(_ { a: 42, c: true, .. })

which only differs from your example by adding _ { at the beginning and } at the end, at which point imo saving three characters is not worth adding a whole new syntactic sugar. You might need to specify the type explicitly if the argument is generic, but I assume this splatting would need that also.

This also allows for the flexibility to have multiple of these if it's useful, like:

struct Foo { .. }

struct FooPartsA { a1: u32, a2: u32, a3: u32 }
struct FooPartsB { b1: u32, b2: u32, b3: u32 }

impl Foo {
    fn from_parts(parts_a: FooPartsA, parts_b: FooPartsB) -> Self { .. }
}

I've written APIs like this Foo::from_parts function, for which each piece might be directly specified at the call site or might be reused, and which aren't as clean to map into your named/default arguments approach.

9 Likes

that doesn't address unnamed arguments, it would be handy to have an official way to do that so we can get rid of extern "rust-call" and finally stabilize implementing the Fn traits, which is what splatting can do.

pub trait Fn<Args: Tuple> {
    type Output;
    fn call(&self, args: ...Args) -> Self::Output;
}