Pre-RFC: Re-think `rust-call` and function arguments

Motivation

Functions are a special child right now in Rust and can only be implemented in traits by using unstable features [e.g once you want to call "any" function it soon gets really complicated].

And while the traits of Fn, FnOnce and FnMut work perfectly for their use case of closures, anyone that tried to do something special with functions will soon end in unstable land or has to special case to just one case.

In many cases an indirect call (to e.g. wrap async functions) can use a helper struct that can call exactly one version, but for all those helpers you need to know the exact function signature.

On the other hand you have the "named arguments, please" crowd, but again the most promising solutions (callable structs?) needs the unstable features.

Also then you have things like Generators that now need to pass a tuple in their yield, because function arguments are special, etc.

I also looked into the definition of Fn, FnOnce and FnMut and one of the things that I love about rust is that it "eats it's own dog food" in most any cases. Many things that make the language so nice are under the hood just some syntactic sugar around simple low-level concepts that are very expressive to write.

Suggested resolution

Add a FnArgs trait that implements something like a DerefToFnArgs that can be used everywhere where currently function arguments are used.

Instead of:

foo(arg_0, arg_1, arg_2);

you can also write

let args:FnArgs = (arg_0, arg_1, arg_2).into(); // Tuples implement the necessary FnArgs From trait automatically
foo(args);

The compiler will then de-sugar args back to:

foo(args.0, args.1, args.2);

so the run-time overhead should be zero.

Pro

One could argue that a function always works on tuples automatically and the () to call a function just means that it is a tuple:

So normally a function would be called like:

foo arguments

and the syntax

foo (arg_0, arg1)

in essence sets

arguments = (arg_0, arg_1)

automatically and then passes that as one variable to the function, which is kinda what rust-call does magically. [I finally got why Haskell does not have parentheses around function calls!]

Future Possibilities

Named arguments are then as "simple" (still needs a macro) as:

impl From<MyArgs> for FnArgs {
  fn from(args: MyArgs) -> Self {
    return (args.x, args.y, args.z).into(); // Tuple can be converted to FnArgs automatically
  }
}

Implementation ?

This let's us get rid of the special rust-call syntax and use a trait for everything else.

Implementation of FnOnce might look like this:

pub trait FnOnce<Args: FnArgs> {
    /// The returned type after the call operator is used.
    type Output;

    /// Performs the call operation.
    fn call_once(self, args: Args) -> Self::Output;
}

Due to the need of Args to implement FnArgs (and I hope I got the syntax right), the compiler knows that he needs to convert any FnArgs to function arguments.

Again this is just a simple de-sugar step:

foo(args: FnArgs) => foo(args.0, args.1, args.2);

Maybe it would not even be needed to get rid of rust-call to implement that, as it's really just one AST pre-process operation [found FnArgs in calling a function? Instead of Type-Error, de-sugar it!]. [might need more thought]

Critical

I can see that allowing variable number of arguments can lead to strange things that the compiler would need to catch and this might not yet be sound and too dynamic.

A better way might be to define FnArgs as generic on the TupleType accepted.

Then type interference can be used to derive the specific FnArgs:

let args:FnArgs<(i32, i32, i32)> = (arg_0, arg_1, arg_2).into(); // FnArgs take a tuple of type (i32,i32,i32)
foo(args);

A larger problem (and a potential dealbreaker for this proposal) might be that it is very hard to deal with &mut mixed with non-&mut etc. in the tuples - though if the whole struct / tuple that is used as input is &mut or borrowed it might still work for those simple cases.

Critical 2

Maybe all of this is already possible as a macro and should not even be in standard lib at all?

Final words

I might have gotten some concepts / implementations wrong [I am still new to Rust], this pre-RFC is presented for the idea of not special casing rust-call and/or allowing a specialized Tuple to be accepted as fn arguments as if the user had split it up manually.

Please send me feedback! :slight_smile:

IIRC the "rust-call" calling convention exists only so that the tuple, which is passed as the last argument here, can be passed as its components on the ABI level, which improves performance. We could instead consider to always perform that transformation on "Rust"-ABI functions that pass a tuple as their last argument, and then remove "rust-call".

Note that FnArgs can not easily be implemented the way you propose because a local of type FnArgs<...> would be an unsized local, and those are an orthogonal feature (and also unstable).

I'm not very deep into the depper aspects of Rust's type system, but I would expect that FnArgs would be sized. Could you help me understand why it wouldn't be sized?

The post describes and uses FnArgs as a trait, and trait objects (what you get when using a trait as a type) are never sized, regardless of any Sized bound on the trait (which would actually make it object-unsafe, so you couldn't use it as a trait object).

1 Like

Honestly, I believe that you're overcomplicating things.

"All" that's needed to have "cleaner" fn traits are

  • Unify extern "Rust" fn and extern "rust-call" fn calling conventions

  • ...and that's really it.

  • (Side note: because the fn traits are lang items, we wouldn't even have to merge the calling conventions at all; we could just "monkey patch" in the different calling convention so it didn't show up in the code, perhaps even saying that the "Rust" calling convention is different for implementations of these three trait methods, so it's still nominally the "Rust" calling convention, just different from if it were any other function. Not a "good" solution, but a solution that prevents exposing the "rust-call" calling convention to user code.)

  • (Or, we could "just" make every explicit implementation of the traits special as well, so you wrote

    impl Fn(&self, a: usize, b: usize) -> usize for Type {
        a + b
    }
    

    or any other completely disimilar implementation syntax, so the weird internal implementation of the traits aren't exposed and you couldn't make a Fn-implementing trait that behaved differently if you called it as FnMut or FnOnce.)

Then item(what, ever, args) would effectively "just" desugar to FnOnce::call(item, (what, ever, args)) (with method autoref behavior to and choosing the correct Fn trait, similar to how Index[Mut] works).


You've specifically failed to demonstrate benefit to your rough proposal. Whatever new solution added to the language to replace the current fn traits' implementation would also be unstable.

If your end goal is just "stabilize the fn traits for implementation," say so. That's a lot easier to discuss (and potentially even stabilize) than some vague "replace extern "rust-call"" goal.

And extern "rust-call" really is just a calling convention. The sugar of function call syntax is magic on the fn traits, not anything to do with the "rust-call" calling convention.

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.