Swift Digest
SE-0401 | Swift Evolution

Remove Actor Isolation Inference caused by Property Wrappers

Proposal
SE-0401
Authors
BJ Homer
Review Manager
Holly Borla
Status
Implemented (Swift 5.9)

01 何が問題だったのか

SE-0316 でグローバルアクターが導入された際、明示的な @MainActor などのアノテーションや nonisolated が付いていない宣言は、さまざまな文脈から isolation を推論するルールが定められました。その一つが次の規則です。

ある struct / class が property wrapper を使った stored property を持っており、その property wrapper の wrappedValue がグローバルアクターで isolated されている場合、外側の型はその property wrapper から actor isolation を推論する。

つまり、グローバルアクターで isolated された wrappedValue を持つ property wrapper を型の内部で使っただけで、その型全体がグローバルアクターで isolated されたものとして扱われていました。SwiftUI の @ObservedObject@StateObjectwrappedValue@MainActor isolated なため、これらを使うと型全体が暗黙に @MainActor になります。

struct MyView: View {
  // StateObject は @MainActor-isolated な wrappedValue を持つ
  @StateObject private var model = Model()

  var body: some View {
    Text("Hello, \(model.name)")
      .onAppear { viewAppeared() }
  }

  // MyView 全体が @MainActor に推論されるため
  // この関数も @MainActor と推論される
  func viewAppeared() {
    updateUI()
  }
}

@MainActor func updateUI() { /* ... */ }

この挙動は利用者にとって分かりにくく、Swift Concurrency を学ぶ上での大きな混乱源になっていました。いくつかの問題があります。

プロパティ宣言の変更が兄弟関数を壊す

上の例で @StateObject@State に変えると、viewAppearedmodel を触っていないにも関わらずコンパイルエラーになります。@StatewrappedValue@MainActor ではないため MyView 全体の @MainActor 推論が消え、viewAppeared から @MainActorupdateUI を呼べなくなるからです。

struct MyView: View {
  @State private var model = Model()  // 変更点

  var body: some View { /* ... */ }

  func viewAppeared() {
    // error: Call to main actor-isolated global function
    // 'updateUI()' in a synchronous nonisolated context
    updateUI()
  }
}

private なプロパティの変更が別ファイルを壊す

SwiftUI に限らず、独自の property wrapper でも同じ問題が起きます。

@propertyWrapper
struct DBParameter<T> {
  @DatabaseActor public var wrappedValue: T
}

// @DBParameter の使用により DBConnection は @DatabaseActor に推論される
struct DBConnection {
  @DBParameter private var connectionID: Int

  func executeQuery(_ query: String) -> [DBRow] { /* ... */ }
}

// 別ファイル
@DatabaseActor
func fetchOrdersFromDatabase() async -> [Order] {
  let connection = DBConnection()
  // connection も DatabaseActor 上なので await 不要
  connection.executeQuery("...")
}

DBConnection.connectionID の property wrapper を外すと DBConnection の isolation 推論が消え、まったく別ファイルの fetchOrdersFromDatabase がコンパイルエラーになります。private なプロパティの変更が外部の別ファイルを壊すのは Swift では極めて異例で、property wrapper から外側への isolation 推論は「離れた場所で起きる不可解な作用」を生み出していました。

property wrapper 作者の意図を超えて isolation が広がる

@MainActor をメインスレッド専有の意思表明として使いたい property wrapper 作者にとっても、この推論は障害になります。次の例では、@OnMainThread を使った Contained 型が意図せず @MainActor 化されてしまい、別の型から作ろうとするとエラーになります。

class MyContainer {
  // error: Call to main actor-isolated initializer 'init()'
  //        in a synchronous nonisolated context
  let contained = Contained()
}

class Contained {
  @OnMainThread var i = 1
}

この推論ルールはもともと @ObservedObject のような SwiftUI の property wrapper を使う際のアノテーション負担を減らす目的で入りましたが、省略できるのは型への @MainActor 一箇所だけで、その見返りに「最小驚きの原則」に反する挙動を招いていました。

02 どのように解決されるのか

Swift 6 言語モードでは、型の内部で property wrapper を使ったことを理由に、その型の actor isolation を推論するルールを 完全に廃止 します。先ほどの MyView は、@StateObject を持っていても @MainActor には推論されません。型を @MainActor にしたい場合は、従来通り明示的にアノテーションを付けます。

@MainActor  // 明示的に付ける
struct MyView: View {
  @StateObject private var model = Model()

  var body: some View { /* ... */ }

  func viewAppeared() {
    updateUI()  // OK
  }
}

Swift 5 言語モードではこれまで通りの推論が維持され、ソース互換性が保たれます。新しい挙動を先取りしたい場合は、upcoming feature flag DisableOutwardActorInference を有効にします(-enable-upcoming-feature DisableOutwardActorInference)。Swift 6 モードに移行した時点で自動的に有効になります。

移行の指針

この変更によって isolation が消えるため、属している型がグローバルアクターで isolated されていることに依存していたコードは影響を受ける可能性があります。移行は次のいずれかの方法で先回りできます。

  • 型に明示的なグローバルアクター(例: @MainActor)を付ける。これは Swift 5 モードでもそのままコンパイルできるので、現時点から適用可能です。
  • ライブラリ側で、プロトコル自体に @MainActor を付けることで、適合する型が一貫して isolated されるように整える(例として、SwiftUI の View プロトコル自体を @MainActor にするような選択肢が考えられます。提案ではあくまで可能性として言及されており、個別のライブラリがそうすべきかには踏み込みません)。

提案者による既存 Swift プロジェクトでの検証では、property wrapper を使うプロジェクト(SwiftUI を使うアプリを含む)であっても、concurrency checking レベルが既定の MinimalTargeted では、この変更によって新たに壊れるものは観測されなかったと報告されています。Complete まで上げると追加のエラーが出うるものの、これは元々 Complete で出ていた警告・エラーの一部に含まれる範囲にとどまるとされています。