Sealed protocols

I understand this very well. However this is not always what you want. This design has a number of consequences. It requires a standalone function for each pieces of logic that would otherwise be contained in a switch statement, including a common result. It also requires that this function be visible on all concrete conforming types. This approach does not work well for single-use logic that should not have such a prominent position and visibility.

Sealed protocols with exhaustive switching can be used in a way that is more analogous to enums than to classes. This enables use cases where enums just don't work because cases are not types and an enum cannot be used as a constraint on types. In these use cases the protocol is quite similar to an enum where each case has an associated value. Switching over a protocol designed in this way would not be something "dirty" and to be avoided, but rather a central aspect of its design. Part of the semantics of the protocol is the specific set of types that conform, just as part of the semantics of an enum are defined by its cases.

These enum-like protocols would be a Swifty protocol-oriented, (sometimes) value-semantic version of the object-oriented "case classes" we see in other languages like Kotlin and Scala. They are not redundant with enums but rather complementary. The discussion of protocols vs enums earlier in the thread demonstrates how they have much different properties and are therefore applicable in different scenarios. There is a hole in the design space available to us right now and exhaustive switch over sealed protocols would fill it very nicely.

If we could have exhaustive switching without any special linguistic construct that would be fine with me. However, many comments in this thread make it sound like it is unlikely to be feasible and perhaps even unlikely to be attempted to implement exhaustive switch if the compiler needs to scan an entire module to find the full set of conformances. So the reason it would be limited to this one flavor of protocol is pretty good: without this flavor of protocol we wouldn't be able to have the feature at all.

If this is true then sealed protocols are likely to be the one chance we have to realize exhaustive switch over protocols. This is an important type system feature that would open up a new range of design choices that are simply not available without it.

FWIW, if we design sealed protocols in this way there is no reason we couldn't also have sealed classes which would also support exhaustive switch. I think the motivation for this in the OO design space is weaker, especially if we have sealed protocols but there would be an argument for consistency and precedent in the Scala and Kotlin "case classes".

I admit that it would be slightly annoying when the use case is something other than the enum-like protocols I described above. But I also think opening up the design space of enum-like protocols is much more important. Keep in mind that it is only the conformance declaration that would need to be in the same file as the protocol. The implementation of all requirements could live in other files. This would be slightly unfortunate as we usually like to declare the conformance together with the implementation of the requirements, but we don't have to. And again, I think opening up the design space of enum-like protocols is more important.

Sure, but the earlier parts of the thread somewhat strongly imply that such a thing is not likely to ever exist.

This advice makes sense given the current capabilities of protocols. It wouldn't always make sense if the language supported switching over sealed protocols, because in that language we would be able to design enum-like protocols.

The earlier parts of this thread make it sound like this kind of exhaustive checking of visible conformances is problematic to implement and therefore unlikely to be implemented.

Under Future Directions, please consider adding the ability to explicitly restrict what may conform to a protocol:

//Something along these lines
sealed protocol Suit as Hearts, Diamonds, Clubs, Spades {}
struct Hearts : Suit {}
struct Diamonds : Suit {}
struct Clubs : Suit {}
struct Spades : Suit {}

That is not how sealed protocols should work. They will behave as normal protocols, the compiler can just provide more staticly observed information to the developer and user and potentially enabled exaustive switching over conforming types, but even that is not the core feature of sealed protocols.

As discussed further upthread, to practically get any of that benefit, the compiler needs the list of conformances up front. Otherwise, it's impossible to know locally whether there are private or local types that conform to a protocol in other files without scanning the entire module, which would be a prohibitive compile-time cost.

Up front or at least declared in the same file, right? One benefit of the same-file approach is that it doesn’t require any syntactic additions to the language.

Perhaps, but then sealed has its own bespoke access control rules, which is also weird.

To bring this back to the previous discussion, if we're going to have a feature like this, we need to know what problems we're trying to solve with it, since that impacts what the design should be.

1 Like

I don't think sealed protocols have anything to do with exhaustive down-casting. As I've said several times before:

  • That's much more of an edge-case feature than sealed protocols (I couldn't find any other languages which have it).
  • If it is a problem, it extends beyond protocols to other kinds of subtyping relationships, like class hierarchies.
  • It has nothing to do with the motivation behind 'sealed', which is to future-proof your protocols by limiting conformances outside the defining module, not within the module.
  • It either makes the compiler far more complex, or requires tradeoffs like declaring all conformances within the same file (which I frankly find unacceptable).

