1. Introduction
我们的系列文章毫不掩饰地推广了自定义操作符的使用,但它们在Swift社区中并不常见。操作符的使用甚至可能是本系列中最具争议的方面! 我们在第一集探讨了操作符的“为什么”,我们继续用具体的——甚至是严格的——标准来证明我们引入的操作符,虽然我们可能说服了你,但要说服你的同事又是另一回事了! 您的团队甚至可能采用禁止它的风格指南!
操作符不应该成为将组合引入代码的瓶颈。在这节课中,我们将探索一些替代方案,它们可能会更温和地向团队介绍自己,甚至可能是让你的同事获得运营商快车头等舱机票的第一步!
2. Outroducing |>
在我们的第一集中,我们探讨了自由函数,以及当它们嵌套时,它们如何变得更难阅读。
incr(2) // 3
square(incr(2)) // 9
我们引入的第一个操作符是|>,这对于使自由函数在调用时更具可读性很重要。 我们通过管道传递一个值,而不是将一个值传递给函数的右边。
2 |> incr |> square // 9
这是一个简单的示例,但是从左到右(就像方法语法一样)阅读它要比通过括号匹配深入并从内到外遵循逻辑容易得多。
Swift社区在过去已经为函数式应用采用了各种非操作符解决方案,尽管它可能没有这么明显!甚至还有一个Swift Evolution的宣传!
它是with函数,常用于构型。
// let label = UILabel()
// with(label) {
// $0.numberOfLines = 0
// $0.systemFont(ofSize: 17)
// $0.textColor = .red
// }
但是我们也可以用|>操作符来做这个:
let label = UILabel()
label |> {
$0.numberOfLines = 0
$0.systemFont(ofSize: 17)
$0.textColor = .red
}
换句话说,with具有函数应用的完美形状。
让我们来定义它。
func with<A, B>(_ a: A, _ f: (A) -> B) -> B {
return f(a)
}
让我们使用它。
with(2, incr) // 3
这招管用,但我们没法把调用连起来。使用像|>这样的操作符允许我们指定结合性和优先级,以便将调用链接起来。
with(2, incr, square)
// error: extra argument in call
目前应用两个函数需要嵌套和两个with。
with(with(2, incr), square)
现在我们要处理嵌套和圆括号,这是运算符解决的问题。也许解决这个问题的一种方法是,以某种方式组合incr和square,这样它们可以只被输入一次。
3. Outroducing >>>
好吧,我们可能记得,一系列的|>s可以用复合函数分解!
2 |> incr |> square // 9
2 |> incr >>> square // 9
用with而不是|>,我们得到这个:
with(2, incr >>> square) // 9
使用>>>允许我们在通过管道传递值之前将这些函数代入到一起。
让我们为正向合成定义一个自由函数。 我们称它为pipe。我们不要把它与|>的“pipe-forward”混淆! 我们在这里依靠的是现有技术,采用了funtional JavaScript社区支持的函数名。
func pipe<A, B, C>(_ f: @escaping (A) -> B, _ g: @escaping (B) -> C) -> (A) -> C {
return { g(f($0)) }
}
现在我们可以组成incr和square。
pipe(incr, square) // (Int) -> Int
这给了我们一个全新的函数,它先增加一个值,再平方一个值。
我们甚至可以用with这个函数。
with(2, pipe(incr, square)) // 9
就像我们的运算符版本一样。我们用文字交换操作符。我们可以这样读:对于2,通过pipe传入increment和square。
像|>一样,>>>操作符也具有结合性和优先级,允许我们组合更大的管道。
incr >>> square >>> String.init
// (Int) -> String
不幸的是,pipe有同样的问题。它一次只包含两个函数。
pipe(incr, square, String.init)
// error: extra argument in call
这是一个命名函数有而操作符没有的问题。
不过,我们可以通过函数重载来解决这个问题。让我们重载一个支持三个参数的pipe版本。
func pipe<A, B, C, D>(
_ f: @escaping (A) -> B,
_ g: @escaping (B) -> C,
_ h: @escaping (C) -> D
)
-> (A) -> D {
return { h(g(f($0))) }
}
现在,我们可以使用带有三个参数的pipe。
with(2, pipe(incr, square, String.init)) // "9"
如果我们想要一个由四个函数组成的管道,可以编写另一个重载。编写这些重载可能会感到乏味,但我们只需要编写一次! 我们甚至可以使用源代码生成工具来自动化这个过程。
在未来,我们甚至可能得到语言支持! 泛型宣言概述了可变型泛型如何允许我们在不需要重载的情况下定义这类函数。
// func pipe<A...>
我们还看到操作符在多行上工作得很好。
2
|> incr
>>> square
>>> String.init
// "9"
with看起来像什么?
with(2, pipe(
incr,
square,
String.init
))
// "9"
这有点嘈杂,但总体上读起来很好。
有趣的是,我们之前指出我们是根据“现有技术”来命名" with "和" pipe "的。看起来,高度可重用的自由函数也可能需要打勾来证明自己。幸运的是,我们已经证明了这些函数的存在! 我们可以把"它的形状好看吗"换成"它的戒指好看吗"
4. Outroducing >=>
在关于副作用的那一集中,我们介绍了“fish”操作符>=>,用于将链式函数组合成更复杂的结构。
例如,我们有一个computeAndPrint函数,它将日志附加到元组的返回值。
func computeAndPrint(_ x: Int) -> (Int, [String]) {
let computation = x * x + 1
return (computation, ["Computed \(computation)"])
}
2 |> computeAndPrint
// (5, ["Computed 5"])
能够控制我们的副作用是很好的,但在这个过程中我们失去了组合。
2 |> computeAndPrint |> computeAndPrint
// error: Cannot convert value of type '(Int) -> (Int, [String])' to expected argument type '(_) -> _'
幸运的是,>=>通过知道如何通过累积日志将这些函数拼凑在一起,恢复了组合。
2 |> computeAndPrint >=> computeAndPrint
// (26, ["Computed 5", "Computed 26"])
我们需要一个非操作符的替代品。让我们为这种组合定义一个名为chain的函数。
func chain<A, B, C>(
_ f: @escaping (A) -> (B, [String]),
_ g: @escaping (B) -> (C, [String])
) -> ((A) -> (C, [String])) {
return { a in
let (b, logs) = f(a)
let (c, moreLogs) = g(b)
return (c, logs + moreLogs)
}
}
Let’s try it out!
with(2, chain(computeAndPrint, computeAndPrint))
不过,就像pipe一样,每当我们想要将更多的函数链接在一起时,就必须重载它。如果没有重载,我们必须这样做
with(2, chain(computeAndPrint, chain(computeAndPrint, computeAndPrint)))
// (677, ["Computed 5", "Computed 26", "Computed 677"])
我们在《副作用》那一集提到的另一件事是>=>和>>>很好地结合在一起。
2
|> computeAndPrint
>=> incr
>>> computeAndPrint
>=> square
>>> computeAndPrint
// (1874162, ["Computed 5", "Computed 37", "Computed 1874162"])
这很好,因为我们有了一种非常直观的方式,看到了纯粹而有效的逻辑在哪里。
命名函数是什么样子的?
with(
2,
chain(
computeAndPrint,
pipe(
incr,
chain(
computeAndPrint,
pipe(
square,
computeAndPrint
)
)
)
)
)
// (1874162, ["Computed 5", "Computed 37", "Computed 1874162"])
操作符消除了这种嵌套的圆括号问题。但是因为chain只接受两个函数,我们被迫进行这种构造。
让我们定义一个接受3个函数的重载chain。
func chain<A, B, C, D>(
_ f: @escaping (A) -> (B, [String]),
_ g: @escaping (B) -> (C, [String]),
_ h: @escaping (C) -> (D, [String])
) -> ((A) -> (D, [String])) {
return chain(f, chain(g, h))
}
现在我们可以通过以特定方式分组纯函数和非纯函数来进行重构:
with(2, chain(
computeAndPrint,
pipe(incr, computeAndPrint),
pipe(square, computeAndPrint)
))
看起来好多了,但是如果我们想要平放括号的话我们就得继续定义重载。即使括号已经变平了,得到的表达式在视觉上和我们开始时很不一样。这样就很难看出pipe和chain之间的代数关系。
5. Outroducing <>
在本系列的前面,我们还介绍了一个<>操作符的组合: 单一类型组成。例如,我们在UIKit样式中使用过它。
func roundedStyle(_ view: UIView) {
view.clipsToBounds = true
view.layer.cornerRadius = 6
}
let baseButtonStyle: (UIButton) -> Void = {
$0.contentEdgeInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
$0.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
}
let roundButtonStyle =
baseButtonStyle
<> roundedStyle
与实际情况一样,操作符本身可能会阻止这种强大的组合进入一些代码库。请记住,我们希望对组合进行更多的限制,就像我们对<>所做的那样。让我们为样式集中使用的版本定义一个名为concat的函数。
func concat<A: AnyObject>(
_ f: @escaping (A) -> Void,
_ g: @escaping (A) -> Void
)
-> (A) -> Void {
return { a in
f(a)
g(a)
}
}
我们将concat与pipe和chain从相同的现有技术中提取出来,但我们可以很容易地将其称为append。
Let’s use it.
let roundButtonStyle = concat(
baseButtonStyle,
roundedStyle
)
// (UIButton) -> Void
它甚至可以很好地处理尾随闭包语法。
let filledButtonStyle = concat(roundedButtonStyle) {
$0.backgroundColor = .black
$0.tintColor = .white
}
有了concat,我们甚至可以避免需要overload! 类型是相同的,所以我们的签名可以是可变的! 让我们确保传递至少两个函数给concat,但允许任意数量的附加函数。
func concat<A: AnyObject>(
_ f1: @escaping (A) -> Void,
_ f2: @escaping (A) -> Void,
_ fs: ((A) -> Void)...
)
-> (A) -> Void {
return { a in
f1(a)
f2(a)
fs.forEach { f in f(a) }
}
}
现在我们有了一个concat函数,我们再也不必为额外的输入重载它了! 我们可以将这个函数粘贴到代码库中,并立即访问这种样式化函数组合。
这打破了尾随闭包,但如果我们能避免大量的重载,这似乎是一个可以接受的折衷选项。
let filledButtonStyle = concat(roundedButtonStyle, {
$0.backgroundColor = .black
$0.tintColor = .white
})
让我们看看如何继续把事情concat起来。
let filledButtonStyle = concat(
baseButtonStyle,
roundedButtonStyle, {
$0.backgroundColor = .black
$0.tintColor = .white
})
很高兴看到concat没有任何pipe和chain类似的重载问题。
6. Algebraic properties
在过去使用中缀运算符时,我们强调的一件事是,它们帮助我们看到可以在实际应用程序中实现的代数关系,比如性能改进。
让我们看一下数学中常见的运算符性质。
// a * (b + c) == a*b + a*c
这里我们看到乘法分布在加法上,而运算符是显示这个关系的好方法。
我们在我们的操作符身上也看到了这一点! 它们让我们能够看到map在构造上的分布:
// map(f >>> g) = map(f) >>> map(g)
在我们关于setter的章节中,我们也看到了类似的情况,它们也分布在构图上:
// first(f >>> g) = first(f) >>> first(g)
We also have the following equation relating pipe forward with forward compose:
// (a |> f) |> g = a |> (f >>> g)
对于命名函数,这有点困难,但仍然是可能的!例如,关于合成的map属性变成:
// map(pipe(f, g)) = pipe(map(f), map(g))
一句话:“管道的map就是maps的管道!”first属性看起来差不多:
// first(pipe(f, g)) = pipe(first(f), first(g))
函数应用程序属性如下:
// with(with(a, f), g) = with(a, pipe(f, g))
所以,从本质上讲,我们可以在引入管道的代价下,通过调用将两个函数压平。似乎是合理的!
结合性呢? 我们看到>>>和>=>都是可结合的因为括号放在哪里无关紧要。
// (f >>> g) >>> h = f >>> (g >>> h)
// (f >=> g) >=> h = f >=> (g >=> h)
对于命名函数,如下所示:
pipe(f, pipe(g, h)) = pipe(pipe(f, g), h)
chain(f, chain(g, h)) = chain(chain(f, g), h)
所以我们可以看到,在搜索函数和结构之间的代数关系时,运算符有助于消除迷雾,但是一旦你知道了这些关系,就很容易将其转换回命名函数并抽象地操作它们。
7. What’s the point?
通常,我们会问“这有什么意义?”“因为我们探索一些非常疯狂的东西,引入运算符或一些奇怪的组成,这些看起来可能不实用或不适用,我们想把这些概念建立在实用主义的基础上。
这一次,我们想反过来问:“这有什么意义?”“给这些漂亮的操作符取人类的名字?”为什么这么做? !
Composition!我们系列的大部分内容都深入探索了组合的强大力量。错过它就太可惜了! 通过采用命名函数,我们可以逐渐地将这些概念引入代码库中,否则可能会遗漏这些概念。
我们仍然喜欢(并且更喜欢)自定义操作符,但如果我们必须在操作符和组合之间做出选择,那就是组合! 我们的大多数运算符都依赖于复合运算,所以如果没有复合运算,我们甚至不会有我们最喜欢的运算符。 不过,我们必须给运算符一些荣誉,因为运算符可以用结合性和优先级来清理圆括号问题,并允许我们每次都避免编写(或生成)一大堆重载。