Compound variable names

Hi everyone! This topic has come up several times over the years, but I've started digging into an implementation recently and wrote up a first draft of a proposal to flesh out a bunch of the details.

Specific areas where I'd like assumptions validated (or don't have the expertise to fill out all the information) have been called out in italics. I of course welcome broad feedback about the proposal, and if anyone can spot additional corners where compound name syntax will have to be explicitly supported, please don't hesitate to raise it below.

Introduction

This proposal introduces a syntax for defining funciton-typed variables which have compound names (i.e., names with argument labels). This allows the call sites of such variables to achieve the same clarity that is achieveable with func declarations.

Previous discussions:
[Update + Commentary] SE-0111: Remove type system significance of function argument labels
Extending declaration names for closures
Include argument labels in identifiers

Motivation

SE-0111 removed the type-system significance of argument labels, instead relegating them to part of the name of closure and function variables. Shortly after acceptance, it became clear that this resulted in a usability regression. Closure parameters and variables could no longer supply argument labels to their parameters, resulting in a reduction of call-site clarity:

// Pre-Swift-3:
let pow: (base: Int, exponent: Int) -> Int = { ... }
let eight = pow(base: 2, exponent: 3)

// Post-Swift-3:
let pow: (Int, Int) -> Int = { ... }
let eightOrNine = pow(2, 3)

The post-Swift-3 state of affairs has persisted to this day, leaving closures unable to benefit from argument labels, which are regularly listed amongst users' favorite features of Swift.

With some APIs introduced in the latest round of Apple's operating systems, this deficiency will become more apparent than ever. Types have begun adopting the "struct-with-closures" pattern in place of delegates/protocols, which, if the pattern continues to be used, will eventually degrade the readability of call sites throughout codebases.

Proposed solution

This proposal would adopt a modified version of the first step of the plan Chris Lattner proposed back when SE-0111 was originally accepted (edited for formatting):

The modification is to drop the commas from Chris's proposed syntax in order to utilize the existing syntax for referring to functions and methods with compound names.

Thus, under this proposal, Chris's example would become:

var op(lhs:rhs:): (Int, Int) -> Int
x = op(lhs: 1, rhs: 2)

func foo(opToUse op(lhs:rhs:): (Int, Int) -> Int) {
  x = op(lhs: 1, rhs: 2)
}
foo(opToUse: +)

The resulting call site is far clearer than if we had just the base name.

Detailed design

Grammar

The language grammar will be extended in several locations to accept compound names where today only identifiers are accepted. These changes to the grammar will make use of the existing argument-names production:

argument-names → argument-name argument-names_opt
argument-name → identifier ':'

Specifically, the following productions will be replaced with equivalents that accept compound names

// Old
variable-name → identifier
// New
variable-name → identifier '(' argument-names ')'

// Old
external-parameter-name → identifier
local-parameter-name → identifier
// New
external-parameter-name → identifier '(' argument-names ')'
local-parameter-name → identifier '(' argument-names ')'

// Old
tuple-element → identifier ':' expression
// New
tuple-element → identifier '(' argument-names ')' ':' expression

// Old
tuple-pattern-element → identifier ':' pattern
// New
tuple-pattern-element → identifier '(' argument-names ')' ':' pattern

// Old (tuple types)
element-name → identifier
// New
element-name → identifier '(' argument-names ')'

Additionally, we will introduce the following productions into the grammar :

pattern → compound-name-pattern type-annotation_opt
compound-name-pattern → identifier '(' argument-names ')'
primary-expression → identifier '(' argument-names ')'

Note that the last production appears to be accepted by the compiler already, although it is not listed in the language reference.

I couldn't find anywhere in the official language documentation that allows the existing syntax for compound names in primary-expression position; is this an oversight on my part, or should it just be added now, separate from this proposal?

Typing rules

