键路径

Swift 4 中添加了键路径 (key paths) 的概念。键路径是一个指向属性的未调用的引用,它和对某个方法的未使用的引用很类似。键路径也为 Swift 的类型系统补全了缺失的很大一块拼图。在之前,你无法像引用方法 (比如 String.uppercased) 那样引用一个类型的属性 (比如 String.count)。和 Objective-C 及 Foundation 中的键路径相比,除了拥有共同的名字以外,Swift 中的键路径有很大不同。

键路径表达式以一个反斜杠开头,比如 \String.count。反斜杠是为了将键路径和同名的类型属性区分开来 (假如 String 也有一个 static count 属性的话,String.count 返回的就会是这个属性值了)。类型推断对键路径也是有效的,在上下文中如果编译器可以推断出类型的话,你可以将类型名省略,只留下 .count

虽然键路径和函数类型引用有紧密的关系,但很不幸的是 Swift 中它们的语法是不同的。不过,Swift 团队表达过在未来的版本中,为函数类型引用也引入这种反斜杠语法的兴趣。

正如其名,键路径描述了一个值从根开始的层级路径。举例来说,在下面的 Person 和 Address 类型中,\Person.address.street 表达了一个人的街道住址的键路径:

struct Address {
    var street: String
    var city: String
    var zipCode: Int
}
struct Person {
    let name: String
    var address: Address
}
let streetKeyPath = \Person.address.street
// Swift.WritableKeyPath<Person, Swift.String>
let nameKeyPath = \Person.name // Swift.KeyPath<Person, Swift.String>

键路径可以由任意的存储和计算属性组合而成,其中还可以包括可选链操作符。编译器会自动为所有类型生成 [keyPath:] 的下标方法。你通过这个方法来“调用”某个键路径。对键路径的调用,也就是在某个实例上访问由键路径所描述的属性。所以,"Hello"[keyPath: .count] 等效于 "Hello".count。或者在我们现在的例子中:

let simpsonResidence = Address(street: "1094 Evergreen Terrace",
city: "Springfield", zipCode: 97475)
var lisa = Person(name: "Lisa Simpson", address: simpsonResidence)
lisa[keyPath: nameKeyPath] // Lisa Simpson

如果你检查上面两个键路径变量的类型,你会注意到 nameKeyPath 的类型是 KeyPath<Person, String>。这个键路径是强类型的,它表示该键路径可以作用于 Person,并返回一个 String。而 streetKeyPath 是一个 WritableKeyPath,这是因为构成这个键路径的所有属性都是可变的,所以这个可写键路径本身允许其中的值发生变化:

lisa[keyPath: streetKeyPath] = "742 Evergreen Terrace”

对 nameKeyPath 做同样的操作会造成错误,因为它背后的属性不是可变的。

键路径不仅可以描述属性,我们也可以用它们来描述下标操作。例如,可以用下面这样的语法提取数组里第二个 person 对象的 name 属性:=

var bart = Person(name: "Bart Simpson", address: simpsonResidence)
let people = [lisa, bart]
people[keyPath: \.[1].name] // Bart Simpson

同样的语法也可用于在键路径中包含字典下标。

可以通过函数建模的键路径

一个将基础类型 Root 映射为类型为 Value 的属性的键路径,和一个具有 (Root) -> Value 类型的函数十分类似。而对于可写的键路径来说,则对应着一对获取和设置值的函数。相对于这样的函数,键路径除了在语法上更简洁外,最大的优势在于它们是值。你可以测试键路径是否相等,也可以将它们用作字典的键 (因为它们遵守 Hashable)。另外,不像函数,键路径是不包含状态的,所以它也不会捕获可变的状态。如果使用普通的函数的话,这些都是无法做到的

键路径还可以通过将一个键路径附加到另一个键路径的方式来生成。这么做时,类型必须要匹配;如果你有一个从 A 到 B 的键路径,那么你要附加的键路径的根类型必须为 B,得到的将会是一个从 A 到 C 的键路径,其中 C 是所附加的键路径的值的类型:

// KeyPath<Person, String> + KeyPath<String, Int> = KeyPath<Person, Int>
let nameCountKeyPath = nameKeyPath.appending(path: \.count)
// Swift.KeyPath<Person, Swift.Int>

让我们用键路径代替函数来重写本章前面提到的排序描述符。我们之前通过一个 (Root) -> Value 函数来定义了 sortDescriptor:

