从基础到进阶:Swift 中的 KeyPath 完全指南

发表于

为您每周带来有关 Swift 和 SwiftUI 的精选资讯!

在 Swift 的世界里,KeyPath 是一个强大而又常被低估的特性。许多开发者在日常编程中不经意间使用它,却未能充分认识到它的潜力和重要性。本文旨在深入剖析 KeyPath 的功能特性,揭示其在 Swift 编程中的独特魅力,帮助你将它转化为开发过程中的得力助手。

什么是 KeyPath

KeyPath 是 Swift 4 引入的一种强大特性,用于引用( 表示 )类型中的属性或下标。

它具备以下特征:

  • 与具体实例无关KeyPath 描述了从某种类型到其属性或下标的访问路径,独立于任何具体的对象实例。它可以被视为一种静态引用,精确定位类型中特定属性或下标的位置。这种抽象可以通过类比来理解:比如”我的车的左前轮”是基于特定实例的描述,而 KeyPath 更像是”汽车的左前轮”——一个基于类型的、普遍适用的描述。这种设计使得 KeyPath 在泛型编程和元编程中特别有用,因为它允许我们在不知道具体实例的情况下操作类型的结构。
  • 仅限描述属性KeyPath 仅能用于描述类型中的属性或下标,不能用于引用类型中的方法。
  • 线程安全:尽管 KeyPath 本身未标注为 Sendable,但它被设计为不可变的,因此可以安全地在线程之间传递。然而,使用 KeyPath 访问的数据本身的线程安全性仍需要单独考虑。
  • 编译时类型检查KeyPath 提供了编译时的类型检查,确保对属性的访问是类型安全的。这样可以避免运行时类型错误。
  • 元编程的组成部分KeyPath 是 Swift 元编程的重要组成部分。它允许开发者通过类型安全的方式动态访问属性,实现了代码的高度灵活性和通用性。
  • 符合 HashableEquatable 协议KeyPath 遵循 HashableEquatable 协议,这使得它们可以作为字典的键或存储在集合中,拓展了其使用场景。
  • 丰富的变体KeyPath 实际上是一个类型家族的总称,包括 KeyPathWritableKeyPathReferenceWritableKeyPath 等。虽然这些变体本质上都描述了属性的访问路径,但它们各自适用于不同的场景。
  • 组合性KeyPath 具有强大的组合能力,允许开发者将多个 KeyPath 串联在一起。这种特性使得我们可以轻松地表达和访问深层嵌套的属性。

基本用法

声明

Swift 并没有为 KeyPath 提供公开的构造方法,开发者需要通过字面量语法来进行声明:

Swift
struct People {
  var name: String = "fat"
  var age: Int = 100
  var addresses: [String] = ["world"]
}

// 描述了访问 People 类型中 name 属性的路径
let namePath = \People.name

// 描述了访问 People 类型中 addresses 属性第一个元素的路径
let firstAddressPath = \People.addresses[0]

KeyPath 的声明语法是在 类型.属性 之前添加反斜杠 \

通过 KeyPath 读取值

Swift 为每个类型都自动生成了一个 subscript[keyPath:] 下标方法,允许我们通过 KeyPath 访问特定实例中的属性值:

Swift
let people1 = People()
print(people1[keyPath: firstAddressPath]) // "world"

var people2 = People()
people2.name = "bob"
print(people2[keyPath: namePath]) // "bob"

通过 KeyPath 设置值

与读取值相似,设置值也需要通过 subscript[keyPath:] 来完成:

Swift
var people = People() // 使用 var 来声明值类型

people[keyPath: namePath] = "bob"
print(people[keyPath: \.name]) // "bob"

设置值时有一些要求和限制,会在后续详细讨论。

将 KeyPath 作为参数

KeyPath 的另一个重要特性是它可以作为参数传递,使得我们能够在不知道具体属性名的情况下操作实例的属性:

Swift
struct People {
  var name: String = "fat"
  var age: Int = 100
  var addresses: [String] = ["world"]

  // 接收一个 KeyPath,其 Root 为 People, Value 为 String
  func getInfo(keyPath: KeyPath<Self, String>) -> String {
    self[keyPath: keyPath]
  }
}

