Skip to content

Commit e2b1b4d

Browse files
authored
[SE-0470] Reinstate sendable-metatype model for type checking generics (#2763)
* [SE-0470] Reinstate sendable-metatype model for type checking generics Based on discussions in the review thread, reinstate the sendable-metatype model for type checking generic definitions rather than the potentially-isolated-conformance model. While slightly less expressive, the sendable-metatype model is easier to reason about and teach. It's still possible to generalize the model toward the potentially-isolated-conformance model, as is already captured in Future Directions. * [SE-0470] Tighten up requirements within generic functions Close a potential data race with my attempt to loosen the rules. In essence, if we're allowed to pass a metatype `T.Type` across isolation boundaries when there are no constraints of the form `T: P`, then we will incorrectly allow a dynamic cast `as? any T & P` to bind to an isolated conformance (because `T` is not `SendableMetatype`).
1 parent badc755 commit e2b1b4d

File tree

1 file changed

+28
-22
lines changed

1 file changed

+28
-22
lines changed

proposals/0470-isolated-conformances.md

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ This is effectively saying that `MyModelType` will only ever be considered `Equa
8282

8383
## Proposed solution
8484

85-
This proposal introduces the notion of an *isolated conformance*. Isolated conformances are conformances whose use is restricted to a particular global actor. This is the same effective restriction as the `nonisolated`/`assumeIsolated` pattern above, but enforced statically by the compiler and without any boilerplate. The following defines an isolated conformance of `MyModelType` to `Equatable`:
85+
This proposal introduces the notion of an *isolated conformance*. Isolated conformances are conformances whose use is restricted to a particular global actor. This is the same effective restriction as the `nonisolated`/`assumeIsolated` pattern above, but enforced statically by the compiler and without any boilerplate. The following defines a main-actor-isolated conformance of `MyModelType` to `Equatable`:
8686

8787
```swift
8888
@MainActor
@@ -144,10 +144,10 @@ func hasNamed<T: GlobalLookup>(_: T.Type, name: String) async -> Bool {
144144
}
145145
```
146146

147-
Here, the type `T` itself is not `Sendable`, but because *all* metatypes are `Sendable` it is considered safe to use `T` from another isolation domain within the generic function. The use of `T`'s conformance to `GlobalLookup` within that other isolation domain introduces a data-race problem if the conformance were isolated. To prevent such problems in generic code, this proposal treats conformances within generic code as if they are isolated *unless* the conforming type opts in to being sendable. The above code, which is accepted in Swift 6 today, would be rejected by the proposed changes here with an error message like:
147+
Here, the type `T` itself is not `Sendable`, but because *all* metatypes are `Sendable` it is considered safe to use `T` from another isolation domain within the generic function. The use of `T`'s conformance to `GlobalLookup` within that other isolation domain introduces a data-race problem if the conformance were isolated. To prevent such problems in generic code, this proposal introduces a notion of *non-sendable metatypes*. Specifically, if a type parameter `T` does not conform to either `Sendable` or to a new protocol, `SendableMetatype`, then its metatype, `T.Type`, is not considered `Sendable` and cannot cross isolation boundaries. The above code, which is accepted in Swift 6 today, would be rejected by the proposed changes here with an error message like:
148148

149149
```swift
150-
error: cannot use potentially-isolated conformance of non-sendable type `T` to `GlobalLookup` in 'sending' closure
150+
error: cannot capture non-sendable type 'T.Type' in 'sending' closure
151151
```
152152

153153
A function like `hasNamed` can indicate that its type parameter `T`'s requires non-isolated conformance by introducing a requirement `T: SendableMetatype`, e.g.,
@@ -379,7 +379,7 @@ actor MyActor: @MainActor P {
379379
}
380380
```
381381

382-
### Rule 2: Isolated conformances can only be abstracted away for non-`Sendable` types
382+
### Rule 2: Isolated conformances can only be abstracted away for non-`SendableMetatype` types
383383

384384
Rule (2) ensures that when information about an isolated conformance is abstracted away by the generics system, the conformance cannot leave its original isolation domain. This requires a way to determine when a given generic function is permitted to pass a conformance it receives across isolation domains. Consider the example above where a generic function uses one of its conformances in different isolation domain:
385385

@@ -406,20 +406,20 @@ The above code must be rejected to prevent a data race. There are two options fo
406406
1. Reject the definition of `callQGElsewhere` because it is using the conformance from a different isolation domain.
407407
2. Reject the call to `callQGElsewhere` because it does not support isolated conformances.
408408

409-
This proposal takes option (1): we assume that generic code accepts isolated conformances unless it has indicated otherwise with a `Sendable` constraint (more information on that below). Since most generic code doesn't deal with concurrency at all, it will be unaffected. And generic code that does make use of concurrency should already have `Sendable` constraints that indicate that it will not work with isolated conformances.
409+
This proposal takes option (1): we assume that generic code accepts isolated conformances unless it has indicated otherwise with a `SendableMetatype` constraint. Since most generic code doesn't deal with concurrency at all, it will be unaffected. And generic code that does make use of concurrency should already have `Sendable` constraints (which imply `SendableMetatype` constraints) that indicate that it will not work with isolated conformances.
410410

411-
The specific requirement for option (1) is enforced both in the caller to a generic function and in the implementation of that function. The caller can use an isolated conformance to satisfy a conformance requirement `T: P` so long as the generic function does not also contain a requirement `T: Sendable`. This prevents isolated conformances to be used in conjunction with types that can cross isolation domains, preventing the data race from being introduced at the call site. Here are some examples of this rule:
411+
The specific requirement for option (1) is enforced both in the caller to a generic function and in the implementation of that function. The caller can use an isolated conformance to satisfy a conformance requirement `T: P` so long as the generic function does not also contain a requirement `T: SendableMetatype`. This prevents isolated conformances to be used in conjunction with types that can cross isolation domains, preventing the data race from being introduced at the call site. Here are some examples of this rule:
412412

413413
```swift
414-
func acceptsSendableP<T: Sendable & P>(_ value: T) { }
414+
func acceptsSendableMetatypeP<T: SendableMetatype & P>(_ value: T) { }
415415
func acceptsAny<T>(_ value: T) { }
416-
func acceptsSendable<T: Sendable>(_ value: T) { }
416+
func acceptsSendableMetatype<T: SendableMetatype>(_ value: T) { }
417417

418418
@MainActor func passIsolated(s: S) {
419-
acceptsP(s) // okay: the type parameter 'T' requires P but not Sendable
420-
acceptsSendableP(s) // error: the type parameter 'T' requires Sendable
421-
acceptsAny(s) // okay: no isolated conformance
422-
acceptsSendable(s) // okay: no isolated conformance
419+
acceptsP(s) // okay: the type parameter 'T' requires P but not SendableMetatype
420+
acceptsSendableMetatypeP(s) // error: the type parameter 'T' requires SendableMetatype
421+
acceptsAny(s) // okay: no isolated conformance
422+
acceptsSendableMetatype(s) // okay: no isolated conformance
423423
}
424424
```
425425

@@ -431,19 +431,19 @@ The same checking occurs when the type parameter is hidden, for example when dea
431431
}
432432