typealias SortDescriptor<Root> = (Root, Root) -> Bool
func sortDescriptor<Root, Value>(key: @escaping (Root) -> Value)
-> SortDescriptor<Root> where Value: Comparable {
    return { key($0) < key($1) }
}
// 使用
let streetSD: SortDescriptor<Person> = sortDescriptor { $0.address.street }

我们可以通过键路径来添加一种排序描述符的构建方式。通过键路径的下标来访问值:

func sortDescriptor<Root, Value>(key: KeyPath<Root, Value>) -> SortDescriptor<Root> where Value: Comparable {
    return { $0[keyPath: key] < $1[keyPath: key] }
}
// 使用
let streetSDKeyPath: SortDescriptor<Person> =
sortDescriptor(key: \.address.street)

不过虽然拥有一个接受键路径的 sortDescriptor 很有用,不过它并没有给我们和函数一样的灵活度。键路径依赖 Value 满足 Comparable 这一前提。只使用键路径的话,我们无法很轻易地使用另一种排序断言 (比如,使用忽略大小写的按区域设置的比较)。

可写键路径 - swift 通过键路径实现双向绑定

可写键路径比较特殊:你可以用它来读取或者写入一个值。因此,它和一对函数等效:一个负责获取属性值 ((Root) ->Value),另一个负责设置属性值 ((inout Root, Value) -> Void)。相比于只读键路径,可写键路径要复杂的多。首先,它将很多代码包括在了简洁的语法中。将 streetKeyPath 与等效的 getter 和 setter 对进行比较:

let streetKeyPath = \Person.address.street
let getStreet: (Person) -> String = { person in
    return person.address.street
}
let setStreet: (inout Person, String) -> () = { person, newValue in
    person.address.street = newValue
}
// 使用 Setter
lisa[keyPath: streetKeyPath] = "1234 Evergreen Terrace"
setStreet(&lisa, "1234 Evergreen Terrace")

可写键路径对于数据绑定特别有用,你想将两个属性互相绑定:属性 1 发生变化的时候,属性 2 的值会自动更新,反之亦然。比如,你可以将一个 model.name 属性绑定到 textField.text 上。API 的用户需要指定如何读写 model.name 和 textField.text,而键路径所解决的正是这个问题

我们还需要对属性的变化进行观察。在 Cocoa 中,我们使用键值观察机制来达到这个目的,不过这样的方式只能作用于类上面,并局限在 Apple 平台上。Foundation 提供了一种新的类型安全的 KVO 的 API,它们可以将 Objective-C 世界中基于字符串的键路径隐藏起来。NSObject 上的 observe(_:options:changeHandler:) 方法将会对一个 (Swift 的强类型) 键路径进行观察,并在属性发生变化的时候调用 handler。不要忘记你还需要将要观察的属性标记为 @objc dynamic,否则 KVO 将不会工作

我们的目标是在两个 NSObject 之间实现双向绑定,不过让我们从单向绑定开始:每当 self 上的被观察值变更,我们就同时变更另一个对象。键路径可以让我们的代码更加泛用,而不必拘泥于某个特定的属性:调用者只需要指定两个对象以及两个键路径,这个方法就可以处理其他的事情:

extension NSObjectProtocol where Self: NSObject {
    func observe<A, Other>(_ keyPath: KeyPath<Self, A>,
    writeTo other: Other, _ otherKeyPath: ReferenceWritableKeyPath<Other, A>)
    -> NSKeyValueObservation
    where A: Equatable, Other: NSObjectProtocol
    {
        return observe(keyPath, options: .new) { _, change in
            guard let newValue = change.newValue,
                other[keyPath: otherKeyPath] != newValue else {
                return // prevent endless feedback loop
            }
            other[keyPath: otherKeyPath] = newValue 
        }
    }
}

这段代码中有不少值得一说的东西。
首先,我们对所有 NSObject 的子类定义了这个方法,通过扩展 NSObjectProtocol 而不是 NSObject,我们可以使用 Self。
ReferenceWritableKeyPath 和 WritableKeyPath 很相似,不过它可以让我们对 (other 这样的) 使用 let 声明的引用变量进行写操作 (我们会在随后讨论细节)。
为了避免不必要的写操作,我们只在值发生改变时才对 other 进行写入。
返回值 NSKeyValueObservation 是一个 token,调用者使用这个 token 来控制观察的生命周期:属性观察会在这个 token 对象被销毁或者调用者调用了它的 invalidate 方法时停止。