print(people.getInfo(keyPath: \.name)) // "fat"

KeyPath 的家族成员

在深入 KeyPath 的细节之前,我们首先需要了解,KeyPath 通常并非特指单一类型,而是一个包含了五种不同类型的家族。

家族成员

KeyPath 家族的类型层次如下:

Shell
- AnyKeyPath
    - PartialKeyPath<Root>
        - KeyPath<Root, Value>
            - WritableKeyPath<Root, Value>
                - ReferenceWritableKeyPath<Root, Value>

1. AnyKeyPath

  • KeyPath 家族中所有类型的基类
  • 不指定 RootValue 类型。
  • 只读访问,不允许写操作。
  • 最大的特点是不使用任何泛型,因此它能够泛化所有 KeyPath 类型。

2. PartialKeyPath<Root>

  • 指定 Root 类型,但不指定 Value 类型。
  • 只读访问,不允许写操作。
  • 使用一个泛型Root),可用于部分特化的 KeyPath

3. KeyPath<Root, Value>

  • 同时指定 RootValue 类型。
  • 只读访问,不允许写操作。
  • 使用两个泛型,提供了 RootValue 的具体映射。

4. WritableKeyPath<Root, Value>

  • 允许对属性进行读取和写入操作。
  • 适用于值类型和引用类型。
  • KeyPath 的可写版本。

5. ReferenceWritableKeyPath<Root, Value>

  • 专门用于引用类型的属性。
  • 允许对属性进行读取和写入操作。
  • 提供额外的性能优化,特别是对 let 属性的支持。

从以上结构可以看出,KeyPath 家族的传承具有以下特点:

  • 泛型的特化程度逐渐增加,从无泛型到使用两个泛型。
  • 访问权限逐渐增强,从只读到可读写。

声明与转换

虽然 KeyPath 家族有多种类型,但它们的声明方式非常统一,都通过字面量语法进行:

Swift
let namePath = \People.name // 推断为 WritableKeyPath<People, String>

在上面的示例中,为什么 namePath 的类型被推断为 WritableKeyPath<People, String> 呢?

这是因为 Swift 编译器会根据类型的种类(值类型或引用类型)以及属性的可读写状态(只读或可读写),自动推断出 KeyPath 家族中最为特化的类型。这种推断确保 KeyPath 能够精确匹配属性的特性,提供更准确的访问或操作能力。

在上述示例中,People 是一个值类型,name 是一个可读写的属性,因此推断为 WritableKeyPath<People, String>。其中,People 对应泛型中的 RootString 对应泛型中的 Value

其他自动推断的例子

Swift
struct People {
  let name: String
}
let peopleNamePath = \People.name // 推断为 KeyPath<People, String>,因为 name 是只读的

class Item {
  var firstName: String
  var lastName: String
  var name: String {
    get { firstName }
    set { firstName = newValue }
  }
}

// 推断为 ReferenceWritableKeyPath<Item, String>,因为 Item 是引用类型,firstName 是可写的
let firstNamePath = \Item.firstName

// 推断为 ReferenceWritableKeyPath<Item, String>,因为 name 有 setter,可写
let itemNamePath = \Item.name

// 推断为 KeyPath<Item, Int>,因为 count 是 String 的只读计算属性
let firstNameCountPath = \Item.firstName.count

因此,如果我们想明确声明一个特定类型的 KeyPath,可以在声明时显式指定类型:

Swift
// 显式声明为 WritableKeyPath
let firstNamePath: WritableKeyPath<Item, String> = \Item.firstName // 声明为比 ReferenceWritableKeyPath 高一级的类型
// 显式声明为 KeyPath
let itemNamePath: KeyPath<Item, String> = \Item.name // 声明为比 ReferenceWritableKeyPath 级别更高的类型
// 直接声明为 AnyKeyPath,避免使用泛型
let firstNameCountAnyPath: AnyKeyPath = \Item.firstName.count

// ❌,声明失败,因为 count 不可写
let firstNameCountAnyPath: WritableKeyPath<Item, Int> = \Item.firstName.count 

转换

KeyPath 家族中,可以将更特化的类型转换为更泛化的类型。例如:

