📲

KeyPathがSendableになれない話

2022/01/02に公開2

背景

次のコードは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でなければならないようです。
そうコンパイラに書いてありました。

https://github.com/apple/swift/blob/d8a3123efdbd810716db88275f6117e50fbaf005/lib/Sema/TypeCheckConcurrency.cpp#L2469-L2471

理由についてですがおそらくは、
引数のキャプチャされた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

脚注
  1. https://github.com/apple/swift-evolution/blob/master/proposals/0258-property-wrappers.md ↩︎

  2. https://github.com/apple/swift-evolution/blob/master/proposals/0252-keypath-dynamic-member-lookup.md ↩︎

Discussion