The typing rules are relatively straightforward:

  1. Any value with a compound name must have function type (or optional function type).
  2. The number of labels must match the number of arguments to the function type.

If these rules are violated, the compiler will produce a diagnostic.

If the user defines a variable with a compound name that does not have function type, a fix-it will be offered to truncate the name to just the base name. If not enough argument labels are provided, a fix-it will be offered to insert additional '_:' labels. If too many argument labels are provided, a fix-it will be offered to remove the extra labels.

Offering fix-its here might be erroneous. We can't necessarily know where the excess/missing argument labels are meant to go—is it better to just not offer the fix-its at all?

Valid positions for compound names

Compound names can be used in any position where a function-typed variable is being given a name by which it can later be referenced. This includes the following

  1. Variable declarations:
struct S {
  let f(x:): (Int) -> Void = { print($0) }
  func foo() {
    let g(y:): (Int) -> Void = { print($0) }
    g(y: 0) // 0
    self.f(x: 0) // 0
  }
}
  1. Pattern-match bindings
enum E {
  case c(f: (Int) -> Void)
}

func foo() {
  let e: E = .c({ print($0) })
  switch e {
  case .c(let f(x:)):
    f(x: 0) // 0
  }
}
  1. Tuple types and expressions
let t: (f(x:): (Int) -> Void, x: Int) = (f(x:): { print($0) }, x: 0)
t.f(x: t.x) // 0
  1. Function, initializer, and subscript parameters
func foo(callback callback(data:): (Int) -> Void) {
  callback(data: 10)
}

Are there any other spots in the language that I'm overlooking here?

Compound names are not permitted to appear in any position which would result in them being the external label to a function parameter. This means that following declaration is malformed:

func fetchImage(from url: URL, callback(image:): (UIImage) -> Void) { ... }

There are several reasons for this rule.

  • For one, it allows us to avoid the potential for unboundedly "deep" declaration names such as f(g(h(x:):):).

  • Additionally, though, it is the author's determination that allowing constructions such as this do nothing to advance the goal of call-site clarity for the labeled function value. The name provided in an argument label is inherently divorced from the name used to call the function.

There may be minor benefit at the point where the function value is passed with a label, but it may also potentially result in users defining functions with awkward argument labels, e.g, if they don't realize that in the above example 'callback(image:)' is both the internal and external name. There is also no clear path forward in this case to extending the use of compound names inline (see Future directions).

This proposal does not close off the possibility of such labels being allowed in the future, and the author suggests that such a feature be evaluated separately from the issue at hand.

When arguments such as the above are defined, the compiler will produce a diagnostic and offer a fix-it of the form:

Error: compound names cannot be used as external argument labels.
Fix-it: Insert 'callback '

in order to provide non-compound argument label.

In practice, this means that enumeration cases are not allowed to use compound names for associated value labels, since these are always API.

Calling variables with compound names

A value defined with name f(x1:...xn:) that has non-optional function type may be called exactly the same as a function declared as

func f(x1: T1, ..., xn: Tn) { ... }

The call site for f in both cases appears as:

f(x1: arg1, ..., xn: argn)

If f(x1:...xn:) has optional function type, the call site instead appears as:

f?(x1: arg1, ..., xn: argn)

Referencing compound names

Like functions and methods, variables with compound names may be referred to by either their base name, or their full name. Thus, the following is valid:

struct S {
  var foo(x:): (Int) -> Void = {}
}

let s = S()
let foo1 = s.foo
let foo2 = s.foo(x:)

Since argument labels are part of the name of the variable, it is perfectly valid to define multiple variables in the same scope/type which share a base name:

struct Foo {
  let f: () -> Void
  let f(x:): (Int) -> Void
}

These differences should be naturally resolved when calling the function. If needed when referencing the variable, users can differentiate by spelling out the full name, such as someFoo.f(x:) or by providing a type annotation, e.g., let g = f as () -> Void. The