Swift
let firstNameAnyPath: AnyKeyPath = firstNamePath
let itemNameAnyPath: PartialKeyPath<Item> = itemNamePath

这种转换方式与 Swift 中父类和子类的转换机制不同,只要类型和属性特性满足条件,我们可以在 KeyPath 的层次结构中自由转换类型。比如,将 AnyKeyPath 转换为更特化的类型:

Swift
let firstNameCountAnyPath: AnyKeyPath = \Item.firstName.count

// 成功转换为 KeyPath
let firstNameCountPath1 = firstNameCountAnyPath as! KeyPath<Item, Int>

// 转换失败,因为 count 是不可写的
let firstNameCountPath2 = firstNameCountAnyPath as! WritableKeyPath<Item, Int>

这种转换之所以可能,是因为即便是最泛化的 AnyKeyPath 类型,内部也保留了特化类型的所有信息。

AnyKeyPath,非一般的类型抹除工具

看到 AnyKeyPath,许多开发者可能会联想到诸如 AnyHashableAnyPublisherAnyView 等类型擦除工具。虽然 AnyKeyPath 确实具有类型擦除的特性(尤其是泛型擦除),但本质上它不仅仅是一个类型擦除工具。AnyKeyPath 是一个包含全面信息的基类,其子类如 PartialKeyPathKeyPath 则通过添加泛型约束,进一步提供了额外的类型安全和编译时检查。这种设计巧妙结合了运行时的灵活性和编译时的安全性,使得键路径系统既强大又安全。

与其他具备类型擦除功能的工具类似,AnyKeyPath 在某些场景下特别有用,尤其是在需要避免泛型约束时,比如数组或字典的声明:

Swift
let keys:[AnyKeyPath] = [\Item.name, \People.age]

而只带有一个泛型的 PartialKeyPath 则适用于已明确 Root 类型的场景。一方面,它对可以使用的键路径进行了约束;另一方面,由于上下文中提供了 Root 类型,开发者也能更方便地录入键路径:

Swift
let keys:[PartialKeyPath<People>] = [\.name, \.age]

组合 KeyPath

KeyPath 可以轻松表达深层嵌套的属性。例如:

Swift
struct A {
  var b:B
}

struct B {
  var name:String
}

let namePath = \A.b.name // WritableKeyPath<A, String>
let nameCountPath = \A.b.name.count // KeyPath<A, Int>

对于一个包含两个泛型约束的 KeyPath 类型来说,无论路径的深度如何,RootValue 的规则始终保持一致:

  • Root:访问路径的起点类型。
  • Value:访问路径终点属性的类型。

因此,\A.b.name.count 的推断类型为 KeyPath<A, Int>,因为 count 属性的类型是 Int

在很多情况下,我们不需要直接声明嵌套层次较深的路径,可以通过 appending(path:) 将两个 KeyPath 组合成一个新路径:

Swift
// WritableKeyPath<A, B>
let bPath = \A.b
// KeyPath<B, Int>
let bNameCountPath = \B.name.count
// KeyPath<A, Int>
let nameCountPath1 = bPath.appending(path: bNameCountPath)

组合 KeyPath 的基本要求是,追加的 KeyPathRoot 类型必须与被追加的 KeyPathValue 类型一致。组合后的 KeyPath 泛型为:原 KeyPathRoot 和追加的 KeyPathValue

当使用 AnyKeyPathPartialKeyPath 时,与其他 KeyPath 类型组合将返回一个可选的 KeyPath,如果类型不匹配,运行时将返回 nil

Swift
// AnyKeyPath
let bPath: AnyKeyPath = \A.b
// KeyPath<B, Int>
let bNameCountPath = \B.name.count
// AnyKeyPath?
let nameCountPath1 = bPath.appending(path: bNameCountPath)

// Root 为 Item
let itemNamePath = \Item.name
// nil
let combinePath = bPath.appending(path: itemNamePath)

需要注意的是,并非所有不同类型的 KeyPath 都可以成功组合。例如,尝试使用 KeyPath.appending(path: AnyKeyPath) 进行组合时会失败,尽管 AnyKeyPath 实际上包含了组合所需的全部信息。在实际使用中,开发者应进行额外测试来确保类型的兼容性。

