Constrain protocol usage outside the module

Indeed, it should not be, according to the library evolution manifesto.

I think jonstjohn is talking about a protocol which is the interface to many types in a library. The library author then adds new functionality to all those types and wants to include it in the interface. This is not something where default implementations are relevant, nor even possible.

If the protocol could be “closed” so that conformance to it can only be declared within the library, then the library author could go ahead and do exactly what they want: add new requirements to the protocol, and update their own types accordingly.

6 Likes

In that scenario, you merely need to write in the documentation of your protocol’s semantics, “This protocol is reserved for conformance by types X, Y, and Z only.” Then you can add whatever requirements you want without making a breaking change.

Well no, since when is documentation considered a thing to guard breaking changes? I really don't buy this argument. Even worse, documentation does not prevent you from API abusage.

Here a quick example:

struct A : _ExpressibleByBuiltinIntegerLiteral {
  init(_builtinIntegerLiteral value: _MaxBuiltinIntegerType) {}
}

struct B : ExpressibleByIntegerLiteral {
  typealias IntegerLiteralType = A
  init(integerLiteral value: A) {}
}

let b: B = 42
3 Likes

Here's an example:

Protocols aren’t just bags of syntax: they have documented semantics, and conformance means that your type adopts the required semantics.

Failure to do so breaks many things, including default implementations (try it sometime when conforming to a standard library protocol and see how badly things break).

“Source-breaking” doesn’t apply to source that abuses the documented APIs or uses undocumented ones. Your code might compile today, but it’s not required to compile tomorrow.

I never said they were bags of syntax I agree with you about the semantics, yet documentation alone shouldn't rule out the major problem here. Lazy programmers may not even document their code. (Not saying Apple devs are lazy but look at GCD, there are almost no docs at all - just to mention one example.)

This is an invisible contract between the client and the developer, where as open/public (closed) protocols is a guarantee that the code of a client won't break unless public API is renamed/removed.

Show me the “lazy programmer” that is too lazy to document the semantics of their protocols but not too lazy to adopt correct semantic versioning of their library.

Well this is nit picky of you because now you're asking me about semantic versioning even though I previously never talked about it and said that documentation alone should not rule over the issue of this thread and the semantics of a protocol, because a documentation does not necessarily have to exist for it to have semantics applied.

Huh? You joined a conversation about semantic versioning. Of course we’re going to talk about semantic versioning. That’s the only thing we’re talking about here.

No you were crystal clearly saying that a solution to guard the user from source breakage in that particular situation is by documenting the protocols like “do not conform to it, because it can break in the future“. To clarify, I only replied in the same general manner.

...and this all has to do with semantic versioning.

As I may recall, this argument of “hands off documentation“ against closed procols were raised previously, but I simply cannot agree with it. ;)

I think this conversation has ceased to be productive.

Here's a question to hopefully get the conversation moving again:

If documentation isn't good enough to prevent subclassing (hence we now have closed classes), why is it good enough to prevent protocol conformances?

Or am I looking at this from the wrong angle?

6 Likes

That's a good question.

To put a fine point on it: SE-0117 was not precisely about "preventing subclassing"--i.e., "closed classes." It was about not enabling subclassing by default--i.e., allowing it to be switched on with open classes. The punchline is in the file name: "0117-non-public-subclassable-by-default."

If you were to go through the extensive archives, you'd see how controversial that distinction was. Defaults are important, and certainly aligning that default with other aspects of Swift's design (e.g., internal by default) was an important consideration.

By virtue of the fact that we are no longer in a phase of Swift's evolution where such defaults can be changed, all of that is off the table. So, this idea is very different from SE-0117. That proposal might have passed the bar, but it doesn't mean that this idea does.

I've taken the time to go back through the discussion, and I'll provide a summary here:

@DevAndArtist's initial proposal was to introduce the public/open distinction for protocols. The main use-case cited being that there are a large number of public, hidden protocols in the standard library, which purportedly aren't intended for external conformance. [1]

@xwu quickly joins the conversation, begins making the point for maintaining source compatibility by not changing the meaning of public. (‘At this stage in Swift, I don’t think that this is realistic’) [2]

Further points are raised in support of the feature:

  • It works with a common interface, making it better than the anonymous enum cases [3]. This is later followed up by an issue with implicit promotion to enums based on types [50], which would only affect Either-like enums with balanced requirements for each case [51].
  • It allows grouping of a finite set of unrelated types (a ‘sum type’). [5]
  • It's noted that from a library evolution standpoint, non-public requirements cannot be added or removed. (I'm unclear on this one) [5]
  • It allows exhaustive switch within the module. [13]

An alternative model is suggested, using a protocol marked as final, with disjoint Self == Type requirements. [12]

It's noted that closed protocols make sense if you're aiming for a ‘sum type’, but not so much for protocols as an encapsulation mechanism. (I think this is a pretty good point) [22]


@DevAndArtist delivered on providing a use case [23] - providing multi-level subscripting into a document type. This is useful because the type is modelled with an enum [46], meaning each level would need extracting, preventing subscript chaining.

Why this example actually makes a case against both anonymous enum cases and closed protocols

