[Amendment] SE-0470: Global-actor isolated conformances

Hi all,

@Douglas_Gregor has proposed an amendment to SE-0470 that will adjust inference rules for nonisolated conformances, reflecting usage experience particularly in conjunction with default main actor isolation. In particular:

  • If the protocol inherits from SendableMetatype (including indirectly, such as from Sendable), then an isolated conformance could never be used, so it is now proposed that the conformance be inferred nonisolated.
  • If all of the declarations used to satisfy protocol requirements are nonisolated, it is now proposed that the conformance be inferred nonisolated.

The rationale for these changes is explained in detail in the above-linked amendment. We will have an expedited review period for the amendment from now through July 15, 2025.

This amendment was simultaneously pitched alongside a related amendment to SE-0466. That other amendment addresses disabling @MainActor inference on types based on the protocols to which they conform and is under review in a separate thread.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at:

swift-evolution/process.md at main · swiftlang/swift-evolution · GitHub

Thank you,

Xiaodi Wu
Review Manager

2 Likes

Because this is an abbreviated review, just wanted to prompt folks as we're going into the weekend to take a look if you haven't already.

These amended rules are meant to improve the user experience for scenarios many are likely to encounter, so now's the time to take a look and raise your voice if you've got examples where they need further refinement!

I don't have a lot to say that I didn't mention in the pitch thread already, but I couldn't help but notice this wording:

If the authors truly believe that an isolated conformance to a SendableMetatype-inheriting protocol can never be used (not my words!), shouldn't this amendment forbid isolated conformances to those protocols altogether, instead of just changing inference rules to avoid them?

Is there any benefit of allowing (explicit) isolated conformances to SendableMetatype-inheriting protocols, if those conformances can't generally (or ever) be used? If there are indeed scenarios in which one may genuinely want an isolated conformance to a SendableMetatype-inheriting protocol, is that flexibility worth the potential confusion created by letting users declare unfulfillable (or unusable) conformances like @MainActor CodingKeys?


Regardless of the above, I've come to believe this amendment would be a net positive: there are some protocols (like CodingKeys) where this change is such an overwhelming improvement that I can't imagine the language not adopting this amendment.

1 Like

[Review manager hat on.]

Keep in mind that this amendment includes two proposed inference changes. Even if you feel one of them is very intuitive and obviously useful, you may want to review both sets of changes carefully to ensure that they meet your needs.

[Review manager hat off.]

I think it's worth explicitly pointing out the source evolution trade-off involved in inferring non-isolated conformance when all declarations that satisfy protocol requirements are themselves non-isolated (whether by default or explicitly).

It's currently the case (with some corner case caveats) that a protocol can add a new requirement with a default implementation while maintaining source compatibility. With the currently proposed inference rule, we are adding a major caveat where that will not be the case.

Consider the following scenario:

// Library A v1.0 (nonisolated by default)
protocol Q {
  static func create() -> Self
}

// MyApp (main actor by default)
class MyType: Q {
  nonisolated static func create() -> MyType { ... }
  func destroy() { ... }
}

With the proposed amendment as-is, the conformance of MyType to Q is inferred non-isolated.

If library A (v1.1) adds a new requirement Q.destroy() with a default implementation, the proposed amendment would suddenly infer @MainActor-isolated conformance to Q. This is because the existing MyType.destroy() would be a suitable @MainActor-isolated implementation of the new requirement. Since not all of Q's requirements are implemented by MyType in a non-isolated manner, the conformance won't be non-isolated. This change in the inferred isolation is likely to be source-breaking even if the author of library A intended no source-breaking changes and did not tag a semver major release.

For completeness, there are two alternatives I can think of, each with their pros and cons:

  1. (Covered in the current alternatives considered.) Require explicit use of nonisolated instead of inferring it.

  2. Consider only non-defaulted requirements in the non-isolated inference rule (provided that the default implementations are non-isolated).

Alternative (1) would produce the desired evolutionary behavior in a straightforward way. Diagnostics at the time of implementation will prompt the user to express the intended isolation of the conformance. Once explicitly stated in code, any future evolution of the protocol will lead to diagnostics that guide the user to adjust their implementation of an added requirement, adopt the default implementation, or change the isolation of the conformance.

One downside of this alternative is that users could be exposed to nonisolated as a concept before they're ready from a progressive disclosure standpoint. However, as far as I can tell, a user would have to explicitly annotate something as either @MainActor and/or nonisolated in order to trigger the scenario in question. An exception is in the case of macro-generated code, but that leads to a larger philosophical conversation I'd subset out from here.

Alternative (2) would produce the desired evolutionary behavior also. It would allow MyApp in this example to have the same behavior whether recompiled or not on an ABI-stable platform. That is, the nonisolated default implementation would remain the witness for the new protocol requirement and the conformance would continue to be inferred nonisolated.

However, from a compiler standpoint, I'd expect this alternative to require more implementation complexity. And though already unavoidable even at present on ABI-stable platforms if MyApp isn't recompiled, we'd also be foregrounding complexity in the user-facing model because Swift here will distinguish two implementations of destroy() based on their static isolation in determining whether one overrides or shadows the other.

This alternative could lead to surprising inferences upfront if a user conforms to a protocol P with all-defaulted implementations in @MainActor-isolated mode. Namely, if they have written concrete implementations for all of P's requirements but they are all @MainActor-isolated, they could unwittingly end up with a nonisolated conformance they won't ever need that ignores their custom implementations. We'd likely want to broaden conformance near-miss diagnostics to detect isolation mismatches for that reason, and those will still necessarily be imperfect.

To my mind, however, since default implementations are supposed to be appropriate for all conforming types, this may still be a superior trade-off in that the downsides of having an unexpectedly non-isolated and less-optimal (but correct) conformance are better than the downsides of an unexpectedly isolated (and thereby source-breaking) conformance.

That's a fascinating alternative. I understand the goal here---keep the set of requirements that will be considered for this inference to be stable, even when the protocol evolves by adding new, defaulted requirements.

I'm concerned about this approach not working out when protocols evolve to gain a "replacement" requirement, in which we will also define a default for an old requirement in terms of a newer one. For example, AsyncIteratorProtocol gained a next(isolation:):

public protocol AsyncIteratorProtocol<Element, Failure> {
  associatedtype Element

  associatedtype Failure: Error = any Error

  mutating func next() async throws -> Element?

  mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element?
}

When adding this, we added default implementations in both directions: next() can be built on top of next(isolation:) (for newer types) and next(isolation:) can be built on top of next() unsafely (for existing types). There's an existing usability issue here, because an empty conformer to AsyncIteratorProtocol is allowed (albeit silly). The alternative you propose would introduce another issue with that approach, because an existing requirement would have gained a default implementation, thereby taking it out consideration for nonisolated inference.

Doug

To close the loop, I am told that recent builds now diagnose this usage with a warning.

2 Likes

Thanks all—the language steering group has decided to accept the amendment as proposed.

Xiaodi Wu
Review Manager