I really, really do not want to litigate this point any more. It can be proposed as an orthogonal feature in the future.


Some more data points:

  • Rust has sealed traits, because it allows public traits to inherit from arbitrary non-public traits. Public protocols in Swift cannot inherit from non-public protocols (although sealed protocols likely could).
  • You can check out some of the things they do with it here. The Rust Syn library is a popular user (something like SyntaxKit, I suppose), but also the Serde JSON library, some itoa implementations, the Rocket web framework. Again, since Rust allows public traits to inherit from arbitrary non-public traits, this query may not be exhaustive

We could copy Rust's approach for Swift (so: add inheritance from non-public protocols and have everybody define their own Sealed protocol), but that would be a significantly worse solution than what is proposed here. In particular, removing the parent and 'un-sealing' the protocol would become ABI-breaking.

I'm not sure if we could hide non-public parents from the protocol's ABI. Maybe? probably? ¯\_(ツ)_/¯


As for use-cases, here are a bunch of Swift protocols which definitely/probably should be sealed:

Stdlib:

  • StringProtocol
  • CVarArg
  • UnicodeCodec (?)
  • I could imagine a design where SIMDStorage was sealed and SIMDScalar was not (to allow CGFloat and other types to be used as scalars, but only if they used one of the built-in SIMD types).

