Referencing Objective-C key-paths
01 何が問題だったのか
Cocoa や Cocoa Touch の Key-Value Coding(KVC)や Key-Value Observing(KVO)では、監視・参照したいプロパティを キーパス と呼ばれる文字列で指定します。Swift 2 までは、この キーパスを文字列リテラルとしてそのまま書く以外に方法がありませんでした。
class Person: NSObject {
dynamic var firstName: String = ""
dynamic var lastName: String = ""
dynamic var bestFriend: Person?
}
let chris = Person(firstName: "Chris", lastName: "Lattner")
chris.valueForKey("firstName") // => "Chris"
chris.valueForKeyPath("bestFriend.lastName") // => "Groff"
この書き方にはいくつかの問題があります。
キーパス文字列のスペルミスを検出できない
コンパイラは文字列の中身までは検査しないため、存在しないプロパティ名を書いてしまってもコンパイルは通り、実行時に初めて想定外の挙動や例外として現れます。プロパティ名を後からリネームしたり削除したりした場合にも、該当の文字列はそのまま残ってしまい、静かに壊れたコードが生まれがちでした。
リファクタリングに追従しない
キーパスは単なる文字列なので、Xcode のリネーム機能などで Swift のプロパティを一括変更しても、キーパス文字列は書き換え対象になりません。プロパティ名と文字列の対応は開発者が目視で守るしかなく、キーパスを多用するコードほどリファクタリングが危険になるという課題がありました。
同様の問題は Objective-C のセレクタにもありましたが、そちらは SE-0022 で #selector 式が導入されたことで、コンパイラにチェックさせる形で解決されています。キーパスについても、同じ方向性での解決が求められていました。
02 どのように解決されるのか
キーパスを文字列リテラルで書く代わりに、プロパティへの参照からキーパス文字列を組み立てる新しい式 #keyPath を導入します。#keyPath にはキーパスとして表現したい型・プロパティの連なりを Swift のドット記法で渡します。コンパイラはそれぞれのプロパティが実在し、かつ Objective-C ランタイムから参照可能(dynamic など @objc 経由で公開されている)であることを静的に検証したうえで、対応する Objective-C キーパス文字列を生成します。
class Person: NSObject {
dynamic var firstName: String = ""
dynamic var lastName: String = ""
dynamic var friends: [Person] = []
dynamic var bestFriend: Person?
}
let chris = Person(firstName: "Chris", lastName: "Lattner")
#keyPath(Person.firstName) // => "firstName"
#keyPath(Person.bestFriend.lastName) // => "bestFriend.lastName"
#keyPath(Person.friends.firstName) // => "friends.firstName"
chris.valueForKey(#keyPath(Person.firstName)) // => "Chris"
chris.valueForKeyPath(#keyPath(Person.bestFriend.lastName)) // => "Groff"
#keyPath(...) の結果は StaticString や StringLiteralConvertible として扱えるコンパイル時の文字列なので、これまで文字列リテラルを書いていた場所にそのまま差し替えられます。プロパティ名のスペルミスや、存在しないプロパティへの参照はコンパイル時にエラーになり、プロパティをリネームすればキーパス側も追従する形で検出・修正できます。
型名の省略
#keyPath には型名から始まる参照だけでなく、現在のスコープから解決できる値のプロパティを起点にした参照も書けます。たとえばメンバ関数の中では、self のプロパティをそのまま書けば Person.firstName と同じ意味になります。
extension Person {
class func find(name: String) -> [Person] {
return DB.execute(
"SELECT * FROM Person WHERE \(#keyPath(firstName)) LIKE '%s'", name)
}
}
この例では #keyPath(firstName) が #keyPath(Person.firstName) として解釈され、文字列 "firstName" が埋め込まれます。
コレクションを経由するキーパス
キーパスの途中にコレクションを挟むこともできます。ただし、Foundation 側のキーパス処理は要素型を強く型付けしないため、#keyPath が辿れるのは SequenceType(現在の Sequence)に適合する型のみに制限されます。コレクションを経由した後は、要素型のプロパティを続けて指定できます。
let swiftArray = ["Chris", "Joe", "Douglas"]
let nsArray = NSArray(array: swiftArray)
swiftArray.valueForKeyPath(#keyPath(swiftArray.count)) // => 3
swiftArray.valueForKeyPath(#keyPath(swiftArray.uppercased)) // => ["CHRIS", "JOE", "DOUGLAS"]
swiftArray.valueForKeyPath(#keyPath(nsArray.count)) // => 3
swiftArray.valueForKeyPath(#keyPath(nsArray.uppercaseString)) // compiler error
要素型として Swift の値型(String など)を書いた場合、実行時には _ObjectiveCBridgeable 経由で対応する Objective-C 型(NSString など)のメソッドが呼ばれます。
今回のスコープ外
KVC のキーパスには @sum や @avg といった コレクション演算子 を含められますが、これらは valueForKeyPath: 相当の動的機構に依存しており Linux では利用できないうえ、設計上の検討も別途必要なため、今回の #keyPath の対象外です。必要に応じて従来どおり文字列リテラルで書くことになります。