WritableKeyPath vs ReferenceWritableKeyPath

WritableKeyPathReferenceWritableKeyPath 都可以用于表示一个可写的属性路径。两者的主要区别如下:

  1. 适用类型

    WritableKeyPath 适用于值类型引用类型,而 ReferenceWritableKeyPath 只能用于引用类型

Swift
struct A {
  var name: String = ""
}

// WritableKeyPath<A, String>
let aNamePath = \A.name

class B {
  var name: String = ""
}

// WritableKeyPath<B, String>
let bNamePath: WritableKeyPath<B, String> = \B.name
  1. 实例声明要求

    使用 WritableKeyPath 时,实例必须由 var 声明才能修改属性;而使用 ReferenceWritableKeyPath 时,即使实例由 let 声明,属性依然可以被修改。

Swift
// WritableKeyPath<A, String>
let aNamePath = \A.name
let a = A()
a[keyPath: aNamePath] = "fat" // 编译错误,因 a 是 let 声明的

// ReferenceWritableKeyPath<B, String>
let bNamePath: ReferenceWritableKeyPath<B, String> = \B.name
let b = B()
b[keyPath: bNamePath] = "bob" // 正确执行,尽管 b 是 let 声明的
  1. 专为引用类型设计

    ReferenceWritableKeyPath 是专门为引用类型属性设计的。它是 WritableKeyPath 的一个特殊子类,为引用类型提供了额外的保证和潜在的优化。

Swift
func strLength<T>(obj: T, strKeyPath: ReferenceWritableKeyPath<T, String>) -> Int {
  obj[keyPath: strKeyPath].count
}

strLength(obj: b, strKeyPath: \.name) // 正常运行,B 是引用类型
strLength(obj: a, strKeyPath: \.name) // 编译错误,A 是值类型

Hashable 和 Equatable

虽然许多类型都遵循 HashableEquatable 协议,但 KeyPath 家族的成员在实现这些协议时有其独特之处。

由于 KeyPath 家族中的不同层级类型(如 KeyPathAnyKeyPath)在内部实际上共享相同的信息,这使得跨类型的比较成为可能:

Swift
let nameKeyPath: KeyPath<People, String> = \.name
let nameAnyKeyPath: AnyKeyPath = \People.name

// 比较 KeyPath<People, String> 和 AnyKeyPath
print(nameKeyPath == nameAnyKeyPath) // true

同样,它们的 hashValue 计算也基于相同的内部信息:

Swift
print(nameKeyPath.hashValue == nameAnyKeyPath.hashValue) // true

这一特性使得开发者在进行 KeyPath 比较或将 KeyPath 用作字典键时更加便捷。无论使用哪种类型的 KeyPath,只要描述的是同一路径,都能作为相同的键:

Swift
var keysCount: [AnyKeyPath: Int] = [:]

keysCount[nameKeyPath, default: 0] = keysCount[nameKeyPath, default: 0] + 1 // KeyPath<People, String>
keysCount[nameKeyPath, default: 0] = keysCount[nameAnyKeyPath, default: 0] + 1 // AnyKeyPath
print(keysCount[\People.name]) // Optional(2)

\.self

在声明 KeyPath 时,当希望 Value 表示类型本身时,可以使用 .self

Swift
var texts = ["b", "o", "b"]
// WritableKeyPath<[String], [String]>
let array = \[String].self
texts[keyPath: array] = ["f", "a", "t"]
print(texts) // ["f", "a", "t"]

var numbers = [3, 5, 6]
// WritableKeyPath<[Int], Int>
let firstElement = \[Int].self[0] // `self` 可以被省略,写成 `\[Int].[0]`
numbers[keyPath: firstElement] = 10
print(numbers) // [10, 5, 6]

不少开发者在 SwiftUI 视图中会使用类似的代码:

Swift
struct DemoView: View {
    let numbers = [3, 5, 6, 8, 5, 3]
    var body: some View {
        VStack {
            ForEach(numbers, id: \.self) { number in
                Text(number, format: .number)
            }
        }
    }
}

此时,ForEach 的构造方法如下:

