Pre-RFC: Named arguments

Adding this to the language only needs done once. Having to write a new-type for every parameter where the new-type does nothing but wrap the actual type (that has no additional constraints) for purposes of naming it is definitely "Heavy Handed" IMHO.

Types and Names are orthogonal. You want me to use a type where a name is what I need and what is useful.

I find these kinds of analogies to be highly unhelpful and add nothing to the debate.

That is incredibly false. Types provide semantic meaning which is what we use names for in natural languages. They are not orthogonal by any stretch of the imagination.

Here's a classic example of this:

both metres and feet are numbers. They are the same "type" by your definition, just with different names. If we go by that than it is easy to get to those famous examples such as the failed NASA rover mission to Mars.

Rust is supposed to be a safe language. Having a function:

fn do_something(timeout_secs: i32);

Is therefore unsafe and bad design compared to something like:

fn do_something(timeout: Seconds); 

Or even better:

fn do_something(timeout: Timespan);

Adding named parameters to Rust goes against one of its core principles imo.

1 Like
fn do_something( timeout_secs: u32, repeat_secs: u32 );
fn do_something( timeout: Seconds, repeat: Seconds);

Would you have me do?

fn do_something( timeout: TimeoutSeconds, repeat: RepeatSeconds )

Just so I can have things read clearly at the call site?

7 Likes

If you want a language that allows for quick prototyping and saves you time on typing than a dynamic language like Python is your best bet!

Again, I'm not arguing against such a use case. Just that it is inappropriate to try to shoehorn this into a language designed for a different set of trade-offs.

Rust requires more typing, yes. It is the price we must pay for the added benefit of safety. This includes the ownership system as well as adding semantic types so that the compiler will verify statically the correctness of the code.

In my previous example, that I cannot pass a number of "metres" to a function that semantically expects to operate on a number of "feet".

Simply naming the parameters differently does not work with such static verification.

1 Like

I reckon that's a contrived example and a straw man argument which I've already addressed in my previous posts. You can still define a single struct with two fields. No one's forcing your hands to have a unique type per each parameter.

1 Like

I think we're arguing past one another and so I'm just going to leave it there.

1 Like

Overloading based on arity alone is isomorphic with optional/default arguments, and there are lots of languages with optional/default arguments.

The same logic allows me to observe that Rust already has overloading based on the type of one (specially marked in the syntax) argument; we just call it dispatching to a trait implementation. Which means that it is already necessary to know the type of that argument in order to read code. Yes, a trait comes with an interface contract ā€” every implementation of std::ops::Add is supposed to perform addition, for some definition of "addition" ā€” but that is only enforced by convention, and there's tons of room for "creative" interpretation of the documentation.

12 Likes

Please absolutely do! I can't emphasize enough how much I don't want to "win arguments by authority".

Also, I am sorry if my wording was too confrontational or otherwise inappropriate. Thanks for your respectful reply!

I can see how overloading based on types can feel quite different since (in particular in Rust) it is a lot more implicit.

I would still argue that any kind of overloading has other issues besides implicitness, like making things harder to parse for humans (the fun part of fun(args) is no longer all the information you need) and making it more awkward to refer to a function rather than calling one. The RFC has to go through some hoops like foo(a:b:) to resolve this, and that feels somewhat clunky.

Of course, these are all very subjective value judgments. What I would really like to understand is why y'all think that named arguments need this name-based overloading to be a good/useful addition to Rust. Some concrete examples would help.

The example in the RFC is about replacing r.ok_or(foo) with r.ok(or: foo). I don't think that is a very compelling usecase for named arguments (ok_or seems perfectly adequate), so I am not convinced by this example that we need name-based overloading. To me, the best motivations for named arguments are functions like the CompileFilter::new that the RFC mentions, and almost every case of a function taking a bool as an argument. We have such functions now and it's not great (e.g., using comments to explain what the arguments are), but having to give a unique name to each function does not seem to be a problem. Why does it become a problem when arguments can be named at the call site? Some more examples would be really good, seeing as overloading and the complexities it creates take up a large fraction of the RFC.

FWIW, I could totally see a good case for default values for arguments, once there are named arguments. However, that can be done without name-based overloading, so it is a lot simpler. So to me, a convincing example for name-based overloading should be one that cannot be handled well with argument default values. The RFC states that default values are orthogonal to named arguments and hence out of scope. I don't disagree, but I consider overloading equally orthogonal. I think default values have nice synergies with named arguments -- you can give any subset of the arguments in any order you like, and things just work as they should (kind of like a built-in builder pattern). I understand you feel the same about synergies between named arguments and name-based overloading. :slight_smile:

21 Likes

The biggest argument for label-based overloading is being able to upgrade std APIs in place to use labels. Perhaps that can be addressed via (edition) optional labels? This would sidestep the need to name multiple functions from the same base path, because they'd be the same item. Some migration path to allow us to add labels to std functions I think is undebatably required in an argument labels proposal, to avoid old std functions feeling second class.