This proposal does not adopt any rules which allow true overloading of variables with compound names, i.e., two variables with the same base name and argument labels, but different types. Such a rule could be considered in a later proposal (see Future directions below).

Synthesized initializers

Since compound names cannot be the external argument label for an initializer argument, a bit of care is required when synthesizing the initializer. This proposal would adopt a rule which simply drops the argument labels from the name of any properties which have compound names to create the argument label. In practice, this would work as follows:

// User writes:
struct S {
  let f(x:) = (Int) -> Void
  let g(y:) = (Int) -> Void
}

// Compiler synthesizes
extension S {
    init(f f(x:): (Int) -> Void, g g(y:): (Int) -> Void) {
    self.f(x:) = f(x:)
    self.g(y:) = g(y:)
  }
}

// Resulting initializer call
S(f: { print($0) }, g: { print($0 + 1) })

This can potentially lead to some ambiguity if two closure variables with compound names have the same base name. However, the proposal author makes the judgement that this will be unlikely in practice, and so does not justify supporting external argument labels with compound names.

Instead, the compiler can offer a warning if two compound names would have the same label in the synthesized initializer, with the option for the user to define an explicit initializer to silence the warning.

KeyPath and compound names

Currently, key paths cannot refer to methods, so there is no compound name syntax in key paths. This proposal introduces the natural syntax for referring to members with compound names:

struct S {
  let f(x:): (Int) -> Void
}

let path: KeyPath<S, (Int) -> Void> = \.f(x:)

Source compatibility

This is a purely additive proposal and maintains full source compatibility.

Effect on ABI stability

TBC: I haven't reached the point in my implementation where I have had to make any changes to ABI-dependent structures, so I can't yet comment on the impact here. Anyone who is more knowledgable on this front, I encourage you to call out any major issues you anticipate!

Effect on API resilience

Changing the name of a public symbol is an API-breaking change, and would remain so under this proposal. Specifically, adding or removing argument labels—or changing an existing argument label—is not API-safe in the general case.

Future directions

Allow declaration of argument labels inline with the function type

With this feature fully implemented, we can move to the second step raised in the post-acceptance discussion of SE-0111. This would allow a declaration such as:

var pow: (base: Int, exponent: Int) -> Int = { ... }

to be a syntactic sugar for

var pow(base:exponent:): (Int, Int) -> Int = { ... }

This should be a relatively straightforward extension of the groundwork laid in this proposal.

Allow external argument labels to have compound names as well

As discussed in Valid positions for compound names, external argument labels cannot have compound names, so the following declaration and call are invalid:

func fetchImage(from url: URL, callback(image:): (UIImage) -> Void) { ... }

// ...

fetchImage(from: someURL, callback(image:): {
  imageView.image = $0
})

Enable overloading for variables with compound names

This proposal supports variables/properties which share a base name, such as:

struct Foo {
  let f: (Int) -> Void
  let f(x:): (Int) -> Void
}

In theory, this could be extended to allow overloading of these properties, so that one could write:

struct Foo {
  let f(x:): (String) -> Void
  let f(x:): (Int) -> Void
}

Allowing the type context at the call/reference site to disambiguate.

Extend optional requirement support for protocol requirements

Currenly, the optional modifier for protocol requirements may only be applied to member of @objc protocols. However, since compound function labels can satisfy protocol requirements, and can also be applied to optional function types, one could imagine a pure Swift version of optional, where the protocol:

protocol P {
  optional func foo(x: Int)
}

is roughly equivalent to something like:

protocol P {
  var foo(x:): ((Int) -> Void)? { get }
}

extension P {
  var foo(x:): ((Int) -> Void)? { nil }
}

@DevAndArtist has raised this possibility before, and with the addition of compound variable names it would become a generally applicable feature for any function requirement.

30 Likes

Is the omission of callAsFunction here intentional or accidental? (And if it is intentional, what is the reason?)