Swift
public init<Data: RandomAccessCollection, ID: Hashable>(
    _ data: Data,
    id: KeyPath<Data.Element, ID>,
    @ViewBuilder content: @escaping (Data.Element) -> Content
)

通过 id: \.self,我们将数组中的每个元素作为 ForEach 的唯一标识符(ID)。但如果数组中有重复的元素,ForEach 中的视图标识符会发生冲突,这个问题在添加或删除元素时尤为明显。因此,最佳做法是让元素遵循 Identifiable 协议,而不是直接使用元素本身作为 ID,以避免潜在的冲突。

性能

在之前的版本中,我认为通过 KeyPath 访问属性与直接访问属性的性能相当。然而,文章发布后,Rick van Voorden 与我通过邮件分享了他对 KeyPath 性能的研究。根据他的测试,KeyPath 的性能目前落后于直接属性访问(尤其是在引用类型中)。他认为,这可能是由于当前实现中缺少必要的优化所致。

他指出了一些需要关注的源码部分:

  1. KeyPath.swift 第334-341行
  2. KeyPath.swift 第405-414行
  3. KeyPath.swift 第2389-2405行

或许在未来的 Swift 版本中,KeyPath 在访问引用类型和结构体时能够提供相同的性能表现。

Rick van Voorden 是 Swift-CowBox 宏的开发者。该宏简化了为自定义类型实现写时复制(Copy On Write,COW)的过程,减少了重复编写代码的负担。

KeyPath 重要吗?

越来越多的开发者逐渐意识到了 KeyPath 的重要性和便利性,如今它已经被广泛应用于系统框架和第三方框架中。

例如,过去使用闭包的高阶函数,现在可以通过更优雅的 KeyPath 来实现:

Swift
let peoples: [People] = []
// 传统方式
let names1 = peoples.map { $0.name }
// 基于 KeyPath 的方式
let names2 = peoples.map(\.name)

在新的谓词宏中,KeyPath 也扮演了重要角色。由于它与实例无关的特性,可以很好地描述谓词条件:

Swift
let predicate = #Predicate<Settings> {
    $0.name == "abc"
}

// 宏展开后
Foundation.Predicate<Settings>({
    PredicateExpressions.build_Equal(
        lhs: PredicateExpressions.build_KeyPath(
            root: PredicateExpressions.build_Arg($0),
            keyPath: \.name
        ),
        rhs: PredicateExpressions.build_Arg("abc")
    )
})

在 Observation 框架中,KeyPath 还用于触发属性变化的通知:

Swift
internal nonisolated func withMutation<Member, T>(keyPath: KeyPath<Settings, Member>, _ mutation: () throws -> T) rethrows -> T {
  try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}

KeyPath@dynamicMemberLookup 结合使用也是一种常见的应用场景。这种方式既保证了内部数据的封装,又提供了灵活且类型安全的属性访问:

Swift
@dynamicMemberLookup
final class Store<State>: ObservableObject {
    @Published private var state: State
    subscript<T>(dynamicMember keyPath: KeyPath<State, T>) -> T {
        state[keyPath: keyPath]
    }
    ...
}

let userName = store.user // 对应 store.state.user

许多苹果官方框架提供的属性包装器(如 @ObservedObject@StateObject)都体现了这一应用。

KeyPath 使开发者的代码更加优雅、安全,并具备更强的通用性。它是 Swift 语言功能逐步完善和强大的一个重要体现。

总结

KeyPath 是 Swift 语言中的核心特性之一,为我们提供了优雅、类型安全且灵活的方式来引用和操作类型的属性。它不仅在访问嵌套属性、处理泛型数据时提供了强大的能力,还能通过其与实例无关的特性,在编译时进行类型检查,确保代码的安全性与稳定性。

KeyPath 的引入标志着 Swift 语言向类型安全、灵活性和性能优化的进一步发展,它为开发者提供了在大型项目中管理复杂数据结构的工具。在未来,KeyPath 很可能会在更多框架和场景中继续发挥作用,成为 Swift 语言日益成熟和强大的重要体现。

每周一晚,与全球开发者同步,掌握 Swift & SwiftUI 最新动向
可随时退订,干净无 spam