Constrain protocol usage outside the module

Is switching over types really something we want to encourage? I‘ve always tried to avoid it.
If you want to abstract over these types with a protocol, I think it would be better to create a protocol with appropriate methods.
Maybe you are reluctant to expose these methods to other modules? That could be something which is worth solving by allowing private requirements.

2 Likes

If we look at protocols as an encapsulation tool then switching over types doesn’t feel right. If we look at them as just another sum type, like enums but different, then switching fits nicely.

Implementation that gets called by the subscript with variadic Int | String parameter in the examples below.

extension Document {
    public subscript(valueAt position: Int) -> Value { ... }
    public subscript(key: String) -> Value? { ... }
    
    subscript(firstKey: String, parameters: [SubscriptParameter]) -> Value? {
        get {
            var currentValue: Value? = self[firstKey]
            for parameter in parameters {
                guard let value = currentValue else { return nil }
                switch value {
                case .document(let document):
                    guard case .string(let key) = parameter else { return nil }
                    currentValue = document[key]
                case .array(let array):
                    guard case .integer(let position) = parameter else { return nil }
                    currentValue = array[position]
                default:
                    return nil
                }
            }
            return currentValue
        }
        set { ... }
    }
}

Current solution:

// Tranforms [Int, String, String, Int] into:

[
  .integer(Int),
  .string(String),
  .string(String),
  .integer(Int)
]

// just to be able to extract the associated types later.
// This is a lot of extra overhead.
extension Document {
    public enum SubscriptParameter {
        case string(String)
        case integer(Int)
    }
}

// From the definition of this thread this is a `non-frozen` and `open` protocol.
public protocol SubscriptParameterType {
    var parameter: Document.SubscriptParameter { get }
}

extension Document {
    public subscript(firstKey: String, parameters: SubscriptParameterType...) -> Value? {
        get { return self[firstKey, parameters.map { $0.parameter }] }
        set { self[firstKey, parameters.map { $0.parameter }] = newValue }
    }
}

Ideal solution using @frozen and anonymous cases with an enum:

extension Document {
    @frozen public enum KeyOrIndex {
        case (String) // anonymous case
        case (Int)    // anonymous case
    }
}

extension Document {
    public subscript(firstKey: String, parameters: KeyOrIndex...) -> Value? {
        get { return self[firstKey, parameters] }
        set { self[firstKey, parameters] = newValue }
    }
}

A solution using @frozen @closed public aka @frozen public (but not open) protocol.

@frozen @closed
public protocol KeyOrIndex /* : where Self == String || Self == Int */ {}

/* Ideally it should be spelled as:
 * @frozen public protocol KeyOrIndex /* : where Self == String || Self == Int */ {}
 * In both versions I included the where clause which forces a concrete conforming type.
 */
extension Int : KeyOrIndex {}
extension String : KeyOrIndex {}

extension Document {
    public subscript(firstKey: String, parameters: KeyOrIndex...) -> Value? {
        get { return self[firstKey, parameters] }
        set { self[firstKey, parameters] = newValue }
    }
}

In the end the user of the library can do very convenient things like aDocument["first_key", 2, "key", "other_key"] to travers nested documents and arrays.

The user is simply not meant to conform to the current SubscriptParameterType because it will probably fail (but not crash). I as the library designer want to prevent such things by also providing guarantees enforced by the compiler.

Furthermore can someone elaborate why it is not possible for Swift to change syntax in different modes while rolling out a feature like this in stages? I think I've seen @beccadax mentioning this kind of process in quite few different topics by now.

Here is one example:

  • In Swift 5 we introduce @closed public protocol and open protocol additionally to public protocol, but public protocol without @closed will emit a warning and provide a fix-it to open protocol.
  • In Swift 6 mode this becomes an error and we'll have @closed public protocol and open protocol only.
  • In Swift 7 mode @closed can be omitted and we'll finally have public protocol and open protocol.
  • In Swift 7.X we also warn that @closed will be deprecated in the next major release.
  • In Swift 8 mode @closed is an error because it's deprecated, now only open protocol and public protocol are allowed.

