プロパティラッパーによる actor isolation inference が削除される

Swift Advent Calendar 2023 の 15 日目です。

TL;DR

  • 現在の swift にはプロパティラッパーによる actor isolation inference という仕様がある。この仕様のために例えば @StateObject@ObservedObject を持つ SwiftUI の View が暗黙のうちに @MainActor になるということが起こっている
  • この仕様が分かりにくいということで Swift 6 で削除されることになった
    • デフォルトで削除されるのは Swift 6 からの予定だが、 Swift 5.9 から --enable-upcoming-feature DisableOutwardActorInference というコンパイラフラグで削除を先取りできる

actor isolation inference とは

global actor にはさまざまな推論のルールがあり、ある class や struct に明示的なアノテーションがなくても暗黙のうちに global actor に隔離されているということが起こる。例えば、親クラスが global actor に隔離されている場合は子クラスは暗黙のうちに同じ global actor に隔離される。

@MainActor
class Parent {}

// 暗黙のうちに @MainActor
final class Child: Parent {}

このように、明示的に actor になっていない宣言が暗黙のうちに actor に隔離されることを actor isolation inference という。 actor isolation inference にはいくつかのパターンがあり、そのルールは SE-0316 にまとまっているが、その中の1つにプロパティラッパーに関するものがある。プロパティラッパーの wrappedValue が global actor に隔離されている場合にそのプロパティラッパーをプロパティにもつ struct や class が同じ global actor に暗黙的に隔離される、というものだ。

iOS アプリ開発をしていると「SwiftUI の View が @StateObject@ObservedObject を持つ場合に View 全体が @MainActor になる」という形でよくこのルールに出会うことになる。もともと SwiftUI の View 自体は @MainActor ではない( View の body プロパティが @MainActor )。しかし、 @StateObject@ObservedObject はその wrappedValue@MainActor なので、 actor isolation inference によりこれらをプロパティとして持つ View 自体が @MainActor になるということになる。

以上のことをコードを書いて確かめてみる。ある処理が @MainActor かどうか確かめる方法に @MainActor の関数が同期的に呼べるかどうかを見る方法がある。

@MainActor func mainActorFunction() {}

これを SwiftUI の View から呼んでみる。以下のように、 body の中では mainActorFunction が呼べるのに対して、 f からは呼べないことがわかる。このことで body@MainActor なのに対して、 SomeView 自体は @MainActor ではなく、そのインスタンスメソッドの f@MainActor ではないことがわかる。

struct SomeView: View {
    var body: some View {
        Text("Some View")
            .onAppear {
                // ✅ body が @MainActor なので OK
                mainActorFunction()
            }
    }

    func f() {
        // SomeView は @MainActor ではないためエラー
        // ❌ Call to main actor-isolated global function 'mainActorFunction()' in a synchronous nonisolated context
        mainActorFunction()
    }
}

一方で、以下のように @ObservedObject をプロパティに持つことにより View 自体が @MainActor になるため、そのインスタンスメソッドからは同期的に mainActorFunction を呼ぶことができるようになる。これが actor isolation inference によって起こっている。

final class SomeViewModel: ObservableObject {}

struct SomeViewWithObservedObject: View {
    @ObservedObject var viewModel: SomeViewModel

    var body: some View {
        Text("Some View With ObservedObject")
    }

    func f() {
        // ✅ actor isolation inference により SomeViewWithObservedObject が @MainActor になっているので OK
        mainActorFunction()
    }
}

ここで、 SomeViewModel 自体が @MainActor であるかどうかは関係ないことには注意が必要。 actor isolation inference が効くかどうかはあくまでプロパティラッパーの wrappedValue プロパティが global actor に隔離されているかどうかによって決まるので、上記の例だと @ObservedObject が以下のように定義されている時点で SomeViewModel@MainActor かどうかに関わらず actor isolation inference が効くことになる。

@frozen @propertyWrapper public struct StateObject<ObjectType> : DynamicProperty where ObjectType : ObservableObject {
    // ...
    @MainActor public var wrappedValue: ObjectType { get }
}

actor isolation inference がなぜ必要だったか / なぜ削除されるか

actor isolation inference は SE-0401 にて削除されることになった。 actor isolation inference がそもそもなぜ必要だったかと、せっかく入った仕様がなぜ削除されるのかを見ていく。

actor isolation inference が仕様に入った理由については、プロポーザルで以下のように述べられている。

The original motivation for this inference rule was to reduce the annotation burden when using property wrappers like SwiftUI's @ObservedObject .

