1. Introduction
我们现在已经花了几集时间来探索“functional setters”:允许我们从小单位构建具有表现力的、不可变的数据转换的函数。我们已经探索了它们如何以令人惊讶的方式组合在一起,从而使我们能够对深度嵌套的值进行更改:通常进行这些更改是很麻烦的。 我们还利用了一个美妙而独特的Swift特性,关键路径(key paths),为我们的类和结构的属性从稀薄的空气中提取setters。
Setters是一个非常强大和广泛有用的工具,但当使用它们时,我们目前编写的函数有些粗糙。它们也不是世界上性能最好的东西:因为setters是不可变的,所以它们在每一步中都会创建它们的值的副本。今天,我们将消除这些粗糙的边缘,并探索如何使用Swift的值变异语义来提高性能。
2. Refining things
让我们从上节课的一个例子开始。我们有Food, Location, User结构体以及user值。
struct Food {
var name: String
}
struct Location {
var name: String
}
struct User {
var favoriteFoods: [Food]
var location: Location
var name: String
}
var user = User(
favoriteFoods: [Food(name: "Tacos"), Food(name: "Nachos")],
location: Location(name: "Brooklyn"),
name: "Blob"
)
为了转换这些属性,我们编写了prop,这是一个函数,给定一个可写的键路径,它会生成一个setter函数。如果没有关键路径,我们需要手动为每个想要修改的属性编写或生成setter,这可能会阻止我们在一开始使用它们!
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
}
}
}
让我们看看一些正在使用的setters。在这里,我们通过一对prop setters将user管道化:一个把他们的名字大写,另一个把他们搬到洛杉矶。
user
|> (prop(\.name)) { $0.uppercased() }
<> (prop(\.location.name)) { _ in "Los Angeles" }
使用prop在实践中看起来有点奇怪,因为它是curry过的:使用key-path调用prop将返回另一个函数。 我们将第一个应用程序包装在括号中,作为一种聚会技巧来恢复尾随闭包语法,但像这样的技巧比其他任何技巧都更令人困惑。 我们可能会遇到这些额外的括号,并想知道:它们为什么在那里? 我们试图删除它们,然后通过一个令人困惑的编译错误。
prop(\.name) { $0.uppercased() }
🛑 Extra argument in call
这样写这些行比较容易理解:
user
|> prop(\.name)({ $0.uppercased() })
<> prop(\.location.name)({ _ in "Los Angeles" })
但这感觉有点恶心。为什么这些函数要curry ? Setter函数需要经过curry处理以实现复合,而我们需要复合来对数据结构进行深度修改。不过,使用setter应该和编写它们一样好,所以我们可以做些什么来消除调用这些curry过的函数的尴尬方式呢?
考虑一下如何使用这些setter的两种常见情况可能会有所帮助: 我们要么采用现有的值并转换它们,就像我们将用户名大写时所做的那样,要么完全替换这些值,就像我们使用它们的位置名所做的那样。我们看看能不能把这两种情况专门化一下。
3. Overloading prop
应用一个没有额外括号的转换应该像编写一个未curry的prop版本一样简单。我们可以编写一个重载来实现这一点。
func prop<Root, Value>(
_ kp: WritableKeyPath<Root, Value>,
_ f: @escaping (Value) -> Value
)
-> (Root) -> Root {
return prop(kp)(f)
}
这允许我们更新之前的代码,使其看起来更合理。
user
|> prop(\.name) { $0.uppercased() }
<> prop(\.location.name) { _ in "Los Angeles" }
改变是微妙的,但值得! 如果我们传递一个命名函数,这种情况就会更加明显。
let addCourtesyTitle = { $0 + ", Esq." }
user
|> prop(\.name, addCourtesyTitle)
|> prop(\.name) { $0.uppercased() }
<> prop(\.location.name) { _ in "Los Angeles" }
没有括号使得代码更容易阅读和理解。
4. Double-overloading prop
如果我们想要完全替换我们的值,比如用户的位置名,那该怎么办呢? 现在我们使用一个transform函数,忽略当前值并返回一个常量字符串。花括号、下划线和in关键字给一个非常简单的操作添加了很多杂音。我们写一个函数来消除这些干扰。我们可以再次重载道具,为什么不呢? 它会取一个键路径,一个值,然后产生另一个Root转换函数。
func prop<Root, Value>(
_ kp: WritableKeyPath<Root, Value>,
_ value: Value
)
-> (Root) -> Root {
return prop(kp) { _ in value }
}
这个函数只是调用uncurried prop,并隐藏了一些噪音。
这将如何改变我们的管道?
user
|> prop(\.name, addCourtesyTitle)
<> prop(\.name) { $0.uppercased() }
// <> prop(\.location.name) { _ in "Los Angeles" }
<> prop(\.location.name, "Los Angeles")
我们把很多噪音都藏起来了!
5. Over and set
我们可能认为这里已经完成了,但我们忽略了setter机制的一个关键部分,即将setter函数组合在一起,以更改数据结构中深深嵌套的值。
例如,我们可以将prop组合到Array上的free map中,并再次组合到prop中,以更改用户最喜欢的食物的名称。
user
|> prop(\.name, addCourtesyTitle)
<> prop(\.name) { $0.uppercased() }
<> prop(\.location.name, "Los Angeles")
<> (prop(\.favoriteFoods) <<< map <<< prop(\.name)) { $0 + " & Salad" }
这就是setter如此强大的原因:我们可以将它们组合在一起,创建全新的setter,改变位于数据结构深处的值。
不幸的是,我们又要用括号来包装了。 为了包含构图,这里的parens感觉是必要的,但与我们其他重载的道具相比,它感觉我们生活在两个不同的世界: 一个世界高度适应于改变或设定价值,另一个世界高度适应于创作。
但是当我们提取这个变换函数并命名它的时候事情就开始走下坡路了。
let healthierOption = { $0 + " & Salad" }
user
|> prop(\.name, addCourtesyTitle)
<> prop(\.name) { $0.uppercased() }
<> prop(\.location.name, "Los Angeles")
<> (prop(\.favoriteFoods) <<< map <<< prop(\.name))(healthierOption)
我们又回到了之前的双括号。
也许重载道具不是最好的办法。也许prop应该稳稳地存在于合成和setters的创建世界中,而我们将推出专门用于使用setters的新命名的helpers。让我们将第一个助手称为over,这是Haskell社区中用于类似转换的艺术术语,我们可以将其视为带有转换的setter函数的映射。
func over<Root, Value>(
_ setter: (@escaping (Value) -> Value) -> (Root) -> Root,
_ f: @escaping (Value) -> Value
)
-> (Root) -> Root {
return setter(f)
}
这个函数的主体最终是普通的ole函数应用程序,这是有意义的给定curry和调用,但当关键路径也在运行时就会隐藏起来。
让我们也一般化替换值的prop重载。 我们将其重命名为set,这是另一个没有道具包袱的艺术术语。
func set<Root, Value>(
_ setter: (@escaping (Value) -> Value) -> (Root) -> Root,
_ value: Value
)
-> (Root) -> Root {
return over(setter) { _ value }
}
这些函数如何影响我们的管道?
user
|> prop(\.name, addCourtesyTitle)
<> prop(\.name) { $0.uppercased() }
<> prop(\.location.name, "Los Angeles")
<> over(prop(\.favoriteFoods) <<< map <<< prop(\.name), healthierOption)
这确实更好,但我们有点困在两个世界之间: 我们的prop重载helpers以及更通用的over和set helpers。让我们把东西完全转换成使用over和set。
user
|> over(prop(\.name), addCourtesyTitle)
<> over(prop(\.name)) { $0.uppercased() }
<> set(prop(\.location.name), "Los Angeles")
<> over(prop(\.favoriteFoods) <<< map <<< prop(\.name), healthierOption)
这行得通,我们避免了prop重载并保持一致,但到处都重载了prop。
在关于getter的那一集中,我们编写了一个get函数,它从任意键路径生成一个(Root) -> Value函数。 这打开了一个全新的合成世界,让我们可以更有表现力地调用映射、过滤和其他高阶函数。 在本集的最后,我们定义了一个前缀操作符^,作为get的缩写,这帮助我们减少了很多干扰。
^\User.name
// (User) -> String
似乎prop也能从类似的处理方式中受益! 让我们使用与get相同的操作符来别名它。
prefix func ^ <Root, Value>(kp: WritableKeyPath<Root, Value>)
-> (@escaping (Value) -> Value)
-> (Root) -> Root {
return prop(kp)
}
这如何清理我们的组合setters呢?
user
|> over(^\.name, addCourtesyTitle)
<> over(^\.name) { $0.uppercased() }
<> set(^\.location.name, "Los Angeles")
<> over(^\.favoriteFoods <<< map <<< ^\.name, healthierOption)
哇!这很有用!如果没有无处不在的道具,就更容易理解这两条线的实质内容。
那些多载集合并没有尽到他们的责任! 我们剩下的是更加一致的: 关键路径通常以^作为前缀,我们只需要处理两个根运算over和set。
我们写了一个新的运算符函数。我们需要打勾吗? 当我们选择^ on get时,我们发现它可能只选择了我们的一半:我们认为它很好,但不像其他一些操作符那么通用。这个操作符是一种“接受或离开”操作符:如果你不想使用操作符,你仍然可以使用prop,因为^实际上就是prop, 但是如果你不介意使用一个操作符,^是一个很好的操作符,用来将一个键路径提升到setter世界。
6. Generalizing over and set
关于over和set还有一个问题。
在关于setters的第一集中,我们使用了一对转换元组元素的函数:first和second。
("Hello, world!", 42)
|> first { _ in [1, 2, 3] }
|> second(String.init)
// ([1, 2, 3], "42")
这非常简洁,但我们应该能够使用set来清理花括号和下划线干扰,over将使事情与我们的其他setter一致。
("Hello, world!", 42)
|> set(first, [1, 2, 3])
|> over(second, String.init)
🛑 Generic parameter ‘Value’ could not be inferred
看来我们还不够一般化! 我们的over和set函数被限制为根和值泛型参数,这阻止了它们更改为不同的类型! 我们可以通过添加更多的泛型来放松这些约束。
func over<S, T, A, B>(
_ setter: (@escaping (A) -> B) -> (S) -> T,
_ set: @escaping (A) -> B
)
-> (S) -> T {
return setter(set)
}
这样我们就完全泛化了可以用任何setter了。我们的元组转换可以编译,读起来也很好!
("Hello, world!", 42)
|> set(first, [1, 2, 3])
|> over(second, String.init)
// ([1, 2, 3], "42")
7. Setter as a type
让我们仔细看看setter的形状。 虽然我们已经看到了很多setters,但我们并没有真正统一它们。将setter本身看作一种类型可能会有所帮助。
typealias Setter<S, T, A, B> = (@escaping (A) -> B) -> (S) -> T
我们已经见过这个形状很多次了。它说,如果你给我一种把A转换成B的方法,它可以被认为是一个更大结构的一部分, 然后我会给你们一种把S变换成T的方法,它可以被认为是整个结构。
我们在数组上的map中看到了这一点:
// ((A) -> B) -> ([A]) -> [B]
这是前面定义的Setter类型别名的一个更具体的版本,[A]和[B]代替S和T作为“whole”结构。
我们在可选值的map中也看到了这一点:
// ((A) -> B) -> (A?) -> B?
在这里,A?和B?是S和T。
我们甚至在first和second函数中看到过。
// ((A) -> B) -> ((A, C)) -> (B, C)
这里,我们看到较大的元组结构是S和T,而第一个(A) -> B转换函数负责转换该元组的一部分。
所有这些setters结果都是相同的,大致形状。现在我们可以考虑over和set这个类型别名!
func over<S, T, A, B>(
_ setter: Setter<S, T, A, B>,
_ set: @escaping (A) -> B
)
-> (S) -> T {
return setter(set)
}
func set<S, T, A, B>(
_ setter: Setter<S, T, A, B>,
_ value: B
)
-> (S) -> T {
return over(setter) { _ in value }
}
我们在这里进行了进一步的抽象,这很好,因为这两个helper主要使用setters作为值。
8. Refactoring an earlier example
现在我们已经有了这些万能的,人体工程学的工具,让我们使用它们吧! 我们可以回顾上节课实际的URLRequest代码。
let guaranteeHeaders = (prop(\URLRequest.allHTTPHeaderFields)) { $0 ?? [:] }
let postJson =
guaranteeHeaders
<> (prop(\.httpMethod)) { _ in "POST" }
<> (prop(\.allHTTPHeaderFields) <<< map <<< prop(\.["Content-Type"])) { _ in
"application/json; charset=utf-8"
}
let gitHubAccept =
guaranteeHeaders
<> (prop(\.allHTTPHeaderFields) <<< map <<< prop(\.["Accept"])) { _ in
"application/vnd.github.v3+json"
}
let attachAuthorization = { token in
guaranteeHeaders
<> (prop(\.allHTTPHeaderFields) <<< map <<< prop(\.["Authorization"])) { _ in
"Token " + token
}
}
URLRequest(url: URL(string: "https://www.pointfree.co/hello")!)
|> attachAuthorization("deadbeef")
<> gitHubAccept
<> postJson
这些是我们用prop构建的一堆辅助函数。他们确保我们设置头文件,即使类型系统使它变得困难,他们允许我们在非常少的代码中组合一堆转换!
让我们看看当我们使用新的助手时会发生什么。
let guaranteeHeaders = over(^\URLRequest.allHTTPHeaderFields) { $0 ?? [:] }
let postJson =
guaranteeHeaders
<> set(^\.httpMethod, "POST")
<> set(
^\.allHTTPHeaderFields) <<< map <<< ^\.["Content-Type"],
"application/json; charset=utf-8"
)
let gitHubAccept =
guaranteeHeaders
<> set(
^\.allHTTPHeaderFields <<< map <<< ^\.["Accept"],
"application/vnd.github.v3+json"
)
let attachAuthorization = { token in
guaranteeHeaders
<> over(^\.allHTTPHeaderFields <<< map <<< ^\.["Authorization"]) { _ in
"Token " + token
}
}
这个看起来已经好多了。我们能够隔离映射值的转换,以及将值设置为新值的转换。
重构可能会有一点滚雪球效应。清理代码可以揭示之前被掩盖的模式,从而导致进一步的清理! 现在很容易看到我们如何设置头文件的复制! 我们使用了一个强大的遍历到可选值的可选字典中,我们可以放心地使用低级组合子设置新值! 导航这些类型需要很多复杂的重复,所以为什么不用一个更简单的接口将这个逻辑包装在函数中呢?
let setHeader = { name, value in
guaranteeHeaders
<> set(^\.allHTTPHeaderFields <<< map <<< ^\.[name], value)
}
这很容易!让我们试试吧!
let postJson =
set(^\.httpMethod, "POST")
<> setHeader("Content-Type", "application/json; charset=utf-8")
let gitHubAccept =
setHeader("Accept", "application/vnd.github.v3+json")
let attachAuthorization = { token in
setHeader("Authorization", "Token " + token)
}
现在这个超短了! 我们用很少的代码为URLRequest构建了高度可重用和可读的setter。我们能够做到这一点,只需要几个小函数。不需要外部库!
9. Setters and performance
到目前为止,我们定义的setters对于保证结构的不变性和修改结构的深层部分非常重要,否则这些部分会很麻烦。不幸的是,它们不是世界上性能最好的东西。当前,每个setter操作都会创建根结构的一个全新副本。每次我们调用或设置时,都会产生性能成本。这就好像我们连续多次调用结构上的map,而不是以某种方式一次将这个突变组合在一起。
幸运的是,我们看到Swift有一个精彩的故事:inout。 inout setter是什么样的呢?让我们再看看我们的不可变setter函数。
typealias Setter<S, T, A, B> = (@escaping (A) -> B) -> (S) -> T
func over<S, T, A, B>(
_ setter: Setter<S, T, A, B>,
_ set: @escaping (A) -> B
)
-> (S) -> T {
return setter(set)
}
func set<S, T, A, B>(
_ setter: Setter<S, T, A, B>,
_ value: B
)
-> (S) -> T {
return over(setter) { _ in value }
}
我们尝试将其重构到inout世界。我们可以从创建Setter类型别名的可变变体开始。
typealias MutableSetter<S, A> = (@escaping (inout A) -> Void) -> (inout S) -> Void
因为函数从单一类型变成了Void,我们失去了一些泛型参数和改变类型形状的能力。
现在我们可以在over和set中交换Setter。
func over<S, A>(
_ setter: MutableSetter<S, A>,
_ set: @escaping (inout A) -> Void
)
-> (inout S) -> Void {
return setter(set)
}
func set<S, A>(
_ setter: MutableSetter<S, A>,
_ value: A
)
-> (S) -> T {
return over(setter) { $0 = value }
}
我们现在有over和set的不可变和可变版本,这可能是过度的重载。 调用这些函数mver和mut可能更合适,以避免潜在的歧义。
func mver<S, A>(
_ setter: MutableSetter<S, A>,
_ set: @escaping (inout A) -> Void
)
-> (inout S) -> Void {
return setter(set)
}
func mut<S, A>(
_ setter: MutableSetter<S, A>,
_ value: A
)
-> (S) -> T {
return over(setter) { $0 = value }
}
现在让我们再看一下原来的例子。
user
|> over(^\.name) { $0.uppercased() }
<> set(^\.location.name, "Los Angeles")
使用我们变异的变种会是什么样子?
user
|> mver(^\.name) { $0 = $0.uppercased() }
<> mut(^\.location.name, "Los Angeles")
我们只需要交换名字并更改大写,就可以使用赋值了!但事情仍然没有进展。
🛑 Type of expression is ambiguou
我们的prop操作符仅为不可变的setter世界定义。让我们写一个使用可变的版本。
prefix func ^ <Root, Value>(
_ kp: WritableKeyPath<Root, Value>
)
-> (@escaping (inout Value) -> Void)
-> (inout Root) -> Void {
return { update in
{ root in
update(&root[keyPath: kp])
}
}
}
好了,我们讲得更远了,但我们还有另一个问题。我们的user是let,我们的|>版本在inout上工作需要一个inout输入。
我们可以复制一份。
var newUser = user
newUser
|> mver(^\.name) { $0 = $0.uppercased() }
<> mut(^\.location.name, "Los Angeles")
这在适当的地方更新了newUser,但它增加了噪音,这删除了很多我们在使用|>时所期望的表现力。我认为我们应该重新考虑我们最初对|>的定义。
当我们第一次用inout定义|>时,我们平衡了签名,使它也返回Void。
func |> <A>(_ a: inout A, _ f: (inout A) -> Void) -> Void {
f(&a)
}
这感觉很有原则,但不实际。我们定义|>是富于表现力的,但在这里不成立。我们应该能够在一个表达式中使用|>并配置一些数据。我们肯定搞错了。我们真正想要的是一个函数,它接受一个A,复制它,然后在返回它之前对这个副本应用一个可变变换。
func |> <A>(_ a: A, _ f: (inout A) -> Void) -> A {
var a = a
f(&a)
return a
}
现在我们有了一个新的、经过转换的用户,而我们以前的用法没有发生变化。
var newUser = user
newUser
|> mver(^\.name) { $0 = $0.uppercased() }
<> mut(^\.location.name, "Los Angeles")
管道转发现在生成副本,而不是就地更改值,这意味着newUser不会得到更新。不过,我们需要做的只是删除一行。
var newUser = user
|> mver(^\.name) { $0 = $0.uppercased() }
<> mut(^\.location.name, "Los Angeles")
我们甚至可以将var改为let!
let newUser = user
|> mver(^\.name) { $0 = $0.uppercased() }
<> mut(^\.location.name, "Los Angeles")
这太酷了! 我们已经能够将两个可变setters压缩到一个副本中,并最终将值重新赋给一个不能被突变的常量!
那操纵数组值的setter呢?
let newUser = user
|> mver(^\.name) { $0 = $0.uppercased() }
<> mut(^\.location.name, "Los Angeles")
<> mver(^\.favoriteFoods <<< map <<< ^\.name) { $0 += " & Salad" }
🛑 Type of expression is ambiguous
我们有一个问题:免费map无法与inout世界联合起来。
func map<A, B>(_ f: @escaping (A) -> B) -> ([A]) -> [B] {
return { $0.map(f) }
}
我们可以去掉第二个泛型参数B,直接在inout A世界中工作。
func map<A>(_ f: @escaping (inout A) -> Void) -> (inout [A]) -> Void {
return {
for i in $0.indices {
f(&$0[i])
}
}
}
由于我们被困在单一类型中,map可能不是一个合适的名称。我们可以将其称为mutEach。
func mutEach<A>(_ f: @escaping (inout A) -> Void) -> (inout [A]) -> Void {
return {
for i in $0.indices {
f(&$0[i])
}
}
}
让我们代入:
let newUser = user
|> mver(^\.name) { $0 = $0.uppercased() }
<> mut(^\.location.name, "Los Angeles")
<> mver(^\.favoriteFoods <<< mutEach <<< ^\.name) { $0 += " & Salad" }
10. One more mutable refactor
URLRequest的setters呢? 它们创建了相当数量的副本,因为每次调用setHeaders也会调用guaranteeHeaders,所以我们看到每个头文件有两个副本! 让我们尝试再次重构它们以使用inout setters。
我们将从guaranteeHeaders开始。
let guaranteeHeaders = mver(^\URLRequest.allHTTPHeaderFields) { $0 = $0 ?? [:] }
这个非常简单:over变成了mver,我们的block现在包含了一个显式的赋值。 让我们看一些更复杂的东西:
let setHeader = { name, value in
guaranteeHeaders
<> mut(^\.allHTTPHeaderFields <<< map <<< ^\.[name], value)
}
用mut替换set不会切断它,我们有另一个map要处理。我们可以为可选值定义一个mutEach等价项,但我们可以通过使用可选链接来利用Swift人机工程学!
let setHeader = { name, value in
guaranteeHeaders
<> { $0.allHTTPHeaderFields?[name] = value }
}
每个可变setter都提炼为一个(inout Root) -> Void函数,这意味着我们可以在任何时候潜入这个世界!
The other helpers are a cinch.
let postJson =
mut(^\.httpMethod, "POST")
<> setHeader("Content-Type", "application/json; charset=utf-8")
我们只需要再多一个 mut,其他一切就都顺利了。我们来测试一下。
URLRequest(url: URL(string: "https://www.pointfree.co/hello")!)
|> postJson,
<> gitHubAccept
<> attachAuthorization("deadbeef")
我们只是创建了一个新的URLRequest,以一种有效的方式对它应用了许多可组合的突变,甚至为作用域的其余部分将它分配给一个不可变的let !
let request = URLRequest(url: URL(string: "https://www.pointfree.co/hello")!)
|> postJson,
<> gitHubAccept
<> attachAuthorization("deadbeef")
11. What’s the point?
好吧,也许是时候问问“这有什么意义?”在前面的章节中,我们看到了如何对setter进行严格的研究,从而导致数据类型的一些令人印象深刻的转换。今天的课程完全是关于加强人类工程学和我们以前工作的价值主张。从某种意义上说,这整件事就是重点。
我们希望人们不仅能看到函数式setter的强大功能,还能消除所有可能阻止他们在代码库中采用它们的障碍。这意味着为最常见的用例提供比prop更友好的API,以便在调用站点更清楚地了解我们正在执行的转换类型。它还意味着允许setters很好地处理Swift值突变语义,这样对性能敏感的用例就不必担心创建太多副本。
现在,对于应该使用不可变值还是可变值没有正确的选择。每个人都有权衡。在关于副作用的那一集中,我们对这一点做了一些研究,其中我们将一些处理引用和函数的代码转换为使用不可变值的代码和返回所有新值而不是修改它们的函数。这个更改修复了我们的一个微小错误,但代价是创建值的副本。然后,我们将该代码转换为具有可变值的inout样式,这允许我们避免复制,但又重新引入了bug。 新版本的语法记录了允许发生突变的地方,这至少可以帮助我们定位错误。
很有可能您甚至不会注意到防止这些额外副本所带来的性能提升,因此不可变是一个很好的切入点。不过,很高兴我们有工具,可以快速地从一个不可变的世界转换到一个可变的世界,而不需要做很多工作。甚至有可能有一天Swift会变得足够聪明,优化掉不可变版本中的所有中间副本。