The second argument is more debatable: supporting the same verb (function path) but with different labeling. And I actually can't come up with a good example that isn't a form of optional/default arguments other than new(in:) versus new_in(_:) (though it's been a long time since I've used Swift, and didn't go digging through the docs).

I have a theory: given any existing argument label overload set that logically fits under a single verb, they can be partitioned into a set of optional arguments sets based solely on the label of the first argument. (Thus, if you put the first argument's label as part of the function name, you no longer need label based overloading.) I would honestly like to see a Swift counterexample to this theory in a library's public API.

(It's maybe worth noting that this has literally just changed the design for my in-my-head "perfect for me" language. I like argument labels, but avoiding needing f(arg:) overload set resolution is enticingly beneficial, as you no longer have the nullary function special case.)

6 Likes

Agreed; we could have a #[optional_name] mechanism if need be.

Yes, the more I think about it, the more I tend to agree with you here. Every example I can think of where I would want the same verb but differing arguments is best supported by either Optional/Default arguments or Generics.

1 Like

I don't think this changes your overall point that much but I disagree with this. I could have a function with 2 arguments and 1 with 3. The 2 argument function could have entirely different types than the one with 3. There is no way to model that with overloading by arity alone. What I meant to say is, there is no way to model that by just default/optional arguments.

(boldface - my emphasis) I don't think it counts as "overloading by arity alone" anymore if you allow the types of the arguments to change at the same time as the arity changes.

The function with 2 arguments is one version, the one with 3 is another version of the same root function name. I've only considered arity. However, if I want to model the same situation with default/optional arguments, I can't; therefore, Default/Optional arguments are not isomorphic with arity-based overloading (to my mind).

I agree that default/optional arguments cannot model the situation where the first two arguments of the 3-argument function aren't required to have the same types as the arguments to the 2-argument function with the same name. However, to my mind, not imposing that requirement means that the language feature we're talking about is now type-based overloading with the additional restriction that the arity has to change at the same time.

I feel like I must be missing something, why does that need overloading? Why can't we just add labels to the existing functions, keeping their name and not do overloading? That would mean support for calling named argument functions with non-named call sites. Is that so bad? Sure, we might forget to port our code (or forget the names in new code), but if we add named and non-named overloads, the same is true.

(If the RFC discusses this then I didn't find it. The "alternatives" section doesn't even mention not doing overloading, so all the arguments for overloading are spread across the entire document, making them hard to find. That said, I know RFCs are tricky to write, and this one is written very well! I just wish it would take not doing overloading a bit more serious, and have a coherent argument in a single location for why it considers that not an option.)

Oh I see, the concern is that for new APIs with names, we don't want people calling them without names.

Yeah that should be covered by letting the function author decide whether names are mandatory or not. I agree an attribute works here, but this is yet another mechanism, so the complexity budget on the "no overloading" side is slowly stacking up (we got default values and optionally-optional-names there now)...

6 Likes

I think something like this is the motivating example:

Let's say you have:

fn new( ... ) -> ...;
fn new_with( ... ) -> ...;

and you'd like to have:

fn new( from ... );
fn new( from... , with ... );

You don't want to have to have instead:

fn new(from ... );
fn new_with( from ..., with ... );

Now the above is a totally bad example but I think it illustrates how you'd like to not have to repeat the parameter "name" in the name of the function.

1 Like

Wouldn't that be handled by having a default for with?

Yes it would. That's why I agreed above that any need for overloading by parameter names is probably better served by an Optional/Default arguments feature and/or by the use of generics.

I was just trying to explain what my understanding of the motivation for saying overloading by parameter name was needed in order to allow for smooth porting of existing libraries.

The more I think about it though and try to come up with examples, the more I realize that all the examples I can think of can either be served by Optional/Default arguments or by the use of Generic code (or a combination of the 2).

TL;DR: I've been convinced that overloading isn't needed to make this viable/useful. However, it seems like Default/Optional arguments should be a separate RFC but should strongly be considered as being implemented along with this or shortly thereafter.

9 Likes

Sure. Option A, the one I recommend, is that function types do not have labels. The type of fn removeAll(&mut self, equalTo value: T) and the type of fn append(&mut self, _ value: T) are both fn(&mut Self, T).

Option B is to keep the labels in the type, giving you fn(&mut Self, equalTo: T) and fn(&mut Self, T). From my perspective, this has a few problems:

  • The labels donā€™t necessarily make sense without the base name, and when youā€™re using function pointers (or function traits), youā€™ve got more than one possible value, with no guarantee they have the same labels. Heck, while Iā€™ve never seen functions actually named add(to: Set<T>) and remove(from: Set<T>), surely they should have the same type!

  • If labels are part of the type systemā€¦are two different function types compatible? Can removeAll(equalTo:) be passed to a parameter of type fn(&mut Self, T)? Probably! But what about fn(&mut Self, value: T)? Is this a new implicit conversion? Can you change fn(x: f64, y: f64) to fn(y: f64, x: f64)?

  • What happens when you put fn(value: isize) into dyn Any? Can you get it out as fn(isize)? As fn(offset: isize)?

  • Putting the labels in the type undermines the user model of ā€œthese functions have different namesā€. (I guess this only matters if you have name-based overloading, which Iā€™m willing to call it. Iā€™ve got some thoughts on Python-style labels without overloading but Iā€™m still working them out.)

Now, there is a downside of Option A too: sometimes function pointers really would benefit from labels. But those labels go with the name of the function pointer argument, not what gets passed to it. We had the idea in Swift that

fn for_each(
  &self,
  apply process: impl FnMut(key: K, value: V)
) {
  // Yes, this is inefficient, donā€™t worry about it.
  for k in self.keys() {
    process(key: k, value: self[k])
  }
}

would be sugar for something like

fn for_each(
  &self,
  apply process(key:value:): impl FnMut(K, V)
) {
  for k in self.keys() {
    process(key: k, value: self[k])
  }
}

but never got around to implementing it. (Still could some day, though; we left space for it.) Itā€™s not strictly necessary, and since there are always functions without labels itā€™s not out-of-place or anything.

So yeah, donā€™t go with Option B.

5 Likes