この "annotation burden" というのは具体的になんなのかを知るために、仮に actor isolation inference の仕様がなかったらどういう負担があったのかを考えたい。 actor isolation inference が削除されるのは Swift 6 からの予定だが、実は Swift 5.9 から --enable-upcoming-feature DisableOutwardActorInference というフラグをつけることで actor isolation inference の削除を先取りできるようになっている。 Xcode では、 Build Settings の Other Swift Flags に以下のような指定をすれば OK。

actor isolation inference を disable した状態で Concurrency 関連のチェックを厳密にするために Strict Concurrency CheckingComplete にすると、例えば以下のようなコンパイルエラーが発生する。

final class SomeViewModel: ObservableObject {
    func someMethod() {}
}

struct SomeViewWithObservedObject: View {
    @ObservedObject var viewModel: SomeViewModel

    var body: some View {
        Text("Some View With ObservedObject")
    }

    func f() {
        // ❌ Main actor-isolated property 'viewModel' can not be referenced from a non-isolated context
        viewModel.someMethod()
    }
}

actor isolation inference がないため SomeViewWithObservedObject@MainActor ではない。そのため @ObservedObjectwrappedValue であることで @MainActor になっている viewModel プロパティに同期的に触れなくなるということが起こっている。この例に限らず、 View が @ObservedObject をプロパティとして持つからには当然インスタンスメソッドで @ObservedObjectwrappedValue を操作したいことが多く、 View が @MainActor でない場合には同期的に触れなくていちいち困ることになる。その対策として SomeView 自体に @MainActor アノテーションをつければ OK だが、いちいちそれをする "annotation burden" を省くためにこれまで actor isolation inference が自動的に SomeView@MainActor にしてくれていたというわけだ。

そんな親切心から実装された actor isolation inference が削除されることになった理由だが、暗黙的で予期しない振る舞いである割にメリットが小さいためだと SE-0401 やそれに関連する議論で言われている。

「SwiftUI の View は本来 @MainActor ではないが @StateObject@ObservedObject をプロパティとして持った瞬間に @MainActor になる」というのはたしかにわかりづらい挙動に思える。この仕様のせいで SwiftUI の View 自体が本来的に @MainActor であるというたぐいの誤解も生まれているかもしれない。その上で、 actor isolation inference が省いてくれる手間は @MainActor というアノテーションを書く部分のみなので、それならプログラマが明示的な @MainActor をつける方がいいじゃんというのは、なかなかうなずける話だと思う。 SE-0401 のレビュースレッドにも、賛同のコメントがどんどん寄せられている。

forums.swift.org

actor isolation inference 削除への対応

そもそも、 actor isolation inference が削除されるのは Swift 6 からという予定になっているので、 Swift 6 が利用できるようになってから対応するのでも大丈夫だと思う。ただ、 Swift 5.9 以降では --enable-upcoming-feature DisableOutwardActorInference をつけて対応を先取りするということもできる。

-enable-upcoming-feature DisableOutwardActorInferenceコンパイラフラグに追加することでこれまで actor isolation inference のおかげで推論が効いて暗黙的に global actor になっていた箇所の中でコンパイルエラーが出る箇所があるので、対応としてはそのエラーを自分で global actor のアノテーションをつけることで直してまわれば大丈夫だと思う。つまり、これまでコンパイラがやってくれていたことを自分でやり直すということだ。

ただ、重要な注意点として、 actor isolation inference を外すことによるコンパイルエラーは Strict Concurrency Checking の設定が Complete 以外の状態では出ないことがある。実際に、先ほど Strict Concurrency Checking が Completeコンパイルエラーが出たコードでも、 Targeted やデフォルトの Minimal に設定するとエラーが出ない。

struct SomeViewWithObservedObject: View {
    @ObservedObject var viewModel: SomeViewModel

    // ...

     func f() {
        // ⚠️ -enable-upcoming-feature DisableOutwardActorInference していても
        // ⚠️ Strict Concurrency Checking が Minimal / Targeted の場合はコンパイルエラーにならない
        viewModel.someMethod()
    }
}

コンパイルエラーが出ないということは、動作が変わっても気づけない可能性があるということだ。この例ではこれまで viewModel.someMethod() はメインスレッドから呼ばれていたが、 global actor inference が外れることでバックグラウンドスレッドから呼ばれることがあり得るため、予期しない動作につながるかもしれない。これを避けるために、先取りで --enable-upcoming-feature DisableOutwardActorInference をつけるのは Strict Concurrency Checking を Complete に変更してからの方がよいかもしれない。