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

I think that struct inference _ { .. } would help here, as pointed above, and I would prefer doing that instead. If we had structural structs as well, then we could also have

impl Bar {
    fn method(&self, args: _ { a: u32, b: Option<String> = None, c: bool}) {}
}
bar.method(_ { a: 42, c: true, .. });

With that, the value of additional sugar becomes less important, but we could always have a purely sugar extension of this:

impl Bar {
    fn method(&self, { a: u32, b: Option<String> = None, c: bool }) {}
}
bar.method { a: 42, c: true, .. };

This would introduce some ambiguity between struct literals and named arg fn calls, but that shouldn't be too much of a problem, and wouldn't be one at all for methods.

1 Like

Oh no, this reminds me of C++, where you need to know the types of A B(C); to know if you should parse it as a variable or a function prototype...

3 Likes

Does it need to be addressed though? That type of function overloading is the far worse case. I would prefer a solution that addresses named optional arguments and doesn't support unnamed overloading (or any overloading really). For FFI it isn't an issue, you can just name the optional arguments on the Rust side of the bindings.

For the case where arguments have entirely different types (as opposed to just optional arguments at the end), maybe we don't need to (nor should) support it. It is not like everything can supported anyway: templates, SFINAE, user defined move constructors, etc.

1 Like

if Rust had that from the beginning imo it would be fine, it's exactly like how struct S(u8, i8, f32) effectively is also the function fn S(a: u8, b: i8, c: f32) -> S { ... }. i'm not so sure we can modify struct S { ... } to generate the same kind of function fn S { ... } -> S and end up with a coherent system when you account for backward compatibility where you could have something else with the exact same name in the value namespace and it wouldn't conflict.

Not having them results in multiplicative duplication of methods, and that in itself prevents Rust from having more useful and refined functions.

String splitting is getting absurd. There's split, split_inclusive, rsplit, splitn, and some of these combinations together, but it's all fragmented and arbitrary, because without a systematic way to deal with such options it's just not sustainable to double all the methods every time a new option is added.

7 Likes

I would rather have multiple methods than the mess that is overloads in C++. See this previous post for why I think that is preferable.

I think this is a feature rather than a problem.

1 Like

That's such a silly baseless ad hominem accusation that I won't even read the rest of your post. I didn't even originally propose this feature. The list of features added to Rust by academics is very, very short. If you think some of my papers are "slop", please be specific about which paper you mean (it's not like I publish that many papers) -- and make sure it's on-topic; nothing in this thread has been about formal proofs or UB. In fact the amount of UB-related papers one would get out of this feature is 0 precisely because it is just syntactic sugar so it does absolutely nothing interesting on the UB level where most of my academic work is happening.

Even if my reply was a strawman (which I don't think it was), this reply is entirely inappropriate. Congrats on being the first person I am blocking in this forum.

24 Likes

I strongly disagree and I don't think Ralf Jung is at all doing what you're accusing him of in the text I quoted. I think splatting is useful for making ergonomic APIs when used appropriately as well as getting rid of the extern "rust-call" wart. I don't think splatting would be even slightly interesting in an academic paper on UB -- it's obviously sound.

5 Likes

How about simply using Vec or generics as below?

1)For the case you don't need to transfer ownerships:

fn f(x: &[Type1], y: &[Type2]) -> Type3 {
    // Do something.
}

2)For the case you need to pass ownerships but want to avoid heap allocation:

fn f<
    const num1: usize,
    const num2: usize,
>(x: [Type1; num1], y: [Type2; num2]) -> Type3 {
    // Do something.
}

3)For the case you prefer heap allocation:

fn f(x: Vec<Type1>, y: Vec<Type2>) -> Type3 {
    // Do something.
}

You're shooting down a problem that you've brought up, which isn't even under consideration. Rust's reliance on type inference is quite incompatible with C++-style overloading, so copying C++ is not even a realistic option even if anyone actually wanted that. I'm pretty sure nobody wants to try to solve duplication of methods by putting methods and functions in overlapping namespaces and giving them weirdly special ADL, and then making that depend on vaguely defined numeric types with implicit conventions including lossy ones with potential for causing UB, and then throwing all that into duck-typed templates with SFINAE.

