Sealed protocols

(Karl) #21

The problem with union types in Swift is (as I see it): what is the interface of SomeNumber? Is it all functions which are the same in Int and Double, even ones which have nothing to do with what SomeNumber is trying to represent? It's not a stable interface to write generic algorithms for, for example.

That's an important question - when I see a SomeNumber in my code, what can I do with it?

So your union type SomeNumber would need to declare some kind of minimum, stable interface that contains the core of what it's about. Maybe add some convenient extensions which build upon those. At that point, you've just described a protocol.

(Tino) #22

This might turn to off topic, but that seems to be the obvious choice (I haven't thought much about it, and it's the one that came to my mind as well).

So are sealed protocols - within the module, you can still add new conformances, and that's imho the essence of protocols:
When you fulfill the requirements, you can conform. "I want this function only to exist for a certain set of types" is a quite different scenario, and afaics, there is nothing that describes it as accurate as union types.

Afaics, it often doesn't make much sense to expose a protocol you can't conform to, and it would be sensible to hide it completely for external modules in many cases (this wouldn't be possible, though).

Union types, on the other hand, are not very different from protocols, as you could define A | B as the interface build up by all methods shared by those two types - and you could always constrain yourself further with (A | B) & SomeProtocol.

(Michael Long) #23

So this is a sealed "public" protocol. Syntactically, why would if not replace "public" entirely, with the semantics of both?

sealed protocol MySealedProtocol {}

Prefixing another keyword doesn't make sense to me. After all, would you ever have a sealed fileprivate protocol or sealed internal protocol?

(Matthew Johnson) #24

I can imagine wanting to have an internal protocol that only allows conformances to be declared in the same file. If we are interested in supporting that behavior we would need syntax along the lines of sealed(file).

(Michael Long) #25

In that case sealed(file) is just a variant of sealed.

That sort of makes my point. Sealed internal, by itself, makes no sense. Internal is already restricted to the module. Sealed fileprivate doesn't quite work either, as a fileprivate protocol isn't visible outside of the file in the first place. And sealed private doesn't work at all.

So I guess I'm back to my original point, if I can only logically combine the modifier with another specific modifier (public), and not with internal, fileprivate, or private, then does it make sense as a protection modifier? Or should sealed simply be another protection level/type?

In that vein, does the following make sense?

sealed struct MyData {
let name: String
let address: String

In that we're specifying a public type that can me passed around and accessed outside of the module, but can never be instantiated outside of the module.

(Tino) #26

Wouldn't that be the exact same behavior we have now without any new keyword (with public and no init)?

But I agree very much to the point that sealed would imply public (just like open does).

(Matthew Johnson) #27

My example demonstrated how sealed(file) would make sense with an internal protocol (as well as a public protocol). One could argue that we don't need this level of control and sealed should drop into the access modifier hierarchy in a similar way to open, implying public and being restricted to a single kind of declaration.

This doesn't make any sense at all to me. I have no idea what it means for a struct to be sealed. Sealed is about protocol conformances which do not have an analog for structs.

(David Sweeris) #28

I’d go for “functions/properties from the protocols that both types implement”, since that set would signify semantic similarities.

Maybe except inits with identical signatures, since that could make things hairy behind the scenes.

Edit: And you could probably only do it for protocols with associated types if all the “combining” types picked the same type for said associated type.

(Karl) #29

Well, C++ unions don't provide a common interface and you have to explicitly check what's inside, just like an enum in Swift.

But you're talking about duck-typing; like unions in TypeScript. TS actually provides all methods on all types in the union (the maximum possible interface), so it's perfectly fine to call (Bird | Fish).fly(), and it will fail at runtime if the variable is actually a Fish.

The danger of an implicit interface, of course, is that any clients could be relying on any part of it. If you define typealias SomeNumber = (Int | Double), how do you know somebody is using it for numerics purposes and not relying on the Codable conformance they'll get with it? So now that you've published your library, and people are using any part of that combined interface, what happens if you want to add BigInt to the list? What interface does is need to implement to not break anybody's code which is relying on your library?

Protocols (even sealed protocols) are different: they have an explicit, lowest-common-denominator interface which is better for encapsulation, and hence for erasure of implementation details. A sealed protocol doesn't mean that the set of conforming types is fixed forever, or even that you can see all of the conforming types.

Protocols in Swift are not just "bags of syntax" - they can have a semantic meaning, too. Just because you can meet the interface requirements (or you think you can), doesn't mean it's safe for you to get substituted in library code. Maybe there are unwritten requirements - like registering with some internal, global objects, that nobody else can actually implement. Look at the (many) previous discussions.

Just a practical point: protocols exist, they are used for erasure, and our ability to evolve the protocols is limited by not having this. It's very important that something be done about it.

(Karl) #30

I'm not opposed to doing that. On the other hand it's not exactly like other kinds of access control. You can inherit from a sealed protocol and have a less-sealed interface than your parent.

// Module 1.
sealed protocol P1 {}
struct A: P1 {}
struct B: P1 {}
struct C {}

// Module 2.
public protocol P2: P1 {} // Refines P1, but is not sealed.
extension A: P2 {} // Okay.
extension C: P2 {} // Error. Introducing conformance to P1.

// Module 3.
extension B: P2 {} // Okay.
extension C: P2 {} // Error. Introducing conformance to P1.

That's like an open class having a non-open superclass, which is not allowed today:

public class A {} // error: superclass 'A' of open class must be open
open class B: A {}

(Matthew Johnson) #31

If people want to discuss unions despite their being on the “not going to happen” list I think we should start a separate thread for that topic. Let’s keep this thread focused on sealed protocols.

(Raphael R ) #32

Yes, please. Together with internal members on public protocols, this would solve some major design restrictions.

I disagree. Breaking changes happen between major Swift versions, anyway, and this particular item seems trivial to fix by the inevitable migration tool (add open to all public interfaces). The real breakage happens once library authors start adding sealed to resp. removing open from existing protocols -- and that has exactly the same impact either way. I'd much rather migrate this once than live with awkwardly inconsistent semantics forever.

(Matthew Johnson) #33

This is my preference as well and I have argued strenuously for this design in the past. If the core team would accept this design I would very much prefer it. However, based on previous threads it seems unlikely that they would accept this change. I wonder if anyone on the core team is willing to comment on whether that would be considered or if it is a straight up non-starter.

(Karl) #34

It would make much more sense when you consider non-public requirements - why limit them to public/internal? You might conceivably want fileprivate requirements, too. private doesn't make sense for a protocol requirement - at least some other type needs to be able to see it, or nobody could conform.

(Michael Long) #35

Regarding sealed struct, I was thinking about a sealed struct or class that a module could instantiate and hand out (tokens, cookies, resource references, etc.) but which wouldn't be safe for a consumer of that module to create or initialize and then attempt to pass it back into the module.

You can do this today by making the initializer private, internal, etc.,, but then you're forced to write the entire initializer.

(Matthew Johnson) #36

Not true. Swift will never synthesize a public initializer.

(Tellow Krinkle) #37

AFAIK the way to get the compiler to restrict it would be to do

case let p as S:

(Karl) #38

So as far as I know the only open issue is whether sealed should imply public. I'm of the opinion that it shouldn't, because it still makes the protocol externally-visible but is more restrictive (so "below" in the access-control hierarchy) than a public protocol. I still think there's value in the "public or above is externally-visible" mental model.

Of course this would be simpler if we could invert this and make "sealed" the default, but as mentioned in the pitch I don't see the core team accepting that.

  • I think we should leave non-public requirements to another proposal, as it has a bigger scope than sealed protocols. It basically allows access control in protocols, and splits who can see the type from who can conform to it. We don't have a way to communicate that across modules without "sealed", though.

  • We should also leave exhaustive downcasting to another proposal. It too has a larger scope than sealed protocols - you could want that on internal protocols or non-open classes, too. Excessive downcasting isn't really a good practice though, so I expect this to be controversial, and hopefully largely obviated by the ability to have non-public requirements.

(Xiaodi Wu) #39

It does not follow that sealed is "below" public because a sealed protocol cannot be conformed to. As with open, it simultaneously closes off certain possibilities and opens other ones. For example, if we extended the same rules regarding @_frozen to sealed protocols, then it would be possible to enumerate exhaustively the conforming types of @_frozen sealed (or, outside the stdlib, sealed) protocols.

(Karl) #40

What do you mean - like promising publicly that the set of conforming types is frozen forever?

The benefit to doing that would be that other modules can make layout assumptions for existentials and unspecialised generic code. It's similar to how 'sealed' could get you the same benefits as inheriting your protocol from AnyObject, but only within the declaring module. If you want that behaviour across modules, you need to make an additional, stronger commitment.

The other option, besides using @_frozen, would be to expand and open up the compiler's existing support for layout constraints. Currently they are underscored and can be used for @_specialize, but not for protocol extensions or conditional conformances. Why couldn't it be another kind of existential constraint, alongside superclass and protocol-conformance constraints?

@_specialize(where T: _Trivial(64)) // works
func generic<T>(_: T) {}

protocol MyProtocol: _Trivial {} // doesn't work.
extension Array where Element: _Trivial {} // also no.

It's closely related, as the kind of "next level" of restricting conformances for optimisability, but it's really its own discussion. I guess the right time for that would probably when we start supporting move-only types, because you'd surely want some methods to be conditionally-(un)available based on that factor, at least.