Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Syntax for keyword/labeled/named arguments #478

Open
josh11b opened this issue Apr 20, 2021 · 20 comments
Open

Syntax for keyword/labeled/named arguments #478

josh11b opened this issue Apr 20, 2021 · 20 comments
Labels
leads question A question for the leads team long term Issues expected to take over 90 days to resolve.

Comments

@josh11b
Copy link
Contributor

josh11b commented Apr 20, 2021

"Named parameters", "named arguments" or "keyword arguments" are great for:

  • disambiguating arguments when calling a function with lots of parameters or ambiguous types (bool, string, int),
  • allowing you to skip optional parameters, and
  • distinguishing overloads.

They are used productively in many existing languages.

What syntax should we use? Whatever we choose should be consistent for parameter lists, argument lists, struct literals, destructuring, etc. This issue is going to just focus on argument lists in function calls, and struct/tuple literals. The questions about how they should be written in function declarations, destructuring, and pattern matching should be tackled in another issue.

Here are the top three candidates:

A. Designator = syntax

Here F is a function that takes two integer arguments, labeled .a and .b, and returns a pair of integers, with the elements labeled .c and .d. We call F with an argument value of 3 for .a and 4 for .b. We compare that to a pair with elements labeled .c and .d. We then declare a variable g that can hold a labeled pair without destructuring.

if (F(.a = 3, .b = 4) == (.c = 5, .d = 6)) {
  var (.c = Int, .d = Int) g = (.c = 3, .d = 4);
  // g.c == 3 and g.d == 4
}

Advantages:

  • Designators look different and so it is more clear that they do not follow regular name lookup.
  • Reminiscent of C/C++20 designated initializers.

B. No-dot = syntax

Here F is a function that takes two integer arguments, labeled a and b, and returns a pair of integers, with the elements labeled c and d. We call F with an argument value of 3 for a and 4 for b. We compare that to a pair with elements labeled c and d. We then declare a variable g that can hold a labeled pair without destructuring.

if (F(a = 3, b = 4) == (c = 5, d = 6)) {
  var (c = Int, d = Int) g = (c = 3, d = 4);
  // g.c == 3 and g.d == 4
}

Or without spaces around the =, following Google's Python style:

if (F(a=3, b=4) == (c=5, d=6)) {
  var (c=Int, d=Int) g = (c=3, d=4);
  // g.c == 3 and g.d == 4
}

Advantages:

Disadvantages:

  • Concern that a = 3 means something very different as a statement vs. in an argument list. I believe this partially motivates Google's Python style for not using spaces around the = when it is a keyword argument.

C. No-dot : syntax

Here F is a function that takes two integer arguments, labeled a: and b:, and returns a pair of integers, with the elements labeled c: and d:. We call F with an argument value of 3 for a: and 4 for b:. We compare that to a pair with elements labeled c: and d:. We then declare a variable g that can hold a labeled pair without destructuring.

if (F(a: 3, b: 4) == (c: 5, d: 6)) {
  var (c: Int, d: Int) g = (c: 3, d: 4);
  // g.c == 3 and g.d == 4
}

Advantages:

  • Cleaner / less noisy
  • Approach used by C#, Elixir, Objective-C, Ruby, Swift
  • Go, JavaScript uses this just in struct literals, not argument lists.
  • Doesn't interfere with using designators for sum types

Others

Other approaches used by languages (found from Rosetta Code):

@josh11b
Copy link
Contributor Author

josh11b commented Apr 20, 2021

My preference is B (just =), followed by C (just :), followed by A (. then =). This is based on looking clean, and how many languages chose that syntax.

@jonmeow
Copy link
Contributor

jonmeow commented Apr 20, 2021

I more or less agree with your ordering for characters (preferring =). However, I don't understand this syntax:

