21
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

iOSAdvent Calendar 2020

Day 9

SwiftのKeyPathと戯れる

Last updated at Posted at 2020-12-08
1 / 24

KeyPathとは?


KeyPath とは?

Swift4で追加された、プロパティに動的にアクセスするための記法

Use key-path expressions to access properties dynamically.1


Swift3.xまでの #keyPath

class Hoge: NSObject { // 🤔
    @objc var foo: String // 🤔
    let bar: String

    init(foo: String, bar: String) {
        self.foo = foo
        self.bar = bar
    }
}

let foo = hoge.value(forKeyPath: #keyPath(Hoge.foo))
print(type(of: foo)) // Optional<Any>

print(#keyPath(hoge.bar)) // error: argument of '#keyPath' refers to non-'@objc' property 'bar'

Obj-C時代からあるKVOでのtypoを防ぐ程度の役割しかない

  • NSObject にしか使えない
  • @objc 宣言されたプロパティのみ
  • 返却値はAny! (型情報が消える😫)
  • 解析が遅い、Darwinでしか使えない2といった問題点も

Swift4からの KeyPath

  • Swiftの強力な型システムに基づいた機構
    • structにもenumにも使える
    • 型情報が維持される
    • Swiftのstdlibに含まれているのでプラットフォームに依存しない

NSObject@objc宣言もいらない

class Hoge {
    var foo: String
    let bar: String
}
let hogeFoo: ReferenceWritableKeyPath<Hoge, String> = \.foo
let hogeBar = \Hoge.bar // KeyPath<Hoge, String>

let hoge = Hoge(foo: "foo", bar: "bar")
print(hoge.foo) // foo
hoge[keyPath: hogeFoo] = "hoge-foo"
print(hoge.foo) // hoge-foo
hoge[keyPath: hogeBar] = "bar" // error: cannot assign through subscript: 'hogeBar' is a read-only key path

structにも使える

struct Fuga {
    var foo: String
    let bar: String
}
let fugaFoo: WritableKeyPath<Fuga, String> = \.foo
let fugaBar = \Fuga.bar // KeyPath<Fuga, String>

var fuga = Fuga(foo: "foo", bar: "bar")
print(fuga.foo) // foo
fuga[keyPath: fugaFoo] = "fuga-foo"
print(fuga.foo) // fuga-foo
fuga[keyPath: fugaBar] = "bar" // error: cannot assign through subscript: 'fugaBar' is a read-only key path

KeyPath関連クラス

_AppendKeyPath
└ AnyKeyPath
  └ PartialKeyPath<Root>
    └ KeyPath<Root, Value>
      └ WritableKeyPath<Root, Value> // Value is variable
        └ ReferenceWritableKeyPath<Root, Value> // Root is class

小ネタ


TupleもRootになれる

let hogeFuga = (hoge, fuga)

let hogeKeyPath = \(Hoge, Fuga).0 // no label
let fugaKeyPath = \(hoge: Hoge, fuga: Fuga).fuga // with labels

hogeFuga[keyPath: hogeKeyPath] // hoge
hogeFuga[keyPath: fugaKeyPath] // fuga

プロパティのネストも可能

struct FooBar {
    var hoge: Hoge
    var fuga: Fuga
}

let fooBarHogeFoo = (\FooBar.hoge).appending(path: \Hoge.foo)
\FooBar.hoge.foo == fooBarHogeFoo // true

print(fooBar.hoge.foo) // hoge-foo
fooBar[keyPath: fooBarHogeFoo] = "foo-bar-hoge-foo"
print(fooBar.hoge.foo) // foo-bar-hoge-foo
fooBar[keyPath: \.hoge.foo] = "foo"
print(fooBar.hoge.foo) // foo

let fooBarFugaFoo = (\FooBar.fuga).appending(path: \Fuga.foo)
fooBar[keyPath: fooBarFugaFoo] = "foo-bar-fuga-foo" // error: cannot assign through subscript: 'fooBar' is a 'let' constant

KeyPathの実用例


プロパティでソートする3

public extension Sequence {
    func sorted<T: Comparable>(by keyPath: KeyPath<Element, T>) -> [Element] {
        sorted { $0[keyPath: keyPath] < $1[keyPath: keyPath] }
    }

    func sorted<T: Comparable>(by keyPath: KeyPath<Element, T?>) -> [Element] {
        sorted {
            guard let l = $0[keyPath: keyPath],
                let r = $1[keyPath: keyPath] else { return false }
            return l < r
        }
    }

    func min<T: Comparable>(by keyPath: KeyPath<Element, T>) -> Element? {
        self.min { $0[keyPath: keyPath] < $1[keyPath: keyPath] }
    }
}

struct Person {
    var id: Int
    var name: String
    var age: Int
}

extension Person: CustomStringConvertible {
    var description: String { name }
}

let people: [Person] = [
    .init(id: 3, name: "Bob", age: 28),
    .init(id: 1, name: "Emma", age: 40),
    .init(id: 4, name: "Amelia", age: 18),
    .init(id: 2, name: "George", age: 22),
]

// Before
people.sorted(by: { $0.id < $1.id })      // ["Emma", "George", "Bob", "Amelia"]
people.sorted(by: { $0.name < $1.name })  // ["Amelia", "Bob", "Emma", "George"]

// After
people.sorted(by: \.id)   // ["Emma", "George", "Bob", "Amelia"]
people.sorted(by: \.name) // ["Amelia", "Bob", "Emma", "George"]

同じAnchor同士へのConstraintを貼る

public extension UIView {
    func equal<Axis, Anchor: NSLayoutAnchor<Axis>>(_ anchor: KeyPath<UIView, Anchor>,
                                                   to target: UIView) -> NSLayoutConstraint {
        self[keyPath: anchor].constraint(equalTo: target[keyPath: anchor])
    }

    func equal<Axis, Anchor: NSLayoutAnchor<Axis>>(_ anchor: KeyPath<UIView, Anchor>,
                                                   to target: UIView,
                                                   constant: CGFloat) -> NSLayoutConstraint {
        self[keyPath: anchor].constraint(equalTo: target[keyPath: anchor], constant: constant)
    }
}

// Before
NSLayoutConstraint.activate([
    view.topAnchor.constraint(equalTo: target.topAnchor),
    view.leadingAnchor.constraint(equalTo: target.leadingAnchor),
    view.trailingAnchor.constraint(equalTo: target.trailingAnchor),
    view.bottomAnchor.constraint(equalTo: target.bottomAnchor)
])

// After
NSLayoutConstraint.activate([
    view.equal(\.topAnchor, to: target),
    view.equal(\.bottomAnchor, to: target),
    view.equal(\.leadingAnchor, to: target),
    view.equal(\.trailingAnchor, to: target)
])


更に関数チックに書いた例
https://www.objc.io/blog/2018/10/30/auto-layout-with-key-paths/


Key Path Expressions as Functions


Key Path Expressions as Functions

  • Swift5.2で追加(SE-0249)
  • KeyPath 表現をクロージャを引数とする関数に渡すことができる
    • コレクション操作などが簡潔に書けるように
  • Xcode12.xならコード補完が効く

struct Department {
    var name: String
    var member: [Person]
}

extension Department {
    var boss: Person {
        member.min(by: \.id)!
    }
}

let departments: [Department] = [
    .init(name: "General Affairs Department",
          member: [
            .init(id: 3, name: "Bob", age: 28),
            .init(id: 1, name: "Emma", age: 40)
    ]),
    .init(name: "Development Department",
          member: [
            .init(id: 4, name: "Amelia", age: 18),
            .init(id: 2, name: "George", age: 22)
    ])
]

Before

departments.flatMap { $0.member }
    .map { $0.name } // ["Bob", "Emma", "Amelia", "George"]
departments.map { $0.boss }
    .map { $0.name } // ["Emma", "George"]
departments.map { $0.boss.name }

After

departments.flatMap(\.member)
    .map(\.name) // ["Bob", "Emma", "Amelia", "George"]
departments.flatMap(\.member.name) // Value of type '[Person]' has no member 'name'
departments.map(\.boss)
    .map(\.name) // ["Emma", "George"]
departments.map(\.boss.name) // ["Emma", "George"]

Performance4

image.png

extension Person {
    var canDrink: Bool { age >= 20 }
}

注意

変数/定数化したKeyPathを渡すことはできない(2020/12/9現在)

let bossKeyPath = \Department.boss
let nameKeyPath = \Person.name
let bossNameKeyPath = bossKeyPath.appending(path: nameKeyPath)

departments.map(bossKeyPath) // Cannot convert value of type 'KeyPath<Department, Person>' to expected argument type '(Department) throws -> T'
    .map(nameKeyPath) // Cannot convert value of type 'WritableKeyPath<Person, String>' to expected argument type '(T) throws -> T'

departments.map(bossNameKeyPath) // Cannot convert value of type 'KeyPath<Department, String>' to expected argument type '(Department) throws -> T'

最後に

  • KeyPathによってSwiftコードがより簡潔に書きやすくなった
  • RxSwift / Combine のストリーム変換にも使えるのでより宣言的に書ける
  • Genericsと併用することで処理の汎用化も大幅に進みそう
  • Sample in Xcode Playground
  1. https://developer.apple.com/documentation/swift/swift_standard_library/key-path_expressions

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

  3. https://qiita.com/417_72ki/items/de4a7e49c32d39d3bfb6

  4. measure関数

21
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?