Isn’t this syntax we have discussed using to access unhurried unbound methods?

1 Like

Sorry, I’m not seeing an immediate connection between the portion you’ve highlighted and callAsFunction—would you mind expanding a bit?

If a type T has a callAsFunction method of type (A1 ... AN) -> R, then one can "call" an instance t : T with arguments of type (A1 ... AN). So the user-facing behavior is as-if T is a subtype of the function type (A1 ... AN) -> R (EDIT: This is incorrect, see Jumhyn's comment below). I was asking if that is supposed to work for arguments as well. For example, will you be able to have an argument like f f(x:): T where T is a nominal type with a callAsFunction method with a single argument? The pitch mentions "must have function type" which excludes such nominal types.

1 Like

I presume the following would be a redeclaration error, but this should be spelled out in the proposal either way:

struct S {
  let f(x:): (Int) -> Void
  func f(x: Int) -> Void {} // ERROR: Redeclaration of `f(x:)`
}
2 Likes

Ah! Thanks for spelling that out. AFAIK we don't today allow conversions to function types from types with matching callAsFunction signatures, e.g., this fails:

struct S {
  func callAsFunction(x: Int) { print(x) }
}

let s = S()
s(x: 0)

func foo(f: (Int) -> Void) {
  f(10)
}

foo(f: s) // Error: Cannot convert value of type 'S' to expected argument type '(Int) -> Void'

Am I missing something?

I haven't seen those discussions personally, but that does seem like a reasonable syntax for such a feature. I'm not troubled by that, really, since already members with compound names and bound methods would be accessed via the same syntax:

struct S {
  var f(x:): (Int) -> Void = {}
  func g(x: Int) {}
}

let f = S().f(x:)
let g = S().g(x:)

Good callout, I think you're right about the behavior there (but still interested to hear what others think!). I'll be sure to include something about this situation.

Ah. So my subtyping claim is incorrect. My bad, I should've checked. But the question about it being permissible for types with callAsFunction still remains. Should the following compile?

struct S {
  func callAsFunction(x: Int) { print(x) }
}
func foo(f f(x:): S) {
  f(x: 10)
}
1 Like

I lean towards "no" since we don't have more general equivalence between "callable" types and function types with matching parameter/return types. Further, these callable types don't suffer from the same problem as closures generally:

struct S {
  func callAsFunction(x: Int) { print(x) }
}

func foo(f: S) {
  f(x: 10) // This already works!
}

Also, there are more difficult questions that would have to be answered, such as what to do with a type that defines multiple callAsFunction methods:

struct S {
    func callAsFunction(x: Int) { print(x) }
    func callAsFunction(y: String) { print(y) }
    func callAsFunction(a: String, b: Int) { print(a); print(b) }
}

func foo(f(x:): S) {
    f(x: 10)
    f(x: "") // ?
    f(x: "", b: 0) // ??
}

Overall, I think this question is better left to a time when (if?) we want some sort of conversion/equivalence for these types.

4 Likes

Nice advice, but not necessary to current syntax system; more harder to write but less benefits to get for developers.
-0.5 for now

I love it. Argument labels are one of the coolest features of swift, and I was annoyed that you lose them when you pass functions around. Your solution is the ideal one in my opinion.

I'm neutral about if Xcode should offer a fixit for removing/adding labels

Can we make it so internal labels are used to guide the autocompletion, so that you don't have to guess which argument of a callback is which?

// today
func takesCallback(callback: (Int, Int, Int) -> Void) { }
takesCallback { (<#Int#>, <#Int#>, <#Int#>) in
    <#code#>
}
// future?
func takesCallback(callback callback(a:b:c:): (Int, Int, Int) -> Void) { }
takesCallback { (a, b, c) in
    <#code#>
}
8 Likes

I really like that. I’m not actually clear on how much of code completion falls under Swift Evolution, but if that’s in-scope then I’m all for it!

2 Likes

This uses different syntax which does not involve key paths. It wouldn’t make sense to access an unbound method from an instance like this. The syntax that has been discussed has always been using keypath syntax.

Here are a couple of threads that have included discussions of this feature:

1 Like

I understand the feature you're talking about, and I wasn't trying to propose an alternate key path syntax for unbound methods—my point was about the syntax used today for normal instance-member access of bound methods.

I took your objection to be something like, "unbound method key paths already are expected to use the \.f(x:) syntax, so this proposal would potentially introduce a conflict." My response was meant to illustrate that, in a future world with both compound-name members and unbound method key paths, I see no issue with using the same syntax to refer to both, since under this proposal we would already use the same syntax to refer to both bound methods and compound-name members on instances.

To be more concrete, I see no problem with the following:

struct S {
  var f(x:): (Int) -> Void = {}
  func g(x: Int) {}
}

let kpf: KeyPath<S, (Int) -> Void> = \.f(x:)
let kpg: KeyPath<S, (Int) -> Void> = \.g(x:)

In fact, I would insist that we should use the same syntax for both precisely because it mirrors the syntax of a normal instance-member access. Additionally, if we adopt @Dante-Broggi's suggestion of disallowing both var f(x:): (Int) -> Void and func f(x: Int) {} in the same type, then we would never even have to worry about conflicting key paths between unbound methods and compound-name members.

If the above is not an acceptable future state of affairs, then IMO neither is

let f = S().f(x:)
let g = S().g(x:)

and so the Referencing compound names section of this proposal would need to be rethought.

If I've misunderstood your objection, would you mind elaborating a bit?

1 Like

@cukr I've thought about this a bit more and have a couple of questions. Mainly, I'm concerned that this code completion behavior would place external argument labels as the internal names for the closure, which may not be the right behavior. E.g.,

// Public interface
func getDownloadURL(then initiateRequest(with:): (URL) -> Void)

// Code completion
getDownloadURL { with in
  <#code#>
}

Given that the argument labels in compound names are meant to be used at the call site, its not clear to me that this would result in helpful (internal) parameter names for the closure itself.

Since we don't have any data currently on how this might be used (unless we look back at pre-Swift-3 code...?), maybe it would be better to wait to see how this feature gets used before we start messing with code completion?

This is not correct. The signature of the unbound, uncurried form of g is (S, Int) -> Void. It would not typically be used in a context expecting a key path, but if it were, it would need to be a key path to a static member, i.e. KeyPath<S.Type, (S, Int) -> Void>.

I think you do misunderstand my comments. I do not necessarily have an objection, just something for consideration. I want to make sure we don’t introduce a feature that would make it more difficult to eventually provide access to unbound, uncurried methods (including mutating methods). This has been a frustrating gap in the language for a long time.

@Joe_Groff do you have any thought on whether this pitch would conflict with the eventual use of key path syntax for accessing unbound, uncurried methods?

2 Likes

Good point. Let's not do that yet.

Ah! Thank you for making that clear. IMO, if type context is sufficient to differentiate between the bound method key path \.f(x:) and the unbound, uncurried method key path \.f(x:) then I still don't see much of an issue with overloading the syntax—a user could always differentiate as \S.f(x:) and \S.Type.f(x:) if necessary.

OTOH, if overloading the syntax in this manner turns out to be an issue in practice, I personally would much rather see \.f(x:) be used to refer to instance members and bound methods. Given that we already have the S().f(x:) syntax, it would be very disappointing if \.f(x:) did not refer to a key path to the same method.

If unbound, uncurried key path syntax fits into that story, then great! But if not, I would rather extend the existing syntax to bound methods in the most natural way, and introduce a new "flatten" operator or something that could be used on S.f(x:) to access the unbound, uncurried method.

It is not possible to access a mutating method in curried form so an uncurry operator is not a viable solution. We need new syntax for accessing an unbound, uncurried method.