有了 observe(:writeTo:😃,双向绑定也就很直接了:我们对两个对象都调用 observe,它们将返回两个观察 token:

extension NSObjectProtocol where Self: NSObject {
    func bind<A, Other>(_ keyPath: ReferenceWritableKeyPath<Self,A>,
    to other: Other,
    _ otherKeyPath: ReferenceWritableKeyPath<Other,A>)
    -> (NSKeyValueObservation, NSKeyValueObservation)
    where A: Equatable, Other: NSObject
    {
        let one = observe(keyPath, writeTo: other, otherKeyPath)
        let two = other.observe(otherKeyPath, writeTo: self, keyPath)
        return (one,two)
    }
}

现在,我们可构建两个不同的对象,Person 和 TextField,然后将 name 和 text 属性互相绑定:

final class Person: NSObject {
    @objc dynamic var name: String = ""
}
class TextField: NSObject {
    @objc dynamic var text: String = ""
}
let person = Person()
let textField = TextField()
let observation = person.bind(\.name, to: textField, \.text)
person.name = "John"
textField.text // John
textField.text = "Sarah"
person.name // Sarah

如果你很熟细函数式编程,可写键路径可能会让你想起函数式编程中的透镜 (lenses)。它们紧密相关:通过一个 WritableKeypath<Root, Value>,你可以创建一个 Lens<Root, Value>。透镜的概念在像是 Haskell 或 PureScript 这样的纯函数式语言中非常有用,不过因为 Swift 内建支持可变性,所以在 Swift 里它没有那么有用。

键路径层级

键路径有五种不同的类型,每种类型都在前一种上添加了更加精确的描述及功能:

  • AnyKeyPath 和 (Any) -> Any? 类型的函数相似。

  • PartialKeyPath 和 (Source) -> Any? 函数相似。

  • KeyPath<Source, Target> 和 (Source) -> Target 函数相似。

  • WritableKeyPath<Source, Target> 和 (Source) -> Target 与 (inout Source, Target) -> () 这一对函数相似。

  • ReferenceWritableKeyPath<Source, Target> 和 (Source) -> Target 与 (Source, Target) -> () 这一对函数相似。第二个函数可以用 Target 来更新 Source 的值,且要求 Source 是一个引用类型。对 WritableKeyPath 和 ReferenceWritableKeyPath 进行区分是必要的,前一个类型的 setter 要求它的参数是 inout 的。

这几种键路径的层级结构现在是通过类的继承来实现的。理想状态下,这些特性应该由协议来完成,但是 Swift 的泛型系统还缺少一些使之可行的特性。这种类的层级有意地保持了对外不可见,这样以便于未来在更新时,现有的代码也不会被破坏。

我们前面也提到,键路径不同于函数,它们是满足 Hashable 的,而且在将来它们很有可能还会满足 Codable。这也是为什么我们强调 AnyKeyPath 和 (Any) -> Any 只是相似的原因。虽然我们能够将一个键路径转换为对应的函数,但是我们无法做相反的操作

对比 Objective-C 的键路径

在 Foundation 和 Objective-C 中,键路径是通过字符串来建模的 (我们会将它们称为 Foundation 键路径,以区别 Swift 的键路径)。由于 Foundation 键路径是字符串,它们不含有任何的类型信息。从这个角度看,它们和 AnyKeyPath 类似。如果一个 Foundation 键路径拼写错误、没有正确生成、或者它的类型不匹配的话,程序可能会崩溃。(Swift 中的 #keyPath 指令对拼写错误的问题进行了一些改善,编译器可以检查特定名字所对应的属性是否存在。) Swift 的 KeyPath、WritableKeypath 和 ReferenceWritableKeyPath 从构造开始就是正确的:它们不可能被拼错,也不会有类型错误

很多 Cocoa API 在原本用函数会更好的地方使用了 (Foundation) 键路径。这其中有一部分是历史原因:匿名函数 (或者在 Objective-C 中所谓的 block) 其实是相对最近才添加的特性,而键路径的存在则要长久得多。在 block 被引入 Objective-C 之前,想要在不用键路径 "address.street" 的条件下,表达类似 { $0.address.street } 这样的函数是很困难的。

参考书籍
Swift进阶