1. Introduction
在上一集中,我们探讨了函数式setters如何允许我们深入到嵌套结构中执行转换,同时保持结构中的其他内容不变。我们尝试了一些简单的例子,比如嵌套的元组和数组,我们展示了一些非常令人印象深刻的东西,但在一天结束的时候,我们并不是典型的转换元组。相反,我们使用的是带有结构体的真实数据。我们想要把上一集的所有想法带到结构体的世界中,这样我们就可以用一种简单而富有表现力的方式来转换一个深度嵌套的结构体。为了做到这一点,我们要利用Swift的key paths!
2. Structs
这里我们有一些相关的结构体:
struct Food {
var name: String
}
struct Location {
var name: String
}
struct User {
var favoriteFoods: [Food]
var location: Location
var name: String
}
let user = User(
favoriteFoods: [
Food(name: "Tacos"),
Food(name: "Nachos")
],
location: Location(name: "Brooklyn"),
name: "Blob"
)
我们有一个Food结构体、一个Location结构体和一个User结构体,它保存一些Food、一个Location和一个name。我们还有一个示例用户可以使用。
如果我们想深入用户所在位置的名称,并将其重新定位到洛杉矶,并产生一个全新的用户价值,该怎么办? 最简单的方法是这样的:
User(
favoriteFoods: user.favoriteFoods,
location: Location(name: "Los Angeles"),
name: user.name
)
我们通过保持大多数值不变来创建一个全新的用户,但提供一个带有更新名称的新位置。
这是一种创建新值并转换一小部分的好方法,但正如我们在上一集元组中看到的,我们看到有一种比每次从零开始构建每个新值更好的方法。
下面是上节课的函数first,它的唯一目的是转换元组的第一个组件:
func first<A, B, C>(_ f: @escaping (A) -> B) -> ((A, C)) -> (B, C) {
return { pair in
(f(pair.0), pair.1)
}
}
让我们用一些具体的类型来替换这些泛型,它们表示如何转换用户的位置名:
func userLocationName(_ f: @escaping (String) -> String) -> (User) -> User {
return { user in
User(
favoriteFoods: user.favoriteFoods,
location: Location(name: f(user.location.name)),
name: user.name
)
}
}
有很多的样板,但它至少把setter带入了函数的世界。我们可以这样使用它:
user
|> userLocationName { _ in "Los Angeles" }
// "Los Angeles"
user
|> userLocationName { $0 + "!" }
// "Brooklyn!"
这看起来很好,因为它开始像我们对元组做的那样了。 但缺点是,我们需要为每种类型的每个字段编写这些setter函数。它一点也不通用!
更重要的是:如果我们对这些结构的任何字段进行更改(添加、删除、重命名),它将破坏所有现有的setter。
3. Key paths
幸运的是,我们可以利用一个很棒的Swift特性来做得更好:Key paths!
关键路径是编译器生成的代码,它为结构的每个字段提供了函数式的getter和setter。 只是他们的语法有点奇怪, 很难看出他们有多好。
要访问键路径,你可以使用下面的特殊语法:
\User.name // KeyPath<User, String>
这给了我们一个从用户到字符串的key path。 第一个泛型称为键路径的“根”,第二个泛型称为“值”。
我们可以用两种方式使用这个key path。一种方法是从用户中提取name。
user[keyPath: \User.name] // "Blob"
很好,但是为什么我们要使用属性点语法呢?
user.name // "Blob"
我们还可以使用这个关键路径在用户中设置name:
var copy = user
copy[keyPath: \User.name] = "Blobbo"
当键路径有setter时,这意味着它们是KeyPath的一个子类,称为WritableKeyPath。
也很简洁,但为什么我们要关心更简单的属性点语法?
copy.name = "Blobbo"
这种key path的使用并不有趣。有趣的是,Swift给了我们一个编译器生成的值,我们可以用它来获取和设置结构体的属性。 这意味着我们可以将数据的概念与更改数据的操作完全分离。点语法不能做到这一点。
4. Compiler-generated setters
为了显式地实现这一点,让我们定义一个helper,它将一个可写的键路径提升到一个根的转换键路径世界中。我们甚至将它一般化一点,允许我们提供一个函数,在把值代回根结点之前进一步转换值。
func prop<Root, Value>(_ kp: WritableKeyPath<Root, Value>)
-> (@escaping (Value) -> Value)
-> (Root)
-> Root {
return { update in
{ root in
var copy = root
copy[keyPath: kp] = update(copy[keyPath: kp])
return copy
}
}
}
如果你仔细观察,你会注意到这个prop函数在keyPath部分之后的签名与我们在元组上定义的第一个和第二个函数相同:
给定值上的变换我们可以得到根上的变换。
只要有了这个lil助手,我们就可以在User结构中使用它了。
prop(\User.name)
// ((String) -> (String)) -> (User) -> User
我们可能会使用尾随闭包语法来调用带有转换的新函数。
prop(\User.name) { _ in "Blobbo" }
// Extra argument in call
但这不起作用:Swift在这里将尾随闭包语法解析为prop的第二个参数。但是,我们可以使用括号来表示我们的意图。
我们可以用圆括号括起闭包,但是后面的语法更便于阅读。幸运的是,我们还可以将对prop的调用包装在括号中,并保留后面的闭包。
(prop(\User.name)) { _ in "Blobbo" }
// (User) -> User
现在我们有了一个可重用的功能,它将一个User作为输入,并输出一个全新的User! 将用户重命名为“Blobbo”可能不是最具可重用性的事情,但是,我们把用户的名字改为大写怎么样?
(prop(\User.name)) { $0.uppercased() }
如果我们想转换用户的location name呢? 正如我们上次看到的,我们可以使用 <<< 来组合setter。
prop(\User.location) <<< prop(\Location.name)
// ((String) -> String) -> (User) -> User
关键路径的一个优点是,它们使用点链接进行组合!
prop(\User.location.name)
// ((String) -> String) -> (User) -> User
我们创建的这个函数与前面手工定义的userLocationName函数完全相同。 然而,这一次,我们能够使用编译器生成的代码来完成大部分工作!
现在让我们转换user价值。
user
|> (prop(\User.name)) { $0.uppercased() }
|> (prop(\User.location.name)) { _ in "Los Angeles" }
因为所有的东西都只是函数,这个东西已经适用于我们之前定义的第一个和第二个元组转换。不需要额外的工作,让我们只是组合函数!
我们可以将用户嵌入到一个元组中,并查看该组合的运行情况。
(42, user)
|> (second <<< prop(\User.name)) { $0.uppercased() }
这很好,但让我们把它变得更好! Swift的键路径允许在任何可以推断类型的地方使用缩写语法。在我们目前的例子中,我们可以删除很多显式类型:
prop(\User.location) <<< prop(\.name)
user
|> (prop(\.name)) { $0.uppercased() }
|> (prop(\.location.name)) { _ in "Los Angeles" }
(42, user)
|> (second <<< prop(\.name)) { $0.uppercased() }
这看起来很干净!让我们再看看最后一个例子。
(42, user)
|> (second <<< prop(\.name)) { $0.uppercased() }
|> first(incr)
这东西真的很强大! 我们之前使用元组的示例似乎并不适用于我们每天编写的代码,但现在我们看到可组合setter也可以很好地处理我们定义的类型。
我们在这里仍然使用元组,那么更常见的数据结构如何呢? 让我们来处理数组。
在User结构体中有一个最喜欢的食物数组。
user.favoriteFoods
// [Food(name: "Tacos"), Food(name: "Nachos")]
在上周的章节中,我们使用了free map函数遍历数组并对每个元素应用转换。这与我们所有的其他setters组合在一起。
如果我们想要改变用户最喜欢的食物,我们可以使用map方法。
user.favoriteFoods
.map { Food(name: $0.name + " & Salad") }
// [Food(name: "Tacos & Salad"), Food(name: "Nachos & Salad")]
但是,这将返回一个食物数组,而不是一个用户,因此我们要处理的问题是必须从头开始重构一个数组。让我们尝试使用setter。
prop(\User.favoriteFoods) <<< map <<< prop(\.name)
// ((String) -> String) -> (User) -> User
我们可以将这个setter与可重用转换一起使用。
let healthier = (prop(\User.favoriteFoods) <<< map <<< prop(\.name)) {
$0 + " & Salad"
}
// (User) -> User
现在我们有了一个可重用的函数,可以自由地通过管道传输数据了!
user |> healthier
现在我们有一位用户,他最喜欢的食物都配有配菜沙拉。因为这些可重用功能只处理单一类型,所以我们可以将它们串联起来,并多次应用相同的转换。
user |> healthier |> healthier
我们可以继续将这些转换链接起来!
user
|> healthier
|> healthier
|> (prop(\.location.name)) { _ in "Miami" } // "Miami"
|> (prop(\.name)) { "Healthy " + $0 } // "Healthy Blob"
如果我们把它嵌套到另一个结构中会发生什么。让我们对用户进行元组化!
(42, user)
|> second(healthier)
|> second(healthier)
|> (second <<< prop(\.location.name)) { _ in "Miami" }
|> (second <<< prop(\.name)) { "Healthy " + $0 }
|> first(incr)
除了我们的第一行,我们还有一堆可以独立存在的逻辑! 让我们使用合成来构建一个巨大的函数。
second(healthier)
<> second(healthier)
<> (second <<< prop(\.location.name)) { _ in "Miami" }
<> (second <<< prop(\.name)) { "Healthy " + $0 }
<> first(incr)
我们有对second元素的所有这些变换,但正如我们在过去看到的,setter分布在复合上,所以我们可以用这个来重塑我们的变换变成更简单的东西。
second(
healthier
<> healthier
<> (prop(\.location.name)) { _ in "Miami" }
<> (prop(\.name)) { "Healthy " + $0 }
)
<> first(incr)
还有其他我们想要穿越的结构。 例如,如果User有一个可选字段,您可能希望能够安全地遍历该字段并执行转换。要做到这一点,你需要使用可选的map安全地穿越到它。但要做到这一点,你需要一个像数组一样的自由map:
func map<A, B>(_ f: @escaping (A) -> B) -> ([A]) -> [B] {
return { $0.map(f) }
}
我们只需要将数组替换为一个问号。
func map<A, B>(_ f: @escaping (A) -> B) -> (A?) -> B? {
return { $0.map(f) }
}
它可以编译,并且正是我们深入研究可选项所需要的函数setter。
它不仅仅是可选的!我们之前讲过Either和Result类型,它们基本上只是Optional的强化版本,所以如果你混合了这些类型的值,你可能还需要一种方法来遍历这些结构并转换东西。
好吧,令人惊讶的是,所有这些都是可能的,它和我们已经做过的非常相似。我们鼓励观众在练习中尝试一下!
5. What’s the point?
当Swift给我们值类型时,我们为什么要这么深入到高阶函数合成的领域? 值类型是用于不可变数据的很好的模型,并为我们提供了一个非常合理的突变模型。我们可以只创建一个副本,进行修改,然后返回新值。
var newUser = user
newUser.name = "Blobbo"
newUser.location.name = "Los Angeles"
newUser.favoriteFoods = copy.favoriteFoods.map {
Food(name: $0.name + " & Salad")
}
这真是太好了!最后一行有点吵,但还不错。这还不够好吗?
Swift在值突变和范围突变方面的能力真的很棒。对于我们在一个局部区域做临时变换的常见情况,这可能是可行的方法。但是,正如我们所看到的,对于更复杂的结构,比如数组和枚举,这种情况就会失控。 我们的特别转换开始吸引越来越多的样板文件,这里有嵌套的映射,那里有if case let,等等。函数设置器将这些繁琐的转换自动化为更简单的、可组合的单元,从而隐藏所有的样板文件。
除此之外,比较函数式setter和可变值会导致表达式和语句之间的巨大区别。表达式是计算出一个值的代码单元,而语句是执行某个操作的代码单元,没有返回值。
在这个newUser突变的例子中,我们有一组语句:一个描述如何复制一个用户,然后是一些强制修改它的步骤。
举一个简单的例子来说明这种区别,让我们以一个整数数组为例。
let xs = [1, 2, 3]
现在让我们用两种不同的方式来转换它,一次作为表达式,一次使用语句。
xs.map { $0 + 1 }
var ys = [Int]()
xs.forEach { x in
ys.append(x + 1)
}
第一个是表达式,因为我们是根据它的计算值来计算它,而不是计算它时发生的动作。 我们甚至不需要给它赋值。第二个是由语句组成的,执行语句是为了将它们的效果传达给世界。第一种样式比第二种更好地表达了代码的意图。
这些函数设置允许我们继续生活在这个表达式的世界里。我们不需要创建许多临时的、可以丢弃的变量,这些变量必须在语句中进行修改才能得到最终值。