433433
@MainActor func isolatedAnyBad(s: S) {
434-
let a: any Sendable & P = s // error: the (hidden) type parameter for the 'any' is Sendable
434+
let a: any SendableMetatype & P = s // error: the (hidden) type parameter for the 'any' is SendableMetatype
435435
}
436436

437437
@MainActor func returnIsolatedSomeGood(s: S) -> some P {
438438
return s // okay: the 'any P' cannot leave the isolation domain
439439
}
440440

441-
@MainActor func returnIsolatedSomeBad(s: S) -> some Sendable & P {
441+
@MainActor func returnIsolatedSomeBad(s: S) -> some SendableMetatype & P {
442442
return s // error: the (hidden) type parameter for the 'any' is Sendable
443443
}
444444
```
445445

446-
Within the implementation, a conformance requirement `T: Q` is considered to be isolated if there is no requirement `T: Sendable`. This mirrors the rule on the caller side, and causes the following code to be ill-formed:
446+
Within the implementation, we ensure that a conformance that could be isolated cannot cross an isolation boundary. This is done by making the a metatype `T.Type` `Sendable` only when there existing a constraint `T: SendableMetatype`. Therefore, the following program is ill-formed:
447447

448448
```swift
449449
protocol Q {
@@ -452,24 +452,25 @@ protocol Q {
452452

453453
nonisolated func callQGElsewhere<T: Q>(_: T.Type) {
454454
Task.detached {
455-
T.g() // error: use of potentially-isolated conformance of non-Sendable type T to Q
455+
T.g() // error: non-sendable metatype of `T` captured in 'sending' closure
456456
}
457457
}
458458
```
459459

460-
To correct this function, add a constraint `T: Sendable`, which allows the function to send the conformance across isolation domains. As described above, it also prevents the caller from providing an isolated conformance to satisfy the `T: Q` requirement, preventing the data race.
460+
To correct this function, add a constraint `T: SendableMetatype`, which allows the function to send the metatype (along with its conformances) across isolation domains. As described above, it also prevents the caller from providing an isolated conformance to satisfy the `T: Q` requirement, preventing the data race.
461461

462-
The `Sendable` requirement described above is a stricter contract than is necessary for a function such as `callQGElsewhere`, which won't ever pass *values* of the type `T` across an isolation domain. Therefore, we introduce a new marker protocol `SendableMetatype` to capture the idea that values of the metatype of `T` (i.e., `T.Type`) will cross isolation domains and take conformances with them. A requirement `T: SendableMetatype` prohibits isolated conformances from being used on type `T`. Now, `callQGElsewhere` can be correctly expressed as follows:
462+
`SendableMetatype` is a new marker protocol that captures the idea that values of the metatype of `T` (i.e., `T.Type`) will cross isolation domains and can take conformances with them. It is less restrictive than a `Sendable` requirement, which specifies that *values* of a type can be sent across isolation boundaries. All concrete types (structs, enums, classes, actors) conform to `SendableMetatype` implicitly, so fixing `callQGElsewhere` will not affect any non-generic code:
463463

464464
```swift
465465
nonisolated func callQGElsewhere<T: Q & SendableMetatype>(_: T.Type) {
466466
Task.detached {
467467
T.g()
468468
}
469469
}
470-
```
471470

472-
The `SendableMetatype` protocol is somewhat special, because according to SE-0302 *all* metatypes are `Sendable`. This proposal refines that statement slightly: all concrete types (structs, enums, classes, actors) implicitly conform to `SendableMetatype`, because their metatypes (e.g., `MyModelType.Type`) are all `Sendable`. Therefore, a call to `callQGElsewhere` for any concrete type will succeed so long as that type has a (non-isolated) conformance to `Q`.
471+
struct MyTypeThatConformsToQ: Q { ... }
472+
callQGElsewhere(MyTypeThatConformsToQ()) // still works
473+
```
473474

474475
The `Sendable` protocol inherits from the new `SendableMetatype` protocol:
475476

@@ -492,7 +493,7 @@ will continue to work with the stricter model for generic functions in this prop
492493

493494
The proposed change for generic functions does have an impact on source compatibility, where functions like `callQGElsewhere` will be rejected. However, the source break is limited to generic code that:
494495

495-
1. Uses a conformance requirement (`T: P`) of a non-marker protocol `P` in another isolated domain,
496+
1. Passes the metatype `T.Type` of a generic parameter `T` across isolation boundaries;
496497
2. Does not have a corresponding constraint `T: Sendable` requirement; and
497498
3. Is compiled with strict concurrency enabled (either as Swift 6 or with warnings).
498499

@@ -580,7 +581,7 @@ Initial testing of an implementation of this proposal found very little code tha
580581

581582
Isolated conformances can be introduced into the Swift ABI without any breaking changes, by extending the existing runtime metadata for protocol conformances. All existing (non-isolated) protocol conformances can work with newer Swift runtimes, and isolated protocol conformances will be usable with older Swift runtimes as well. There is no technical requirement to restrict isolated conformances to newer Swift runtimes.
582583

583-
However, there is one likely behavioral difference with isolated conformances between newer and older runtimes. In newer Swift runtimes, the functions that evaluate `as?` casts will check of an isolated conformance and validate that the code is running on the proper executor before the cast succeeds. Older Swift runtimes that don't know about isolated conformances will allow the cast to succeed even outside of the isolation domain of the conformance, which can lead to different behavior that potentially involves data races.
584+
However, there is one likely behavioral difference with isolated conformances between newer and older runtimes. In newer Swift runtimes, the functions that evaluate `as?` casts will check of an isolated conformance and validate that the code is running on the proper executor before the cast succeeds. Older Swift runtimes that don't know about isolated conformances will allow the cast to succeed even outside of the isolation domain of the conformance, which can lead to different behavior that potentially involves data races. It should be possible to provide (optional) warnings when running on newer Swift runtimes when a cast fails due to isolated conformances but would incorrectly succeed on older platforms.
584585

585586
## Future Directions
586587

@@ -660,3 +661,8 @@ This is a generalization of the proposed rules that makes more explicit when con
660661
* If not `T: SendableMetatype`, `T: P` is interepreted as `T: isolated P`.
661662

662663
The main down side of this alternative is the additional complexity it introduces into generic requirements. It should be possible to introduce this approach later if it proves to be necessary, by treating it as a generalization of the existing rules in this proposal.
664+
665+
## Revision history
666+
667+
* Changes in review:
668+
* Within a generic function, use sendability of metatypes of generic parameters as the basis for checking, rather than treating specific conformances as potentially isolated. This model is easier to reason about and fits better with `SendableMetatype`, and was used in earlier drafts of this proposal.

0 commit comments

Comments
 (0)