感谢收看《Point-Free》的第一集! Point-Free将涵盖很多函数式编程概念,所以让我们从定义什么是函数开始:一个带有input和output的计算。
让我们定义一个function。我们可以定义一个自增函数,该函数接受Int型参数并返回Int型参数:
func incr(_ x: Int) -> Int {
return x + 1
}
要调用我们的函数,我们需要向它传递一个值。
incr(2) // 3
现在让我们定义一个square函数来平方一个整数:
func square(_ x: Int) -> Int {
return x * x
}
我们可以用类似的方式调用它:
square(2) // 4
我们甚至可以嵌套函数调用。先递增,然后平方一个值:
square(incr(2)) // 9
这很简单,但在Swift中并不常见。通常避免使用高阶的自由函数,而倾向于使用方法。
我们可以通过扩展Int定义incr和square为方法:
extension Int {
func incr() -> Int {
return self + 1
}
func square() -> Int {
return self * self
}
}
要使用incr方法,可以直接调用它:
2.incr() // 3
我们可以通过链接我们的方法调用来平方结果:
2.incr().square() // 9
方法(method)是从左到右,所以拥有很好阅读体验,而自由函数(function)是从内到外读取的,而且它在我们看清调用squere之前调用incr花费比较多的脑力。这可能就是为什么免费函数在Swift中不那么常见的原因。使用传统的函数调用来读取一个非常简单的表达式要困难得多。 我们可以想象,函数调用的嵌套越复杂,解包就越困难。方法没有这个问题。
1. Introducing |>
有几种编程语言有免费的函数,但是通过为函数应用使用infix运算符的方式来保持这种良好的可读性。Swift允许我们定义自己的操作符,所以让我们看看能否做同样的事情。
infix operator |>
这里我们定义了一个“pipe-forward”操作符。它基于现有技术:f#、Elixir和Elm都使用该操作符进行函数应用。
要定义这个操作符,我们需要编写一个函数:
func |> <A, B>(a: A, f: (A) -> B) -> B {
return f(a)
}
A和B两种类型都是通用的泛型。左边是A类型的值,而右边是从A到B的函数。最后我们通过调用函数并传参A返回B。
现在我们可以获取一个值并将其通过管道传递到自由函数中。
2 |> incr // 3
我们应该能够将这个结果传递到另一个自由函数中:
2 |> incr |> square
但是我们得到了一个错误。
Adjacent operators are in non-associative precedence group 'DefaultPrecedence'
//直白说就是编译器不知道运算的优先级
当我们的操作符在一行中多次使用时,Swift不知道该先对操作符的哪一边求值。在左边,我们有:
2 |> incr
通过管道将2传入incr是有意义的,因为incr接受一个整数。在右手边,有:
incr |> square
通过管道将incr函数传入square函数没有太大意义:square需要的是整数,而不是函数。
我们需要给Swift一个提示,告诉它先运算哪个表达式。一种方法是用括号将左边的表达式括起来,这样它就会先求值。
(2 |> incr) |> square // 9
这个行得通,但有点乱。这是一个非常简单的组合,但是一个更复杂的示例将需要更多嵌套的圆括号,并且更难理解。 让我们给Swift一个更好的提示。
Swift允许我们使用优先级组来定义运算符的结合性。让我们为函数应用定义一个优先组:
precedencegroup ForwardApplication {
associativity: left
}
我们给它一个left结合性,以确保左边的表达式先求值。
现在我们需要确保操作符符合优先级组。
infix operator |>: ForwardApplication
现在可以去掉括号了。
2 |> incr |> square // 9
2. Operator interlude
我们已经解决了嵌套函数的可读性问题,但我们还有一个新问题:自定义操作符。自定义操作符并不常见。事实上,我们许多人避免它们,因为它们有一个坏名声。 自定义操作符的一个典型问题是其思想的一个子集:重载操作符(overloaded operators)。
例如,在*c++中,不能定义新的操作符,但可以重载语言提供的现有操作符。如果我们用c++编写一个向量库,我们可以重载+来表示两个向量的和,我们可以重载来表示两个向量的点积。然后,可能意味着两个向量的叉乘,所以任何选择使用都需要三思,因为它容易导致混淆。
我们永远不会建议在函数应用中重载乘法:
2 * incr // What does this mean!?
在代码库中遇到这种情况并理解其含义是非常困难的。
幸运的是,我们这里没有这个问题。我们使用的是一个全新的操作符|>,Swift事先不知道这个操作符。有人可能会说,Swift不知道的操作符也是Swift开发人员不知道的操作符,但在这种情况下,我们要寻找现有技术:f#、Elixir和Elm都以相同的方式使用该操作符。熟悉这些语言的Swift工程师将会熟悉操作符。它的形状也很漂亮! 管道(|)调用Unix,在这里我们将程序的输出作为输入通过管道输送到其他程序。 箭头也向右(>),这给了我们一个很好的从左到右的阅读体验。让我们回顾一下这个操作符的用法:
2 |> incr
即使我们不熟悉这个运算符,我们也可以猜出这里发生了什么。
我们将在Point-Free中使用很多操作符,所以让我们确保我们是负责任的,并证明我们引入的符号是正确的。在引入一个新的操作符之前,有几个我们需要勾选的框:
- 我们不应该将具有现有意义的操作符重载为新的意义。
- 我们应该尽可能地利用现有技术,并确保我们的操作符有一个很好的“shape”来唤起它的语义:在本例中,|>很好地描述了将值传递到函数的管道。
- 我们不应该发明运算符来解决非常特定领域的问题。我们应该只介绍可以以非常普遍的方式使用和重用的操作符。
我们的|>运算符在所有这些框上都打勾了。
3. What about autocompletion?
虽然操作符为我们提供了这种可读性,但我们仍然缺少方法所具有的一个特性:自动补全(autocomplete)。
在Xcode中,我们可以引用一个值,输入一个点,然后出现一个完整的方法列表,我们可以调用这个值。
我们甚至可以键入几个字符来将列表限制为方法的子集,包括incr方法。
这对于代码的可寻性非常好,并且对于方法来说是一个很好的胜利,但是自动补齐实际上与方法没有任何关系。自动补齐也是我们的自由函数免费获得的功能。
不过,这是在顶层,因此我们失去了通过方法完成获得的一些作用域。 尽管如此,没有什么能阻止我们的IDEs理解,给定一个值和|>,应该自动完成将该值作为输入的函数。希望新版本的Xcode会更好。
4. Introducing >>>
同时,在自由函数世界里有一些东西在方法世界里是不可能的:函数组合(function composition)。函数组合是将两个函数的输出与另一个函数的输入相匹配,这样我们就可以将它们粘在一起,得到一个全新的函数。为了实现这一点,我们将引入另一个运算符:
infix operator >>>
这被称为“forward compose”或“right arrow”操作符。让我们来定义:
func >>> <A, B, C>(f: @escaping (A) -> B, g: @escaping (B) -> C) -> ((A) -> C) {
return { a in
g(f(a))
}
}
这是一个泛型函数拥有三个泛型参数:A、B和C。它需要两个函数,一个从A到B,和一个从B到C,粘在一起通过返回一个新函数。
现在我们可以将incr函数向前组合到square函数中:
incr >>> square
我们现在有一个全新的函数,从(Int) -> Int,它先递增,然后平方。
我们甚至可以翻转它,得到一个新函数,先平方然后递增:
square >>> incr
我们可以用传统的方式(圆括号)调用这些新函数:
(square >>> incr)(3) // 10
这读起来不太好,但我们应该能够使用|>操作符来帮助我们:
2 |> incr >>> square
不幸的是,我们得到了另一个错误:
Adjacent operators are in unordered precedence groups 'ForwardApplication' and 'DefaultPrecedence'
我们混合了两个操作符,Swift不知道先用哪个。在对函数应用值之前,我们需要对它们进行组合。我们不能把一个值应用到一个函数上,然后把结果组合到另一个函数上。
我们可以使用不带括号的优先级组来解决这个问题。让我们为函数组合定义一个新的优先级组:
precedencegroup ForwardComposition {
associativity: left
higherThan: ForwardApplication
}
我们已经指定这个组的优先级高于ForwardApplication,以便它将首先被调用。现在我们只需要让箭头操作符符合:
infix operator >>>: ForwardComposition
现在我们的操作符合作得很好:
2 |> incr >>> square // 9
在我们对这个新的操作符太过兴奋之前,让我们确保它符合所有证明它存在的条件。
- 该操作符目前在Swift中不存在,因此没有overloaded的机会。
- 这个操作符有很多现有技术:Haskell、PureScript和其他具有大型函数式编程社区的语言。它还有一个从左到右的很棒的形状,与我们的构图相匹配。
- 它是解决普遍问题还是解决特定领域的问题? 操作符有三种泛型类型,非常通用,函数组合也是非常通用的。
看起来>>>符合我们所有的条件!
5. Method composition
function组合在method世界中是什么样子的? 如果我们想要组合这个功能,我们没有其他选择,只能再次扩展我们的类型,并编写另一个将每个方法组合在一起的方法。
extension Int {
func incrAndSquare() -> Int {
return self.incr().square()
}
}
在使用中,我们可以对一个值调用new方法:
2.incrAndSquare() // 9
这行得通,但还有很多事情要做! 我们已经编写了5行代码,使用了4个关键字,必须指定类型,当我们放大我们关心的那部分,*square().incr()*时,它只是整体的一小部分。当写作需要这么多的样板和努力时,我们必须问自己:这值得吗?
同时,函数组成是一个小块,完全完整,没有任何噪音:
incr >>> square
您可以通过查看最小的有效组件来进一步了解重用情况。 如果我们删除部分的函数组合和应用程序,我们仍然有一个有效的程序。
2 |> incr >>> square
// every composed unit still compiles:
2 |> incr
2 |> square
incr >>> square
对于方法,我们不能在没有值的情况下引用它们或它们的组合。
// valid:
2.incr().square()
// not:
.incr().square()
incr().square()
因此,默认情况下,它们的可重用性更低!
虽然在Swift中,我们通常使用的是方法,而不是函数,但我们每天都在使用函数,甚至可能不会考虑它。
我们每天使用的一个非常常见的函数是初始化式! 它是一个产生值的全局函数。所有Swift的初始化器都可以用于函数组合。我们可以将前一个组合向前组合到一个String初始化式中。
incr >>> square >>> String.init
// (Int) -> String
我们可以通过管道传递一个值来产生一个字符串结果。
2 |> incr >>> square >>> String.init // "9"
同时,在method世界中,我们不能将结果与初始化式链接在一起。我们需要通过将初始化式包装在方法周围来改变读取内容的顺序。
String(2.incr().square())
除了初始化器提供给我们大量的自由函数外,标准库中还有大量的函数接受自由函数作为输入。 在Array中,我们有一个名为map的方法:
[1, 2, 3].map
// (transform: (Int) throws -> T) rethrows -> [T]
该方法接收一个将数组元素类型转换为另一种类型T的自由函数,并将每个元素转换为一个新的T数组并返回。
通常,我们在这里传递一个特别的函数。例如,我们可以使用increment和square:
[1, 2, 3].map { ($0 + 1) * ($0 + 1) } // [4, 9, 16]
当我们只使用方法时,我们似乎避免了可重用性。不过,我们使用的是函数,可以直接重用它们。
[1, 2, 3]
.map(incr)
.map(square)
// [4, 9, 16]
我们不需要打开一个新的特别函数或指定参数,这就是所谓的“point-free”风格。 当我们定义函数并指定参数时,即使是$0,这些参数也被称为“points”。 “point-free”风格的编程强调对函数和组合的关注,因此我们甚至不必参考正在操作的数据。这就是这个系列的名字!
先用square对数组进行映射,然后再用incr对结果数组进行映射,这等价于函数合成! 我们可以只映射一次,并将incr前向合成为square:
[1, 2, 3].map(incr >>> square) // [4, 9, 16]
这真的很酷!我们可以看到合成操作符与map之间的关系,这在方法中是很难看到的。在这里,map分布在>>>组合上:map在两个函数上的组合两次就是两个函数在一个map上的组合。有很多这样的模式,我们将在未来探索它们!
6. What’s the point?
让我们放慢脚步,问问自己:这有什么意义? 我们为什么要做这些? 在本集中,我们介绍了两个自定义操作符,并用自由函数污染了全局名称空间。为什么不继续使用我们所知道和喜爱的方法呢?
希望我们今天所写的代码为将函数引入我们的工作流提供了强有力的理由:函数以方法所不能的方式组合。 用方法组合功能需要更多的工作和样板,然后试图看到组合需要过滤噪音。只需几个操作符,我们就可以解锁一个我们之前没有的合成世界,并且我们还保留了很多我们所期望的可读性!
Swift也没有真正需要我们担心的“全局名称空间”。我们可以用很多不同的方式来界定我们的功能:
- 我们可以定义文件私有的函数。(We can define functions that are private to a file.)
- 可以在结构体和枚举上定义静态成员函数。(We can define functions that are static members on structs and enums.)
- 我们可以定义作用域为模块的函数。我们可以使用几个定义相同函数名的库,但是用库的模块名来限定它们。(We can define functions that are scoped to modules. We can use several libraries that define the same function name, but qualify them by the library’s module name.)
我想可以这样说:“不要害怕函数。”
在Point-Free上我们会经常用到函数。很难想象我们不会用到自由函数。我们将构建非常复杂的系统,其本质只是函数和组合。看到它是如何运作的,以及所有东西是如何组合在一起的,真的是非常美丽和令人兴奋。 函数组合将继续帮助我们看到没有它我们无法看到的东西。
这一集就到这里吧。请继续关注!