SwiftのKeyPathについて調べた

KeyPathについて調べたので、その内容をまとめて記しておきます。
2020.02.23

概要

大阪オフィスの山田です。先月、パパになりました。 今回はKeyPathについて調べたのでその内容をまとめて記しておきます。

KeyPathとは

swift4にて導入されました。公式ドキュメントによると、KeyPathを使うことで動的にプロパティにアクセスできる、と書かれています。

KeyPathの種類

公式ドキュメントによるとKeyPathには以下の種類があります。 その特性で分類しました。

クラス名 Writable 型の指定
AnyKeyPath No なし
PartialKeyPath No Root
KeyPath No Root, Value
WritableKeyPath Yes(値型) Root, Value
ReferenceWritableKeyPath Yes(参照型) Root, Value

列挙したクラスは上から順番に継承されています。Rootは、対象のプロパティを含むクラスを指し、Valueは対象のプロパティを指します。

動作を見てみる

Read only 例.AnyKeyPath

検証用のstructを定義し、インスタンスを生成します。

struct Cat {
    var name: String
    var age: Int

    func meow() -> String {
        return "meow"
    }
}

var cat = Cat(name: "mi-san", age: 10)

AnyKeyPath変数を宣言します。

let anyKeyPath: AnyKeyPath = \Cat.name

バックスラッシュの後に、Rootとなる型、それとプロパティを指定します。 続けて、この変数をprintで出力して観察してみます。

print("AnyKeyPath: \(anyKeyPath)")    // AnyKeyPath: Swift.WritableKeyPath<__lldb_expr_3.Cat, Swift.String>
print(cat[keyPath: anyKeyPath]!)      // mi-san
print(type(of: anyKeyPath).rootType)  // Cat
print(type(of: anyKeyPath).valueType) // String

anyKeyPath変数をprintすると、AnyKeyPathではなく、何故かWritableKeyPathとログに出力されています。これは原因が不明でした。 rootType、valueTypeで、定義したstructの名前と値の型の名前が取得できます。 実際に、値を変更しようとすると、リードオンリーのためエラーが発生します。

cat[keyPath: anyKeyPath] = "nyaaan"  // Cannot assign through subscript: 'cat' is immutable

型の指定

Rootのみ型を指定するPartialKeyPath

変数宣言時に、Rootとなる型を指定します。これにより、具体的なKeyPathを指定する際にRootとなる型を省略することが可能です。

let partialKeyPath: PartialKeyPath<Cat> = \.name

RootとValueそれぞれ型を指定する 例.KeyPath

変数宣言時に、RootとValueの型を指定します。

let keyPath: KeyPath<Cat, String> = \.name

Valueの型が違うプロパティを指定するとコンパイルエラーが発生します。

let keyPath: KeyPath<Cat, String> = \.age // Key path value type 'Int' cannot be converted to contextual type 'String'

Writable

以下のように指定するか、もしくは型を省略しても指定するKeyPathがstruct型の場合は自動でWritableKeyPathとして解釈してくれる模様です。

let writableKeyPath: WritableKeyPath<Cat, String> = \.name

or

let writableKeyPath = \Cat.name

実際に値を変更して、printしてみると、値が変わっていることがわかります

let writableKeyPath = \Cat.name
print(cat.name)                           // mi-san
print(type(of: writableKeyPath))          // WritableKeyPath<Cat, String>
cat[keyPath: writableKeyPath] = "nyaaan"  // 値を変更する
print(cat.name)                           // nyaaan

ただし、このケースにおけるcat変数はletで宣言されている場合は変更ができません。

// catをletで宣言した場合
cat[keyPath: writableKeyPath] = "nyaaan" // Cannot assign through subscript: 'cat' is a 'let' constant

WritableKeyPathは値型に適用できます。今回、structは値型なのでWritableKeyPathが適用されています。一方でReferenceWritableKeyPathは参照型に使用することが可能です。

以下のようなclassを定義します。

class Dog {
    var name: String
    var age: Int

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

    func bowwow() -> String {
        return "bowwow"
    }
}

定義したクラスでKeyPath変数を作成しprintしてみます。ReferenceWritableKeyPathクラスが自動で適用されています。

var dog = Dog(name: "bi-guru", age: 15)
let referenceWritableKeyPath = \Dog.name
print(type(of: referenceWritableKeyPath))   // ReferenceWritableKeyPath<Dog, String>

ネストしたクラスのプロパティへアクセスする

AnyKeyPathクラスは_AppendKeyPathプロトコルを継承しています。このプロトコルのappendingメソッドを使うことで、ネストしたクラスや構造体のプロパティへのKeyPathを作成することができます。 以下のような構造体を用意します。

struct Shop {
    var id: String
    var name: String
    var address: Address
}

struct Address {
    var postCode: String
    var address: String
}

