Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

先日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:

shiz
iOSエンジニア 受託開発会社でモバイルアプリからインフラの構築まで行う雑多なエンジニアでしたが、 Swiftへの関心が特に強く、ご縁をいただきiOSエンジニアへと転身。日々勉強中。 開発言語経験: Swift, kotlin, Javascript(Angular, Node.js), C#, PHP, Java
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした