Proposal link:
https://github.com/apple/swift-evolution/blob/master/proposals/0091-improving-operators-in-protocols.md
<https://github.com/apple/swift-evolution/blob/master/proposals/0091-improving-operators-in-protocols.md>
Hi all,
> On May 17, 2016, at 8:33 PM, Chris Lattner <[email protected]> wrote:
> The review of "SE-0091: Improving operator requirements in protocols" begins
> now and runs through May 23. The proposal is available here:
My apologies for being very, very late with this review. As has been noted
elsewhere, the core team has been rather DoS’d for the last few weeks, and even
very important things are getting lost in the shuffle.
I support the goals of this proposal, but I’m strongly against the approach it
takes because it is fundamentally based on forwarding functions in the global
scope.
My Complaints with the Proposal
1) Adding an operator function to a type doesn’t “just work”, which is
surprising. Let’s do something silly and add a ‘*’ operator to repeat strings:
extension String {
static func *(lhs: String, rhs: Int) -> String { … }
}
If I try to use this in the obvious way
print(“hello” * 3)
I will get a compiler error. I have two paths at this point, neither of which
is obvious: either I need to find (or author!) a protocol to conform to that
allows my particular brand of ‘*’ to match and has a global forwarding
operator, or I need to implement the operator at the global scope:
func *(lhs: String, rhs: Int) -> String { ... }
2) Creating a new operator now requires more boilerplate:
a) An operator definition, e.g.,
infix operator +++ { }
b) A protocol that describes this new operator,
protocol Concatable {
func operator+++(lhs: Self, rhs: Self) -> Self
}
c) A forwarding operator function based on that protocol
func operator+++<T : Concatable>(lhs: T, rhs: T) -> T {
return T.+++(lhs, rhs)
}
Yes, creating a new operator shouldn’t be the easiest thing in the world, but
that is a ton of boilerplate. Moreover…
3) The protocols used to describe these operators aren’t really natural: they
are bare-bones, purely-syntactic protocols that have no meaning other than to
do forwarding. Just putting “+” into Arithmetic isn’t good enough: we’ll need
another one for Strideable, and we’ll almost surely end up with a
“HasBinaryPlus” protocol like this:
protocol HasBinaryPlus {
func operator+(lhs: Self, rhs: Self) -> Self
}
so that other non-arithmetic types that want to introduce a binary plus with
this form can opt to the protocol rather than having to write the forwarding
function I complained about in (1). Moreover, Arithmetic will inherit this
HasBinaryPlus. Scale that out and you have Arithmetic being composed of a pile
of meaningless syntactic protocols: HasBinaryPlus, HasBinaryMinus,
HasBinaryStar, HasBinarySlash, HasPrefixPlus, HasPrefixMinus. It makes
Arithmetic confusing because the requirements are scattered.
It’s not even that there is just one protocol per operator, either: even just
with the standard library, + will have at least two protocols associated with
it: HasBinaryPlus and Strideable to cover the various cases in the standard
library. It’s probably not enough, and there will surely be more protocols
created for binary + simply to provide the forwarding functions.
4) The rule prohibiting operator functions defined in a type that don’t conform
to a protocol limits retroactive modeling. If you don’t have a protocol in
hand, you have to use a global operator.
5) Forwarding functions aren’t good for tools. Under this proposal, if I write
“1 + 2” and use a tool to look at which “+” it resolved to, what will we see?
The generic forwarding operator. Even though I could look in the source and see
this:
extension Int {
static func +(lhs: Int, rhs: Int) -> Int { … }
}
and even those that’s what will get called, my tools aren’t going to interpret
the body of the global forwarding function for + to resolve it in the obvious
way.
The Good Parts
With all that negative, one might get the impression that I don’t like
operators in types. I think there are improvements here:
I) Writing an operator function in a type/extension of a type is far more
natural that writing one at global scope for the common case. Even if you’re
not planning on conforming to a protocol, it just feels right that (say) String
+ String should be defined in an extension of String. It’s better for tooling
(which can more easily associate the operator + with the String type), code
organization, works with the new meaning of the “private” access modifier, and
simply feels like Swift.
II) The requirement to use “static” on the operator function requirement in the
protocol makes perfect sense to me. It’s a lot clearer, and communicates the
semantics better. I can’t recall why we didn’t do this in the first place.
III) The goal to reduce the total number of overloads is laudable. It can help
type checker performance (fewer overloads == less exponential behavior) and
improve diagnostics (fewer candidates to display on error). The key insight
here is that we don’t want to consider both a generic operator based on some
protocol (e.g., + for Arithmetic types) and the operator functions that are
used to satisfy the corresponding requirement.
An Alternative Approach
Let’s accept (I) and (II). But, let’s make operator lookup always be global, so
that it sees all operators defined at either module scope or within a
type/extension of a type. This gives us the syntactic improvements of the
SE-0091 “immediately”, and eliminates all five of my complaints above: the
natural Swift thing of defining your functionality within the type or an
extension thereof “just works”. It’s weird in the sense that operators will be
the only place where we do such global lookup—finding entries at both global
and type scope. However, SE-0091 is introducing a different weird name lookup
rule, and it feels like there’s really no way to avoid it: we simply don’t want
normal lexical name lookup for operators when they can be defined in types.
This approach does not (directly) give any of the type checker performance/QoI
improvements of (III). However, we can achieve that by making the key insight
of (III) part of the semantic model: when we find all operators, we also find
the operators in the protocols themselves. The operators in the protocols are
naturally generic, e.g., the Arithmetic + effectively has a generic function
type like this:
<Self: Arithmetic> (Self, Self) -> Self
which is basically what the forwarding functions look like in SE-0091 at a type
level. Then, we say that we do not consider an operator function if it
implements a protocol requirement, because the requirement is a generalization
of all of the operator functions that satisfy that requirement. With this rule,
we’re effectively getting the same effects of SE-0091’s approach to (III)—but
it’s more automatic.
Note that this approach could change semantics. When you type-check a generic
function, you’re inferring the generic type arguments. That could end up type
checking differently than considering a more specific function. For example,
let’s say we were allowed to fulfill an operator function requirement with an
operator function that was a subtype of the requirement (a commonly-requested
feature), e.g.,
class Super { }
class Sub : Super, Equatable {
static func ==(lhs: Super, rhs: Super) -> Bool { … } // note: currently
ill-formed, but requested often
}
func testMe(sup: Super) -> Bool {
return sup == sup // error: Equatable.== fails because “Super” is not
equatable,
// and Sub.== isn’t considered because it satisfies the
Equatable.== requirement
}
I suspect this is acceptable. If we ever did start to allow one to satisfy a
requirement with something that is a subtype, perhaps we just wouldn’t extend
that rule to operator function requirements. Note that it’s possible that you
can trigger this in the current type system as well—I haven’t tried.
One could experiment with this solution just with the standard library: take
away all of the concrete +’s and map them to “Arithmetic.add” or
“Strideable.add” to get down to the minimal set, and then put the forwarding
functions in to see how well the type checker copes with it (e.g., performance,
diagnostics, what unexpected breakage do we see). There’s a way to push the
experiment further—by teaching the type checker to do this pruning rule, which
doesn’t actually depend on introducing the ability to define an operator
function within a type—but of course that requires more implementation effort.
- Doug
_______________________________________________
swift-evolution mailing list
[email protected]
https://lists.swift.org/mailman/listinfo/swift-evolution