Constrain protocol usage outside the module

It's fine to write imperfectly; I make plenty of mistakes on this list. However, in return, I understand that people might not understand my intended meaning and will ask for clarification. It's extremely upsetting when you accuse me of "ripping out context" and being not constructive when I'm asking for clarification.

Swift 4 is not source compatible with Swift 3.

There's no absolute bar to breaking source compatibility, as far as I know, but the standard now for doing so is empiric evidence of active harm. Otherwise, a change that breaks source compatibility is not acceptable for Swift 5 or later.

Yes, that means that we can fairly reliably conclude that a number of fundamental features of Swift will never change again.

I didn't meant to be disrespectful or something, yet I got the impression I described. If by any chance I offended you or someone else, I apologize formally.

1 Like

Would you mind to post it here now? I'm not sure I follow your though about the "subtyping relationship" there. However if I recall correctly anonymous enum cases and automatic promotion was put into the same box as commonly rejected Int | String types.

Is it strictly necessary to have a variadic subscript? Otherwise you could have a chain of unary subscripts with possibly a partially-indexed type

It is, because the returned Value? type even from a different subscript with one key parameter is an enum that stores another document, array or even other types. You cannot subscript over that enum in a logical way, not even with optional chaining involved - at least not how it's designed right now.

public enum Value {
  case double(Double)
  case string(String)
  case document(Document)
  case array([Value])
  case binary(BinarySubtype, data: [Byte])
  case objectID(ObjectID)
  case bool(Bool)
  case date(Date)
  ...
}

I think it's an acceptable level of source-breakage. Swift 5 is already going to come with lots of library-related source breakage (primarily around non-frozen enums), so why not also ask library vendors to audit their protocols for access levels? We don't have a stable ABI, and there are still language and standard library changes which will force these libraries to need a re-compile anyway. If this was Swift 10, with lots of frameworks which people depend on but can't/don't update, then source-compatibility would be a larger issue.

Not to mention that there are levels of source-breakage. The changes we made to Collection indices (in... Swift 3 IIRC?) were comprehensive and may have required restructuring your code. This change would require a single word be added to certain declarations which library authors wish to grant a non-obvious behaviour. So it's important to take that in to consideration.

The only alternative we have is to assume protocols are open by default, which appears to go against all of the other reasoning regarding library evolution. Open protocols are more restrictive than closed protocols, because they cannot add requirements between versions. It would be the opposite model to the one which we have for classes, enums and structs.

Given how unpalatable the alternative is, how many source-breaking changes we are already making in this domain, and how easy this would be to migrate to Swift 5 in your codebase, I think it's worth it.

Closed protocols are a really important feature. They allow you to hide implementation details of your library while retaining internal type-safety.

2 Likes

Firstly, no enum proposal has yet been accepted, and the proposal itself had as its premise that switching over non-exhaustive enums should be a rare occurrence. Secondly, the alternative to changes to enums now is undefined behavior, which is a safety issue; nothing like that is implicated here.

This is factually untrue. Open protocols can have added requirements between versions as long as a default implementation is provided.

There is nothing inconsistent or unpalatable about this state of affairs; concrete types cannot be subtyped either at all or by default outside the module. Protocols can be conformed to by default, because that's what a protocol is for!

If I can find it again, I'll share it here. It was some time ago.

If it was rejected, then we have an insurmountable problem here, because everything about how you want closed protocols to work would be the same thing with a different syntax. If the type checker cannot handle automatic promotion from an associated type to an enum, I fail to see how any alternative spelling could enable essentially the same feature under the hood when written as a protocol existential.

If it was rejected, then we have an insurmountable problem here, because everything about how you want closed protocols to work would be the same thing with a different syntax. If the type checker cannot handle automatic promotion from an associated type to an enum, I fail to see how any alternative spelling could enable essentially the same feature under the hood when written as a protocol existential.

The problem is with the subtype between generic associated types and an enum. In particular, consider:

enum Either<Left, Right>  {
    case left(Left)
    case right(Right)
}

When we have Either<Int, Int> how should Int be promoted? Into the left case or the right case? Either choice would be arbitrary and neither would be right!

This problem simply does not exist for closed protocols.

Obviously this problem does not exist for non-generic enums with mutually exclusive associated types either. It does complicate the design of any feature that introduces promotion of associated types, however. It would be valuable to have this in some form eventually but it alone is not a substitute for closed protocols. We would also need a way to forward members from the enum to the associated types. Any such mechanism would likely be driven by protocols anyway so a related protocol would also need to be designed.

Closed protocols are a much simpler solution to some design problems. For example, a closed protocol may be a good choice for implementing a value-semantic type that has multiple library-defined representations (similar to a class cluster) that each implements a common interface. There are certainly other ways to implement a type like this and one may even be better, but closed protocols would be a great option to have available.

I've thought about this. At first blush it appears that this is a rather large difficulty but it needn't be. If you'll recall, we recently had a proposal to model enum cases with associated types as functions rather than tuples. So here the behavior would fall out naturally: it should work like overload resolution. So, say you have

