Should we allow cross-module overloading of variables and properties?

Background

A quirk of Swift 4.1

In Swift 4.1 it was possible to overload a property defined in the body of a generic type with a property defined in an extension of that type.

That is, we permitted the following:

struct S<T> {
  var foo: String { return "" }
}
extension S {
  var foo: Int { return 0 }
}

let x1: String = S<Void>().foo
let x2: Int = S<Void>().foo

And, across modules:

// Module A

public struct S<T> {
  public var foo = 0
}
// Module B

import A

extension S {
  var foo: String { return "" }
  func bar() {
    let x1: Int = foo // resolves to module A's `foo: Int`.
    let x2: String = foo // resolves to module B's `foo: String`.
  }
}

In all other cases, for example if both definitions of foo were in an extension of S, or if the type S was non-generic, we would reject the redeclaration in the first example, and only ever be able to resolve to the current module's foo: String in the second (therefore rejecting let x1: Int = foo).

The fact that we allowed the overload in this very specific case appears to be more an accident of the compiler implementation than an intentional rule. Specifically, it had to do with the variable’s "overload type", which is a type that determines if two declarations can overload each other – if the overload types for two declarations differ, then those declarations are considered overloads.

In Swift 4.1, variables were only given custom overload types if they were defined in an extension of a generic type (and that overload type depended only on the context; not the type of the property). Otherwise, they were all given the same overload type – the null type. This therefore meant that variables could only overload if one was in an extension of a generic type, and the other wasn’t.

Swift 4.2

In #15412, the overload type logic was cleaned up such that variables now get consistent overload types, dependant only on the context in which they are defined (meaning that we never consider variables to overload). This therefore removed the 4.1 quirk allowing for properties to overload if one was in a generic extension, and the other wasn’t. The old behaviour for the first example is preserved under Swift 4 compatibility mode in 4.2 (but in Swift 5 mode, it’s rejected as a redeclaration).

Though unfortunately, the second cross-module example was missed, so the compiler cannot currently resolve to foo: Int in Swift 4.2 under Swift 4 mode (looking to restore this in Swift 5 under Swift 4 mode in #18488 though).

Cross-module variable overloads?

This change got me thinking though – why not allow cross-module variable overloads in the general case? That is, consider viable variable/property candidates from other modules while resolving a reference to a variable/property that has a (possibly unviable) candidate in the current module.

For example, this would permit the following:

// Module A

public var global = 0

public struct S {
  public var foo = 0
}
// Module B

import A

var global = ""

let x1: Int = global // resolve to module A's `global: Int` (currently an error).
let x2: String = global // resolve to this module's `global: String`.
let x3 = A.global // fine, we're disambiguating by module
let x4 = global // should be an an ambiguity error (currently resolves to `global: String`)

// (and exactly the same behaviour if S is generic and/or module A's definition of `foo`
//  is defined in an extension)
extension S {
  var foo: String { return "" }
  func bar() {
    let x1: Int = foo // resolve to module A's `foo: Int` (currently an error).
    let x2: String = foo // resolve to this modules's `foo: String`.
    let x3 = foo // should be an an ambiguity error (currently resolves to `foo: String`)
  }
}

We would allow the overloading and reject ambiguities if there's not enough context to infer which module's overload to select.

(Specifically, my thinking is that for the purposes of redeclaration checking, we should use the current overload type logic for variables, but for lookup we should use a more lenient overload type for variables, that depends both of their type and the context – allowing for such cross-module overloads).

This would bring the behaviour more in-line with that of functions, where the following is already permitted:

// Module A

public struct S {
  public func foo() -> Int { return 0 }
}
// Module B

import A

extension S {
  func foo() -> String { return "" }
  func bar() {
    let x1: Int = foo()    // resolves to module A's `foo() -> Int`.
    let x2: String = foo() // resolves to module B's `foo() -> String`.
  }
}

My reasoning for suggesting that we only allow the overloading of variables across modules rather than in the general case is that allowing such variable overloads in the general case would IMO encourage tricky code that relies on overload resolution by "return type", which I believe is something we want to discourage (listed in the API design guidelines under "General conventions" > "Methods can share a base name").

However given that we already allow what would otherwise be re-declarations across module boundaries for both properties and methods, I think that it seems reasonable to enable this overloading behaviour for variables across modules. Otherwise I think the current shadowing behaviour of ignoring the variable defined in the other module is a bit unintuitive.

I’d be interested to hear other people’s thoughts on this matter. Should we allow cross-module overloads of variables? And if so, would this require a formal Swift evolution cycle?

1 Like

I find all the examples difficult to follow and would therefore prefer they were all rejected and the programmer had to use a different name.

We ought to reject these overloaded properties where we can, such as when the extension adding the overload is obviously in the same module as the original definition. However, it's impossible to prevent completely, because protocol extensions and extensions from independent modules can inadvertently add declarations with the same names independently. In resilient libraries, the originating module could introduce declarations in a new version that overload names from downstream extensions as well. Ideally we'd have a syntax for disambiguating these situations, and IMO if we did you ought to be forced to use it at use sites instead of being subjected to the whims of overload resolution.

5 Likes

+1

Completely agree – AFAIK in Swift 5 mode we reject all such "overloaded properties" that occur within the same module as redeclarations.

I agree it would be nice to have generalised syntax for disambiguating declarations based on the module in which they are defined, as currently we can only do this for global decls.

Could I clarify that when you say you think that the user should be forced to use such syntax, you mean that this should preclude the ability to disambiguate across-modules based on type inference? (i.e if module A defines foo: Int and module B defines foo: String, you couldn't just say let x: Int = foo to refer to module A's foo?) And if so, would this extend to functions and disambiguation by argument type? (i.e if module A defines func foo(_ x: Int) {} and module B defines func foo(_ x: String) {}, saying foo("") wouldn't be sufficient to disambiguate?)

My personal taste is anti-overloading, though I know Swift is not. However, when overloads are completely non-overlapping such as your example of Int and String, then favoring one based on type context is probably fine. It's when there are overlaps that I feel disambiguation is most strongly required.

2 Likes