🧰 CasePaths

CI Slack

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 environmentunsafe mutable pointers 中。

不幸的是,枚举 case 不存在这样的结构。

enum UserAction {
  case home(HomeAction)
  case settings(SettingsAction)
}

\UserAction.home  // 🛑

🛑 key path 无法引用静态成员 'home'

因此,无法编写通用代码来放大和修改枚举中特定 case 的数据。

在库中使用 case paths

到目前为止,case paths 最常见的用途是在分发给其他开发人员的库中作为工具。Case paths 被用于 Composable ArchitectureSwiftUI NavigationParsing 和许多其他库中。

如果您维护一个库,并期望您的用户使用枚举来建模他们的域,那么向他们提供 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 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>

Case paths vs. key paths

提取、嵌入、修改和测试值

正如 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 查找

由于 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 的 embedextract 功能的属性来创建

@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>

案例研究

您是否有使用 case paths 的项目想要分享?请打开一个 PR 并附上它的链接!

社区

如果您想讨论此库或对如何使用它来解决特定问题有疑问,您可以在许多地方与 Point-Free 爱好者进行讨论

文档

CasePaths API 的最新文档在此处可用

致谢

特别感谢 Giuseppe Lanza,他的 EnumKit 启发了这个库使用的原始的、基于反射的 case paths 解决方案。

有兴趣了解更多?

这些概念(以及更多)在 Point-Free 中进行了深入探讨,这是一个由 Brandon WilliamsStephen Celis 主持的视频系列,探索函数式编程和 Swift。

此库的设计在以下 Point-Free 剧集中进行了探讨

video poster image

许可证

所有模块均在 MIT 许可证下发布。有关详细信息,请参阅 LICENSE