@TaskLocalプロパティラッパーを触る機会があったので、使い方の備忘録として記事にしました。
TaskLocal は static なグローバル変数を、コンテキストを限定して値を注入して、注入した値を読み取ることができる様にします。
https://developer.apple.com/documentation/swift/tasklocal
TaskLocalは以下OSバージョンから使うことができます。(結構昔から使えてたみたいで、驚きました)
- iOS 13.0以降
- iPadOS 13.0以降
- Mac Catalyst 13.0以降
- macOS 10.15以降
- tvOS 13.0以降
- visionOS 1.0以降
- watchOS 6.0以降
Swift6.0 からは、static なグローバル変数だけでなく、static でないグローバル変数を TaskLocal を適用できるようです。
https://developer.apple.com/documentation/swift/tasklocal#Task-local-declarations
公式のドキュメントでは以下の様に説明されています。
A task-local value is a value that can be bound and read in the context of a Task. It is implicitly carried with the task, and is accessible by any child tasks it creates (such as TaskGroup or async let created tasks).
日本語訳
タスクローカル値とは、 のコンテキスト内でバインドおよび読み取り可能な値です Task。この値はタスクに暗黙的に保持され、タスクによって作成されたすべての子タスク(TaskGroup や作成されたタスクなど async let)からアクセスできます。
文面で説明を見ても、イメージ掴みづらい気がするので、どんな動作をするのか実際のコードから確認していきます。
サンプルコードで動作確認
例のコードとして以下を用意しました。
struct Store {
@MainActor static var value: Int = 0
}
@MainActor
func main() {
print("value: \(Store.value)")
Store.value = 1
print("value: \(Store.value)")
}
main()
main()の実行で、コンソールに以下が出力されます。
value: 0
value: 1
最初はStore.valueには 0 が入っているからvalue: 0と出力され、
そのあとStore.valueに1を代入しているので、value: 1が出力されます。
これは、Store.valueの値の変更がグローバルなコンテキストで行われているからです。
この例に@TaskLocalを用いて、Store.valueの値の変更のコンテキストを限定してみましょう。
struct Store {
@TaskLocal static var value: Int = 0
}
@MainActor
func main() {
print("value on global context: \(Store.value)")
Store.$value.withValue(100) {
print("value on context1: \(Store.value)")
}
print("value on global context: \(Store.value)")
}
main()
main()の実行で、コンソールに以下が出力されました。
value on global context: 0
value on context1: 100
value on global context: 0
Store.valueに@TaskLocalを付与すると、Store.$valueの様に、$valueに参照できます。
$valueはTaskLocal<Int>型で、withValueメソッドを使って、限定したコンテキストからのみ読み取りできる値を注入できます。
今回だと、Store.$value.withValue(100)とすることで、クロージャ内のコンテキストからStore.valueを参照したときに100が取れるようになります。
そのためコンソールの主力結果は、クロージャ内のログはvalue on context1: 100となり、それ以外のログは、value on global context: 0となりました。
ここまでで、TaskLocal を使うことで、コンテキストを限定した値の注入のイメージが少し沸いたでしょうか。
ちなみにの話ですが、Swift6 環境では、static な変数は通常複数のスレッドから読み書き可能な状態になってしまうため、Actor に隔離するなどしてスレッドセーフな状態にしないとコンパイルエラーが出てしまいます。
ただ、@TaskLocalが付与されていれば、表面上は read only の定数として振る舞う様になります。実際に値を変更しようとすると、以下の様なエラーが表示されるでしょう。
Cannot assign to property: 'value' is a get-only property
static な定数であれば、Sendable である限りスレッドセーフであることが保証されるので、@MainActorで Actor に隔離せずともコンパイルエラーが表示されなくなるので、@MainActorは削除しています。
withValue で注入した値の生存期間
TaskLocalでコンテキストを限定した値の生存期間はどのくらいか気になったので、もう少し動作を深掘りしました。
ドキュメントによると、withValueメソッドで注入した値は、withValueメソッドのoperationクロージャ内でのみ有効ですが、クロージャ内であってもTask.detached内でも注入した値は引き継がれないようです。
ただ、Taskのイニシャライザのクロージャ内であれば、注入した値は引き継がれます。
以下サンプルコードで動作を確認してみましょう。
@MainActor
func main() {
print("value on global context: \(Store.value)")
Store.$value.withValue(100) {
print("value on context1: \(Store.value)")
Task {
print("value on context1 in task: \(Store.value)")
Task {
print("value on context1 in task2: \(Store.value)")
}
}
Task.detached {
print("value on context1 in detached task: \(Store.value)")
}
}
print("value on global context: \(Store.value)")
}
main()
main()の実行結果は以下のとおり。
value on global context: 0
value on context1: 100
value on context1 in detached task: 0
value on global context: 0
value on context1 in task: 100
value on context1 in task2: 100
ドキュメントに記載されている通り、withValueのクロージャ内で、Task.detachedのクロージャ内のprint出力以外では注入した値が取れていそうです。
Task.detachedのクロージャ内は想定通り、デフォルトの0が取れていました。
ただ、ドキュメントでは記載されていない内容で、DispatchQueueのクロージャでもTask.detachedと同じく注入した値は引き継がれませんでした。
@MainActor
func main() {
print("value on global context: \(Store.value)")
Store.$value.withValue(100) {
print("value on context1: \(Store.value)")
DispatchQueue.main.async {
print("value on context1 in DispatchQueue.main: \(Store.value)")
}
DispatchQueue.global().async {
print("value on context1 in DispatchQueue.global(): \(Store.value)")
}
}
print("value on global context: \(Store.value)")
}
main()
value on global context: 0
value on context1: 100
value on global context: 0
value on context1 in DispatchQueue.global(): 0
value on context1 in DispatchQueue.main: 0
withValueメソッドの戻り値として、operationクロージャ内で return したインスタンスを返す様になっているのですが、
その特徴から、withValueの戻り値のviewModelの中でも注入したStore.valueの読み取りができるのかなと勘違いしていました。
クロージャ外に出ている時点で TaskLocal のコンテキスト外なので、デフォルトのStore.valueの値しか読み取れません。
以下のコードの場合は、ViewModel.onAppear実行時はvalue on ViewModel: 0と表示されます。
final class ViewModel {
func onAppear() {
print("value on ViewModel: \(Store.value)")
}
}
struct Store {
@TaskLocal static var value: Int = 0
}
@MainActor
func main() {
var viewModel = Store.$value.withValue(1) {
return ViewModel()
}
viewModel.onAppear()
}
main()
ViewModelのインスタンス生成時など、withValueのoperationクロージャ内でStore.valueの値を保持する様にすれば、TaskLocal のコンテキスト外でも値を読み取りできます。
final class ViewModel {
let value: Int
init() {
value = Store.value
}
func onAppear() {
print("value on ViewModel: \(value)")
}
}
struct Store {
@TaskLocal static var value: Int = 0
}
@MainActor
func main() {
var viewModel = Store.$value.withValue(1) {
return ViewModel()
}
viewModel.onAppear()
}
main()
使い所は?
@TaskLocalの使い方や挙動を見てきましたが、結局このAPI何に使えるのか考えてみました。
まず最初に思い当たるのは、DIの仕組みとして使用できることです。
どうやってDIの仕組みとして使えるかは、以下記事を参照ください。
また、以下記事では、Loggerのログレベルを制御するのに、@TaskLocalを用いる例もありました。