Result<T, U> where U : Error

Then anything comforming to Error would be promoted to case u(U) and everything else to case t(T) just as it would work if you had a function with two overloads constrained analogously.

If, as you show above, you've got a scenario with two entirely unconstrained generic types, then you've got an ambiguity in overload resolution, which we've got the diagnostics to deal with.

In the concrete context you could simply specify the left and right types: let x: Either<Int, String> = myString. In the generic context you could "manually promote" to resolve the ambiguity if nothing else will do, but generally whether something is promoted to the left case or the right case would be unambiguous when you "plumb through" the generic parameters:

func f<T, U>(_ t: T, _ u: U) -> Either<T, U> {
  return t.isAwesome ? t : u
}

Here, the first argument, if returned, is always promoted to the left case, and the second to the right case, because that's how I've declared the return type.

I am not a fan of automatic promotion in general. Automatic promotion to Optional has already created problems (I'm thinking of the flatMap variant removed with SE-0187) and automatic conversions have always been a problem in C++.
@DevAndArtist: Is explicit wrapping really so bad? I haven't really understood your Document example.

If yes, I would suggest that we have to reevaluate the decision of putting union types (i.e. Int | String as in Ceylon) on the list of Commonly Rejected Proposals (the only reasoning given for that was that supporting union types is "something that the type system cannot and should not support" and a note that the C++ committee had problems with that while there is no mention at all of the success that Ceylon had when introducing that concept alongside intersection types).

I don't understand your concerns about automatic promotion.

As for my document type example you as library user never would want to type manually

document[.string("key"), .integer(0), .string(stringInstance)]

// instead of
document["key", 0, stringInstance]

Same issue with optionals. I don't see why anyone should be forced to write .some(value) everywhere.

@xwu about automatic promotion on enum cases. Would it make sense to introduce an extra promoting keyword for enum cases? This is orthogonal to closed protocols, but I wanted to share the idea here first. If someone thinks this is an interesting idea to track, feel free to move that to an extra thread.

public enum Optional<T> {
  promoting case some(T)
  case none
}

One could use that for custom enums:

@frozen public enum IntOrString {
  promoting case integer(Int)
  promoting case string(String)
}

The case name is completely independent and the promotion is done using a single instance or a labeled tuple in case of an enum case with multiple associated types. In case of ambiguity like @anandabits described above one can use the normal enum case representation .right(value).

I wouldn't go as far as with the indirect keyword and allow promoting enum because this only creates issues like issues with access_modifier extension.

You do not think that the overloaded flatMap variant has been a problem?

Actually your document example reinforces that I would rather write

document[key("key"), page(0), reference(stringInstance)]

or something like that which makes clear what these values mean. In your example they are just strings and integers which leaves us clueless what their semantics is, as exemplified by @xwu's and my difficulties to understand your example.

Well, in Haskell I never had a problem with writing Just value (Haskell's equivalent of .some(value)).

No, not that much of a deal for me, because I never used flatMap with the sequence constrain. That said I'm only using the compactMap all the time.

That is more or less a personal preference. Any type that provides access using subscripts implies you to read the documentation anyways. That is at least my point of view.

What brought me to this topic originally, and what I think is a motivating example, is an attempt to model JSON data using an enum type. In so many ways, it's a perfect fit because Swift enums support indirect cases; many behaviors you'd want from JSON fall right out. Very elegant.

However, the ergonomics of using this were terrible; everything was littered with .string() or the like, and eventually the syntactic weight of wrapping and unwrapping at every use site made the whole thing fall over; any attempt to implement even moderately interesting uses of JSON collapsed under the weight. I tried writing convenience initializers to smooth some of this over, but it only prolonged the suffering; several hours after trying to use the thing, I removed it all from the project.

If I can find the code again, I'll share it, but it was more than a year ago now.

As a rule I'm skeptical of additional keywords, and this case is no exception. Promoting behavior is either confusing or not at the use site, and I don't agree that adding on keywords in the definition makes a difference.

I wrote a lengthy document a while ago exploring the design space for value subtyping, including many enum-related features. It includes my thoughts on how we might approach this. You can find it here: ValueSubtypeManifesto.md ¡ GitHub.

The Argo library has such a JSON type. While verbose, I enjoyed working with it more than having to deal with JSONSerialization's Any output. Argo's functional design and operators helped with use quite a bit too.

1 Like

"This is factually untrue", as you like to say. We could also say that adding cases to an enum is ABI-breaking, and define the behaviour that way. Also, the proposal actually states that @frozen enums are the rare occurrence, and that most of the time library authors like to leave room to add cases in the future.

No it isn't; I didn't mention anything about default implementations. A protocol requirement with a default implementation is a very different thing to a requirement where no default is possible. So you just chewed and twisted the question up until it fit the answer you wanted, which was to shoot me down.

My point is that I consider having protocols open by default, but other types closed by default, not to be a desirable state of affairs. It's easier to implement from source-compatibility, but as I said, I think the breakage would be acceptable if we wanted a unified, closed-by-default design.