This is part 4 of a series on how overloaded functions in Swift are chosen. Part 1 covered overloading by return type, part 2 was about how different functions with simple single arguments were picked on a “best match” basis, and part 3 went into a little more detail about protocols.
We started the series with the question, why do you get a Range
type back from 1...5
instead of a ClosedInterval
? Today we cover how generics fit into the matching hierarchy, and we’ll finally have enough information to answer that question.
Generics are Lower Priority
Generics are lower down the pecking order. Remember, Swift likes to be as “specific” as possible, and generics are less specific. Functions with non-generic arguments (even ones that are protocols) are always preferred over generic ones:
// This f will match a single argument of any type, // but is very low priority func f<T>(t: T) { print("T") } // This f takes a specific implemented type, so will be // preferred over anything else when passed a String. func f(s: String) { print("String") } // This f takes a specific protocol, so would be // preferred over the generic version (though not // over a version of f that took an actual Bool) func f(b: BooleanType) { print("BooleanType") } // this prints "String" f("wotcha") // this prints "BooleanType" f(true) // this prints "T" f(1)
OK that’s simple enough. But once we’re in generic territory, there’s a bunch of additional rules for picking one generic function over another.
Making Generic Placeholders More Specific with Constraints
Within a type parameter list, you can apply various different constraints to your generic parameters. You can require generic placeholders conform to protocols, and that their associated types be equal to a certain type, or conform to a protocol.
Just like before, the rule of thumb is: the more specific you make a function, the more likely it is to be picked in overload resolution.
This means a generic function with a placeholder that has a constraint (such as conforming to a protocol) will be picked over one that doesn’t:
func f<T>(t: T) { print("T") } func f<T: IntegerType>(t: T) { print("T: IntegerType") } // prints "T: IntegerType" f(1)
If the choice is between two functions that both have constraints on their placeholder, and one is more specific than the other, the more specific one is picked. These rules follow a familiar pattern – they mirror the rules for non-generic overloading.
For example, when one protocol inherits from another, the inheriting protocol constraint is preferred:
func g<T: IntegerType>(t: T) { print("IntegerType") } func g<T: SignedIntegerType>(t: T) { print("SignedIntegerType") } // prints "SignedIntegerType" g(1)
Or if the choice is between adhering to one protocol or two, two is preferred:
func f<B: BooleanType>(b: B) { print("BooleanType") } func f<B: protocol<BooleanType,Printable>>(b: B) { print("BooleanType and Printable") } // prints "BooleanType and Printable" f(true)
The where
clause introduces the ability to constrain placeholders by their associated types. These additional constraints make the match more specific, bumping it up the priority:
// argument must be an IntegerType func f<T: IntegerType>(t: T) { print("IntegerType") } // argument must be an IntegerType // AND it's Distance must be an IntegerType func f<T: IntegerType where T.Distance: IntegerType>(t: T) { print("T.Distance: IntegerType") } // prints "T.Distance: IntegerType" f(1)
Here, the extra where clause is analogous to a regular argument needing to conform to two protocols.
Swift will also distinguish between two where clauses, one of which is more specific than another. For example, requiring an associated type to conform to an inheriting protocol:
func f<T: IntegerType where T.Distance: IntegerType>(t: T) { print("T.Distance: IntegerType") } // where T.Distance: SignedIntegerType is more specific than // where just T.Distance: IntegerType func f<T: IntegerType where T.Distance: SignedIntegerType>(t: T) { print("T.Distance: SignedIntegerType") } // so this prints "T.Distance: SignedIntegerType" f(1)
Where clauses also allow you to check a value is equal to a specific type, not just that it conforms to a protocol:
func f<T: IntegerType where T.Distance == Int>(t: T) { print("Distance == Int") } func f<T: IntegerType where T.Distance: IntegerType>(t: T) { print("Distance: IntegerType") } // prints "Distance == Int" f(1)
It isn’t surprising that the version that specifies Distance
is equal to an exact type is more specific than the version that just requires it conforms to a protocol. This is similar to the rule for non-generic functions that a specific type as an argument is preferred over a protocol.
One interesting point about checking equality in the where clause. You can check for equality to a protocol. But it probably isn’t what you want – equality to a protocol is not the same as conforming to a protocol:
// create a protocol that requires the // implementor defines an associated type protocol P { typealias L } // implement P and choose Ints as the // associated type struct S: P { typealias L = Int } let s = S() // Ints are printable so this will match func f<T: P where T.L: Printable>(t: T) { print("T.L: Printable") } // == is more specific than :, so this will // be the one that's called, right? func f<T: P where T.L == Printable>(t: T) { print("T.L == Printable") } // nope! prints "T.L: Printable" f(s) // here is a struct where L really is == Printable struct S2: P { typealias L = Printable } let s2 = S2() // and this time, yes, it prints "T.L == Printable" f(s2)
It’s actually pretty hard to do this by accident – you can only check for type equality if a protocol you’re equating to has no Self or associated type requirements (i.e. the protocol doesn’t require a typealias. or use Self
as a function argument), same as you can’t use those protocols as non-generic arguments. This rules out most of the protocols in the standard library (Printable
is one of only a handful that doesn’t).
Beware the Unexpected Overload
So, if given a choice between a generic overload and a non-generic one, Swift choses the non-generic one. If choosing between two generic functions, there’s a set of rules that are very similar to the ones for choosing between non-generic functions.
It’s not a perfect parallel, though – there are some differences. For example, if given a choice between an inheriting constraint or more constraints (“depth versus breadth”), unlike with non-generic protocols, Swift will actually chose breadth:
protocol P { } protocol Q { } protocol A: P { } struct S: A, Q { } let s = S() // this f is for an inherited protocol // (deeper than just P) func f(p: A) { print("A") } // this f is for more protocols // (broader than just P) func f(p: protocol<P, Q>) { print("P and Q") } // error: Ambiguous use of 'f' f(s) // however, if we define a similar situation // with generics instead: func g<T: A>(t: T) { print("T: A") } func g<T: protocol<P, Q>>(t: T) { print("T: P and Q") } // the second one will be picked // prints "T: P and Q" g(s)
Let’s see that example again with some real-world types from the standard library:
func f<T: SignedIntegerType>(t: T) { print("SignedIntegerType") } // IntegerType is less specific than SignedIntegerType, // but depth beats breadth so this one should be called: func f<T: protocol<IntegerType, Printable>>(t: T) { print("T: P and Q") } // prints "SignedIntegerType" // wait, what? f(1)
If the example with P
and Q
worked one way, why the difference with SignedIntegerType
and Printable
?
Well, turns out IntegerType
itself conforms to Printable
(by way of _IntegerType
). Since it already conforms to it, the Printable
in protocol
is redundant and can be ignored – it doesn’t make that version of f
any more specific than T
just conforming to IntegerType
. Since SignedIntegerType
inherits from IntegerType
, it is more specific, so that’s the one that gets picked.
I point this out not to be fussy about the hierarchy of standard library protocols, but to point out how easy it is to get an unexpected version of an overloaded function. For this reason, it’s a good rule to never overload a function with different functionality. Overload for performance optimization (like a collection algorithm that extends), or to extend a function to cover your user-defined new type, or to provide the caller with a more powerful but basically equivalent type (like with Range
and ClosedInterval
). But overload to vary functionality based on the input and sooner or later you’ll be sorry.
Anyway, with that little mini-lecture out of the way, we finally have enough of the details of overloading to answer the original question – why does Range
get picked? We’ll cover that in the next article.