fn F(a = Int a, b = Int b) -> (c = Int, d = Int) {

To the extent that argument defaults, names of returns values, defaults for return values, etc might be specified, I'd expect:

fn F(Int a = 0, Int b = 1) -> (Int c = 2, Int d = 3) {

Then called:

var (Int e, Int f) = F(a = 4, b = 5);

(note the placement of var for tuples is unclear to me, but mainly I wanted to note the lack of c= there)

@josh11b
Copy link
Contributor Author

josh11b commented Apr 20, 2021

The issue is that the name of the label may need to be different from the name of the variable. For example, in

var (c = Int e, d = Int f) = F(a = 3, b = 4);

The return value of F is a tuple with elements named c and d, which we want to assign to variables e and f respectively.

I updated the text to hopefully make this more clear.

@josh11b
Copy link
Contributor Author

josh11b commented Apr 20, 2021

fn F(a = Int a, b = Int b) -> (c = Int, d = Int) {

is supposed to mean: "F takes two Int arguments, named a and b and returns a tuple with two int elements name c and d".

I updated the text to hopefully make this more clear.

@josh11b
Copy link
Contributor Author

josh11b commented Apr 20, 2021

Another clarification: I'm assuming something like the Swift model, where there is an optional syntax to indicate a name in the function declaration, and if it the parameter is named the argument has to be named as well. Not like the Python model, where you have a choice about whether to use the name at the caller.

I updated the text to hopefully make this more clear.

@zygoloid
Copy link
Contributor

I'm concerned that option B looks too much like an assignment, and that this would mean that the same syntax (A = B) means very different things in pattern versus expression contexts.

How do we imagine default arguments fitting into this, if at all? Assuming we support default arguments,

fn F(.a = Int a = 1, .b = Int b = 2) -> (.c = Int, .d = Int) {
  return (.c = a, .d = b);
}

... seems unappealing to me. So I'm leaning towards option C being my preferred approach; the : is also reminiscent of Smalltalk / Objective-C, and very close to Swift's syntax. Something like:

fn Sort[Sequence s](Ptr(s) seq, order_by: Comparison comp = Less(s.Element)) {
  // ...
  if (comp(a, b))
  // ...
}
// ...
Sort(&vec, order_by: whatever)

... seems quite nice to me syntactically.

@jonmeow
Copy link
Contributor

jonmeow commented Apr 28, 2021

Regarding argument labels, as I commented on #339, it's not clear to me why they should be provided. I don't understand why developers shouldn't be expected to rename their arguments when they want callers to refer to them by a different name:

  • If the name callers use is too brief for local use in a way that makes it unclear, then it should also be considered too unclear for a caller.
  • If the name callers use is too verbose for local use in a way that makes it an annoyance, it's probably also an issue for callers.
    • Locally it's also easier to make an alias, if not with the alias keyword precisely, then with something like var auto& x = my_really_long_parameter_name;.

The main use-case I can see is in refactoring, when renaming parameters: however, argument labels don't appear to assist in that in Swift. By my reading, keywords are only available under one label, and so not useful when renaming with call-sites that specify by label. Also, incremental refactoring could be handled by providing an overload, such as:

fn DoSomething(Int new_name = 3) ...
fn DoSomething(Int old_name = uninit) { DoSomething(new_name = old_name); }

(i.e., using uninit to indicate that old_name is required, to resolve ambiguity for DoSomething();)

I will note though, allowing (requiring?) everything to be specified by argument name creates a refactoring impairment, in that it means renaming parameters is a significant refactoring. As a consequence, it may be preferable to constrain to opt-in at the function site, rather than Swift's opt-out approach.

Is there rationale for Swift's argument label feature? Am I on the fringe for being hesitant about argument label support?

@jonmeow
Copy link
Contributor

jonmeow commented Apr 28, 2021

For Swift discussion, argument labels appear to be covered by SE-0001: the goal was to allow language keywords (in, inout) to be used as argument names without backtic escaping.

Do we want to use that approach? I think we may already have the particular issue covered, if desired, under the "raw identifier" idea which allows identifiers to be retained even if new keywords are added that overlap with them.

@jonmeow
Copy link
Contributor

jonmeow commented Apr 28, 2021

I think I should also note, _ is being proposed as syntax for locally unused identifiers (#476), imitating pattern matching. Using _ for both argument label opt-out (mirroring Swift) and as an anonymous identifier could lead to confusion and syntax ambiguity.

@geoffromer
Copy link
Contributor

Is there rationale for Swift's argument label feature?

The Swift docs offer this rationale:

Here’s a variation of the greet(person:) function that takes a person’s name and hometown and returns a greeting:

func greet(person: String, from hometown: String) -> String {
   return "Hello \(person)!  Glad you could visit from \(hometown)."
}
print(greet(person: "Bill", from: "Cupertino"))
// Prints "Hello Bill!  Glad you could visit from Cupertino."

The use of argument labels can allow a function to be called in an expressive, sentence-like manner, while still providing a function body that’s readable and clear in intent.

However, I think this rationale really doesn't work if the separator is = -- the purpose of separating from and hometown is so that the call expression approximates an English phrase in which the label and argument play different grammatical roles, which would be badly undermined by a syntax that encourages you to think of them as equal, as in greet(person = "Bill", from = "Cupertino").

Am I on the fringe for being hesitant about argument label support?

I'm hesitant about Swift-style argument labels too. Simple examples like the above are appealing, but also make this approach seem fairly ad hoc. For example, person: actually makes the callsite less sentence-like, because it's clearly meant to be understood as naming the argument, rather than providing sentence-like scaffolding. Worse, the callsite syntax doesn't distinguish argument-name-like labels from sentence-scaffolding labels, which means that reading such a callsite could require a fair amount of trial and error. Also, the sentence-like flow seems brittle against changes in argument order: greet(from: "Cupertino", person: "Bill") sounds like the greeting, rather than Bill, is coming from Cupertino.

However, I think if we assume that a parameter list is a restricted kind of tuple pattern, then it actually seems very difficult to avoid having the parameter name be separate from the argument label, even if the function author wants them to be the same.

@josh11b
Copy link
Contributor Author

josh11b commented Apr 28, 2021

Regarding argument labels, as I commented on #339, it's not clear to me why they should be provided. I don't understand why developers shouldn't be expected to rename their arguments when they want callers to refer to them by a different name:

I think there is a communication gap here. I don't perceive this feature as having anything to do with what you are describing.

  • If the name callers use is too brief for local use in a way that makes it unclear, then it should also be considered too unclear for a caller.

  • If the name callers use is too verbose for local use in a way that makes it an annoyance, it's probably also an issue for callers.

    • Locally it's also easier to make an alias, if not with the alias keyword precisely, then with something like var auto& x = my_really_long_parameter_name;.

The main use-case I can see is in refactoring, when renaming parameters: however, argument labels don't appear to assist in that in Swift. By my reading, keywords are only available under one label, and so not useful when renaming with call-sites that specify by label. Also, incremental refactoring could be handled by providing an overload, such as:

fn DoSomething(Int new_name = 3) ...
fn DoSomething(Int old_name = uninit) { DoSomething(new_name = old_name); }

(i.e., using uninit to indicate that old_name is required, to resolve ambiguity for DoSomething();)

I will note though, allowing (requiring?) everything to be specified by argument name creates a refactoring impairment, in that it means renaming parameters is a significant refactoring. As a consequence, it may be preferable to constrain to opt-in at the function site, rather than Swift's opt-out approach.

Is there rationale for Swift's argument label feature? Am I on the fringe for being hesitant about argument label support?

I listed what I perceive as the benefits at the very beginning of the issue. I just reformatted them so they should stand out more now.

@josh11b
Copy link
Contributor Author

josh11b commented Apr 29, 2021

I'm going to remove the function declaration syntax from this issue, since that is more complicated.

@josh11b josh11b changed the title Syntax for keyword arguments Syntax for keyword/labeled/named arguments Apr 29, 2021
@josh11b
Copy link
Contributor Author

josh11b commented Apr 29, 2021

I've also removed the destructuring, since that should ideally use the same pattern syntax as function declarations.

@josh11b
Copy link
Contributor Author

josh11b commented May 19, 2021

An argument against using the dot/designator syntax "A" is that .a really looks like it should be "the a field of a struct that we are going to figure out from context." For example, this is its interpretation in Swift. There really isn't a struct involved when calling a function, and so the expectations from other programming languages makes option A a bit confusing/misleading.

@josh11b
Copy link
Contributor Author

josh11b commented May 21, 2021

I've been having some conversations about this recently, and with the recent resolution of #542 I think we are in a better position to answer this question. What I've been hearing and thinking:

To be clear: I absolutely haven't talked to everyone so please do chime in if you feel differently! That being said, from what I heard there are two top contenders:

The struct literal approach

Instead of having a dedicated syntax for specifying named arguments directly, we lean into passing an anonymous "options" struct literal as the last argument of a function. For example, using the conventional choice of writing struct literals inside curly braces {...}, you might write:

F(1, 2, {.x = 3, .y = 4});

Presumably this last argument would be optional if the function specified defaults for all of the fields of the options struct. This argument would not really be special except by convention; you could just as well pass in any value in that position as long as it had a type that could be converted to the struct type expected in the function declaration.

Advantages:

  • One fewer mechanism.
  • Closer match to C++.
  • Approach used by JavaScript and Go.

Disadvantages:

  • Heavy, particularly if named arguments are going to be common, or for functions being passed no positional arguments (F({.x = 3, .y = 4})) or just one named argument (F(1, 2, {.x = 3})).
  • Named arguments aren't peers of positional arguments.
  • There is nothing to distinguish providing an anonymous struct value to one parameter vs. providing a set of named arguments.

You might also like this approach as a temporary solution, postponing the inclusion of a dedicated labeled argument syntax until we get more information.

Option "B", or "the Python approach"

This option is basically: "I think we want a dedicated syntax for writing keyword arguments, lets go with what's popular." You might write:

F(1, 2, x=3, y=4);

If we wanted to encapsulate a set of keyword option values in a struct value, presumably you would use them in an argument list using the ... operator (F(1, 2, my_struct...)), analogously to how you would use that operator to pass in a tuple value as positional arguments.

Advantages:

  • Concise, labeled arguments are only minimally more typing than positional.
  • Proven syntax, found to be accessible and popular in languages like Python and Kotlin.

@chandlerc
Copy link
Contributor

FWIW, I like focusing on these two high level options. I actually think they are both based on fairly proven approaches that have found to be accessible and popular in languages (JS/Go on one hand, Python on the other). That's part of why I think they somewhat stand out as good ways to model this.

I also can see reasons to consider the lack of parity an advantage -- if we want to encourage use of positional parameters where they make sense. But maybe its more a consequence of the design choice here: whether named (and non-positional) arguments are at parity indicates whether the language is (somewhat) opinionated about their use. I lean slightly towards encouraging positional parameters when it makes sense, but I know others feel differently about that. My leaning comes from making APIs in Carbon stay a bit more similar to C++ APIs.

@josh11b
Copy link
Contributor Author

josh11b commented May 26, 2021

Please let us know your opinion! Vote here: https://discord.com/channels/655572317891461132/709488742942900284/846932433832378410

@chandlerc
Copy link
Contributor

Adding a comment here to just clarify where my opinion came from ...

The only part of option "B" that really bothers me is that the identifier looks like an unqualified identifier. I understand that we can look ahead to the = and figure out that it is a named parameter. And I understand that this is not a problem in practice in Python. But that's the issue that trips me up with the option. I don't have any deeper problem, and it really is just that syntax issue. Anyways, I mostly didn't want my issue with option "B" to be interpreted as anything more broad than that. I'd be happy with it given a syntax that doesn't look like an unqualified name. But I also understand that undermines one of its best features: matching Python. =/ Anyways, just recording this for posterity. Sorry for anyone that already heard me say this live.

@chandlerc
Copy link
Contributor

Just to leave a note here that the leads are explicitly deferring this question and #505 . We're not opposed to named parameters and arguments, but the initial motivation for prioritizing this right away seems better addressed separately, and it seems valuable to more fully understand the expected syntax for things like struct literals and pattern matching generally if possible to better inform any decision.

Leaving the question open to make it clear that this is something we can and should expect to revisit in the future.

@github-actions

This comment was marked as outdated.

@github-actions github-actions bot added the inactive Issues and PRs which have been inactive for at least 90 days. label Sep 16, 2021
geoffromer added a commit that referenced this issue Oct 15, 2021
Rationale: Based on the status of #478 and #505, Carbon won't have this feature for a while, and it will be simpler not to support it on spec in the meantime.
chandlerc pushed a commit that referenced this issue Jun 28, 2022
Rationale: Based on the status of #478 and #505, Carbon won't have this feature for a while, and it will be simpler not to support it on spec in the meantime.
@jonmeow jonmeow added leads question A question for the leads team long term Issues expected to take over 90 days to resolve. and removed inactive Issues and PRs which have been inactive for at least 90 days. labels Aug 10, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
leads question A question for the leads team long term Issues expected to take over 90 days to resolve.
Projects
None yet
Development

No branches or pull requests

5 participants