iOS
Swift

【Swift】型を使うという意味を考える(Swift4で導入されたKeyPathを通して)

先日Phantom Typeについての記事を書き、

他にも型を活用するということで色々検討していた際に

Swift4で導入されたKeyPathの存在をふと思い浮かべました。

そんな中で

try! Swift2019に参加した際に

Benedikt TerhechteさんがKeyPathについて紹介していたこともあり

今回改めて自分の中で勉強し直してみようと思い、記録として内容をまとめてみました。

https://speakerdeck.com/terhechte/introduction-to-swift-keypaths



あと個人的な理由として

今回のtry! Swiftでは役割上スピーカーの方の横にいることが多く

Benediktさんの発表はランチ前で

発表後にランチの順番を待ちながら色々話をすることができたので深く印象に残っていたという点もあります。

(「お腹空いたねー。弁当何食べる?」とかほんとたわいのない内容でしたがw)


KeyPathとは

ドキュメントによると


Use key-path expressions to access properties dynamically

「動的にプロパティにアクセスするために使用する」ための表現と書かれています。

https://developer.apple.com/documentation/swift/swift_standard_library/key-path_expressions


なぜ必要だったの?

プロポーサルには下記のようなことが書いてあります。


Stringよりもより扱いやすくすることができる

既存の#keyPath()は安全にプロパティにアクセスできるけど、以下の制限がある


  • 型情報が失われる(Anyになる)

  • 解析が不必要に遅い

  • NSObjectsを継承しないと使えない

  • Darwinプラットフォームでしか使用できない

https://github.com/apple/swift-evolution/blob/master/proposals/0161-key-paths.md#we-can-do-better-than-string


特徴を使用する

メソッドはlet x = foo.bar()の代わりにlet x = foo.barを使うことで

メソッドを実行しないでメソッドの参照を獲得できるが、プロパティやsubscriptではできない

具体的なクラスのプロパティへの間接的な参照を作ってしまうと

プロパティのメタデータや将来的に追加される動作を外部にさらしてしまう

https://github.com/apple/swift-evolution/blob/master/proposals/0161-key-paths.md#usemention-distinctions


より多くの表現を可能に

現状では不可能なコレクションやその他の添字アクセス可能なプロパティにKeyPathを使ってアクセスできるようにしたい

https://github.com/apple/swift-evolution/blob/master/proposals/0161-key-paths.md#more-expressive-keypaths

では、次に具体的なKeyPathの中身を見ていきたいと思います。


5つのクラス

実装を見てみると

以下の5つのクラスが階層になって構成されています。


AnyKeyPath

PartialKeyPath<Root>

KeyPath<Root, Value>

WritableKeyPath<Root, Value>

ReferenceWritableKeyPath<Root, Value>

https://github.com/apple/swift/blob/master/stdlib/public/core/KeyPath.swift

各クラスの適用範囲と何ができるのか(値の読み取りは全てできるので変更が可能かどうか)

を簡単にまとめると下記になります。

クラス
適用範囲
値の変更(set)が可能

AnyKeyPath
全て
×

PartialKeyPath
全て
×

KeyPath
全て
×

WritableKeyPath
Value Semantics(値型)

ReferenceWritableKeyPath
Reference Semantics(参照型)

それでは

個々のクラスについて見ていきます。

今回、例として下記のクラスを使っています。

※ varを使用しているのは意図的にmutableであることを示したいからです。


struct User {
let id: String
let name: String
let age: Int
}

var user = User(id: "1", name: "hoge", age: 20)


検証環境

Xcode10.2 beta3


AnyKeyPath

https://developer.apple.com/documentation/swift/anykeypath

これはType Eraserと呼ばれるものの一種で

RootとValueの両方ともコンパイルの時点では型は特定されず

実行時に具体的な型に解決されます。


let kp: AnyKeyPath = \User.id
print(type(of: user[keyPath: anyKeyPath])) // Optional<Any>

下記のプロパティを参照すると具体的な型はわかるようです。


print(type(of: anyKeyPath).rootType) // User
print(type(of: anyKeyPath).valueType) // String

また、ReadOnlyなため

値の変更しようとするとエラーになります。


// Cannot assign through subscript: 'user' is immutable
user[keyPath: anyKeyPath] = "3"

エラーメッセージはなんか不思議ですね:sweat_smile:


PartialKeyPath

https://developer.apple.com/documentation/swift/partialkeypath

これもType Eraserと呼ばれるものの一種で

Rootはコンパイルの時点で型が特定していますが、

Valueは実行時に具体的な型に解決されます。

どういう時に活用できるかというと

ある特定の型のKeyPathの配列を保持したい場合などに活用できます。


let partialKeyPaths: [PartialKeyPath<User>] = [
\User.id, \User.age
]

