Smart KeyPaths: Better Key-Value Coding for Swift
01 何が問題だったのか
Swift にはプロパティを「値として取り出さずに、プロパティそのものを指し示す」手段がありませんでした。foo.bar() に対する foo.bar のような「呼び出さない参照」がメソッドにはある一方で、プロパティやサブスクリプトについては、名前で間接的に参照する方法が貧弱だったのです。
Darwin プラットフォームでは Objective-C 由来の #keyPath() 構文があり、プロパティを安全に参照する手段として使えました。しかし、検証後に得られるのは結局 String であり、次のような制約がありました。
- 型情報が失われるため、API が
Any中心の扱いにくいものになる - 文字列のパースが必要でランタイムのコストが無駄に大きい
NSObjectのサブクラスにしか使えない- Darwin プラットフォームでしか使えない
また、コレクションに対する添字アクセスなど、サブスクリプトを含むパスを表現することもできませんでした。
class Person {
var name: String = ""
var friends: [Person] = []
}
// これまでは「Person の friends[0].name というパス」を
// 型安全に値として持ち回る方法がなかった
ライブラリ側から見ると、「呼び出し側が指定したプロパティを、こちらで読み書きする」ような汎用 API(いわゆる Key-Value Coding に相当するもの)を、型安全に設計する手段が存在しませんでした。プロパティの識別子を渡してもらうには文字列に頼るしかなく、そうすると上に挙げた String の制約をすべて引き受けることになります。
クロスプラットフォームで、型安全で、サブスクリプトや Optional チェーンも扱え、値型にも参照型にも使える「プロパティへの参照」を第一級の値として表現できる仕組みが求められていました。
02 どのように解決されるのか
プロパティやサブスクリプトへの「呼び出さない参照」を表す KeyPath 型のファミリーと、それを作るための \ 始まりのキーパス式を導入します。キーパスは型情報を保った値として持ち回ることができ、あらゆる型の値に対して [keyPath:] サブスクリプトで読み書きできます。
キーパス式の書き方
キーパス式は \<Type>.<path> の形を取ります。<path> はプロパティアクセス・サブスクリプト・Optional チェーン/強制アンラップを連ねたパスです。型が文脈から推論できる場合は型名を省略して \.<path> と書けます。
class Person {
var name: String
var friends: [Person] = []
var bestFriend: Person? = nil
init(name: String) { self.name = name }
}
var luke = Person(name: "Luke Skywalker")
luke.friends.append(Person(name: "Han Solo"))
// キーパスを値として作る
let firstFriendsName = \Person.friends[0].name
// [keyPath:] サブスクリプトで読み書き
let name = luke[keyPath: firstFriendsName] // "Han Solo"
luke[keyPath: firstFriendsName] = "A Disreputable Smuggler"
// 型が文脈から決まるなら省略形
luke[keyPath: \.friends[0].name]
// サブスクリプトから始めるときも、パスは必ずドットで始める
luke.friends[keyPath: \.[0].name]
luke.friends[keyPath: \[Person].[0].name]
// Optional チェーンもそのまま書ける
let bestFriendsName = \Person.bestFriend?.name
let value = luke[keyPath: bestFriendsName] // Optional<String>
サブスクリプトをパスに含める場合、その引数の型は Hashable である必要があります(キーパス自身の同値性・ハッシュ可能性のため)。
KeyPath 型のファミリー
キーパスは、どこまで型情報を持っているかと、値の書き換えが可能かに応じて階層化されたクラス群として提供されます。
AnyKeyPath: ルート型も値型も消去された最上位の型。実行時に型を照会する API を持ち、合成などの操作は失敗しうるため Optional を返す。PartialKeyPath<Root>: ルート型Rootは分かっているが、値型は分からない。KeyPath<Root, Value>: ルート型・値型の両方が分かっており、読み取りができる。WritableKeyPath<Root, Value>: 値型の値をinoutなRootに対して書き換えられる(値型プロパティの更新用)。ReferenceWritableKeyPath<Root, Value>: 参照型のプロパティなど、Rootがletでも値を書き換えられる。
より具体的な型同士をつなぐほど結果の型も具体的になるよう、appending(path:) による合成メソッドが用意されています。
let nameKeyPath: KeyPath<Person, String> = \Person.name
let friendsKeyPath: KeyPath<Person, [Person]> = \Person.friends
// 実行時の型消去もできる
let anyPath: AnyKeyPath = nameKeyPath
値の取得と更新
すべての型に対して、キーパスを受け取る [keyPath:] サブスクリプトが用意されるかのように振る舞います。
// 読み取り
let n: String = luke[keyPath: \Person.name]
// 書き込み(WritableKeyPath / ReferenceWritableKeyPath の場合)
luke[keyPath: \Person.name] = "Luke"
[keyPath:] は self を inout として扱うので、値型のネストしたプロパティを更新する際も無駄なコピーが発生しません。既存のサブスクリプトと衝突しないよう、ラベル付き引数として設計されています。
従来の #keyPath との関係
文字列を返す #keyPath(...) 式はそのまま残ります。今回の \ で始まる式はまったく別の構文で、結果として KeyPath オブジェクトを生成します。\ は Swift においてすでに文字列補間やエスケープなど「一瞬だけ別のモードに切り替える」印として使われているため、通常の式と区別しつつキーパスを作るのに適しています。
パフォーマンス
キーパス経由のアクセスは、対応するプロパティを直接呼ぶのに近いコストで動作するよう設計されています。文字列ベースの #keyPath のようにランタイムでパスをパースするコストはかかりません。
今後の展望
このプロポーザルはまず基本機能の導入に絞られていますが、今後の拡張余地として次のような方向性が言及されています(いずれも将来の別プロポーザルで検討される speculative なものです)。
- キーパスの分解(どのプロパティを指しているかを取り出す)
- カスタムサブクラスのサポート
- 文字列からの動的なキーパス生成
Codableへの適合- ルート値にあらかじめ束ねた「バウンド・キーパス」の提供