carbon-language / carbon-lang

Carbon Language's main repository: documents, design, implementation, and related tools. (NOTE: Carbon Language is experimental; see README)
http://docs.carbon-lang.dev/
Other
32.4k stars 1.48k forks source link

Syntax for keyword/labeled/named arguments #478

Open josh11b opened 3 years ago

josh11b commented 3 years ago

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

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:

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:

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:

Others

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

josh11b commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago
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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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:

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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

josh11b commented 3 years ago

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

josh11b commented 3 years ago

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 commented 3 years ago

I've been having some conversations about this recently, and with the recent resolution of https://github.com/carbon-language/carbon-lang/issues/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:

Disadvantages:

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:

chandlerc commented 3 years ago

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 commented 3 years ago

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

chandlerc commented 3 years ago

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 commented 3 years ago

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[bot] commented 3 years ago

We triage inactive PRs and issues in order to make it easier to find active work. If this issue should remain active or becomes active again, please comment or remove the inactive label. The long term label can also be added for issues which are expected to take time. This issue is labeled inactive because the last activity was over 90 days ago.