Swift 4 Swift 5 Swift 6 Swift 7 Swift 7.X Swift 8
public protocol (warning) public protocol (fix-it to) open protocol (error when public protocol) open protocol open protocol Same as in Swift 7 Same as in Swift 7.X
- @closed public protocol @closed public protocol (warning @closed can be omitted) public protocol Same as in Swift 7 but with a deprecation warning of @closed (error when @closed, because it's deprecated now) public protocol
2 Likes

I'm sorry, I can't decipher this. It's a solid block of code. Can you describe, in words, the motivating problem?

1 Like

The problem is that it changes the meaning of public protocol. Whether you do this over one release of Swift or five releases of Swift (which is arguably much worse), you're making a source-breaking change. This requires a high level of justification of active harm.

Sorry but I already did. Just look closer at the examples. The first block is just a block which shows where the Int and String are extracted. Then there are three examples of a possible implementation:

  • Current implementation (with overhead and no possible constraints at all)
  • Ideal solution using enums, but with a feature we probably never get
  • A solution using protocols to be able to switch over the Self type in a constrained but safe manner

Can you elaborate the source breaking change here, because I don't see it. The staged example release does not have a source breaking change from my point of view, it just deprecates specific syntax over time, which should be allowed, or how can you justify the move from private to a fileprivate-like access modifier for Swift 4?

Maybe it's just me, but I need more English text than you have written.

You've shown "three examples of a possible implementation" ...of what? In the earlier post, you call them the "current solution" and "ideal solution" ...to what? What does your code do? Why are you extracting Int and String? Why is this an extension on Document?

1 Like

You cannot. The core team made it clear that changes like private to fileprivate were not to be possible after Swift 3.

Maybe because you asked @karim this

and I replied to you with an example of an array of Int | String! I'd very much appreciate if you'd follow the replies and don't just reply to everything in a general way ripping out the context completely. ;)

What is it that I cannot? I didn't ask anything like that. Again you're ripping out the context and not answering to questions but expect yours to be answered. I'm sorry, but again I'd very much appreciated if you could provide constructive answers to this topic, otherwise it's very tiring to answer general questions of yours over and over again.

I still don't understand. What is the code that you posted an example of? That is to say, what does it do? I didn't ask for "an example of an array of Int | String"--I asked @karim what a problem was that he encountered which required such an array. What is the problem that you're solving with your block of code?

I think we are not understanding each other. You asked how private to fileprivate could be justified in Swift 4. I answered that it cannot be justified in Swift 4: no such change would now be acceptable.

That's more to the point now.

The code I posted above is solving one issue, it provides a subscript that allows the user to travers a document object using string keys and integer indexes.

For example if a document instance would look something like this:

[
   "key_1": /* array */ [ 1 , 2 , 3 ],
   "key_2": [
       "key_1": /* array */ [ /* documents */  [ "key_1" : 42] , [ "key_1" : [0, 1, 2, 99]]  ]
   ]
]

Let's call the document instance aDocument. The API above allows you to travers the document like this:

let firstKey = "key_2"
let index = 1
aDocument[firstKey, index, "key_1", 3] // This will return a type `Value?` which stores the number `99` 

The issue with the implementation is that it still allows the user to conform to our protocol which is used to allow the variadic Int | String in first place. Furthermore the protection using an enum or simply a guarding type in the protocol requirements adds additional layer of overhead to the program, which could have been avoided if we had language support for closed protocols.

Okay I get it now, but you still could not elaborate why the pitched staged release would be source-breaking. Then you're saying the the core team just did the breaking change for Swift 4, but we cannot provide a change to the language that is not source breaking (my point of view, please proof me otherwise) even in move that will take quite a few major releases to keep source compatibility, just to get us to the point where we should have been in Swift 3 already (MHO).

There's a dramatic overhead cost to implementing this as an array of protocol existentials. If you could express closed protocols, then the overhead cost might be optimized away by a sufficiently intelligent compiler, but the overhead cost would then merely be the same as a frozen enum. In other words, frozen enum vs. closed protocol would be entirely an issue of cosmetics.

This is why I asked why it is that we weren't talking about how to make enums more ergonomic. Here, you'd want a sort of subtyping relationship between a value of type T or type U and an enum E { case t(T); case u(U) } and automatic promotion. I drafted a sketch of this a long time ago, which I may or may not have ever sent to the list.

1 Like

Source breaking means that code that compiles in version X and means one thing ceases to compile in version Y or means a different thing where Y > X. Changing the meaning of public protocol is source breaking no matter what. This requires meeting a high bar of showing active harm being cause by the existing way of doing things.

I don't understand this sentence. But changing the meaning of public protocol cannot be done now, I think there is fairly good consensus about that.

About that, please respect that not everyone is eloquent as you might expect personally. I'm definitely not and have hard times to justify everyones expectations.

Hmm but isn't that all about source compatibility? We can use Swift 3 modules in Swift 4 even if the syntax has already changed. The Swift 3 module is compiled to Swift 4 in a way that Swift 4 project understands it, but it shouldn't be possible the other way around because then the language can simply not evolve any further.