print(partialKeyPaths) // Swift.KeyPath<User, Swift.String>, Swift.KeyPath<User, Swift.Int>

また、PartialKeyPathを使用した際に下記のような結果となります。


let partialkeyPath: PartialKeyPath<User> = \User.id

print(type(of: user[keyPath: partialkeyPath])) // String
print(user[keyPath: partialkeyPath] is String) // true

ドキュメント上だと

any resulting value type.

なのでAnyが返ってくると思ったのですが、型はStringになっています:thinking:

もしご存知の方がいらっしゃいましたら教えてください:bow_tone1:

また、ReadOnlyなため

値の変更しようとするとエラーになります。


// Cannot assign through subscript: 'user' is immutable
user[keyPath: partialkeyPath] = "3"


KeyPath

https://developer.apple.com/documentation/swift/keypath

RootもValueもコンパイルの時点で型が特定されます。


let keyPath: KeyPath<User, String> = \User.id
print(type(of: user[keyPath: keyPath])) // String

上記のkeyPath変数の型を確認すると


print(type(of: keyPath)) // WritableKeyPath<User, String>

と出力されます。

しかし、ReadOnlyなため

値の変更しようとすると下記のようにエラーになります。


// Cannot assign through subscript: 'keyPath' is a read-only key path
user[keyPath: keyPath] = "3"

型がWritableKeyPathと出力される理由がいまいちわかっていません:sweat_smile:


WritableKeyPath

https://developer.apple.com/documentation/swift/writablekeypath

RootもValueもコンパイルの時点で型が特定され

値の変更も可能です。

WritableKeyPathはValue Semantics(値型)に使用できます。

※ Value Semanticsかどうかは下記の部分でReference Prefixがないことをチェックしています。


_internalInvariant(!buffer.hasReferencePrefix,
"WritableKeyPath should not have a reference prefix")

https://github.com/apple/swift/blob/master/stdlib/public/core/KeyPath.swift#L300

下記のように値の変更ができます。


let writableKeyPath = \User.id

print(type(of: writableKeyPath)) // WritableKeyPath<User, String>

user[keyPath: writableKeyPath] = "3" // OK

上記の例からもわかるように変数に代入する際に

structの場合だと明示的に型を指定しない場合はWritableKeyPathになります。

注意点としては

値を書き換えようとしている変数がletで宣言されている場合は変更できません。


let user2 = User(id: "2", name: "hoge2", age: 22)
let writableKeyPath = \User.id

// Cannot assign through subscript: 'user2' is a 'let' constant
user2[keyPath: writableKeyPath] = "3"


ReferenceWritableKeyPath

https://developer.apple.com/documentation/swift/referencewritablekeypath

RootもValueもコンパイルの時点で型が特定され

値の変更も可能です。

ReferenceWritableKeyPathはReference Semantics(参照型)に使用できます。


class UserClass {
var id: String
var name: String
let age: Int

init(id: String, name: String, age: Int) {
self.id = id
self.name = name
self.age = age
}
}

var classUser = UserClass(id: "3", name: "piyod", age: 23)

let referenceWritableKeyPath: ReferenceWritableKeyPath<UserClass, String> = \UserClass.id
print(type(of: referenceWritableKeyPath)) // ReferenceWritableKeyPath<UserClass, String>

classUser[keyPath: referenceWritableKeyPath] = "5"

ちなみにstructに無理やり適用しようとしてもキャストできません。


let writableKeyPath: ReferenceWritableKeyPath<User, String> = \User.id as! ReferenceWritableKeyPath<User, String>

Could not cast value of type 'Swift.WritableKeyPath<User, Swift.String>' to 'Swift.ReferenceWritableKeyPath<User, Swift.String>'

また、classをWritableKeyPathに適用しても

ReferenceWritableKeyPathになります。


let referenceWritableKeyPath: WritableKeyPath<UserClass, String> = \UserClass.id

print(type(of: referenceWritableKeyPath)) // ReferenceWritableKeyPath<User, String>

以上の5つのクラスを使用して

クラスのプロパティへアクセスします。


ネストされたクラスのプロパティへも簡単にアクセスが可能

KeyPathは_AppendKeyPathというprotocolに適合しており、

これのappendingメソッドを使用することで

ドット記法を用いて内部のプロパティのKeyPathへアクセスできます。

https://developer.apple.com/documentation/swift/appendkeypath


struct Owner {
let name: String
var pet: Pet
}

struct Pet {
var name: String
let age: Int
}

let petNameKeyPath = (\Owner.pet).appending(path: \Pet.name)
print(type(of: petNameKeyPath)) // WritableKeyPath<Owner, String>
print(type(of: petNameKeyPath).rootType) // Owner
print(type(of: petNameKeyPath).valueType) // String

var owner1 = Owner(name: "owner", pet: Pet(name: "ペット", age: 30))
print(owner1[keyPath: petNameKeyPath]) // ペット