I think it's worth analysing this example - the subscript essentially takes an array of SubscriptParameters (an enum summing String and Int), and returns a Value? enum instance. Two more possible subscripts are given - one using anonymous cases (an ‘ideal solution’), one using a closed protocol (‘another solution').

But this is already solvable elegantly in Swift, I think the problem here lies in @DevAndArtist's use of protocols as a ‘sum type’, rather than as an encapsulation mechanism.

The suggested closed protocol solution is to introduce a SubscriptParameterType protocol, with a single property requirement returning the SubscriptParameter enum. The implemented subscript works with this enum, switching over the type.

The proper translation of this to work with a protocol, is to move the concrete logic to a protocol requirement - func lookup(from value: Value) -> Value?, thus exchanging public implementation details (a closed protocol AKA sum type, and an unhelpful wrapper enum AKA sum type) for a full, extensible feature.

Both sum types here are better replaced with a protocol. This is why sum types are on the commonly rejected proposals list!


After a tangent over the breakage (or not) potential of non-frozen enums, the discussion continues, with further points made, but nothing really adding anything new:

  • @xwu continues to reiterate against changing the meaning of public [48]. Based on the motivating example given, I'm inclined to agree, as closed protocols should be discouraged.
  • The point is raised that closed protocols are not conceptually useful types. [63]
  • The counterpoints are made for it being a valid design technique [64] (I find this is reinforced by their possible use in the stdlib), and that Never could potentially be modelled like this [70].
  • The Never use case is invalid - protocols can't act as a Bottom type, and it's not a very efficient way to model the concept. [72]
    @xwu also makes the point that the standard library could still potentially add a conformance, to which I say:
  • Info on closed type families in Haskell. [76]
  • Closed protocols are currently hackily possible with associated types. [77]
  • Then another use case - protocols as a public interface, without the versioning issues. This is the post that revived the topic. [79]

I spent longer on this than I intended to, so I'm going to conclude briefly. I think we need more use-cases to assess the importance of the feature - ones which can't be remodelled to use open protocols. Looking to possible uses in the standard library seems a good option for this.

Even then, closed protocols should be discouraged - they make very good sum types, which are generally an anti-pattern, and are in the commonly rejected proposals list. They certainly shouldn't be on equal footing (public/open).

4 Likes

It sounds a proposal along these lines could satisfy a use case I’ve hit a couple of times:

  • I have some unwieldy class / struct that wants breaking into smaller pieces.
  • Protocol extensions would be a nice way to decompose it.
  • That unwieldy type is part of a public API, and the methods I want to move into a protocol extension are public.
  • This means the protocol must also be public.
  • However, I’m not ready to commit to long-term maintenance of that protocol as a public API. I want to be able to add or remove methods from it — and perhaps remove it altogether! — as I refactor things differently in future releases, but without breaking compatibility.

In short, I want an internal protocol that exposes public extension methods.

In this situation, I’ve either (1) abandoned the nice decomposition or (2) prefixed the protocol with _. It feels like a hack. Language support would be nice.

I’m not sure anyone has brought up this scenario here yet? Apologies if I've missed it. Note that this is related to but distinct from @jonstjohn’s post about making an API be all protocols. Here the concern is decomposition. I’m using the protocol for its extensions, and while the protocol does make sense as an internal type with meaningful behavior, I don’t (yet) want that behavior to become part of the library’s external contract.

I've heard this requested before. Allowing public extensions to protocols seems a good solution to encourage this kind of decomposition.

Another compelling use case is @jonstjohn's exposing protocols as a public interface, allowing for mocking within and between projects:

Depending on how this works in practice, this could also be solved with submodules. Otherwise, I'd also look to the standard library's practice of wrapping type families in structs. I believe String does some of this internally, for example when bridging from ObjC.

I think closed protocols could be useful when combined with generics.

Phantom types can be used to add type safety to an API. Swift Talk has a good episode that explains this concept (and it's freely available!).

The basic idea looks like this:

struct Path<FileType> {
    var components: [String]

    private init(_ components: [String]) {
        self.components = components
    }

    // ...
}

extension Path where FileType == Directory {
    // ...

    func appending(file: String) -> Path<File> {
        return Path<File>(components + [file])
    }
}

By using Path<Directory>s and Path<File>s, you can avoid mistaking directories and files for each other. Methods like appending can be added only for directories. Methods for reading a file can require that the path be to a file.

But Swift isn't as helpful as it could be. You must be very careful to only expose public initializers in type-constrained extensions. If the private init above were made public, then you could create a nonsensical Path<Int> or Path<Bool>, e.g.

Closed protocols would let this be expressed more naturally.

closed protocol FileType {}
enum File: FileType {}
enum Directory: FileType {}

struct Path<T: FileType> {
  …

  // This would be safe since the type parameter is constrained
  public init() {}
}

There are other examples where this would be useful: readonly vs. readwrite vs. append APIs, state machines, etc.

This would also ease the implementation by exhaustively switching over the generic type—thereby allowing for different behaviors based on the type.

Closed protocols would let you express the mutual exclusivity of enums at the type level.

1 Like