Pre-RFC: Named arguments

Very relevant: @Gankra's newest article:

5 Likes

I think we can learn from python in this aspect:

Arguments appearing after a '*' argument must be called using the argument name.

def foo(x, y, z):
    pass

foo(1, 2, 3) #OK
foo(1, y=2, z=3) #OK

def foo(*, x, y, z):
    pass

foo(1, 2, 3) #Error
foo(x=1, y=2, z=3) #OK

def foo(x, *, y, z):
    pass

foo(1, 2, 3) #Error
foo(1, y=2, z=3) #OK
foo(x=1, y=2, z=3) #OK

And In rust it could be:

fn foo(*, pub x: u32, pub y: u32, pub z: u32) {}

This gives the flexibility to choose from which argument the caller needs to use argument names (I'm not sure we need this).

Overall, I’m not completely against having named arguments in Rust, but I have a few problems with your proposal.

  • Allowing for overloading is pretty iffy for Rust, especially since it doesn’t really allow that unlike earlier languages. I do understand why the RFC chose to allow it, but I think there are better solutions for the problems it addresses:
    • Default/optional arguments: this is another issue entirely so I won’t mention it further.
    • For migration of existing functions, some kind of syntax to allow passing an argument with or without its label, such as
    pub fn insert(pub? elem: T, pub? at: usize) { /* ... */ }
    
    as well as an attribute (#[deprecated_positional]) to deprecate passing an argument positionally.
    • Having the same verb for different but related actions (e.g. using ok for both ok and ok_or) is iffy in the first place and not much shorter than using different method names.
    • If you do propose overloading, then overloads which only differ in the order of argument labels should be forbidden.
  • A few notes about the syntax:
    • As Aloso said, the label name syntax creates syntactic ambiguity. I suggest going with pcpthm’s approach and using pub even when the label and the parameter name are different, with an @ between them as an analogy with match patterns:
    fn foo(pub name @ (a, b): Name);
    
    • Using pub to mark a named argument is an interesting idea. I find it fitting with the existing meanings of pub: to expose something (in this case, the argument name) to the public.
    • I do like the idea of using : for named arguments (as it parallels struct literal syntax), but I’m worried about the conflict with type ascription. Of course, this could be worked around by requiring type ascription expressions to be wrapped in parentheses when in an argument list, but this would have to be done before type ascription becomes stable.
    • Personally, I don’t have a problem with being able to declare a named argument whose label differs from the name used inside the function definition. You could add something like let db = to;instead of declaring the parameter as to db: Database, but by that logic, mut arguments would be unnecessary as you could write let mut param = param;.
    • For calling, I’d rather have the ability to reorder arguments at call site than be able to create overloads that have the same set of labels but in a different order. I don’t see any other reason to forbid reordering arguments, but we could add the ability to do so in a later proposal anyway.
  • I don’t think parameter labels should be part of the type system, especially when it is for Fn and friends but not for fn.

One advantage(?) to having required in-order and distinct-from-binding labels which has been overlooked is the possibility of having two argument labels be the same.

Is it a good idea? Probably not. But it is an actual feature which cannot coexist with reorderable labels.

2 Likes

I use rust analyser vscode extension which shows the names of the arguments as hints. It is super helpful. Just so you know.

1 Like

The easy way to allow this is to disallow base-name overloading. if f and f(x:, y:) are different functions there is otherwise no way to tell which should be called by f(x, y). Especially if I can add that second function at a later date; it's a breaking change unless we require at least f(_: x, _: y). Which is one of (several) reasons why the base-name overloading question has to be answered so early (and probably one of the reasons it should be answered with "no").

2 Likes

It also has the advantage that it's not a breaking change to update those argument names to be more useful.

4 Likes

That sounds like a theoretical concern. How often do you rename the arguments to be more useful? I doubt I ever did this, unless I made significant changes to the function. But at that point the same could be said about the function name itself, so perhaps one should just create a new function if they want more descriptive argument names?

Within the function, its private argument names can be easily implemented today with an inner let binding, without any extra syntax.

3 Likes

I rename the argument to be more useful all the time. Usually, it's because the function started out tiny and the parameter started out as a single letter, but when the function became longer, I needed a better name.

5 Likes

Well, for example, I did it here because it helped make the documentation for the behaviour of the function read better: Redo the docs for Vec::set_len by scottmcm · Pull Request #56425 · rust-lang/rust · GitHub

It's not uncommon for a parameter to start out n: usize, but then get elaborated on later. If they're documentation, not fail-the-build, that's one less thing you have to get perfect the first time, which is nice.

3 Likes

I ended up writing a post about the intersection between name-based overloading and default arguments:

My conclusion is that the two features can each be added independently without making the language that much more complex, but once you have both things get a lot trickier. So it's probably not a good idea to for the lang team to try to consider them separately!

My secondary conclusion is that default arguments are more useful for Rust and easier to add to Rust than label-based overloading, but that it's still worth adding argument labels without overloading (like Python), mainly because they make default arguments way better but also because they're still nice for, well, labeling arguments at the call site. I'll leave that to people better at Rust language RFCs, though!

EDIT: Take this with a grain of salt! I know Swift a lot better than I know Rust.

19 Likes

So much replies. Back to the drawing board it is, and thanks @jrose for the time you dedicated to this

3 Likes

Thank you for writing it! The different perspective is incredibly useful, so we can learn from what went well (or poorly) elsewhere. I know C#, and have certain feelings based on experience there, so combining that with your Swift experience helps a ton for getting a broader picture.

Posts like that summarizing tradeoffs and impacts are most helpful for me with my lang hat on, since I obviously can't become an expert in idiomatic use of every language out there :upside_down_face:

13 Likes

Here is a (very basic) idea that I had. Basically, in a call with named arguments, the arguments get appended to the function name with underscores between, and an and before the last argument name, then name resolution proceeds from there. This would have the benefit of already working with APIs like HashMap:with_*. To declare and import those functions, you would just use the full name. This would open up a way to use closures and fn items with it: simply name the variable that holds the fn with _argname_and_argname2on the end.

As much as I want named arguments this kind of idea will probably not make thing move forward.

I think that any proposal about named arguments (or anything that improve stuff related to arguments) should include:

  • a clear description of the problem we are trying to solve
  • a clear explanation of why the currently available technique are not a good solution (create multiple function with suffix, use traits to mimic overloading, strong typing, the builder pattern, …)
  • a clear explanation of the chosen solution (most proposition describe only this part)
  • a clear explanation of the limitation(s) of the chosen solution (is it explicit/implicit, does it need modification in the caller and/or the callee to benefit from this improvement, is it visible to the type system, is there a high chance of creating churn in the ecosystem, …). I unfortunately don’t thing that any solution could be a pure improvement without any downsides.

And I also think that all of those points should have a TL;DR. Given all the discussion I read about this subject, we both need a (very) detailed description, and a short-one to cover the need of everyone.

And yes, this is a lot of work.

9 Likes

I confirm, a lot of work. I kinda burned out on this, then I moved and got a job so I may not come back to it in the near future, though I hope to find the time, I still wish to have named arguments in Rust in some form

3 Likes

Both of these are incredibly boilerplatey at both the definition site and the use site. So this is a good argument for named arguments. (Or improvements to boilerplate.)

If your argument was "nobody needs that stuff--it's technically possible but in practice it just doesn't come up", that'd be different. Then the feature would possibly be not pulling its weight.

3 Likes

Yes, exactly!

Every time someone claims that Rust doesn't have overloading and it's bad for Rust, I do a (virtual) facepalm--Rust would be unusable without overloading (that it already has).

c.size_of_collection() f.size_of_file()

No thank you!

Heck, the entire magic of into() is based on overloading.

If someone wants to argue that (x, y).foo() is good overloading but foo(x, y) is bad overloading, well, sure, have at it. But let's not pretend it isn't there (at least isomorphically)!

3 Likes

This isn't overloading/polymorphism; this is just method lookup (which can be seen as related, since it's type-dispatched). These are still two different functions with two different names, Vec::size and File::size.

Notably, method lookup is done statically.

There is a meaningful difference, though.

fn foo(x: &i32);
fn foo(x: &str);

This is ad-hoc polymorphism. There is no connection between the two implementations. The potentially surprising and problematic bit of overloading in e.g. Java is that you could call foo(Object) but get foo(ArrayList) instead, based on the dynamic type of your object.

fn foo(x: dyn Display)

This is parametric polymorphism. The foo you call is determined solely by the static type.


Is there actually a difference between the two in a language that doesn't enforce parametricity, though? (E.g. Rust allows violating parametricity via specialization and TypeId.) You could make a pretty solid argument for no (that virtual method call could do anything!) but the fact that which function is being called (even if that function just dispatches to the vtable ptr) is a useful property for program reasoning.

7 Likes

I think the parenthetical might be the key, here.

"Rust doesn't have named arguments" isn't the problem. Rust doesn't have lots of things.

So the best way to make this move forward might be to make progress on something that helps similar intent to named arguments, but without having such pervasive effects as exactly named arguments.

For example, there are steps that could make builder-lite APIs easier, and thus more likely to be used. Perhaps things like ekuber's RFC in Pre-pre-RFC: syntactic sugar for `Default::default()` - #75 by ekuber

4 Likes