owner1[keyPath: petNameKeyPath] = "Pet"

print(owner1[keyPath: petNameKeyPath]) // Pet


具体的な活用例を見てみる

KeyPathを活用することで


  • 重複の排除を行いコードを短くして可読性を向上させる

  • 特定のクラスに依存せずに値の取得・設定ができる

といったメリットがあります。

事例としてはたくさん存在しますが

その中でも特徴的だと思われるものをいくつか紹介したいと思います。


ソート条件を指定する


extension Sequence {
func sorted<T: Comparable>(by keyPath: KeyPath<Element, T>) -> [Element] {
return sorted { a, b in
return a[keyPath: keyPath] < b[keyPath: keyPath]
}
}
}

let users = [
User(id: "1", name: "a", age: 20),
User(id: "2", name: "d", age: 30),
User(id: "3", name: "c", age: 18),
User(id: "4", name: "b", age: 22)
]

let nameSortedUsers = users.sorted(by: \.name)
print(nameSortedUsers)

/*
[
User(id: "1", name: "a", age: 20),
User(id: "4", name: "b", age: 22),
User(id: "3", name: "c", age: 18),
User(id: "2", name: "d", age: 30)
]
*/

let ageSortedUsers = users.sorted(by: \.age)
print(ageSortedUsers)

/*
[
User(id: "3", name: "c", age: 18),
User(id: "1", name: "a", age: 20),
User(id: "4", name: "b", age: 22),
User(id: "2", name: "d", age: 30)
]
*/

これと同じ要領でフィルター条件などにも使えます。

KeyPathKitの作者のVincent Pradeillesさんがここら辺を講演の中で詳しく解説されています。

https://www.youtube.com/watch?v=20k3000Pn4s

KeyPathKit

https://github.com/vincent-pradeilles/KeyPathKit


AutoLayoutの同じAnchorへの制約を設定する

コードでAutoLayoutを設定する場合に


let view1 = UIView()
let view2 = UIView()

view1.topAnchor.constraint(equalTo: view2.topAnchor)

と書きますが、

topAnchorを2回書くのがちょっと冗長な気がします。

これをKeyPathを使うと下記のように書くことができるようになります。


import UIKit

typealias Constraint = (UIView, UIView) -> NSLayoutConstraint

func equal<L, Axis>(_ to: KeyPath<UIView, L>) -> Constraint where L: NSLayoutAnchor<Axis> {
return { view1, view2 in
view1[keyPath: to].constraint(equalTo: view2[keyPath: to])
}
}

let v1 = UIView()
let v2 = UIView()

let constraint = equal(\.topAnchor)(view1, view2)

https://www.objc.io/blog/2018/10/30/auto-layout-with-key-paths/


共通の項目に個々のクラスのプロパティ値を設定する

Benedikt Terhechteさんの発表では

アプリ内の設定画面を構築する際に異なるモデルに対して

共通的に処理できるようにしていました。

同じような使い方としてUITableViewCellに対して

値を設定するという活用法もあります。


struct CellConfigurator<Model> {
let titleKeyPath: KeyPath<Model, String>
let subtitleKeyPath: KeyPath<Model, String>
let imageKeyPath: KeyPath<Model, UIImage?>

func configure(_ cell: UITableViewCell, for model: Model) {
cell.textLabel?.text = model[keyPath: titleKeyPath]
cell.detailTextLabel?.text = model[keyPath: subtitleKeyPath]
cell.imageView?.image = model[keyPath: imageKeyPath]
}
}

https://www.swiftbysundell.com/posts/the-power-of-key-paths-in-swift

個々のモデルに依存せず、

どれがセルのどのViewに当てはめられるのかをKeyPathを通して伝えることができるようになります。


2019/3/29 追記 Key Paths Expressions as Functions

ちょうど

SE-0249: Key Path Expressions as Functions

がAcceptedになったので追記します。

https://forums.swift.org/t/accepted-se-0249-key-paths-expressions-as-functions/22287

これによって

mapやfilterなどにもKeyPathが活用できるので

下記のような感じでより簡潔な記法が可能になるようです。


struct User {
var id: String
var name: String
var isActive: Bool
}

users.map(\.name)
users.filter(\.isActive)


まとめ

KeyPathの基礎的な部分の見直しからどうやって使われているのかを少し見てみました。

改めてKeyPathは

型安全な面と動的な面の良さを兼ね備えた存在だなと感じました。

他にも活用例はまだまだたくさんありますし

さらに気づいていない活用法はもっとあると思うので

まず今回見直した基礎を踏まえた上で

どうKeyPathを活用できるのか

なぜこの状況でKeyPathを使うのか

こういったことを考えながら検証していきたいと思います。

ご指摘などございましたら教えていただけますと幸いです:bow_tone1: