1. Introduction

在上两集中,我们深入探讨了setter函数的世界,并看到了它们如何让我们精确地操作大型数据结构。这只是事情的一半!getters?让我们来探索如何从结构中访问数据,探索getter是如何组成的,并看看关键路径如何在这一过程中进一步帮助我们!

2. Properties

类和结构通常将它们的数据存储在属性中,访问属性的方式与使用点语法的方法非常相似,但是在必须调用方法的地方,属性会立即返回它们的值。

struct User {
  let id: Int
  let email: String
}

我们可以实例化一个用户并使用点语法访问它的属性。

let user = User(id: 1, email: "blob@pointfree.co")
user.id // 1
user.email // "blob@pointfree.co"

属性就像没有参数的方法,反过来又像只有一个参数的函数,self就是隐式参数。 这有点神奇,因为它是我们最喜欢的函数组合形状!

当然,它们不能很好地进行开箱即用,但是短闭包使这变得非常容易。

比如,如果我们想从用户中提取id并将其转换为字符串:

{ (user: User) in user.id } >>> String.init
// (User) -> String

这个可以,但是有点吵。Swift为我们提供了一个更强大的语言功能。

3. Key paths as getters

Swift有一个强大的编译器生成的语言特性,它允许我们在没有实例的情况下引用属性访问:关键路径(key paths)。语法看起来有点滑稽。

\User.id // KeyPath<User, Int>

这里我们有一个KeyPath,它是根容器类型User和被访问的值类型的泛型,在本例中是Int类型。

那么我们可以用这些关键路径做什么呢? 我们可以用它们来访问一个值的属性。

user[keyPath: \User.id] // 1

当我们使用点语法时,这似乎不是特别有用。

user.id // 1

但是正如我们在过去所看到的,在抽象中引用这种功能的能力可以解锁所有类型的组合。除此之外,Swift还免费提供编译器生成的代码! 我们应该能够利用这一点来简化我们之前的写作尝试:

{ (user: User) in user.id } >>> String.init

左边只是一个函数,它接受User并返回该用户的属性值,这正是键路径getter提供的功能。

让我们定义一个函数,给定一个键路径,它将生成一个getter函数。

func get<Root, Value>(_ kp: KeyPath<Root, Value>) -> (Root) -> Value {
  return { root in
    root[keyPath: kp]
  }
}

How do we use this?

get(\User.id) // (User) -> Int

我们得到一个全新的从用户到Int的免费函数! 这很整洁。这正是我们之前组合所需要的。

get(\User.id) >>> String.init
// (User) -> String

Swift也有“计算”属性的概念,这些属性不直接存储数据,而是存储逻辑。

计算属性实际上只是伪装的函数,但它们让我们能够隐藏私有的实现细节,将getter-setter函数对统一到一个单独的构造中,并在调用点包含更少的视觉噪声。对于什么时候应该使用计算属性而不是方法,并没有编译器强制的规则,但一般感觉是,对于给定的状态,属性访问应该是廉价且可预测的。

让我们向用户添加一个computed属性。

extension User {
  var isStaff: Bool {
    return self.email.hasSuffix("@pointfree.co")
  }
}

它包含了一些检查电子邮件是否有某个后缀的逻辑。

user.isStaff // true

Swift也为这些计算属性生成关键路径!

\User.isStaff // KeyPath<User, Bool>

我们可以像以前一样使用它。

user[keyPath: \User.isStaff] // true

当然,这不是使用关键路径的实际方法。但是,我们的get函数使它立即变得有用。

get(\User.isStaff) // (User) -> Bool

由于getter可以被认为是专注于较大结构的一部分的函数,所以它们可以被认为是非常基本的转换函数。标准库中有一种高阶方法,它采用这些转换函数,可以很好地与关键路径组合在一起:map。 例如,假设我们有一个用户数组。

let users = [
  User(id: 1, email: "blob@pointfree.co"),
  User(id: 2, email: "protocol.me.maybe@appleco.example"),
  User(id: 3, email: "bee@co.domain"),
  User(id: 4, email: "a.morphism@category.theory")
]

我们可以用一个闭包来映射它,以生成一个电子邮件地址数组。

users
  .map { $0.email }

看看关键路径是如何工作的,我们可能希望这样:

users
  .map(\User.email)

不幸的是,该语言还不支持将键路径转换为getter函数,标准库也没有定义接受键路径的映射重载。

我们可以自己定义重载。遍历属性是map的一种非常常见的操作。

extension Sequence {
  func map<Value>(_ kp: KeyPath<Element, Value>) -> [Value] {
    return self.map { $0[keyPath: kp] }
  }
}

现在我们可以传递一个关键路径!

users
  .map(\User.email)

如果我们利用类型推断,我们得到的东西甚至比点语法版本更短。

users
  .map(\.email)

这个看起来很漂亮!现在我们可能还想这样写:

users
  .filter(\.isStaff)

但我们需要另一个超载。这不是很好地扩展。我们真的想为标准库中每个相关的高阶函数定义重载吗? 那么其他第三方代码呢?

幸运的是,我们已经定义了一个桥接! 我们可以在所有这些情况下使用get。

我们可以使用带有Swift的mapget,而不是依赖于我们的临时超载。

users
  .map(get(\.email))

我们可以将get传递给标准库方法,而不是依赖于filter的键路径重载。

users
  .filter(get(\.isStaff))

我们不需要定义任何重载。我们可以免费使用所有这些api的关键路径。

现在我们把关键路径放到函数世界里,我们甚至可以把它们组合在一起。

我们来回顾一下map的一个性质。我们看到可以对数组进行两次map:

users
  .map(get(\.email))
  .map(get(\.count))
// [17, 33, 13, 26]

我们看到,这可以用一个单一的map和组合来重写。

users
  .map(get(\.email) >>> get(\.count))
// [17, 33, 13, 26]

这也是一个很好的性能提升! 一次遍历而不是两次遍历。

不过,关键路径是自己组成的!这里不需要箭头运算符。

users
  .map(get(\.email.count))
// [17, 33, 13, 26]

Getter组合解锁了很多好东西。我们可以将isStaff getter与!操作符结合。

users
  .filter(get(\.isStaff) >>> (!))

以这种顺序使用前缀操作符可能读起来有点奇怪,但幸运的是,我们使用了反向组合来解决这个问题!

users
  .filter((!) <<< get(\.isStaff))

我们可以很容易地将这些单元插入到map和filter中,并且可以自由地将这些getter转换与其他函数组合在一起,这真是令人惊讶!

4. Sorting

还有哪些高阶函数可以从键路径组合中受益?排序呢?

按照属性排序是很常见的,但是Swift在这里并没有真正帮助我们,没有开箱即用。我们总是被拉到点语法的世界。

user
  .sorted(by: { $0.email < $1.email })

这并不糟糕,但我们可以想象,在深度嵌套的属性上进行排序会有点麻烦。

users
  .sorted(by: { $0.email.count < $1.email.count })

这变得越来越冗长了。我们必须根据我们排序的嵌套属性在每一边重复我们自己。这个属性只是一个键路径,也许我们可以从键路径世界找到另一个桥梁来执行sorted期望的函数。

排序到底是什么样的?

users.sorted(by: <#(User, User) throws -> Bool#>)

它接受一个非常特定的函数:一个接受一对元素并返回其中一个是否应该出现在另一个之前。

我们应该能够生成这种类型的函数,但要深入到User并对嵌套属性进行排序。我们就这么做吧。

func their<Root, Value>(
  _ f: @escaping (Root) -> Value,
  _ g: @escaping (Value, Value) -> Bool
  )
  -> (Root, Root)
  -> Bool {

    return { g(f($0), f($1)) }
}

这里有很多东西,但需要注意的是我们可以把从Root到Value的转换单独应用到两个根上。这允许我们创建一个全新的功能,关心Values。

如何使用这个函数?

users
  .sorted(by: their(get(\.email), <))

不坏!我们可以用一个字符来反转排序。

users
  .sorted(by: their(get(\.email), >))

我们还可以更深入地研究,并根据用户电子邮件地址的字符数进行排序。

users
  .sorted(by: their(get(\.email.count), >))

不坏!我们设法避免了定义重载。我们的组合器很好地插入了现有的方法。我们已经构建了一些可以使用任何函数的东西,而不仅仅是关键路径!还有什么其他的方法可以受益呢?

max怎么样?它接受一个函数(User, User) -> Bool。

users
  .max(by: their(get(\.email), <))

我们也可以用min做同样的事!

users
  .min(by: their(get(\.email), <))

诚然,指定<有点奇怪,因为它是获得最大值或最小值的唯一有效方法。也许我们可以定义一个超载来帮助你。

func their<Root, Value: Comparable>(
  _ f: @escaping (Root) -> Value
  )
  -> (Root, Root)
  -> Bool {

    return their(f, <)
}

通过将Value约束为Comparable,可以为Comparable类型定义一个默认版本。

现在我们的max和min就不用担心额外的参数了。

users
  .max(by: their(get(\.email)))

users
  .min(by: their(get(\.email)))

这也有助于sorted,因为我们的默认值可能是升序的。

不坏!我们采用了三个标准库方法,并定义了一个连接关键路径的方法。

5. Reduce

让我们再看一个高阶函数:reduce。这是一个有趣的函数,它取一个起始值,然后通过一个转换函数传递每个元素,将数据折叠到其中。

[1, 2, 3]
  .reduce(0, +)
// 6

如果我们的数据结构更复杂怎么办?如果我们想根据数据的深度嵌套属性来减少数据,该怎么办?

struct Episode {
  let title: String
  let viewCount: Int
}

let episodes = [
  Episode(title: "Functions", viewCount: 961),
  Episode(title: "Side Effects", viewCount: 841),
  Episode(title: "UIKit Styling with Functions", viewCount: 1089),
  Episode(title: "Algebraic Data Types", viewCount: 729),
]

如果我们想要合计所有这些剧集的观看次数,我们可以编写以下代码:

episodes
  .reduce(0) { $0 + $1.viewCount }

这段代码很短,但主要依赖于我们一眼就能记住$0和$1指的是什么。

让我们专注于形状,看看我们是否可以写一个函数来连接这些世界,让它更清晰一点,我们如何在更大的值的子结构的基础上积累。

func combining<Root, Value>(
  _ f: @escaping (Root) -> Value,
  by g: @escaping (Value, Value) -> Value
  )
  -> (Value, Root)
  -> Value {

    return { value, root in
      g(value, f(root)) }
    }
}

