[Pre-RFC] named arguments

I’d be okay even with foo({ x: 1, ...}) or similar sugar

Good point. I missed it, and should agree with you.

1 Like

Isn't that better served by anonymous structs? Personally if he had anonymous structs it would make other things easier:

 fn ret_foo() -> Foo {{
     bar: x, ..
 }}

Yes, we are suggesting that annonymous structs would fix these issues. I'm not sure what your example is showing though? That doesn't solve the kwarg issue.[quote="dan_t, post:181, topic:3831"] Then you could write:

fn foo(opts: { x: i32, y: i32 = 3 }) { ...}

[/quote] I would require that you set defaults for all arguments (so that it could be sugar for the current Default Trait), but yes that is the general idea!

That would make it less useful, because then you can't have values that have to be given by the caller.

The Default Trait is all or nothing, which makes sense for some structs, but for others you can't set sensible defaults for all fields, which at the end might be one of the reasons for the emergence of the builder pattern.

2 Likes

Couldn't agree more.

In that case it should be a positional arg instead of a kwarg/struct (or none of the struct has default args).

Edit: you could have something like this:

fn foo(a: u8, b: u8, 
       named_args: {bar: i64, baz: u64}, 
       default_args: {
           d: u8 = 10, 
           e: u64 = 10000, 
           f: bool = false
       }) {
    // do foo
}

You would then call like:

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

You could, but I certainly wouldn't call this a nice api and also wouldn't like to use it.

4 Likes

I can understand that people prefer to be explicit about the usage of default values by using ..., but it would also make functions using anonymous structs more brittle for breakage, because the struct can't be easily extended with new fields (having default values) without breaking call sites, which currently is one of the points of the builder pattern.

Wasn't there already some discussion about generally adding default values for struct fields?

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.