1. Reflection in Swift

让我们从简单的开始,实现我们刚才描述的例子: 我们希望实现这样一个函数:当给定任何struct值时,它将返回一个字符串数组,该数组列出了该值中存储的每个属性名。 这样的函数将具有以下特征:

func allProperties(_ value: Any) -> [String] {
}

我们可以通过构建一个反映我们值的镜子来访问Swift的反射能力(明白吗?)

func allProperties(_ value: Any) -> [String] {
  let mirror = Mirror(reflecting: value)
}

这个镜像值保存有关传入值内部内容的信息。 特别是,有一个子属性:

A collection of Child elements describing the structure of the reflected subject.

这有点含糊不清,但正是这个集合保存了结构体中有关store属性的信息。我们可以映射这个集合,试图从每个子元素中提取一些有用的东西:

func allProperties(_ value: Any) -> [String] {
  let mirror = Mirror(reflecting: value)
  mirror.map { child in }
}

每个子元素都有一个label属性和value属性。label看起来很有前途,但它返回一个可选的字符串,所以我们真的需要一个compactMap,让我们从函数中返回这个值:

func allProperties(_ value: Any) -> [String] {
  return Mirror(reflecting: value).children
    .compactMap { $0.label }
}

然后,如果我们使用它,我们会发现它似乎是有效的:

allProperties(users) // ["id", "isAdmin", "location", "name"]

然而,我们不知道这是否适用于交给该函数的所有结构体。Swift的反射API非常简单,文档也不是很好,所以不清楚这是否可能返回更多信息,比如计算属性。

这就是使用反射的代价。通过在运行时反省我们的values,它给我们带来了很多力量,但也带来了很多不确定性。


2. Reflecting into enums

有了这些警告,让我们看看反射向我们揭示了关于枚举的什么。让我们构造一个Authenticated类型的值:

let auth = Authentication.authenticated(AccessToken(token: "deadbeef"))

像以前一样,我们需要一个镜像来反射值来访问它的属性:

let mirror = Mirror(reflecting: auth)

结果是这个子集合只包含一个值:

mirror.children.count // 1

如果我们打印这个唯一的子元素,我们会看到一些有趣的东西:

dump(mirror.children.first!)
▿ (2 elements)
  ▿ label: Optional("authenticated")
    - some: "authenticated"
  ▿ value: __lldb_expr_7.AccessToken
    - token: "deadbeef"

有趣的是,镜像不仅向我们显示了枚举中保存的数据(包含字符串“deadbeef”的访问令牌),而且还显示了该值所在的case的名称(“authenticated”)。这个信息被保存在一个包含命名组件的元组中,如果我们进入值组件,就会得到访问令牌:

mirror.children.first!.value
//AccessToken

然而,这是一种误导。我们没有一个真实的AccessToken值,镜像没有任何类型信息:

mirror.children.first!.value as AccessToken

🛑 ‘Any’ is not convertible to ‘AccessToken’

这里的值的类型是Any,所以我们能做的最好的是尝试将它转换为AccessToken:

mirror.children.first!.value as? AccessToken

我们已经开始了解如何使用反射实现提取函数。我们在这里看到,仅仅给定值,我们就能够最终从值中获得访问令牌。没有开关,涉及if case lets or guard case let,只有反射镜。

让我们尝试实现一个通用函数,它能够从根枚举值中提取相关的值:

func extract<Root, Value>(from root: Root) -> Value? {
}

它的实现基本上就是我们在上面所做的。我们需要获取根节点的镜像,尝试获取镜像的第一个子节点,取出该子节点的值,然后尝试将其转换为value类型:

func extract<Root, Value>(from root: Root) -> Value? {
  let mirror = Mirror(reflecting: root)
  guard let child = mirror.children.first else { return nil }
  return child.value as? Value
}

如果我们尝试使用这个,我们会看到在类型中有一些歧义:

extract(from: auth)

🛑 Generic parameter ‘Value’ could not be inferred

这是因为我们没有提供任何信息来让它知道我们想从根值中提取什么。我们可以告诉它我们期望编译什么类型的东西:

extract(from: auth) as AccessToken?

令人惊讶的是,这很有效,但当然这并不完全正确。首先,我们告诉它我们想从根中提取什么类型,但我们没有告诉它在什么情况下。这是我们现在忽略的非常重要的信息。举个愚蠢的例子,如果我们有一个枚举,两个关联的值都是整数:

enum Example {
  case foo(Int)
  case bar(Int)
}

如果我们想从Example类型的值中提取一个整型,我们无法说明我们希望从哪种情况中获取整型:

extract(from: Example.foo(2)) as Int?

这个API中没有任何东西允许我们说我们想要bar case下的整数,而不是foo case下的整数。