我们如何使用它?

episodes.reduce(0, combining(get(\.viewCount), by: +))

这有点冗长,但它更易于阅读,并且是由小型的可组合单元构建的。

6. Operator overload?

我们在很多地方看到了get的好处,我相信我们还会看到更多! 在这里接受额外的噪音可能是明智的,但是……我们已经有一段时间没有引入操作员了,我认为我们可以尝试一些东西,使这个过程更轻量化。

prefix operator ^
prefix func ^ <Root, Value>(kp: KeyPath<Root, Value>) -> (Root) -> Value {
  return get(kp)
}

前缀运算符的一个好处是,我们不必担心优先级或结合性!

我们如何使用这个运算符?

^\User.id
// (User) -> Int

这就是我们的getter ! 我们可以直接把它传递给map

users.map(^\.id) // [1, 2, 3, 4]

这看起来不错!非常接近我们定义的原始map重载。那其他的例子呢?

我们可以映射用户电子邮件的字符数。

users.map(^\.email.count)
// [17, 33, 13, 26]

我们仍然有能力将这些功能向前组合成新的功能。

users.map(^\.email.count >>> String.init)
// ["17", "33", "13", "26"]

我们不需要什么仪式就能过滤。

users.filter(^\.isStaff)

// or

users.filter((!) <<< ^\.isStaff)

这不是魔术!它只是函数复合。 **!**运算符只是一个接受一个布尔参数并否定它的函数。这样很好。

我们之前的桥接呢? 我们可以根据用户的电子邮件进行分类。

users.sorted(by: their(^\.email))

甚至按降序排列。

users.sorted(by: their(^\.email, >))

Our max and min work just as before.

users.max(by: their(^\.email.count))
users.min(by: their(^\.email.count))

所有这些例子都集中在他们所关心的事情上。get函数非常短,但它似乎足够常见,如果可以的话,可以避免使用操作符。

不过,我们引入了一个新的操作员。它符合我们的要求吗?

  1. 与前面的操作符不同,这个操作符在Swift中已经存在,但作为中缀操作符,而不是前缀操作符。作为前缀操作符,它似乎不太可能造成太多的混淆,但它肯定与现有的符号空间重叠。
  2. 它有现有技术和良好的形状吗? 它的形状很好,有点像一个向上的箭头,就好像它把一条关键路径提升到函数世界。这个形状还唤起了Objective-C的块语法,它们本身就是函数。其他语言有类似的功能吗? Ruby使用前缀&来做类似的事情,但我们不能使用相同的符号,因为前缀&是为Swift中的inout调用站点保留的。
  3. 它是在解决一个普遍的问题吗? Swift到处都有属性访问,所以是的。

它并没有很好地解决问题,但取决于你使用它的频率,它可能是一个有价值的添加,以减少括号和噪音。

7. What’s the point?

我们引入了一个get函数,它只处理KeyPath值,这是我们在日常工作中不经常处理的东西。有时它会让事情变得更短。有时它没有!我们应该关心这种事情吗?

我们是这样认为的! 我们认为它有助于展示关键路径是多么强大,以及为什么编译器生成的代码也是如此! 关键路径对我们的日常代码、语法和所有内容来说有些陌生,但让我们关注它们给我们带来了什么。

这一整集基本上是在庆祝一个简单的功能:get。它所做的只是打开了一条通往函数世界的关键路径。这让我们能够立即使用许多其他库代码,从map和filter开始。只要编译器可以为我们工作,我们要做的工作就会少一些。让我们拥抱这些吧!