[Pre-RFC] named arguments

What I think you are saying is that you would prefer:

foo(1, 2, {bar: 10, baz: 22222, e: 20000, ...})

but where bar and baz are "required" and e is not. This would also break having the "declared defaults" be a wrapper for the Default trait, which you prefer because it is more ergonomic.

I think I have to disagree with you on pretty much every point:

  • not integrating with the Default trait is a pretty big shot against standards
  • there is a reason that you can't name some args as defaults and others as not default in the default trait -- doing otherwise would requiring adding onto the compiler.
  • I don't think adding onto the compiler is worth it for this case
  • I actually prefer being completely explicit and just falling back to simple struct definitions. It is much more clear what is happening under the hood -- which is what rust is all about.
I can understand that people prefer to be explicit about the usage of default values by using `...`

I think this is a non-starter for defaults in structs. Without ... my example would look like bar, baz and e were the only attributes in the struct, which is simply not the case!

There could easily be a lint that anonymous structs given into functions always use ..., or people could suffer for not having that forward looking compatibility in their code and have to add a few ... markers when they update to a new version (or else their code won't compile). Either way would be acceptible IMO.

Sorry for being late to the party.

There exists ways to make code reading in hindsight pleasurable without using named arguments. My subjective impression is that there is a tendency to overload constructor arguments in many languages that possess named keyword arguments:

shape = ShapeGenerator(one_characteristic=…,
                       another_characteristic=…,
                       unrelated_characteristic=None)

In Rust which does not have a traditional object-oriented class system like Python or Java, it is not uncommon to have multiple special-purpose constructors to construct a thing of essentially the same Gestalt, or shape:

let label = Label::new({value: "About", mnemonic: None});

vs.

let accessible_label = Label::new_with_mnemonic("&About");

There is also a point to be made for the simplicity of a language. Named arguments arguably introduces complexity and would necessarily have an effect on library API design. It’s not clear to me that having the powers of multiple constructors and named arguments would encourage consistent API design throughout the ecosystem. To the contrary I think one would instead see fragmentation.

As the number of arguments to a function grows, one should consider introducing a new function with a more explicit name. I find particularly function arguments such as Option<T> are prone to the problems I describe above.

I think you are mixing default arguments and named arguments together. Named arguments on their own don't give you any extra ability in the overloading department. You seem to also focus very heavily on constructors which are only a subset of all functions.

I get your point though, special-purpose constructors are part of good API design. But my opinion is that some tools are better for some jobs, there could be a right balance of both named / default arguments and special-purpose constructors.

Let me try to make up an example. Let's imagine default arguments are a thing.

fn quadratic(pub a: i32 = 0, pub b: i32 = 0, pub c: i32 = 0) -> Polynomial { 
    // ... 
}

Now this could be used with:

quadratic(1, 2, 3) // positional
quadratic(a: 1, b: 2, c: 3) // named
quadratic(a: 1)
quadratic(b: 2, c: 3)
quadratic(a: 1, c: 3) 
// etc...

Now imagine if you have to write a specialized constructor for every case? Not that practical anymore.

I like all the API design patterns that already exist and I don't want to make them go away, which a lot of people seem to think apparently. I just want one more tool that we can use in certain situations where other patterns fall a bit short or are awkward to use. It's not a binary choice, I think every pattern can co-exist in a healthy balance.

How could this lead to fragmentation? I really don't see how this feature could bleed into other peoples code or cause incompatibility. But if you do, I definitely want to hear about it because that would be a major drawback!

1 Like

So this has been a very long thread. I think that for posterity’s sake, it would be really helpful if someone were to go through and summarize the various points that were made both pro and con – we could then add a link to this summary from the top.

One thing I’ve taken away from this discussion is that, when it comes to named arguments, the details seem to matter a lot: e.g., are the parameters reorderable? How does one opt-in? If you give names, is the caller obligated to use them? A lot of the arguments e.g. around there being more than one way to do it seem to hinge on these questions.

5 Likes

Indeed, it seems like a very controversial proposal :slight_smile: I will try to summarize everything as best as I can, but it may take some time.

Summary

So there has been a lot of discussion, way more than I anticipated.

I will try my best to summarize all the points that have been raised in order to refocus the discussion and make it less intimidating to join in. If you feel like I have left out something important, please let me know.

1. Parsing ambiguity

The syntax proposed in this proposal causes parsing ambiguity with type ascriptions. To be more precise, the colon at call-site can not be used because it is a valid type ascription.

fn foo(pub a: i32) { ... }

foo(a: 42) // <-- This causes problems

A lot of people have agreed that this syntax would have been the most well-suited due to the symmetry with structs. But it is highly unlikely that the type ascription syntax will change at this point and we should look for a different syntax.

Other syntaxes have been proposed:

  • =
    Using foo(a=5) is already legal today. It is an assignment in a function call. Even though it is improbable that people do this in practice it would be a breaking change.

  • =>
    Arrows generally suggest some flow, like -> for return values in functions and => in match arms.

  • :=

2. How to make an argument named

Most of the participants agree that we should not make all arguments named by default. Because that would make all argument names part of the public API which would add a huge burden to API authors. Every change in an argument's name would become a breaking change. So we need a way to mark arguments as named.

The idea of overloading pub for this purpose is liked, but other proposals have been made.

Different internal / external name

Like in Swift, we could allow for an internal name and an external name.

fn foo(ext int: i32) { ... }

This addition would make the overloading of the pub keyword unnecessary. And allows some functions to read better while retaining a meaningful name in the function's implementation.

fn increment(_ value: i32, by increment: i32) {
    value + increment
}

increment(value: 3, by: 5)

But, like you can see in the example, it frequently occurs that you want the same external as internal name. In that case you would use an underscore _ to avoid the repetition of name name: i32

3. Alternatives

A lot of people have stated their doubt about the need for named arguments giving alternatives. Here are the alternatives with their advantages and drawbacks:

1. Plain structs

You can already be more explicit with normal structs.

struct MyArgs {
    a: i32,
    b: i32,
}

fn foo(args: MyArgs) { ... }

fn main() {
    foo(MyArgs {a: 42, b: 21});
}

This has the advantage of being possible right now. But it means you have to define a struct for every function that would need named parameters, you can't use the positional form anymore and, worst of all, the user has to import all those argument functions.

2. Builder pattern

struct Foo {
    a: i32,
    b: i32,
}

impl Foo {
    fn new() -> Self { ... }
    fn a(self, a: i32) -> Self { ... }
    fn b(self, b: i32) -> Self { ... }
}

Foo::new().a(42).b(21)

The builder pattern is a nice API pattern. It allows for default arguments, it scales well, adding an argument is not a breaking change, etc. But is very verbose to write for the API authors, it also requires a struct and only works for methods.

3. Anonymous structs

fn foo({a: i32, b: i32})

foo({a: 42, b: 21})

Anonymous structs have been proposed as an alternative. They are not part of the language now, so they would have to be added. Arguments for this proposal are:

  • Less boilerplate than actual structs
  • Doesn't have to be imported by the user
  • Could provide useful in other parts of the language

Drawbacks include:

  • Not being able to use the positional form, "named arguments" would be mandatory
  • Not clear if you could implement traits from other crates, think Default. How would this be extended for default arguments?

4. How does it work with the type-system?

People are unsure about how named arguments would or should affect the type-system. Would

fn fn foo(a: i32, pub b: i32, pub c: i32) { ... }

be of type

Fn(i32, i32, i32)

or

Fn(i32, b: i32, c: i32)

In other words, should named arguments be part of the type?

The easiest solution would be to not make named parameters part of the type. They would get resolved to their positional alternative before type-checking making them 100% compatible with the current functions. However by not making it part of the type, it can't be used with closures, making them second class citizens. Nobody yet has commented to say if that would be a problem or not.

Making them part of the type would make this feature significantly more complex and nobody has presented any benefits in doing so.

5. Should named arguments be named-only?

In the current proposal, even though a function proposes named arguments, you can still use the positional form.

Swift was taken as an example where, if named arguments are present, you have to use them. This is perceived as annoying and users want to choose for themselves if they need / want to use named arguments in their code base. If the user already uses clear variable names, named arguments can sometimes be redundant causing noise.

Also, this allows arguments to be "promoted" from positional-only to optionally named without a breaking change.

However, other people are unsure about being able to mix both forms simultaneously.

6. Should named arguments be reorderable at call-site?

One of the benefits of named arguments would be to make the order of the arguments irrelevant. Allowing users to provide arguments in the order of their choice. Examples:

fn position(lat: f32, lon: f32) { ... }

position(50.85, 4.35)
position(lat: 50.85, lon: 4.35)
position(lon: 4.35, lat: 50.85)

This provides more freedom to the user and is not a problem because the code is self-documenting. However some feel uneasy about this liberty and would rather follow Swift making the order of named arguments matter because this would make the feature simpler.

In particular, the way in which many implementations of this feature have to pick a "cutoff point" in an argument list between the permutable and non-permutable ("positional") arguments, based on inspecting the labels-provided at call site and labels-allowed at callee, seems like a major comprehensibility hazard.

Also consider this scenario:

The user starts out with the named form.

let lat = 50.85;
let lon = 4.35;

position(lon: lon, lat: lat)

The order is not the same as it would be in positional form, but this doesn't cause any problems. However, if the user now decides to cut the named parameters because it feels redundant in this case, we obtain

let lat = 50.85;
let lon = 4.35;

position(lon, lat)

Which is not the same! Subtle bugs could be introduced this way.


I hope I covered everything, if not comment about it below. :slight_smile:

16 Likes

Again, this doesn't cause problems. ident : can always be parsed as a named argument in argument position. Type ascription is unstable (and rarely used), we can change the meaning of foo(a: 42).

2 Likes

I haven’t seen patterns mentioned in the thread. Function parameters are actually patterns, not plain identifiers and patterns can’t generally serve as names for named parameters. Does functions with named parameters lose the ability to destructure their arguments in place? Can the Swift’s idea with external and internal names be somehow extended to support patterns in functions with named parameters.

P.S. In general I feel skeptical about the whole idea of named parameters, at least until we get default parameters. P.S.2 Alternatives with unnamed structs look plain horrible.

1 Like

Just for completeness (and not to be pushy :slight_smile: ), but type name inferrence like I mentioned further up would alleviate the last point about having to import these. The example would then look like

struct MyArgs {
    a: i32,
    b: i32,
}

fn foo(args: MyArgs) { ... }

fn main() {
    foo(_ {a: 42, b: 21});
}

Again, I like that it's not just applicable for function arguments, but anywhere. It would also benefit (or maybe even need, for convenience reasons) from the considerations regarding defaults.

1 Like

I really like this idea, and I'd love to see it as a standalone RFC. You often know via type inference what kind of value you have to provide. And if you don't have to explicitly name the type, you also don't have to import the name of the type.

If you allow this, I think it also make sense to allow inferring the type of tuple structs: _(10, 20, 30)

3 Likes

I don’t care much for named arguments. I do think the language should have an answer to the builder pattern. My favorite suggestion is making functional record update work for private fields as discussed by @comex. Bonus for { foo: 10, bar: 20, .. } sugar for ..Default::default().

Edit: The “Extensible structs” alternative of postponed RFC 757 is an interesting alternative for builders, by making the builder extensible it could be just a struct instead of chained method calls. It is also necessary for FRU to replace the use of a builder since a big advantage of builders is that they are extensible.

Hm, thats very subjective. The beauty of the ObjC Cocoa API is what got me hooked into programming in the first place. I wouldn't be here as an enthusiastic software engineer if it weren't for these APIs.

Indeed it is, and I can’t claim otherwise. But it is one very real reason for people like me to stay away from programming languages with that “extremely long fn names” trait. I guess I just don’t see the point of something like "[giveMeYourMoney: "all", andThenOnRobberyDone: some_action]; when something like rob_money(MoneyTransferType::All, some_action); does the job more succinctly while remaining clear in intent.

Add to that 2 facts:

  • Ultimately this problem can be seen as simply tying more-or-less random symbols (i.e. function and var names) to some kind of meaning (a fn body or var definition). Of course they’re not completely random since the entire point is choosing symbols that evoke the corresponding semantics in people’s minds (a computer doesn’t care whether something is called a or my_descriptive_pony).
  • Humans have a limited working memory, even the best of us

Given the above, I reach the conclusion that shorter symbols are better, especially when juggling a number of them at any one time*. This matters because forgetting a symbol means having to look it up, an action which costs time. For one such symbol it usually doesn’t take more than a few minutes at most, but such quanta of “lost time” can quickly add up since it’s a systemic factor i.e. a forgetful person isn’t particularly likely to start remembering things better out of the blue.

But apparently it doesn’t work like that for everyone? In the interest of clarity and perhaps a better-informed end decision, would you mind attempting to explain how that process works for you?

*Compare this to how many numbers a person can at any one time remember reliably, I think remembering numbers in this way and remembering symbols works very similarly though not identically since numbers usually have no meaning attached for us, which influences our ability to remember them.

One benefit is that it allows generic code to call a function with named parameters. Presumably this still has all the same motivations about clarifying ambiguous argument order. Something like:

fn move<F>(&mut self, callback: F)
where F: Fn(lat: f64, lon: f64)
{
  for pos in self.update_positions() {
    callback(lat: pos.lat, lon: pos.lon);
  }
}

This might be the same as your point about closures, but I'm not sure.

1 Like

For the parsing ambiguity, it would probably be worth mentioning

as an option even if it would affect type ascription syntax. Though that particular comment didn't get any direct replies, there were a few mentions:

but none added much to that discussion aside from opinions.

Another thing mentioned multiple times was omitting the argument name when the name of a variable being passed in matches. This is different than just passing said variable as a positional argument. This would address this comment:

and was mentioned three separate times that I see:

1 Like

I think this would be a bad idea if we allow both positional and named form, because you would have no way to immediately know which of the two forms is being used. Simply renaming a variable could cause a subtle change from the named form to the positional form. Best case scenario, you get a compile error because the type don't match, worst case, you silently introduce a logic bug.

2 Likes

All three of the mentions I quoted used some syntax that made it explicit that it was a use of the named form which omitted the (redundant) name. Using the syntax originally proposed for named arguments, the name could be replaced with an underscore (example for demonstrative purposes only; actual syntax would depend on the rest of the named arguments syntax):

fn increment(amount: i32, pub by_amount: i32) -> i32 {
    return amount + by_amount;
}
let amount = 4;
let by_amount = 6;

println!("{}", increment(amount, _: by_amount));

Changing a variable name should give a clear compile error when it doesn’t match a named argument.

Ah yes! Sorry, I missed that. Personally, I feel that if you are going to omit the named arguments you should go with the positional form instead. In a language where you would be forced to use named arguments this feature would make a lot of sense. But if we allow to freely choose between named and positional, the only advantage I can think of would be to reorder parameters.

Depending on where things fall (re: Should named arguments be reorderable at call-site?), the benefit would either be a compile time check that you have the order correct or simply not having to worry about it. If they were just positional args, they could mistakenly be entered in the wrong order even with the correct names. Avoiding that seems to be one of the major motivations behind named arguments. Either way, those three people seemed to come to the same conclusion independently, which seems to merit a mention in the summary.

As small time rust user I would like to chip in. This feature seems like natural fit into the langauge for me. I would advocate for folowing form:

  • fn(name:val) syntax
  • supporting positional arguments in place of named ones.
  • correct order preserved by the compiler(can be changed later - backwards compatible & conservative solution
  • Positional argument cut-off : after named argument has been used, no more positional arguments can be used ( common, safe & elegant solution )
  • using pub or similar keyword to specify which arguments are named.

Explanation

I’m in favor of fn(a:b) syntax because it follows already used conventions and “feels” natural. a=b case is also very nice, but since it is also an expression with () return type, it would probably mean some special case handling.

Most frequent use of : is in structs. When declaring new struct, usage is name:type. When creating new instance of this struct, usage is name:value. This whole concept is really elegant, since : signifies concept of binding. When you are defining struct, you are binding the type to name, and when instantiaring, you are binding value to name. Similarly, when defining function, you use : to bind parameter type to parameter name. I think preserving this concept in function calls should be a priority.

Type ascriptions do not follow these conventions, since the form vale:type does not express binding, it is there simply to help the compiler with type inference. I think the is syntax would have been more fitting.

Making named arguments opt-in is a no-brainer. Backwards compatible & non-interfering solution.

Enforcing argument will probably be almost friction-less to programmer, prevents subtle bugs presented by graydon and can be changed later in backwards compatible manner.

No positional arguments after named one : ditto. Safe choice that can be extended later in backwards compatible manner if needed

I also think usge of pub to specify first named argument/which arguments are named is great idea, since by using named arguments, the argument names become part of the api.

Other

Default arguments: subject for another discussion, would fit right into propsed variant of named arguments with name:type = value.

Implementation difficulty of this should be minimal.

The only BIG problem is collision with type ascriptions, if type ascription syntax is set at using : then there are few solutions:

  • Disallow type ascription in arguments.
  • Parentheses

Anyways my 2¢. Sorry for typos, written on mobile.

4 Likes