KeyPathがSendableになれない話
背景
次のコードはSwiftコンパイル時のフラグに-warn-concurrency
を付与した場合に警告が表示されます。
import SwiftUI
@dynamicMemberLookup struct Box<Value> {
var value: Value
subscript<T>(dynamicMember keyPath: WritableKeyPath<Value, T>) -> T {
get { value[keyPath: keyPath] }
set { value[keyPath: keyPath] = newValue }
}
}
struct Dog {
var id: Int?
var name: String = ""
}
struct ContentView: View {
@State private var state = Box(value: Dog())
var body: some View {
TextField("", text: $state.name) // Cannot form key path that captures non-sendable type 'WritableKeyPath<Dog, String>'
}
}
Cannot form key path that captures non-sendable type 'WritableKeyPath<Dog, String>'
何かのKeyPathがSendableでないことで警告が出ています。
KeyPathはプロパティへの参照を表した、メタプログラミング文脈で使われるオブジェクトです。
この警告から察するに、$state.name
の呼び出しがactor-isolated contextをまたぐ可能性があって、かつWritableKeyPath<Dog, String>
がSendableでないことがわかります。
一見concurrencyに関係なさそうなこのコード、なぜこのような警告が出ているのでしょうか。
$state.nameの実態
まず$state.name
には多くの省略表現が含まれているので、その正体を丁寧に紐解きます。
state
には@State
のpropertyWrapperが付与されていることから、$state
の実態は_state.projectedValue
です。
projectedValueの型はBinding<Box<Dog>>
であり、Binding
は@dynamicMemberLookup
を持っていることからsubscript(dynamicMember:)
による呼び出しが可能になっています。
さらに呼び出されうる先のBox<Dog>
自体もさらに@dynamicMemberLookup
を持っているため、2重にDynamicMemberLookupされます。
(propertyWrapper[1]やKeyPathによるDynamicMemberLookup[2]はSwift5.1で追加されたちょっと特殊な機能ですが、ここでは説明は省略します)
つまり省略部分をすべて展開するとこうなります。
_state.projectedValue[dynamicMember: \Box<Dog>.[dynamicMember: \Dog.name]]
元々が$state.name
なのでかなり省略されてますね。
subscriptはKeyPathを持てる
先程展開した式の\Box<Dog>.[dynamicMember: \Dog.name]
に着目します。
私はこの式を見て初めて知ったのですが、subsctiptにもKeyPathが存在するようですね。
例えばDictionaryの場合は以下のように使用できます。
let d = [1: "one", 2: "two"]
let keyPath = \[Int: String].[1]
print(d[keyPath: keyPath]!) // one
KeyPathには引数をキャプチャできる
そろそろ勘づいてきた方もいらっしゃるのではないでしょうか。
先程のコードのlet keyPath = \[Int: String].[1]
に着目します。
この式、よく見たら1
という値をKeyPathに格納していますね?
subscriptは引数を受け取ることで実質的な関数のように扱えますが、subscriptをKeyPathに対応させるためにはこの関数としての呼び出しに必要な引数をKeyPathに埋め込む必要があったということですね。
// ↓ここに1を指定した状態をKeyPathは保持している
subscript(key: Key) -> Value?
これのおかげで、同じ引数を使用したsubscriptの呼び出しを別々のインスタンスに対して実行できます。
let d = [1: "one", 2: "two"]
let d2 = [1: "satu", 2: "dua"]
let keyPath = \[Int: String].[2]
print(d[keyPath: keyPath]!) // two
print(d2[keyPath: keyPath]!) // dua
KeyPathにキャプチャされる引数はSendableでなければならない
さて、ここで新たなルールを紹介します。KeyPathにキャプチャされる引数は、Sendableでなければならないようです。
そうコンパイラに書いてありました。
理由についてですがおそらくは、
引数のキャプチャされたKeyPathがどのactor-isolated contextで使用されても良いようにするためだと思います。
引数に指定された値はsubscriptの内部で変更される可能性があるため、もしSendableでない場合、keyPathを使ったsubscriptの呼び出しを並列して行うことでその引数を壊すことができてしまいます。
冒頭で「actor-isolated contextをまたぐ可能性があって」と述べましたが、ここでその考慮が発生しているのだと思います。
KeyPathをSendableにするためには
1つのKeyPathを複数のTaskから使用する場合、KeyPath自体もSendableになるべきだと思います。
が、現状KeyPathはSendableではありません。Sendableにするための方法を考えます。
KeyPathには引数キャプチャを用いてSendableでない値を埋め込むことができてしまうため、@unchecked Sendable
を用いて安直にSendableを満たすべきではありません。
ジェネリックな型をSendableに適合させる場合は通常、Conditional Conformanceを用います。例えばArrayの場合は以下のようになります。
extension Array: Sendable where Element: Sendable {}
このような記述で、中の要素さえSendableであればArray全体もSendableになれる、ということを表すことができています。
ではKeyPathはどうでしょうか。
KeyPathが持っている型パラメータは<Root, Value>
となっており、Root
はそのKeyPathの持ち主の型、Valueが出力される型になっています。
さて、KeyPathの型にはsubscriptの引数となる値の型が現れていません。
キャプチャされる値はクロージャ型などと同じように、暗黙のコンテキストとなっています。
クロージャ型では@Sendable
指定を付与することができますが(例: let _: @Sendable () -> () = {}
)、KeyPathにはそれがないようです。
よって今のところKeyPathを安全にSendable指定する方法がないように思っています。
結論
2重KeyPathDMLが発生した場合、Sendable警告に遭遇します。この警告に対して安全に対応する方法はなさそうです。
とはいえsubscriptのKeyPathを使ったロジックを書くことはほとんど無いと思うので、例外ケースがあることを承知しつつ@unchecked Sendable
で済ませるのが現実的かなと思いました。
環境: Swift 5.5.2
Discussion
これで解決してるかもしれないですね.(Swift 6.0)
お、いいですね。楽しみです