[swift-evolution] [Review] SE-0091: Improving operator requirements in protocols

Jordan Rose jordan_rose at apple.com
Tue May 24 22:18:15 CDT 2016


> On May 24, 2016, at 09:07, Tony Allevato <allevato at google.com> wrote:
> 
> On Mon, May 23, 2016 at 9:58 PM Jordan Rose <jordan_rose at apple.com <mailto:jordan_rose at apple.com>> wrote:
> [Proposal: 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, Tony. Thanks for working on this. I have to say I’m incredibly concerned with this direction, for two main reasons. I’ll try to break them down here. (Sorry for squeaking in at the end of the review period!)
> 
> No worries, thanks for the detailed feedback! I've tried to address your concerns inline.
> 
> 
> Overrides
> 
> People up until now have been fairly unhappy with how operators are statically dispatched and can’t be overridden. We went all the way towards providing == that automatically calls isEqual(_:) on NSObject, but then you provide == for your subclass and it never gets called…no, wait, it does get called when you do a simple test, but not from the actual code that has NSObject as a static type.
> 
> This proposal stays in that space: the proposed “trampoline” operator will dispatch based on the static type of the objects, not the dynamic type. Why? Consider using == on an NSURL and an NSString, both statically typed as NSObject. Given the definition of the trampoline from the proposal
> 
> func == <T: Equatable>(lhs: T, rhs: T) -> Bool {
>   return T.==(lhs, rhs)
> }
> 
> T can’t possibly be anything but NSObject. (Neither NSURL nor NSString matches the types of both ‘lhs’ and ‘rhs’.) This isn’t a regression from the current model, as you say, but it does make the current model even more surprising, since normally you’d expect methods to be dynamically dispatched.
> 
> Setting Objective-C aside for a moment, is this example consistent with Swift's design philosophies about type safety though? Swift doesn't even let you compare types that seem reasonably compatible, like Float and Double. Should we expect users to be able to compare completely distinct types like NSURL and NSString without a little extra work?
> 
> If having `==` map to `NSObject.isEqual` is important for Objective-C compatibility, then there's no reason the runtime can't provide the following operator:
> 
> func ==(lhs: NSObject, rhs: NSObject) -> Bool {
>   return lhs.isEqual(rhs)
> }
> 
> which should be more specific than the generic one and always get chosen in the context you desire.
> 
> The situation is a little better when one of the types being compared has the other type as a base, because then implementing the operators as class methods instead of static methods does the right thing (with the only caveat being you have to cast the arguments down to the correct type).
> 

> 
> Here’s an alternate formation of the trampoline that’s a little better about this…
> 
> func == <T: Equatable>(lhs: T, rhs: T) -> Bool {
>   return lhs.dynamicType.==(lhs, rhs)
> }
> 
> …but I’m still not convinced. (People are especially likely to get this wrong without the trampolines being synthesized.)
> 

Yeah, I don’t think arbitrary symmetric operators do work generally across object types. Equality is special since there’s a reasonable default, but that’s probably not the common case. That does make this a little better—if you never have to override an operator function, the static dispatch doesn’t matter.

I’m still concerned about asymmetric operators, however. That includes assignment operators, but also things like operators for chaining tasks, DSLs for constraint systems, etc. It’s not too hard to implement these to forward to something dynamically dispatched (as we’ve both shown), but the default behavior, which currently doesn’t do what you want, will continue not doing what you want.

> Assignment Operators
> 
> A mutating requirement and a static method with an inout parameter mean different things for a conforming class: the former can only access the class’s properties, while the latter can replace the caller’s reference as well.
> 
> class Player { … }
> 
> extension Player {
>   static func roulette(_ player: inout Player) {
>     if randomValue() > 0.1 {
>       player.roundsSurvived += 1
>     } else {
>       // Replace this player…but not any other references to them!
>       player = Player()
>     }
>   }
> 
>   /*mutating*/ func solitaire() {
>     self.roundsSurvived += 1
>     // Cannot replace ‘self’
>     //self = Player()
>   }
> }
> 
> I’m not sure if one of these is obviously better than the other (more capable ↔︎ more footgun). I agree with Nicola's point about mutating methods looking better than static methods taking an inout parameter, but that probably shouldn’t be the ultimate deciding factor.
> 
> You make a good point here. This is hitting on one of the fundamental problems I've had with mutating requirements in protocols in some of my own projects, which is that even when a class type conforms to such a protocol (or worse, even if the protocol itself is restricted to `class` types only), if I want to use it in a generic context with that protocol as a constraint, I still have to pass it unnecessarily as an inout argument.
> 
> On the one hand, fixing that underlying problem might help us here as well. An obvious alternative would be to disallow `inout` on class conformances to assignment operators, but that introduces inconsistency that I'm trying to avoid. I'm not sure if it's any better, either.

Yeah, I’d definitely like to change the behavior we have now. We discussed it previously and decided this was the least bad thing to do, but I think that decision was wrong. That deserves a whole proposal of its own, though.


> 
> 
> 
> I know we want to improve type-checker performance, and reducing the number of overloads seems like a way to do that, but I’m not convinced it actually will in a significant way (e.g. “you can now use ten operators in a chain instead of seven” is not enough of a win). It still seems like there ought to be a lot of low-hanging fruit in that area that we could easily clear away, like “an overload containing a struct type will never match any input but that struct type”.
> 
> I personally really want to move operators into types, but I want to do it by doing member lookup on the type, and fall back to global operators only if something can’t be found there. That approach
> 
> - also has potential to improve type-checker performance
> - also removes operators from the global namespace
> - also removes the need for “override points” (implementation methods like NSObject.isEqual(_:) and FloatingPoint.isLessThan(_:))
> 
> It does privilege the left-hand side of a binary operator, but I think that’s acceptable for the operators we’ve seen in practice. (Of course we would need real data to back that up.)
> 
> As Brent pointed out in his reply, without multiple dispatch, you don't really benefit from privileging the lhs argument, and in fact you can end up in situations where the behavior is surprising if you don't implement both orders. For example, in your (NSString, NSURL) example, each class would have to be extended to explicitly support comparison with the other in order to support commutativity if equality was an instance method. I'm not sure that's any better for those particular types than just having the operators global in the first place.

I assume you’d still have to implement it with NSObject as the “other” type, like you do in Objective-C. You’d just return false in that case. But you’d get the correct answer for comparing two NSURLs that are statically typed as NSObject, which you wouldn’t using the vanilla trampoline.


> 
> My argument would be that there are still significant enough benefits to move forward:
> 
> - for value types and for binary operators that have the same typed arguments, static methods provide a clear benefit w.r.t. consistency (the declaration of the global operator and the static operator look the same) and obvious semantics

Agreed…for operators that go with protocols. Is that all of them? Most of them? I’m not sure.

> - for binary operators with differently typed arguments, static operators support commutativity much better than instance methods because both implementations live in the most natural type for that particular case (for example, String + Character and Character + String)

Agreed, but I consider this only a mild plus because extensions are pretty easy in Swift. We put initializers and helper methods on related types all the time.

> - if later on we can auto-generate trampolines, the choice of instance vs. static methods for value types becomes a wash because the compiler is effectively doing the same dispatch under the hood anyway

That’s true for value types whether or not we autogenerate, isn’t it?

(Also, for autogeneration, I like the idea of it being opt-out the same way default initializers are for a struct: if you provide a global operator implementation in the same module with the correct signature, the compiler doesn’t need to generate one.)

> - for class types, regardless of whether one is a base of the other or both share a common third base type, neither static nor instance methods completely solve the problem and won't until/unless Swift supports multiple dispatch, and the proposed behavior is not a regression in those cases

I guess I’m not convinced of the chain of reasoning here. “Multi-method dispatch is the most correct way to solve the problem” is fine; “therefore, anything short of that isn’t worth doing” is where I get stuck. Instance methods partially solve the problem, and it’s possible (again, no data on hand) that they solve the problem in the majority of cases.

(It’s also possible that the prevalence of OO has made people prefer operators that can be dispatched based on the left-hand side, so I guess I’d want to go look at, e.g. Haskell and Perl to see what operators don’t fit in that bucket.)


I guess I’d summarize my stance as “this proposal enshrines our current problems with operator semantics in order to improve consistency in the syntax” (with “enshrines” meaning “makes harder to change later”), and that doesn’t seem like a good enough reason to change from what we have now.

(In particular, we don’t have any operator members now, besides in protocols. That means adding them in any form can probably be an additive change, or at least nearly so. Once we do, however, we’d have a hard time switching to a different model without breaking everyone’s code, and we should really stop breaking everyone’s code with new language changes at some point.)

Jordan

P.S. Thanks for taking my concerns seriously and taking the time to respond to them. :-)

-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.swift.org/pipermail/swift-evolution/attachments/20160524/ef62ec2a/attachment.html>


More information about the swift-evolution mailing list