ShopはAddressをプロパティとして持っています。これらの構造体のプロパティのKeyPathを作成します。

let address = Address(postCode: "123-1234", address: "住所")
let shop = Shop(id: "AX001", name: "店舗名", address: address)

let addressKeyPath = \Shop.address
let postCodeKeyPath = \Address.postCode
let shopPostCodeKeyPath = addressKeyPath.appending(path: postCodeKeyPath)

print(shop[keyPath: addressKeyPath])
print(address[keyPath: postCodeKeyPath])
//print(shop[keyPath: postCodeKeyPath])    // Shop構造体にはpostCodeプロパティが存在しないのでコンパイルエラーとなる
print(shop[keyPath: shopPostCodeKeyPath])  // Shop -> address -> postCodeと辿ることができ、postCodeの値 "123-1234"が出力される

Shop構造体のaddressプロパティと、Address構造体のpostCodeプロパティのKeyPathを作成した後、addressKeyPathのappendingメソッドを使い、postCodeKeyPathと連結しています。 appendingを使用して連結しましたが、\Shop.address.postCodeと、KeyPathを指定することも可能です。

Swift5.2で変更があった部分について

Swift5.2でKeyPathをファンクションのように扱うことが可能となりました。 詳細はこちらに記載されています。Swift Evolution proposal: Key Path Expressions as Functions

let cats = [
    Cat(name: "mi-san", age: 8),
    Cat(name: "kotaro", age: 6),
    Cat(name: "momiji", age: 12),
    Cat(name: "mame", age: 2),
    Cat(name: "chi-", age: 13)
]

print(cats.map { $0.name }) // 今までの書き方
print(cats.map(\.name))  // swift5.2で使えるようになった書き方

mapだけでなくfiltercompactMapも同様の書き方ができるようになっています。

KeyPathの使いみち

いくつか使い方はあるかと思いますが一例を取り上げます。

AutoLayoutの設定に使用する

以下の記事でKeyPathを使ってAutoLayoutの設定をスマートに記述している例が記載されています。 Swift Tip: Auto Layout with Key Paths その方法について、紹介します。 まず、2つのViewの同じタイプの制約をつけるequalヘルパーメソッドを定義します。

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

このヘルパーメソッド自体は、UIViewのNSLayoutAnchorのKeyPathを受け取ります。返却値として、二つのViewを引数として受け取り、レイアウトの制約を返す関数を返却します。

使い方としては以下のようになります。

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

view1とview2のtopAnchorをつけることができます。

関数のシグネチャをわかりやすくするために、(UIView, UIView) -> NSLayoutConstraintをtypealiasで別名をつけるとスマートになります。

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])
    }
}

当然ながら、2つのViewで違うanchorに制約を付けたい場合もあるので以下のように別定義し、constantも指定できるようにします。元の定義は1つの引数を引き受けられるように修正します

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

func equal<L, Axis>(_ to: KeyPath<UIView, L>, constant: CGFloat = 0) -> Constraint where L: NSLayoutAnchor<Axis> {
    return equal(to, to, constant: constant)
}

また、dimension anchorにも設定できるように以下の定義を追加します。

func equal<L>(_ keyPath: KeyPath<UIView, L>, constant: CGFloat) -> Constraint where L: NSLayoutDimension {
    return { view1, _ in
        view1[keyPath: keyPath].constraint(equalToConstant: constant)
    }
}

制約を設定する際に、毎回親Viewの指定をしないですむように、UIViewの拡張としてaddSubviewメソッドを定義します。

extension UIView {
    func addSubview(_ other: UIView, constraint: [Constraint]) {
        other.translatesAutoresizingMaskIntoConstraints = false
        addSubview(other)
        addConstraints(constraint.compactMap { $0(other, self) })
    }
}

ここまでを経て、以下のように使用することが可能となりました。制約がスマートに設定できていると思います。

override func loadView() {
        let view = UIView()
        view.backgroundColor = .white
        self.view = view

        let view1 = UIView()
        view1.backgroundColor = UIColor.red
        view.addSubview(view1, constraint: [
            equal(\.centerXAnchor),
            equal(\.centerYAnchor),
            equal(\.heightAnchor, constant: 200),
            equal(\.widthAnchor, constant: 200)
        ])

        let view2 = UIView()
        view2.backgroundColor = UIColor.blue
        view1.addSubview(view2, constraint: [
            equal(\.centerYAnchor, \.bottomAnchor),
            equal(\.leftAnchor, constant: 10), 
            equal(\.rightAnchor, constant: -10),
            equal(\.heightAnchor, constant: 100)
        ])
    }

その他の利用方法

  • モデルに依存しないで、共通の項目にクラスのプロパティを設定する 上記の方法についてこちらの記事で紹介されていますのでご参照ください。 The power of key paths in Swift

おわり

寝かしつけスキルを上げたい

参考