It's also worth noting that the standard library hacks around this by having a lot of public protocols which are hidden with underscores (e.g. _Pointer, _UTFParser, _UTFEncoding, _ObjectiveCBridgeable, etc). If that wasn't possible (like it isn't for 3rd party libraries), we surely would have added this a long time ago.

Take a closer look at UnicodeCodec, for instance - the parent protocol Unicode.Encoding is a typealias for the underscored protocol _UTFEncoding.

SyntaxKit:

  • Syntax (the library's main protocol)

SwiftPM (it's a library too, y'know):

  • BuildSettingsCondition (only used for erasure in BuildSettings.Assignment. Custom conditions will not work)
  • Traceable (only used for erasure in GeneralTraceStep type)

... and a bunch of stuff in Foundation like ReferenceConvertible, but I think I've made the point.

3 Likes

Here’s a rough sketch of the kind of problem that can only be solved with a sealed protocol.

First, we have a basic ID type that is used to model identifiers in a way that scopes them appropriately to an entity.

public protocol IdentifierScope {
    associatedtype RawIdentifier: Hashable
}
public struct ID<Scope: IdentifierScope> {
    let rawIdentifier: RawIdentifier
}

Then we have a refining protocol which is used to associate a fixed set of scopes / entities that are all related to the same domain. For the sake of discussion, assume this domain has a handful of entities but not an enormous number of them.

public sealed protocol MyDomain: IdentifierScope {}

Now I have some behavior exposed by my library which requires a different implementation for different scopes / entities.

public func doSomethingInMyDomain<Scope: MyDomain>(with id: ID<Scope>) {
    switch Scope.self {
    ...
    }
}

Of course in theory this behavior could be dispatched via the MyDomain protocol if we had non-public requirements. However, that is undesirable as it would significantly bloat the protocol with requirements that are really unrelated to the semantics it is intended to express. Instead, we have a small-ish domain and would prefer to implement the behaviors locally using a switch statement. Enums are clearly not an option in this case - we need a protocol and we need the ability to switch over it without having to provide a default clause with a fatalError().

3 Likes

We probably only get one chance to design sealed protocols. If exhaustive switch over conformances was truly an orthogonal feature I would leave the discussion for the future, but it is not an orthogonal feature. That means it is relevant to this discussion even if you would prefer it wasn’t.

To be clear: I’m not asking you to impelement exhaustive switch or to even include it in your proposal. What I am asking for is a design that leaves space for exhaustive switch to be added in the future.

It sounds like exhaustive switch will not be possible if you allow conformances to be declared anywhere in the module and do not require the types to be listed alongside the protocol declaration. In order to implement exhaustive switch the compiler needs to be able to determine the set of conformances when it reads the file containing the protocol declaration.

I will continue to argue for a sealed protocol design that is compatible with exhaustive switch. If you can demonstrate a viable future direction for exhaustive switch that is compatible with your proposal as written perhaps you can change my mind. But I am unable to think of one that would be acceptable if it required additional syntax beyond the design for sealed. Maybe it’s just my lack of imagination - if that is the case, please illuminate me. :slight_smile:

1 Like

You will need additional syntax (if only to list the conforming types). And you're probably going to want that syntax to work for classes as well. It has nothing to do with sealed which - again - is all about out-of-module conformances. It is not about organising your own code within your own module.

I completely reject that a plain sealed modifier (with no qualifications) should limit conformances to the same file. That's taking things in a different direction entirely.

A poster suggested an possible syntax for restricted in-module subtyping literally a few posts above:

It could also be done with some kind of attribute. All of that can be done as a later extension if there is demand for it.

Please start a new thread if you want to continue talking about exhaustive downcasting. Sealed protocols might help you do that in the case of one kind of decl because it provides a protocol equivalent to open/public, but it is not the motivation behind the feature. I feel the discussion is being hijacked by this.

I have to agree with @anandabits that exhaustive downcasting is not an entirely orthogonal feature, so we have to discuss whether any design for sealed protocols will make it possible.

The onus is on the other side to show how their future plans may be blocked by this proposal. Am I supposed to go through every conceivable spelling of every feature that anybody would like?

Comments like this have it entirely backwards:

Show me how this would preclude exhaustive downcasting and I’ll be happy to adjust the proposal to accommodate. I can’t accommodate a wish-list with no concrete design.

Adding a new codec outside the standard library is cromulent. I’ve actually done it myself.

RangeExpression is designed as an extension point.

There are no sides here. We're all working together to discuss the design space for a proposed new feature. The constraint we have here is that exhaustive downcasting will likely require all conforming types to be enumerated in the same file as the protocol declaration. It doesn't serve to advance the discussion to "completely reject" this constraint and the use case it supports.

2 Likes

Unless I have a completely incorrect interpretation of the word... wrong, of course there are different sides here. Imho it's also ok if the starter of a pitch has limited interest in certain paths of discussion, and wants to advance it in another direction:
I think there are already some cases where people got really frustrated by endless threads, and that's rather unfortunate.

"Endless" discussions may increase the chances of turning a pitch into something that ends up in a change of Swift, and especially in the early phase, I would try to see each negative reaction as a possibility to improve your own arguments — but it should be the authors freedom to decide when to give up and accept that he won't convince everyone.

3 Likes

It's not clear if it's intended to be extendable, as it inherits from an underscored protocol. I'll add a "?".

I wasn't aware that RangeExpression was supposed to be extended.

I'm 90-95% of the way to just washing my hands of this proposal. I left it before, for the same reason, and the discussion totally died, just as it died each of the several previous times it was proposed. I still think it's an important feature, but at this point people are arguing about it possibly precluding a feature which has no design and may fail to ever materialise. I'm getting demands that I should prove that a design space exists for that nonexistent design (and show a "viable design" for a feature I do not actually think should be part of the language and would vote against in a review).

I will reiterate that I could not find another language which supports exhaustive down-casting.

Forcing all conformances to a sealed protocol to be in the same file as the declaration would be a radical departure from the open/public model we have for classes and take this feature in an entirely different direction, all to support ideas of how that nonexistent design might be implemented.

That's not what I propose, and I do not intend to propose such a design. If that's what you want, you are free to start your own proposal.

2 Likes

It's important that we consider the design space in a comprehensive way for any proposed feature. This can certainly take things in directions that you don't intend, and that can be frustrating if you have strong opinions in a different direction. But ultimately, a proposal should have reasons to support why its choices are most appropriate in light of the alternatives, why it addresses some use cases and not others, etc.

If you're unwilling to engage in a discussion of those alternatives and other use cases and intend only to push through with the design if it is specifically what you want, then I think you've already made up your mind to wash your hands of the whole process. One cannot complain that a design for others' suggestions is nonexistent and simultaneously ask them not to talk about designing it.

Are you actually serious? I have engaged more than anybody in this discussion.

But that's enough - I'm out of this :soap::palms_up_together:

It does not matter how loud you beg for answer to your messages, Xiaodi: you have no right pushing Karl in a corner he does not want to engage, and your behavior is on the verge of bullying. I'm calling @forum_admins for a moderator's advice.

1 Like

The Java team is in the process of implementing both sealed types and records (data classes, case classes) as part of their ergonomics improvements plan (Project Amber). I think lessons can be drawn from how they handle the constraints of improving a language with such strict adherence to backwards compatibility. I've attached a document from one of the architects discussing the current design. The section on sealed types is at the bottom quarter of the document.
http://cr.openjdk.java.net/~briangoetz/amber/datum.html

5 Likes