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
というコンパイラフラグで削除を先取りできる
- デフォルトで削除されるのは Swift 6 からの予定だが、 Swift 5.9 から
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 Checking を Complete
にすると、例えば以下のようなコンパイルエラーが発生する。
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
ではない。そのため @ObservedObject
の wrappedValue
であることで @MainActor
になっている viewModel
プロパティに同期的に触れなくなるということが起こっている。この例に限らず、 View が @ObservedObject
をプロパティとして持つからには当然インスタンスメソッドで @ObservedObject
の wrappedValue
を操作したいことが多く、 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 のレビュースレッドにも、賛同のコメントがどんどん寄せられている。
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
に変更してからの方がよいかもしれない。