Case paths 将 key path 层级结构扩展到枚举 case。
Swift 为每个结构体和类属性赋予了 key path。
struct User {
let id: Int
var name: String
}
\User.id // KeyPath<User, Int>
\User.name // WritableKeyPath<User, String>
这是编译器生成的代码,可以用于抽象地放大结构的某一部分,检查甚至更改它,同时将这些更改传播到结构的整体。它们是许多由 动态成员查找 驱动的现代 Swift API 的幕后功臣,例如 SwiftUI bindings,但也更直接地出现,例如在 SwiftUI environment 和 unsafe mutable pointers 中。
不幸的是,枚举 case 不存在这样的结构。
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
\UserAction.home // 🛑
🛑 key path 无法引用静态成员 'home'
因此,无法编写通用代码来放大和修改枚举中特定 case 的数据。
到目前为止,case paths 最常见的用途是在分发给其他开发人员的库中作为工具。Case paths 被用于 Composable Architecture、SwiftUI Navigation、Parsing 和许多其他库中。
如果您维护一个库,并期望您的用户使用枚举来建模他们的域,那么向他们提供 case path 工具可以帮助他们将域分解为更小的单元。例如,考虑 SwiftUI 提供的 Binding
类型
struct Binding<Value> {
let get: () -> Value
let set: (Value) -> Void
}
通过 动态成员查找 的强大功能,我们能够支持点链语法,用于派生值成员的新 bindings
@dynamicMemberLookup
struct Binding<Value> {
…
subscript<Member>(dynamicMember keyPath: WritableKeyPath<Value, Member>) -> Binding<Member> {
Binding<Member>(
get: { self.get()[keyPath: keyPath] },
set: {
var value = self.get()
value[keyPath: keyPath] = $0
self.set(value)
}
)
}
}
如果您有一个用户的 binding,您可以简单地将 .name
附加到该 binding,以立即派生到用户名称的 binding
let user: Binding<User> = // ...
let name: Binding<String> = user.name
但是,枚举没有这样的便利
enum Destination {
case home(HomeState)
case settings(SettingsState)
}
let destination: Binding<Destination> = // ...
destination.home // 🛑
destination.settings // 🛑
无法使用简单的点链语法来派生到目标 binding 的 home
case 的 binding。
但是,如果 SwiftUI 使用了 CasePaths 库,那么他们可以非常轻松地提供此工具。他们可以提供一个额外的 dynamicMember
subscript,它使用 CaseKeyPath
,这是一个用于选出枚举 case 的 key path,并使用它来派生到枚举特定 case 的 binding
import CasePaths
extension Binding {
public subscript<Case>(dynamicMember keyPath: CaseKeyPath<Value, Case>) -> Binding<Case>?
where Value: CasePathable {
Binding<Case>(
unwrapping: Binding<Case?>(
get: { self.wrappedValue[case: keyPath] },
set: { newValue, transaction in
guard let newValue else { return }
self.transaction(transaction).wrappedValue[case: keyPath] = newValue
}
)
)
}
}
通过这样的定义,可以annotation使用 @CasePathable
宏来注释他们的枚举,然后立即使用点链来从枚举的 binding 中派生 case 的 binding
@CasePathable
enum Destination {
case home(HomeState)
case settings(SettingsState)
}
let destination: Binding<Destination> = // ...
destination.home // Binding<HomeState>?
destination.settings // Binding<SettingsState>?
这是一个库如何为其用户提供工具以拥抱枚举,而又不会失去结构体的符合人体工程学的示例。
虽然库工具是使用此库的最大用例,但在第一方代码中也可以使用 case paths。该库通过引入我们称之为 “case paths” 的概念来弥合结构体和枚举之间的差距:枚举 case 的 key paths。
可以使用 @CasePathable
宏为枚举启用 Case paths
@CasePathable
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
它们可以从 “case-pathable” 枚举中通过其 Cases
命名空间生成
\UserAction.Cases.home // CaseKeyPath<UserAction, HomeAction>
\UserAction.Cases.settings // CaseKeyPath<UserAction, SettingsAction>
与任何 key path 一样,当枚举类型可以被推断时,它们可以被缩写
\.home as CaseKeyPath<UserAction, HomeAction>
\.settings as CaseKeyPath<UserAction, SettingsAction>
正如 key paths 打包了获取和设置根结构上的值的功能一样,case paths 打包了可选地提取和修改根枚举的关联值的功能。
user[keyPath: \User.name] = "Blob"
user[keyPath: \.name] // "Blob"
userAction[case: \UserAction.Cases.home] = .onAppear
userAction[case: \.home] // Optional(HomeAction.onAppear)
如果 case 不匹配,则提取可能会失败并返回 nil
userAction[case: \.settings] // nil
Case paths 具有额外的能力,即可以将关联值嵌入到全新的根中
let userActionToHome = \UserAction.Cases.home
userActionToHome(.onAppear) // UserAction.home(.onAppear)
可以使用 case-pathable 枚举上的 is
方法测试 Cases
userAction.is(\.home) // true
userAction.is(\.settings) // false
let actions: [UserAction] = […]
let homeActionsCount = actions.count(where: { $0.is(\.home) })
并且可以使用 modify
方法就地修改其关联值
var result = Result<String, Error>.success("Blob")
result.modify(\.success) {
$0 += ", Jr."
}
result // Result.success("Blob, Jr.")
Case paths 与 key paths 一样,可以组合。您可以使用熟悉的点链深入到枚举的 case 的枚举中
\HighScore.user.name
// WritableKeyPath<HighScore, String>
\AppAction.Cases.user.home
// CaseKeyPath<AppAction, HomeAction>
或者您可以将它们附加在一起
let highScoreToUser = \HighScore.user
let userToName = \User.name
let highScoreToUserName = highScoreToUser.append(path: userToName)
// WritableKeyPath<HighScore, String>
let appActionToUser = \AppAction.Cases.user
let userActionToHome = \UserAction.Cases.home
let appActionToHome = appActionToUser.append(path: userActionToHome)
// CaseKeyPath<AppAction, HomeAction>
Case paths 也像 key paths 一样,提供一个 identity 路径,这对于与使用 key paths 和 case paths 的 API 交互很有用,但您希望使用整个结构。
\User.self // WritableKeyPath<User, User>
\UserAction.Cases.self // CaseKeyPath<UserAction, UserAction>
自 Swift 5.2 以来,key path 表达式可以直接传递给诸如 map
之类的方法。使用动态成员查找注释的 Case-pathable 枚举为每个 case 启用属性访问和 key path 表达式。
@CasePathable
@dynamicMemberLookup
enum UserAction {
case home(HomeAction)
case settings(SettingsAction)
}
let userAction: UserAction = .home(.onAppear)
userAction.home // Optional(HomeAction.onAppear)
userAction.settings // nil
let userActions: [UserAction] = [.home(.onAppear), .settings(.purchaseButtonTapped)]
userActions.compactMap(\.home) // [HomeAction.onAppear]
由于 case key paths 在底层是真正的 key paths,因此它们可以用于相同的应用程序,例如动态成员查找。例如,我们可以通过使用下标扩展 SwiftUI 的 binding 类型到枚举 case
extension Binding {
subscript<Member>(
dynamicMember keyPath: CaseKeyPath<Value, Member>
) -> Binding<Member>? {
guard let member = self.wrappedValue[case: keyPath]
else { return nil }
return Binding<Member>(
get: { self.wrappedValue[case: keyPath] ?? member },
set: { self.wrappedValue[case: keyPath] = $0 }
)
}
}
@CasePathable enum ItemStatus {
case inStock(quantity: Int)
case outOfStock(isOnBackOrder: Bool)
}
struct ItemStatusView: View {
@Binding var status: ItemStatus
var body: some View {
switch self.status {
case .inStock:
self.$status.inStock.map { $quantity in
Section {
Stepper("Quantity: \(quantity)", value: $quantity)
Button("Mark as sold out") {
self.item.status = .outOfStock(isOnBackOrder: false)
}
} header: {
Text("In stock")
}
}
case .outOfStock:
self.$status.outOfStock.map { $isOnBackOrder in
Section {
Toggle("Is on back order?", isOn: $isOnBackOrder)
Button("Is back in stock!") {
self.item.status = .inStock(quantity: 1)
}
} header: {
Text("Out of stock")
}
}
}
}
}
注意 以上是在我们的 SwiftUINavigation 库中提供的下标的简化版本。
Key paths 是为每个属性创建的,即使是计算属性也是如此,那么 case paths 的等价物是什么呢?嗯,“计算” case paths 可以通过扩展 case-pathable 枚举的 AllCasePaths
类型以及实现自定义 case 的 embed
和 extract
功能的属性来创建
@CasePathable
enum Authentication {
case authenticated(accessToken: String)
case unauthenticated
}
extension Authentication.AllCasePaths {
var encrypted: AnyCasePath<Authentication, String> {
AnyCasePath(
embed: { decryptedToken in
.authenticated(token: encrypt(decryptedToken))
},
extract: { authentication in
guard
case let .authenticated(encryptedToken) = authentication,
let decryptedToken = decrypt(token)
else { return nil }
return decryptedToken
}
)
}
}
\Authentication.Cases.encrypted
// CaseKeyPath<Authentication, String>
SwiftUINavigation 使用 case paths 来增强 SwiftUI bindings,包括导航和枚举。
The Composable Architecture 允许您将大型功能分解为更小的功能,这些功能可以使用户 key paths 和 case paths 粘合在一起。
Parsing 使用 case paths 将非结构化数据转换为枚举,然后再转换回来。
您是否有使用 case paths 的项目想要分享?请打开一个 PR 并附上它的链接!
如果您想讨论此库或对如何使用它来解决特定问题有疑问,您可以在许多地方与 Point-Free 爱好者进行讨论
CasePaths API 的最新文档在此处可用。
特别感谢 Giuseppe Lanza,他的 EnumKit 启发了这个库使用的原始的、基于反射的 case paths 解决方案。
这些概念(以及更多)在 Point-Free 中进行了深入探讨,这是一个由 Brandon Williams 和 Stephen Celis 主持的视频系列,探索函数式编程和 Swift。
此库的设计在以下 Point-Free 剧集中进行了探讨
所有模块均在 MIT 许可证下发布。有关详细信息,请参阅 LICENSE。