那么我们如何将这种情况合并到这个提取函数中呢? 记住,镜像给了我们访问case的名称,它在子元组的标签组件中:

mirror.children.first!.label // "authenticated"

所以,我们可以做的一件愚蠢的事情是让extract函数取我们想要提取的case的名称,然后我们可以在函数的实现中使用这些信息:

func extract<Root, Value>(case: String, from root: Root) -> Value? {
  let mirror = Mirror(reflecting: root)
  guard let child = mirror.children.first else { return nil }
  guard `case` == child.label else { return nil }
  return child.value as? Value
}

现在,我们有能力确切地指定我们想从中提取的情况:

extract(case: "foo", from: Example.foo(2)) as Int? // 2
extract(case: "bar", from: Example.foo(2)) as Int? // nil

然而,stringy API并不理想,因为这意味着我们可能会引入一个打字错误,而编译器不会捕捉到它,如果我们稍后重构case names,所有东西都会无声地中断。

我们可以访问一些东西,比如这个字符串的静态版本。enum的case是编译器知道的静态符号,名称中的任何拼写错误都会立即导致一个编译器错误:

Example.foo // (Int) -> Example
Example.bar // (Int) -> Example

所以也许我们可以使用这个静态信息而不是字符串来决定我们计划从哪个case中提取:

func extract<Root, Value>(
  case: (Value) -> Root,
  from root: Root
) -> Value? {
  let mirror = Mirror(reflecting: root)
  guard let child = mirror.children.first else { return nil }
  guard `case` == child.label else { return nil }
  return child.value as? Value
}

现在直接检查case标签不再有效,因为case是一个函数。我们怎样才能确定child.value是否与传入的case函数描述的情况相同?

我们可以用case函数来把child.Value返回到根目录,反射这个新的root,并确定新根是否与当前的子节点相同。听起来很多,但做起来很简单:

func extract<Root, Value>(
  case: @escaping (Value) -> Root,
  from root: Root
) -> Value? {
  let mirror = Mirror(reflecting: root)
  guard let child = mirror.children.first else { return nil }
  guard let value = child.value as? Value else { return nil }

  let newRoot = `case`(value)
  let newMirror = Mirror(reflecting: newRoot)
  guard let newChild = newMirror.children.first else { return nil }

  guard newChild.label == child.label else { return nil }

  return value
}

3. Reflecting case paths

就像这样,我们有一个API,允许我们静态地确定我们试图从enum中提取哪种情况:

extract(case: Authentication.authenticated, from: auth) // AccessToken
extract(case: Authentication.authenticated, from: .unauthenticated) // nil

extract(case: Result<Int, Error>.success, from: .success(42)) // 42

struct MyError: Error {}

extract(case: Result<Int, Error>.failure, from: .failure(MyError())) // MyError

extract(case: Example.foo, from: .foo(2)) // 2
extract(case: Example.bar, from: .foo(2)) // nil

这似乎很有效! 现在,我们可以为特定情况下的枚举值提取相关值。因为我们可以这样做,这意味着我们也可以通过知道嵌入函数是什么来神奇地免费创建case路径。我们甚至可以在CasePath上创建一个特殊的初始化器,它只在给定的嵌入函数中起作用:

extension CasePath {
  init(_ case: @escaping (Value) -> Root) {
    self.embed = `case`
    self.extract = { root in
      extractHelp(case: `case`, from: root)
    }
  }
}

为了避免与实例变量extract混淆,我们还必须重命名我们的extract函数,所以我们选择了extractHelp:

