[swift-evolution] [Pitch] consistent public access modifiers
Brent Royal-Gordon
brent at architechies.com
Mon Feb 13 08:24:51 CST 2017
Sorry, I meant to jump in a lot earlier than this, but lost track of this thread on my mental to-do list. I've read most of the thread, but I really can't keep all the replies in my head at once, so I apologize if some of this is duplicative.
I agree with Jordan Rose that "closed enums" and "closed protocols" are separate things and they should be discussed separately, so I'll be doing that here. But first, a criticism of both:
> On Feb 8, 2017, at 3:05 PM, Matthew Johnson via swift-evolution <swift-evolution at swift.org> wrote:
>
> This proposal introduces the new access modifier `closed` as well as clarifying the meaning of `public` and expanding the use of `open`.
If the `closed` keyword is to stand alone as an access level, I think `closed` is a bad choice. `open` is acceptable because it sounds as visible or even *more* visible than `public`. (AppKit is "public", but GTK is "open". Which exposes more of itself to a programmer?) But `closed` sounds like some form of privacy. I think that, unless it's paired with a word like `public`, it will not be understood correctly.
Closed enums:
> A recent thread (https://lists.swift.org/pipermail/swift-evolution/Week-of-Mon-20170206/031566.html) discussed a similar tradeoff regarding whether public enums should commit to a fixed set of cases by default or not. The current behavior is that they *do* commit to a fixed set of cases and there is no option (afaik) to modify that behavior. The Library Evolution document (https://github.com/apple/swift/blob/master/docs/LibraryEvolution.rst#enums) suggests a desire to change this before locking down ABI such that public enums *do not* make this commitment by default, and are required to opt-in to this behavior using an `@closed` annotation.
>
> In the previous discussion I stated a strong preference that closed enums *not* be penalized with an additional annotation. This is because I feel pretty strongly that it is a design smell to: 1) expose cases publicly if consumers of the API are not expected to switch on them and 2) require users to handle unknown future cases if they are likely to switch over the cases in correct use of the API.
The thing is, you do *lots* of things with enums other than exhaustively switching on them. One extremely common example is `Error` enums. But even leaving that aside, there are lots of cases where you construct an enum and hand it off to a library to work or interpret it; adding a case won't break these.
Basically, when an enum is an input to a library, adding cases is safe. When it's an output from a library, adding cases is potentially unsafe, unless the code using the enum is designed to permit additional cases.
> The conclusion I came to in that thread is that we should adopt the same strategy as we did with classes: there should not be a default.
But classes *do* have a default: closed, i.e., `public`. It is an extremely soft default, and we've made it as easy as possible to switch to `open`, but `public` is absolutely the default—both because `public` is the keyword people will know from other languages, and because `public` is the term used with non-class-related symbols.
And remember, it's the default for a good reason: `public`'s semantic is the more forgiving one. If you make a class `public` when it should be `open`, fixing it is not a breaking change, but the opposite is.
In the case of enums, public-but-nonexhaustive is the more forgiving semantic. If you make something public-but-nonexhaustive when it should be public-but-exhaustive, fixing it is not a breaking change, but the opposite is.
As I mentioned earlier, I don't think `closed` is a good keyword standing alone. And I also think that, given that we have `open`, `closed` also won't pair well with `public`—they sound like antonyms when they aren't.
What I instead suggest is that we think of a closed enum as being like a fragile (non-resilient) struct. In both cases, you are committing to a particular design for the type. So I think we should give them both the same keyword—something like:
@fixed struct Person {
var name: String
var birthDate: Date
}
@fixed enum Edge {
case start
case end
}
As I mentioned in another post, inheriting from an enum is not really a sensible thing to do. It is perhaps possible that we could eventually introduce `open enum`s, which would permit you to add cases in outside extensions. (That might be useful for error enums, but I honestly can't think of many other use cases.) But enums would need to gain new features—like member overrides attached to cases—to make that useful. All in all, I'm not entirely convinced about open enums.
Closed protocols:
> There have also been several discussions both on the list and via Twitter regarding whether or not we should allow closed protocols. In a recent Twitter discussion Joe Groff suggested that we don’t need them because we should use an enum when there is a fixed set of conforming types. There are at least two reasons why I still think we *should* add support for closed protocols.
>
> As noted above (and in the previous thread in more detail), if the set of types (cases) isn’t intended to be fixed (i.e. the library may add new types in the future) an enum is likely not a good choice. Using a closed protocol discourages the user from switching and prevents the user from adding conformances that are not desired.
>
> Another use case supported by closed protocols is a design where users are not allowed to conform directly to a protocol, but instead are required to conform to one of several protocols which refine the closed protocol. Enums are not a substitute for this use case. The only option is to resort to documentation and runtime checks.
I'm definitely on board with having closed protocols. I've seen several use cases where they'd be helpful.
I don't see it mentioned here (maybe I just missed it), but even though we *could* do exhaustiveness checking on non-open protocols, I'm not convinced that's a good idea. Usually when you have several types conforming to a protocol, you should access type-specific behavior through polymorphism, not by switching on the protocol. A protocol is supposed to represent a behavior, not just mark a type in some arbitrary way.
> Finally, a protocol that refines a `closed` protocol need not be `closed`. It may also be `open`.
I support this, and I'd like to demonstrate a use case for it. (Here I will use `open` for the current semantics of `public`, and `public` for visible-but-not-conformable.)
Suppose that you're writing a SQLite wrapper and want to support binding parameters. There are a number of types SQLite supports natively:
public protocol SQLiteValue {
init(statement: SQLiteStatement, columnAt index: Int) throws
func bind(to statement: SQLiteStatement, at index: Int) throws
}
extension Int: SQLiteValue {
public init(statement: SQLiteStatement, columnAt index: Int) throws {
self = sqlite3_column_int(statement.stmt, index)
}
public func bind(to statement: SQLiteStatement, at index: Int) throws {
try throwIfNotOK(
sqlite3_bind_int64(statement.stmt, index, self)
)
}
}
extension Double: SQLiteValue {…}
extension Data: SQLiteValue {…}
extension String: SQLiteValue {…}
extension Optional: SQLiteValue where Wrapped: SQLiteValue {…}
But you also want people to be able to conform their own types to a protocol which adapts itself to this:
open protocol SQLiteValueConvertible: SQLiteValue {
associatedtype PrimitiveSQLiteValue: SQLiteValue
init(primitiveSQLiteValue: PrimitiveSQLiteValue) throws
var primitiveSQLiteValue: PrimitiveSQLiteValue { get }
}
extension SQLiteValueConvertible {
public init(statement: SQLiteStatement, columnAt index: Int) throws {
let primitive = try PrimitiveSQLiteValue(statement: statement, columnAt: index)
try self.init(primitiveSQLiteValue: primitive)
}
public func bind(to statement: SQLiteStatement, at index: Int) throws {
let value = primitiveSQLiteValue
try value.bind(to: statement, at: index)
}
}
// Usage:
extension Bool: SQLiteValueConvertible {
public init(primitiveSQLiteValue: Int) {
self = primitiveSQLiteValue != 0
}
public var primitiveSQLiteValue: Int {
return self ? 1 : 0
}
}
In this case, there is no good reason to make `SQLiteValue` open—all the functions you'd need are encapsulated anyway. You could even make its requirements non-public, since users don't need to use them directly. But `SQLiteValueConvertible` should be open—it has a narrow interface that's easy to conform to, with all the implementation details neatly encapsulated.
I also think you should be able to refine a `closed` protocol from outside the module, but to conform to the sub-protocol, you'd either need to conform to an `open` sub-protocol of the original, or add a retroactive conformance to a type that already conforms. For instance, suppose I want to write a SQLite logging system in a separate module:
protocol SQLiteLoggable: SQLiteValue {
var logDescription: String { get }
}
struct Money: SQLiteLoggable, SQLiteValueConvertible {
// OK, conforms to SQLiteValue through SQLiteValueConvertible
}
extension Int: SQLiteLoggable {
// OK, retroactive conformance on type original module conformed to SQLiteValue
}
struct ID: SQLiteLoggable {
// Error: Cannot conform directly to SQLValue from this module
}
This is a somewhat more niche feature, and could perhaps be delayed.
> This proposal affects both public enums and public protocols. The current behavior of enums is equivalent to a `closed` enum under this proposal and the current behavior of protocols is equivalent to an `open` protocol under this proposal. Both changes allow for a simple mechanical migration, but that may not be sufficient given the source compatibility promise made for Swift 4. We may need to identify a multi-release strategy for adopting this proposal.
>
> Brent Royal-Gordon suggested such a strategy in a discussion regarding closed protocols on Twitter:
>
> * In Swift 4: all unannotated public protocols receive a warning, possibly with a fix-it to change the annotation to `open`.
> * Also in Swift 4: an annotation is introduced to opt-in to the new `public` behavior. Brent suggested `@closed`, but as this proposal distinguishes `public` and `closed` we would need to identify something else. I will use `@annotation` as a placeholder.
> * Also In Swift 4: the `closed` modifier is introduced.
>
> * In Swift 5 the warning becomes a compiler error. `public protocol` is not allowed. Users must use `@annotation public protocol`.
> * In Swift 6 `public protocol` is allowed again, now with the new semantics. `@annotation public protocol` is also allowed, now with a warning and a fix-it to remove the warning.
> * In Swift 7 `@annotation public protocol` is no longer allowed.
I still support this general approach. One spelling could simply be `@nonopen`. Although if we don't use `closed`, we could simply use `@closed` like I suggested—here it really *would* be an antonym to `open`.
> A similar mult-release strategy would work for migrating public enums.
What is it that needs migrating here? Lack of exhaustiveness checking? It sounds like we were planning to break that anyway in some fashion.
--
Brent Royal-Gordon
Architechies
More information about the swift-evolution
mailing list