Swift regret: operator function lookup rules— Jordan Rose (@UINT_MIN) December 17, 2021
This is a situation where Swift started with a very expressive model that had some quirks, as well as poor compiler performance. Unwilling to sacrifice expressivity, we traded some quirks for others and got slightly better perf. :-/
Part of the Swift Regrets series.
This is a situation where Swift started with a very expressive model that had some quirks, as well as poor compiler performance. Unwilling to sacrifice expressivity, we traded some quirks for others and got…slightly better perf. :-/
In Swift 1, all operators were top-level functions, which honestly makes a lot of sense for how you call them. (There’s no member access dot.) However, that was weird when you wanted to make an operator a protocol requirement. Every other requirement is a required member. Additionally, looking at all possible implementations of an operator made the type-checker do a lot of work. Unlike “usual” overloads, you might have dozens of the same operator. (See previous controversial post about not having type-based overloading at all.)
I don’t want to get too deep into the compile time stuff, though there’s a bit more of it in the type-based overloading post. Let’s just say that with Swift’s chosen set of features, it’s a hard problem, and something has to give, or some expressions will be “too complex to be solved in reasonable time”.
Anyway, addressing the protocol/operator mismatch and hopefully improving compile time was Tony Allevato’s SE-0091, which put operators in today’s preferred form: static methods. Not instance methods, because we knew it was already weird for
b.equals(a) to be different when ‘a’ and ‘b’ have the same static type. This comes up sometimes in Objective-C as well as Python and Ruby.
If we had gone further and only allowed static methods things might have been okay. You could say “the operator is always looked up in the left-hand type” or “the operator is looked up in both types” for easier things like mixed-type multiplication. (Think scalar
* vector.) I also really like the idea of “the operator is looked up in the result type”, but that gets weird for comparison operators, which are
(T, T) -> Bool. DSLs also want to do interesting things with operators that may not match their “usual” signatures, like generating constraints.
But only allowing operators as members would rule out operators that worked on non-nominal types, like
=== (object and class identity), and
< on tuples (since handled more thoroughly through compiler and runtime magic by Alejandro Alonso’s SE-0283). Plus whatever custom operators were out there. And there are also cases where you don’t have enough type context for your operators and so you can’t look them up in one of the arguments:
let x: UInt8 = 32 | 0x80
It’s not that common and the workaround is easy, but…
In the end, Swift 3 was already strongly source-breaking, despite the best efforts of Argyrios and everyone else who worked on the migrator. I wish we had been bolder and said operators have to be members and are looked up as members. Special-casing
=== if we had to. Instead we have something that nearly always does what you want, but with a complicated model underneath that still causes compile time problems despite a lot of effort by the type-checker folks.
By the way, another clever idea that was tried was to associate operator declarations—not the functions that implement them—with one or multiple protocols, whose requirements would be preferred over other implementations of that operator. It broke existing code. But maybe it could work if you did it from the start. Still, I don’t love Rust’s semantics-less operator traits, which would be similar in practice, so I’d want to see if the static member lookup approach pans out.