Functions
第一节讨论的都是函数!我们将会进行一波“是什么让函数特别”、“函数式编程与我们日常的编码方式的差异”以及一些关于操作符及其结合方式的探索式讨论。
Introduction
欢迎加入Point-Free! Point-Free将会囊括许多函数式编程的概念,所以让我们从定义一个计算输入输出的函数开始吧。
定义对Int
类型数值进行加1操作的一个函数:
func incr(_ x: Int) -> Int {
return x + 1
}
调用这个函数,入参为2
incr(2) // 3
定义一个求平方的函数:
func square(_ x: Int) -> Int {
return x * x
}
同样调用这个函数:
square(2) // 4
现在我们将这两个函数进行组合调用:
square(incr(2)) // 9
很简单,但是在Swift中并不常见。在设计最外层、可使用的纯函数时通常会避免“方法“的调用(注: 翻译不准确,请移步原始内容)。
我们可以通过给Int进行扩展来实现这两个方法:
extension Int {
func incr() -> Int {
return self + 1
}
func square() -> Int {
return self * self
}
}
想使用incr()方法时,我们可以直接调用
2.incr() // 3
后面还能让这个函数的结果再进行平方:
2.incr().square()
这样使用纯函数后从左至右读起来更加的漂亮和流畅,比起之前的调用方法,在调用square()函数之前去看看incr()函数做了什么更加节省思考时间。这也许是纯函数在Swift中并不常见的原因。一个简答的表达式在使用传统的方法调用时阅读起来会更加困难。可以想象,如果使用嵌套函数结合起来,那读起来基本就GG了。单纯的方法调用则不会有这个问题。
Introduction |> 操作符
其他的某些编程语言通过一些前缀操作符实现了纯函数并且保持了代码的可读性。Swift允许我们自定义操作符,让我们来试试:
infix operator |>
这里我们定义了一个“管道传递”操作符,F#,Elixir以及Elm中都用来进行函数的组合操作。
func |> <A, B>(a: A, f: (A) -> B) -> B {
return f(a)
}
这个函数中有两个泛型:A和B。左侧是我们的输入值,类型为A,右侧是我们的操作函数f: (A) -> B。我们最终通过函数f操作输入A来得到返回值B。
现在我们能将一个输入值加入到纯函数的管道中:
2 |> incr // 3
我们应该也能将管道上端的输入加入管道并调用下一个函数:
2 |> incr |> square
但是我们会得到一个编译错误:
adjacent operators are in non-associative precedence group 'DefaultPrecedence'
2 |> incr |> square
当我们在一行代码中多次使用|>操作符时,Swift并不知道这个操作符应该先计算哪一边。在左边:
2 |> incr
将2加入到管道中当然是可以的,因为incr函数的入参就需要一个Int。在右边:
incr |> square
将incr函数传递给square函数当然是不行的,因为square函数入参需要的是一个Int类型,而不是一个 (Int) -> Int 类型的函数。
我们需要给Swfit加上一些提示,让编译器知道|>操作符的结合顺序:
(2 |> incr) |> square // 9
工作正常,但是看起来有点凌乱。这只是一个简单的操作结合,试想如果有非常多的操作时,将会有非常非常的括号,难以阅读。所以让我们给Swift加上一个更加有效的提示。
Swift允许我们通过使用优先级组来定义操作符的结合顺序:
precedencegroup ForwardApplication {
associativity: left
}
我们让操作符具有左结合性,使其优先求得操作符左侧的值。
现在让我们定义的|>操作符遵守这个ForwardApplication优先级组
infix operator |>: ForwardApplication
然后就可以将括号给移除了:
2 |> incr |> square
看起来非常像之前的链式调用:
2.incr().square()
操作符插曲
我们已经解决了函数嵌套的可读性问题,但是又碰到了一个新的问题:自定义操作符。自定义操作符并不常用。事实上大多数人因为其难以预计的风险而避免使用自定义操作符。一个典型的例子就是重载操作符。
比如在C++中,我们无法定义新的操作符,但是可以重载已有的操作符。如果我们打算写一个矢量库,我们可以重载+操作符用于求得两个矢量相加的结果,重载操作符求得两个矢量点乘的结果。然而,也可以表示两个矢量叉乘的操作,这样就使得*操作符的使用变得有些混淆和迷惑。
我们绝不建议在函数的实际应用中重载乘法操作符:
2 * incr // 这是什么鬼?
如果我们定义了这样一个操作符,将很难知道它到底是什么意思。
但是很幸运,我们这里没有这样的问题。我们是重新定义的操作符|>,Swift并没有预置它。也许有人会反驳说既然Swift没有这个操作符,那么Swift开发人员也没有必要知道和使用它,然而在其他语言比如F#,Elixir和Elm,都在使用它。熟悉这些编程语言的Swift开发者应该也很熟悉这个操作符。而且它看起来非常简洁美观。管道标识(|)的灵感来自Unix,意思是一个程序的输出作为另一个程序输入。箭头(>)也指向右侧,给我们直观地从左至右的阅读体验,
即使我们现在对这个操作符不太熟悉,也让我们继续下去,看看接下来的内容。
由于在Point-Free中我们会用到非常多的操作符,所以有必要给出这些操作符的规范和使用。下列是我们觉得在介绍新操作符之前必须了解的规则:
- 不能重载已有确定意义的操作符
- 操作符应该拥有简洁、直观的符号来表示其意义
- 不能为特定操作定义操作符,而只是应该抽象出非常通用,可复用的操作符
自动补全呢?
虽然操作符赋予了代码良好的可读性,但我们仍然忽视了代码编写时一个非常重要的特性:自动补全。
在Xcode中,我们可以输入一个值,随后就可以使用"."号来唤出其所有可调用的方法。甚至可以再输入一些字符让方法补全的范围缩小到想要使用的范围。
这是代码可查找性非常好的地方,但自动补全对方法的实现本身来说并不重要。纯函数也会有代码补全。
但这是顶层设计时应该考虑的问题,所以我们现在缺失了自定义操作时的代码补全功能,毕竟这些新的操作符并不会阻碍我们的开发环境理解它们,输入一个值,然后调用|>方法,应该有一个自动补全|>让这个值作为入参。希望这个问题可以再新版本的Xcode中得到解决。
>>>操作符
与此同时,纯函数的某些特性在普通方法的范围内也有无法解决的地方:函数的结合。两个函数中,如果一个函数的输出和另一个函数的输入匹配,就可以将两个函数粘连在一起形成一个新的函数,这里我们要介绍一个新的操作符 >>>:
infix operator >>>
很明显它看起来就是左结合或者右输出的操作符。我们来定义它的实现:
func >>> <A, B, C>(f: @escaping (A) -> B, g: @escaping (B) -> C) -> ((A) -> C) {
return { a in
return g(f(a))
}
}
这个函数有3个泛型约束,A, B, C。入参是两个函数f:(A) -> B,g:(B) -> C。f的入参是A类型,输出是B类型;g的入参是B类型,输出是C类型。将这两个函数结合在一起,形成一个新的入参为A类型的函数,并且将输出的B类型参数传递给函数g,返回值为C类型的新的函数。
现在我们将incr函数和square函数结合起来:
incr >>> square
也可以
现在有了一个新的Int -> Int类型的函数,先进行加1再平方。
也可以反转结合顺序:
square >>> incr
我们可以用常用的方法调用这个新的函数:
(square >>> incr)(3) // 10
上面的代码读起来可能不太流畅,可以使用之前的|>操作符增加可读性:
2 |> square >>> incr
不幸的是这时我们会得到一个错误:
Adjacent operators are in unordered precedence groups 'ForwardApplication' and 'DefaultPrecedence'
由于我们混用了两个操作符,Swift并不知道哪个操作符应该先使用。我们的想法是先将square和incr操作结合起来得到一个新的函数,再由2来调用。如果按照当前的顺序,2先调用square方法,得到Int型的返回值,再调用操作符>>>,显然类型不匹配,无法调用成功。
此时我们再使用precedencegroup来解决这个问题:
precedencegroup ForwardComposition {
associativity: left
higherThan: ForwardApplication
}
infix operator >>>: ForwardComposition
上面的代码可以看出,ForwardComposition比ForwardApplication拥有更高的优先级,在它们同时出现的时候会优先调用。现在我们的运算看起来会非常直观了:
2 |> incr >>> square // 9
但先别高兴得太早,我们还需要验证这个新的操作符是否符合之前我们提出的3个规则:
- 不能重载已有确定意义的操作符
- 操作符应该拥有简洁、直观的符号来表示其意义
- 不能为特定操作定义操作符,而只是应该抽象出非常通用,可复用的操作符
看起来全部满足!
方法的结合
在方法调用的世界中函数的结合是什么样呢?如果我们想要用函数式结合它们,除了为Int添加一个结合了两个方法的新的犯法外别无选择:
extension Int {
func incrAndSquare() -> Int {
return self.incr().square()
}
}
调用这个新的方法:
2.incrAndSquare()
工作正常,但是我们另外写了好几行代码,规定了调用的类型,在看看incr()和square()方法,只是整个场景中非常小的功能。如果这样的结合需要这么多的工作,我们是不是应该问问这是否值得呢?
与此同时,看看我们没有任何其他影响,即开即用的新操作符
incr >>> square
你可以进一步看看最小的有效组件的重用情况。即使我们删去部分的函数调用,整个程序依然工作正常。
2 |> incr
2 |> square
2 |> incr >>> square
如果只使用方法的形式,在没有输入的情况下却无法得到可编译的程序:
// 有效
2.incr().square()
// 无效
.incr().square()
.incrAndSquare()
正因如此,它们的复用性就没有那么好了!
也许在Swift的日常使用中我们感觉一直在使用方法而不是函数,但其实函数无处不在只是我们没有意识到而已。
一个日常使用中我们经常使用的便是初始化!它是一个返回当前类型值的全局函数。而且所有的初始化方法在我们的函数结合操作中无缝使用。我们可以将之前的操作和String的初始化方法结合使用来一窥管豹:
incr >>> square >>> String.init
调用:
2 |> incr >>> square >>> String.init // "9"
与此同时,在方法的世界中,无法将每个方法的调用结果链式传递到下一个方法,而必须调整方法的调用顺序。
String(2.incr().square())
除了初始化方法是纯函数的,标准库中还有大量操作函数将纯函数作为输入。比如Array,我们有一个map方法:
[1, 2, 3].map // (transform: (Int) throws -> T) rethrows -> T
map方法使用一个纯函数作为入参,并将Array中的每个元素都在这个纯函数的映射作用下输出T类型的新元素,这些新元素组成的新数组作为map方法整个的返回值。
一个典型的操作,将数组中的每个元素加1后再平方:
[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"编程风格中强调函数以及函数间的结合,所以我们几乎都不用关心所操作的数据。这也是我们这个讨论系列叫做"point-free"的原因!
在使用incr函数映射我们的数组后,再用square函数继续映射得到的结果和我们之前谈到的函数的结合是完全等价的。我们可以使用一次操作完成这个任务:
[1, 2, 3].map(incr >>> square) // [4, 9, 16]
这看起来酷极了!我们看到使用方法进行map时难以直观了解的操作如何在函数结合中得到清晰的展现。在这里我们可以看到map操作将两步操作合并成一个结合起来的函数,分发到各个元素。之后我们将会探索更多类似的操作!
目的何在?
让我们放慢脚步,问问自己:这样做的目的何在?我们为什么要做这些?这一节我们自定义了两个操作符,使用纯函数污染了全局命名空间,为什么不继续使用我们熟悉并且喜爱的方法式调用呢?
希望我们今天编写的代码为将函数引入日常工作提供了一个强力的佐证:函数可以在方法式调用无法完成的方式下结合起来。使用方法式调用将这些函数结合起来会费时费力,并且结合之后给阅读代码造成困难。仅仅构造出几个操作符,我们解锁了之前不曾看到的新的由小及大的结合型的世界,并且得到了我们所期望的代码可读性!
Swift也没有我们所担心的真正意义上的全局命名空间。我们可以为我们定义的函数定制多种使用范围:
- 可以为一个文件定义私有的函数
- 可以将函数定义为结构体或者枚举的静态成员
- 可以为模块定制函数。甚至可以在不同模块间定义同名的函数,并在不同的模块中定制这些函数。
一句话:“不必害怕函数”
我们在"Point-Free"中将大量使用函数。几乎很难找到我们某一节内容中不使用纯函数。我们将会在仅仅使用函数及其结合的情况下构造出复杂应用。在实践它们如何使用以及结合时,将会非常美妙、有趣。函数的结合将会帮助我们看到以前未知的世界。
这就是这节的内容,请继续关注我们!
网友评论