概要
SwiftUI で開発する際、ViewModel を @MainActor
にしてそのメソッドを Button
の action
パラメータに渡すということがよくあると思います。このとき、 action
に渡すクロージャの中で @MainActor
である ViewModel のメソッドが await
せず同期的に呼べるので action
引数は @MainActor
なのかと思っていたのですが、ある日同じメソッドを引数に直指定すると @MainActor
を @MainActor
でない型に渡すなという旨の warning が出てしまいました。
一見、もし action
が @MainActor
なら // 2
のメソッドを直指定するケースで warning が出ないはずだし、 @MainActor
でなければ // 1
のクロージャの中でメソッドを呼ぶケースで actor の制約のため Button(action: { await viewModel.onTapped() })
のように await
で呼ばなければいけないはずなので説明がつかないように思えます。
同じことは Button
の action
だけではなく、例えば onTapGesture
の perform
パラメータなど SwiftUI のクロージャを渡す API 全般で発生します。
この理由が気になって考えてみたのでこの記事でまとめます。やや自信のない箇所もあるので、もし間違いを見つけた方はコメント等で教えてください。
記事中の動作検証は Xcode 14 Beta 5 で行っています。
TL;DR
前提として、 @Sendable
でないクロージャは定義された箇所の actor コンテキストを受け継ぐ。SwiftUI.Button
の action
引数は @MainActor
ではないが、
-
SwiftUI.Button
のaction
引数は@Sendable
ではない -
action
にクロージャを渡す場合SwiftUI.View
のbody
の中で定義されることになり、かつbody
は@MainActor
である
という2つの事実により、 action
に渡すクロージャをその場で定義した場合は @MainActor
を受け継ぐため、クロージャの中で @MainActor
のメソッドを同期的に呼ぶことができる。 action
自体が @MainActor
なわけないのでクロージャをその場で定義せずにメソッドを直指定すると warning が出る。
@Sendable
でないクロージャは actor コンテキストを引き継ぐ
まず前提として、 「actor の中でクロージャが定義されたときにそのクロージャが @Sendable
でない場合は actor のコンテキストを受け継ぐ」ということを理解していきます。SE-0306 Actors からクロージャと actor の関係について引用します。
A closure that is not
@Sendable
cannot escape the concurrency domain in which it was formed. Therefore, such a closure will be actor-isolated if it is formed within an actor-isolated context. This is useful, for example, when applying sequence algorithms likeforEach
where the provided closure will be called serially ...
A closure formed within an actor-isolated context is actor-isolated if it is non-
@Sendable
, and non-isolated if it is@Sendable
.
簡単にまとめると、クロージャが @Sendable
でない場合は actor コンテキストを受け継ぎ、 @Sendable
の場合は受け継がないということです 1 。例えば Task.detached
に渡すクロージャは @Sendable
、 Array#forEach
に渡すクロージャは @Sendable
ではないので以下のように forEach
のクロージャの中でのみ actor の状態に同期的にアクセスできます。
actor MyActor {
var number: Int = 42
func f(values: [Int]) {
Task.detached { // @Sendable なクロージャ
// actor のコンテキスト外なので number プロパティにアクセスするために await が必要
await print(self.number)
}
values.forEach { value in // @Sendable でないクロージャ
// actor のコンテキスト内なので number プロパティに同期的にアクセスできる
print(value + self.number)
}
}
}
以下、 @Sendable
でないクロージャは、 Concurrency ドメインの壁を越えることができず、他のタスクや actor では実行できないので安全に actor のコンテキストを受け継ぐことができることを理解していきます。
@escaping
でないクロージャ
forEach
渡すクロージャのように @escaping
でないクロージャについては、必ず同期的に実行されるので actor コンテキストを受け継ぐのは自然です。以下 // 1
と // 2
のループは同じように実行されますが、前者の for
文の中で実行されるコードは当然 actor コンテキスト内で実行されるので、後者の forEach
に渡すクロージャも actor コンテキスト内で実行されて問題ないということです。
actor MyActor {
var number: Int = 42
func f(values: [Int]) {
// 1
for value in values {
print(value + self.number)
}
// 2
values.forEach { value in
print(value + self.number)
}
}
}
@escaping
なクロージャ
@escaping
でないクロージャが actor コンテキストを受け継いでも問題ないことは明らかな一方で、 @escaping
なクロージャが actor コンテキストを受け継いで問題ないのかは気になるところです。 @escaping
なクロージャの使い道としてよくある
- その場で非同期に実行される
- プロパティに保存されて後で実行される
についてそれぞれ見ていきます。
@escaping
なクロージャが非同期に実行される場合
クロージャが actor のコンテキストを受け継ぐということは actor のもつ状態を変更できたり、状態を同期的に読み取れるということです。そのクロージャが非同期に実行されてしまっては actor にデータ競合を持ち込んでしまうように思えます。例えば下記のようなコードでは // 1
は DispatchQueue.global().async
でスレッドを切り替えて非同期に実行されるので、 // 1
の操作と // 2
の操作が同時に別のスレッドから実行されてデータ競合が発生する可能性があります。
struct Executor {
init(operation: @escaping () -> Void) {
DispatchQueue.global().async {
operation()
}
}
}
actor MyActor {
var number: Int = 42
func f() {
let _ = Executor {
self.number += 1 // 1
}
self.number += 1 // 2
}
}
しかし、実際にはコンパイラが下記のような warning を出してくれてデータ競合の可能性を防げるようになっています。
struct Executor {
init(operation: @escaping () -> Void) {
DispatchQueue.global().async {
// ❗️ Capture of 'operation' with non-sendable type '() -> Void' in a `@Sendable` closure
operation()
}
}
}
DispatchQueue#async
だけでなく、 Task.detached
や DispatchQueue#asyncAfter
のようなメソッドに置き換えても同様の warning が出ます。
現在の Swift では上記の Task.detached
や DispatchQueue#async
のような非同期に処理を実行する API は @Sendable
なクロージャを受け取るようになっています。そのため、 actor 内で定義した @Sendable
でないクロージャが最終的に非同期に実行されようとしたところで warning が出るようになっています。コンパイラの warning を無視しない限りはそのクロージャは同期的に実行される必要があり、結果として安全に actor コンテキストを受け継ぐことができるというわけです。
注意点として、上記の warning は Xcode 14 Beta 5 の時点ではこの warning は Strict Concurrency Check を Complete
に設定しないと出ないようになっています。おそらく、 Swift 6 の時点ではこの warning はデフォルト設定でも error になるのではないかと思っています。 Strict Concurrency Check について詳しくは以下の記事を参照ください。
@escaping
なクロージャがプロパティに保存されて後で実行される場合
@escaping
なクロージャはプロパティに保存することができるので、そのクロージャが actor のコンテキストを受け継いでいる場合にプロパティに保存されたクロージャを後から実行しても問題が発生しないか見てみます。例えば以下のコードでは // 1
の操作と // 2
の操作が同時に別のスレッドから実行されることがありうるのでデータ競合が発生する可能性があります。
struct Executor {
private var operation: () -> Void
init(operation: @escaping () -> Void) {
self.operation = operation
}
func executeOperation() {
operation()
}
}
actor MyActor {
var number: Int = 42
func f() {
let executor = Executor {
self.number += 1
}
self.number += 1 // 1
DispatchQueue.global().async {
executor.executeOperation() // 2
}
}
}
このようなコードに対してもコンパイラが warning を出してくれます。
actor MyActor {
func f() {
// ...
self.number += 1
DispatchQueue.global().async {
// ❗️ Capture of 'executor' with non-sendable type 'Executor' in a `@Sendable` closure
executor.executeOperation()
}
}
}
ここでは Executor
が Sendable
に準拠していないことが warning の原因になっています。先ほども述べたように非同期処理を実行する API は @Sendable
なクロージャを受け取るようになっており、 Sendable
に準拠していない型である Executor
のインスタンスはそのようなクロージャでキャプチャできないようになっているためです。
もちろんこの warning は Executor
を Sendable
に準拠させれば消えますが、そうするとまた別の warning が発生します。
struct Executor: Sendable {
// ❗️ Stored property 'operation' of 'Sendable'-conforming struct 'Executor' has non-sendable type '() -> Void'
private var operation: () -> Void
// ...
}
@Sendable
でないクロージャをメンバーに持つ型は Sendable
に準拠することができないことがこの warning の原因です。このことから、クロージャが @Sendable
でない限りはプロパティに保存されたとしても、クロージャが後で別のスレッドで実行されて actor とデータ競合を起こすということはコンパイラが防いでくれることがわかります。
ここまでの議論で、 @Sendable
なクロージャは最終的に Concurrency ドメインの壁を越えられないのでそのクロージャが定義された actor のコンテキストを受け継いでも安全であることが納得できると思います。
SwiftUI.Button
の action
は @MainActor
なのか
それでは、記事の最初に提示した疑問である SwiftUI.Button
の action
は @MainActor
なのかについて考えていきます。結論から書くと、SwiftUI.Button
の action
は @MainActor
ではないと思います。
Xcode で Button
の init
にコードジャンプしてみると @MainActor
のアトリビュートがついていないことがわかります。
public struct Button<Label> : View where Label : View {
public init(action: @escaping () -> Void, @ViewBuilder label: () -> Label)
}
また、 @MainActor
のクロージャを直接渡すと warning が出ることも action
が @MainActor
ではないことを示しています。
@MainActor
final class ViewModel: ObservableObject {
func onTapped() {
print("onTapped")
}
}
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
// ❗️ Converting function value of type '@MainActor () -> ()' to '() -> Void' loses global actor 'MainActor'
Button(action: viewModel.onTapped) { Text("Tap Me") }
}
}
ここで、action
が @MainActor
でないとすると、以下のコードが warning を出さないことが不思議に思えます。
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
// ✅ OK
// MainActor である viewModel.onTapped を同期的に呼び出せている
Button(action: { viewModel.onTapped() }) { Text("Tap Me") }
}
}
実際に、例えば上記の Button
のケースと同じようなことをしているはずの以下のコードを書いてみると warning が出ます。
@MainActor func mainActor() {}
struct MyButton {
init(action: () -> Void) {}
}
func f() {
// ❗️ Call to main actor-isolated global function 'mainActor()' in a synchronous nonisolated context; this is an error in Swift 6
let _ = MyButton(action: { mainActor() })
}
以上の疑問は、前の節で理解した「@Sendable
でないクロージャは定義された actor のコンテキストを引き継ぐ」ということを考えると解消します。
まず、 SwiftUI のコードに Xcode でジャンプしてみると SwiftUI.View
の body
プロパティは @MainActor
であることがわかります。
public protocol View {
associatedtype Body : View
@ViewBuilder @MainActor var body: Self.Body { get }
}
その上で、 Button
の action
は @Sendable
ではないクロージャを受け取ります。
public struct Button<Label> : View where Label : View {
public init(action: @escaping () -> Void, @ViewBuilder label: () -> Label)
}
@Sendable
でないクロージャは定義された actor のコンテキストを引き継ぐという前提のもと、
-
View
のbody
は@MainActor
である -
Button
のaction
に渡すクロージャは@Sendable
ではない
という事実を考え合わせると、Button
の action
に渡すクロージャをその場で定義した場合にそのクロージャが @MainActor
のコンテキストを受け継ぐことがわかります。これにより、中で @MainActor
のメソッドを同期的に呼び出すことが可能になっているというわけです。
struct ContentView: View {
// @MainActor
@StateObject private var viewModel = ViewModel()
// @MainActor
var body: some View {
// action に渡したクロージャは @MainActor を受け継ぐので
// @MainActor である onTapped メソッドを同期的に呼び出せる
Button(action: { viewModel.onTapped() }) { Text("Tap Me") }
}
}
以上の説を確認するため、 action
に渡すクロージャをあえて @Sendable
にしてみます。 @Senadble
なクロージャは actor コンテキストを受け継がないのでこの変更によりクロージャが @MainActor
ではなくなり、エラーが発生するようになります。
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
- Button(action: { viewModel.onTapped() }) { Text("Tap Me") }
// ❌ Main actor-isolated property 'viewModel' can not be referenced from a Sendable closure
// ❗️ Call to main actor-isolated instance method 'onTapped()' in a synchronous nonisolated context; this is an error in Swift 6
+ Button(action: { @Sendable in viewModel.onTapped() }) { Text("Tap Me") }
}
}
別の観点からの確認として、 @MainActor
ではない適当な関数で Button
を定義して、 action
のクロージャ内で @MainActor
のメソッドを呼んでみると warning が発生します。これは f
が body
と異なり @MainActor
ではないことから、 f
の内部で定義したクロージャも @MainActor
ではないためです。
@MainActor func mainActor() {}
func f() {
// ❗️ Call to main actor-isolated global function 'mainActor()' in a synchronous nonisolated context; this is an error in Swift 6
let _ = Button(action: { mainActor() }) { Text("Tap Me") }
}
この f
自体を @MainActor
にしてあげると body
と同じ状況になるため warning は消えます。
@MainActor
func f() {
// ✅ OK
let _ = Button(action: { mainActor() }) { Text("Tap Me") }
}
参考
-
例外として、
Task.init
が受け取るクロージャは@Sendable
であるにも関わらず actor コンテキストを受け継ぎます ↩