This particular feature only applies to libraries shipped with Apple OSs, but that does include the Swift standard library, so it's relevant to the Swift Open Source project. And it does involve new syntax in the language.
Background
Let's say it's suddenly vitally important that Int conforms to Collection.
extension Int: Collection {
public var startIndex: Int { 0 }
public var endIndex: Int { self.bitWidth }
public subscript(index: Int) -> Int { (self >> index) & 1 }
}
What's the problem? Well, to start, none of these accessors exist in iOS 13, which means that if someone tries to use this new conformance in iOS, their app will just crash when running on iOS 13. That's a limitation of shipping Swift as part of the OS.
There are two ways to resolve this today: @available
with a set of minimum OS versions, or @_alwaysEmitIntoClient
. But that still doesn't stop someone from writing this code...
let someInt = 42
myArray.append(contentsOf: someInt)
...and trying to run that on iOS 13, where the conformance doesn't exist. If the append(contentsOf:)
is not optimized away, this will cause problems.
In theory, we have the same two options for how to deal with this: restrict the use of the conformance with availability, or emit the conformance into client binaries. I'll discuss each of those in turn and then a secret third option that I think is the best choice.
Conformances with Availability
Pros:
-
Supports witnesses (the types and members that satisfy requirements) that have their own availability constraints. Types can't be backwards-deployed at this time, so if a protocol adopter's associated type
AssocImpl
has newer availability than either the protocol or the adopter, the conformance necessarily can't be backwards-deployed before whenAssocImpl
was introduced. -
On the implementation side: enforcing this might have been hard...if we hadn't just implemented it for implementation-only imports (still not a finalized feature). Instead, this'll "just" be another check in the same locations.
-
More on the implementation side: the only compiler changes required in SIL/IR layers are to weak-link conformance symbols.
Cons:
-
Can't backwards-deploy, of course.
-
If a client had its own implementation of a conformance, the current compiler rules will ignore the client's implementation in favor of the library's, even when backwards-deploying. This is how the standard library was able to make String conform to Collection even when third-parties had added their own conformances (have I mentioned that retroactive conformances are problematic?)
Emit-into-client
Pros:
-
Can backwards-deploy.
-
The implementation is basically just pretending there was a retroactive conformance written in the client module.
Cons:
-
Doesn't support witnesses that have availability themselves.
-
The "inlinable" problem: there might be multiple conformances floating around that are subtly different. (This is already true of explicitly-written retroactive conformances, but still.)
-
Code size implications: every client will have a copy of the conformance for all time.
"Why Don't We Have Both?"
For backwards-deployable conformances, we can actually combine both approaches, resulting in an annotation that means "if you're on a new enough OS, use the library's copy of the conformance; otherwise, use a local copy". I'd call this a "polyfill" approach.
Pros:
-
Supports witnesses with availability.
-
Supports backwards-deployment without the costs when not backwards-deploying.
Cons:
-
More complicated to implement (all of the availability work, plus extra work).
-
Slight run-time cost, to check for the canonical conformance symbol before falling back to the local copy. (I hope there are no direct references to conformance symbols in data sections?)
-
Doesn't completely solve the inlinable problem when running on older OSs (one client could have a copy of the conformance from Library v6 and another from Library v7, and it's possible they're different).
The work
1. Add attribute syntax for conformances
(needed for all three approaches)
extension Int:
@foo Equatable,
@bar Collection {
}
that is, the attributes for a conformance go before the name of a protocol in an inheritance list. Some design questions:
-
Should this be allowed for names that refer to protocol compositions? (Probably not.)
-
Should this be allowed for protocols written directly on the type, or only in extensions? (Type is probably fine too.)
-
Should we apply the restrictions from conditional conformances where parent protocols have to be written explicitly? That is, should Int have to explicitly conform to Sequence as well? (I'm leaning towards "no"; it'd get too noisy and we plan to keep improving the swift-api-digester tool.)
-
How do we disambiguate type attributes and conformance attributes in this position? (Assume conformance attribute, since you can always put parentheses around types, and type attributes are super rare today.)
This is not being proposed here, but there have been some other things we've been wanting this attribute syntax for. The main one is determining whether a conformance is "devirtualizable" or not—that is, whether the current set of witnesses is guaranteed to stay the same (or at least remain correct) from release to release. Currently this is based on whether the conforming type is frozen, which...doesn't really make sense. This actually has very similar semantics to @inlinable
on functions, but I'm not sure whether that's a clear enough name to reuse for conformance devirtualization.
2. Add availability to conformances
(needed for the first and third approaches)
This has a few sub-tasks, but is mostly pretty straightforward:
2a. Parse availability attributes in conformance position, and preserve them through serialization.
2b. Validate that all witnesses used for a conformance are supported given the conformance's availability. (This is already happening based on the protocol and conforming type's availability.)
2c. Check availability at all conformance use sites.
2d. Change IRGen to weak-link conformances based on deployment-target. (This should already be happening in some form, but it'd need to check a different availability.)
3. Add "polyfill" support
(third approach only)
The main thing this needs is a spelling, which I don't want to bikeshed at the moment. We need some way to communicate "this conformance is valid all the way back to iOS 11, but clients need to keep their own copy if they're not deploying to iOS 13". Once we pick a spelling, we have some work to do:
-
Sema: make sure the compiler checks the right availability!
-
IRGen: emit references to the conformances as preferring the weak-linked symbol in the library and falling back to a copy in the client. (Or doing some kind of uniquing like the type metadata for imported C types.)
Alternative designs
Emit-into-client approach instead
The simplest thing to implement would be emitting a conformance from a library as a retroactive conformance in every client that needs it. That's implementable and has some behavior, even if it's going to have rough edges (especially around dynamic casting). However, it doesn't solve all the problems, particularly the case where a witness cannot be backwards-deployed, and so I think we'd need to do conformances-with-availability at some point anyway. At that point we'd end up with two different mechanisms to accomplish the same thing, just like we have with functions but harder to reason about. So I think it's worth doing the bigger thing now, even if we don't get to the polyfill support.
Polyfill without preferring the library version
This is roughly how @inlinable
works today: there may be many versions of a conformance floating around including the one in the library. Clients would still save on code size when not backwards-deploying, and it'd be a little simpler to implement, but I like preserving the ability of the library author to change the witnesses used in the conformance on newer OSs, perhaps to fix bugs. Maybe that's too subtle, though—the new witness still has to be backwards-deployable too.
Thoughts?