Doing something about the problem doesn't mean automatically following C++'s one-of-a-kind path. Language design space is huge, and not a line that starts at Rust and ends at C++.

There are significantly different options: ObjC/Swift have successfully "faked" overloading and optional arguments with a syntax sugar for a naming scheme. Proposals for Rust tend to be just a sugar for passing a struct argument with no real overloading happening. Actual overloading would probably have to be restricted to overloading by arity only (robust for type inference, dodging the ADL entirely).

But in the topic you quoted I even said that the solution may not need any language changes, and could be solved with rustdoc UI instead!

2 Likes

Personally, I am quite partial to the approach used by Swift. It’s quite natural to have .sort() and .sort(by: key) instead of .sort() and .sort_by(comp) (Swift’s ability to have distinct callsite names and function body names is also quite nice. Inside the body of .sort(by: comp) the argument specified with by would be called comp). As methods this isn’t a big difference but sort(slice, by: comp) is much better than sort_by(slice, comp).

This form of non-optional keyword arguments is effectively letting you spread the name of the function between the arguments as best for clarity instead of Rust’s approach (name of the function including all the arguments up front, then unnamed arguments) or Python’s approach of free-form chaos where named arguments can be placed in any order and are syntactically optional. I think the safest form of “overloading” is one where you are only allowed to overload based on the sequence of (possibly empty) names of arguments and not based on types.

I think it would honestly be perfectly suitable to Rust, and since it’s just a fancy flavor of naming functions, it poses no issues for type inference/name resolution/etc. It also comfortably avoids all the bullshit and confusion you get with Python-style named arguments (which is exactly what big arg structs with ..Default::default() give you) where one function can do a variety of similar things with different sets of arguments and you just have to know which combinations of arguments are legal and which are invalid or override each other)

4 Likes

That's an interesting example because I'd want .sort(), .sort(by: comparison_function) and .sort(by_key: key_function), without allowing .sort(by: comparison_function, key: key_function). My proposal above cannot express that.

1 Like

This proposal also depends on inlining for handling the dispatching to various function variants. Usually this works alright but it does mean a lot more bloat in debug builds, slowing down compilation.

by with by_key actually can make sense, especially if the function inputs are being constructed alongside some kind of user input. It's by_cached_key that winds up changing the meaning enough from by_key. @Nadrieril here's some very relevant motivation:

I think there are two kinds of functions that can benefit from named, optional arguments:

  • Some behave almost identical and would ideally be implemented by just having an additional argument. Most likely these will not allow mutually exclusive arguments. This is what this proposal would add. [1]
  • Others need a significantly different implementation (e.g. sort_by_cached_key vs sort_by/sort_by_key). Implementing them with an additional argument would (as epage mentioned) depend on inlining and a require bit of extra boilerplate to forward to the relevant code block. Preferrably they would behave like a separate function, but without needing to be named separately. [2]

I believe we need to consider both, as both are equally valid and useful. For the first kind, a struct with options (+ syntax suggar) as proposed here is could be a good solution. For the second kind it probably makes the most sense to stay with familiar syntax and allow defining multiple implementations with the same name, but enforce that arguments with the same name MUST have the same type (or be generic over the same trait bounds). [3]

Then allow them to be mixed, thus additionally allowing restrictions on which can be combined.

struct Opts {
    stable: bool,
}
struct ByOpts<F> {
    by: F,
    stable: bool,
}

impl Foo {
    fn sort(&self, args: ...Opts) {}
    fn sort<F>(&self, args: ...ByOpts<F>) {}
    #[with_arg(cached=true)]
    fn sort<F>(&self, args: ...ByOpts<F>) {}
}

This would let the implementation side decide which kind it needs/prefers and allow (some) flexibility in allowed combinations [4].

Yes, the above example is still simplified and has issues (but they also exist in the other proposals I saw): Mainly that the first 2 can't really be combined because the type of F could not be inferred, thus requiring two different functions even with the "splatting". Because of this I also did not include by_key or combining by and by_key. [5] It might be simpler to only use the second kind for sort, that way the generics issue can be avoided.

