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?