func extractHelp<Root, Value>(
  case: @escaping (Value) -> Root,
  from root: Root
) -> Value? {

现在我们可以非常容易地在运行中创建案例路径:

CasePath(Example.foo)
CasePath(Example.bar)
CasePath(Authentication.authenticated)

这看起来真的很好,我们不仅可以从早些时候的情节删除大量重复的代码,但无论何时我们创建一个新的枚举或遇到一个来自第三方,我们不需要做任何事情来获取每个case的case paths。我们可以不做功就直接推导出它们。


4. Reflection gotchas

然而,有一个巨大的警告。正如我们之前提到的,任何时候处理反射问题都是如履薄冰。首先,Swift的反射文档相当稀疏,其次,通过使用反射,我们超出了编译器的监视范围,因此我们无法静态保证我们正确地编写了提取助手。

事实上,它现在不是很正确。目前的实现不支持一些边缘情况。首先,如果在我们的例子中使用参数标签,提取助手将总是失败:

enum ExampleWithArgumentLabels {
  case foo(value: Int)
}

extractHelp(case: ExampleWithArgumentLabels.foo, from: .foo(value: 2)) // nil

虽然它应该会成功,但却失败了。 这是可能的,事实上很容易解决的,但我们将把它留给观众作为练习。

还有另一种边界情况,即没有关联值的枚举。在这种情况下,我们的extract helper甚至不能编译,因为没有关联值的enum甚至不是函数:

extractHelp(case: Authentication.unauthenticated, from: .unauthenticated)

🛑 Cannot convert value of type ‘Authentication’ to expected argument type ‘(_) -> _’

这是另一个要解决的边界情况,这是完全可能的。

另一个需要注意的注意事项是,只有当您给CasePath一个实际的枚举情况的嵌入函数时,这种反射魔术才会起作用。它并不意味着在任何其他情况下都能神奇地发挥作用。作为一个愚蠢的例子,让我们假设我们试图构建一个case路径到一个地点的国家。

let countryCasePath = CasePath<Location, String>(
  { country in Location(city: "Brooklyn", country: country) }
)

这当然很愚蠢,因为Location是一个结构,所以使用键路径来关注它的部分比case路径更合适。 但是,没有什么可以阻止我们编写这个奇怪的代码。如果我们这样做,case路径就不会像我们期望的那样:

countryCasePath.extract(from: Location(city: "Brooklyn", country: "USA"))
// "Brooklyn"

在这里,我们试图使用国家case路径将国家从一个位置提取出来,但它返回“Brooklyn”。这当然是没有意义的,但我们并不打算为这些情况创建case路径。

但撇开所有这些不谈,我们已经大大改进了创造case路径的人机工程学。我们可以简单地在CasePath初始化式中封装一个enum case构造函数,然后立即得到一个case路径。例如,Result和Optional类型是enum,我们立即获得它们的case路径,不仅在它们的case上,而且在任何通用的情况下:

CasePath(Result<Int, Error>.success)
CasePath(Result<String, Error>.success)
CasePath(Result<String, Error>.failure)
CasePath(Result<String, NSError>.failure)

CasePath(Optional<Int>.some)
CasePath(Optional<String>.some)
CasePath(Optional<[Int]>.some)

这里有大量的枚举,不仅在我们自己的代码中,也在第三方代码中,比如苹果的框架中。Foundation提供了一个名为DispatchTimeInterval的程序,它描述了以秒、毫秒、微秒和纳秒为单位的时间间隔,我们可以免费获得所有这些情况的路径:

CasePath(DispatchTimeInterval.seconds)
CasePath(DispatchTimeInterval.milliseconds)
CasePath(DispatchTimeInterval.microseconds)
CasePath(DispatchTimeInterval.nanoseconds)

甚至连Combine框架也有自己的一些枚举,比如描述publisher完成方式的Completion枚举。我们免费获得case路径:

CasePath(Subscribers.Completion<Error>.failure)

5. Introducing the / operator

这真的很棒,但我们可以让人体工程学更好。让我们把一个关键路径和一个case路径放在一起,这样我们就可以看到语法上的区别:

\User.id
CasePath(DispatchTimeInterval.seconds)

看起来不太对称。如果我们能让case路径的语法看起来更像键路径,那就太好了。如果有可能引入一个前缀操作符,我们可以把它放在enum之前,这样我们就可以消除CasePath干扰? 我们不仅可以这样做,我们甚至可以使用一个正斜杠作为操作符,使它看起来像这样:

\User.id
/DispatchTimeInterval.seconds

那就太棒了。要实现这一点,我们只需要声明一个新的操作符:

prefix operator /

然后用它定义一个前缀函数,简单地在下面创建一个CasePath:

prefix func / <Root, Value>(
  case: @escaping (Value) -> Root
) -> CasePath<Root, Value> {
  return CasePath(`case`)
}

现在完全可以这么做了:

\User.id
/DispatchTimeInterval.seconds

键路径在一个方向上使用斜线,而case路径在另一个方向上使用斜线。毕竟,它们是不同但相等的操作。一枚硬币的两面。如果你愿意的话。

如果我们将这个前缀操作符与复合操作符一起使用,我们可以做更强大的事情,比如构造一个遍历枚举成功的case路径,然后是调度间隔的秒数:

/Result<DispatchTimeInterval, Error>.success .. /DispatchTimeInterval.seconds

但是我们已经引入了另一个操作符,所以我们应该问自己它是否满足我们希望操作符满足的要求。

  • 我们是否重载了具有新含义的现有操作符? /是一个中缀操作符,在Swift中表示除法,但它不作为前缀操作符存在,所以我们不太可能造成/的额外中缀使用所造成的混淆。
  • 前缀/有现有技术吗? 不完全是,但如果我们考虑到它是Swift用来识别关键路径的\的镜像,它的形状似乎非常完美。
  • 它是解决了一个普遍的问题,还是它解决的问题更特定于某个领域? 它可以让我们很容易地,用一个单一的角色,凭空得出case路径。Swift在语言级别为结构体键路径提供了这个功能,所以类似的枚举路径解决方案似乎是合理的。

这是另一个不完全勾选所有方框的运算符。虽然我们喜欢它在关键路径和case路径之间架起了人机工程学的桥梁,但我们知道,并不是每个人都愿意将操作符引入他们的代码库。幸运的是,该操作仍然可以作为CasePath上的初始化器引入,因此您仍然可以从强大的功能中受益,只需要增加一点额外的噪音。


6. What’s the point?

让我们花一点时间来体验case路径的乐趣,这样我们就可以开始熟悉case路径的旅程了。

现在我们已经有了一些枚举,我们一直在摆弄它们。让我们再定义一些,让事情更有趣。假设我们有一个通用的enum来表示某些数据的加载状态:

enum LoadState<A> {
  case loading
  case offline
  case loaded(Result<A, Error>)
}

通过case路径,我们可以很容易地表达这样的想法:在这个enum的加载案例中,我们想要关注结果的成功案例:

/LoadState<Int>.loaded .. /Result.success
//CasePath<LoadState<Int>, Int>

所以,如果我们有一个装载状态的数组,我们可以很容易地提取出所有成功的值,通过使用这个case路径。考虑以下的状态数组:

let states1: [LoadState<Int>] = [
  .loaded(.success(2)),
  .loaded(.failure(NSError(domain: "", code: 1, userInfo: [:]))),
  .loaded(.success(3)),
  .loading,
  .loaded(.success(4)),
  .offline,
]

它包含一些加载中的值,一些已加载的值,依次有一些成功的值和一些失败的值。如果我们想在每次成功的情况下轻松地提取整数,我们可以简单地这样做:

states1
  .compactMap(^(/LoadState.loaded .. /Result.success)) // [2, 3, 4]

这很神奇,但让我们更进一步。假设我们有一个身份验证值的加载状态数组:

let states2: [LoadState<Authentication>] = [
  .loading,
  .loaded(.success(.authenticated(AccessToken(token: "deadbeef")))),
  .loaded(.failure(NSError(domain: "", code: 1, userInfo: [:]))),
  .loaded(.success(.authenticated(AccessToken(token: "cafed00d")))),
  .loaded(.success(.unauthenticated)),
  .offline
]

现在我们有一些加载中的值,一些已加载的值。加载的值可以处于成功或失败状态。甚至成功也可能处于经过验证或未经过验证的状态。

我们如何从这个复杂的结构中取出所有的访问令牌? 通过将三个case路径附加在一起,我们可以构造一个case路径,遍历整个访问令牌:

states2
  .compactMap(^(/LoadState.loaded .. /Result.success .. /Authentication.authenticated))

然而,我们有一个小问题:

🛑 Adjacent operators are in non-associative precedence group ‘DefaultPrecedence’

我们需要告诉操作符它应该如何将括号隐式地括在这个表达式的各个部分。我们可以选择将括号向左加权:

(/LoadState.loaded .. /Result.success) .. /Authentication.authenticated

或向右:

/LoadState.loaded .. (/Result.success .. /Authentication.authenticated)

实际上,在这种情况下这并不重要,因为case路径的组合是左联想和右联想的。 不管你怎样用括号括住表达式,得到的结果都是一样的。因此,我们只需要选择正确的结合律

precedencegroup Composition {
  associativity: right
}

infix operator ..: Composition

我们现在可以不使用括号来组合这些关键路径:

states2
  .compactMap(^(/LoadState.loaded .. /Result.success .. /Authentication.authenticated))
// [{token "deadbeef"}, {token "cafed00d"}]

这太酷了。这是一个非常复杂的遍历到一个非常复杂的结构。

另一种方法是在闭包中进行显式模式匹配

states2
  .compactMap {
    if case let .loaded(.success(.authenticated(token))) = $0 {
      return token
    }
    return nil
}

但即使这样也不行,因为在Swift中多行闭包需要显式类型。所以我们需要为这个闭包中的变量引入一个名称,并指定返回类型:

states2
  .compactMap { state -> AccessToken? in
    if case let .loaded(.success(.authenticated(token))) = state {
      return token
    }
    return nil
}

有更多的地方可以隐藏小错误。

所以我们确实能够使用这个工具清理一些代码,而且这只是开始。 我们希望每个人都能看到case路径的概念是多么有趣,谁知道呢,也许有一天Swift会给我们提供一流的支持来分析枚举,就像我们能够使用结构体一样。

当我们想要使用和类型和枚举时,case路径将是我们的朋友,因为它允许我们单独处理枚举的部分。

事实上,我们将在下周展示case路径,以极大地清理可组合体系结构中的一些代码。