Personally I'd prefer a simpler syntax where the argument names are visible in the signature directly, but for sake of argument I'm going to use the syntax proposed here.

If the decision which implementation to use only depends on name + arguments they could even differ in their return type. And in the long run the return type could be considered if there is no overlap in allowed types (see specialization).


  1. Today implemented with an options struct, builder pattern or an always-present argument ↩︎

  2. Today implemented by using different function names. ↩︎

  3. Forcing the same type should eliminate most (if not all) problems with C++-like overloading and type inference, as the implementation to use only depends on which (named) arguments are present, outputting a compile time error if there is a type mismatch between implementations. ↩︎

  4. "Some" because representing "any combination but not all 4" is still problematic. ↩︎

  5. As far as I know Rust doesn't have a default type to use if the type could not be inferred because it isn't used, so "splatting" alone would likely not help much for the sort example. ↩︎

Ok, I'm normally weakly against optional or named arguments. But this has actually won me over — it's a resolution to the perma-unstable magic in the Fn traits (currently spelled extern "rust-call"), making that functionality available in the surface language.

I'm quite iffy on splatting a function generic into its signature, since type inference seems like it would be a pain. If the splatted trait is not sealed, can any crate add new "overloads" to the function? What types in the transitive dependency tree are eligible during overload selection? It feels like this portion would be better served by splatting a concrete type and defining what argument lists are valid by some trait FromArgList<Args: Tuple>, since that gives us useful orphan rules semantics and reuses exactly how we already know type inference to function.

The general idea of splatting tuples feels at home, and seems like it could fairly easily be extended to working for variadic generic use cases as well. But overloading doesn't, imo, and my instinct is telling me that splatting named fields is better served by type inferred struct literal syntax.

2 Likes

Am I missing something here (in understanding)?

  • If all "overload" [1] implementations must have "same argument name => same type or same bounds", how would that make type inference any more difficult?
  • That could be loosened to "same argument name => same type or subset" with specialization, but that shouldn't make type inference worse either, as at that point it is the same as with specialization.
  • Only if it would be full C++-like overloading we get issues with type inference. But as far as I can tell many don't want that (except perhaps for FFI compatibility).

Wouldn't the same apply if there is only a single function that takes its named arguments in a struct? [2] At that point type inference does not even depend on which arguments are present. [3]

If there is a trait that can be implemented by anyone, yes there would/could be many issues. But do we actually want/need that?

Overloading with this "same name same type" restriction is probably enough (especially with specialization), as at that point it effectively just allows specialization over the list of arguments used (still not easy/trivial, yes). Or it can be seen as being generic over the set of present keys instead of just their types, which doesn't really make type inference worse from what I can tell, as long as the two are independent of each other and you can't rely on key isn't present so we must have tighter bounds so it must be this type (especially without specialization).


  1. Yes, slightly different problem, but related. I can almost guarantee that people (myself included) would want to combine splatting with specialization (which is effectively overloading with type restrictions). ↩︎

  2. Though I'm still not convinced that is the best option for the "many nearly-identical methods" problem. ↩︎

  3. Except for having to figure out what type the not-present arguments have to be. ↩︎

If all "overload" implementations must have "same argument name => same type or same bounds", how would that make type inference any more difficult?

My OP proposal does not include that, nor do I know how we'd impose that in the trait system. I do see that others have proposed it but idk how that would work.

But as far as I can tell many don't want that (except perhaps for FFI compatibility).

C++ FFI is my main motivation for having any kind of overloading. I don't particularly want overloading in the language outside of that, it just so happened that there was a tiny-looking syntactic feature that would give it "for free".

If the splatted trait is not sealed, can any crate add new "overloads" to the function?

You can only splat a generic that implements Tuple, which is only implemented for built-in tuples, so only the crate that defines the trait can define impls for it, unless I'm forgetting some piece of orphan rules magic.

Wouldn't an #[attribute] do better here than trying to give extra meaning to other Rust code especially on the "[not wanting] overloading in the language outside of that" front? (Assuming C++-calling-Rust FFI direction.)

1 Like