Functional thinking with examples in F# I
原创:顾远山
著作权归作者所有,转载请标明出处。
笔者最近在重读Neal Ford所著的《函数式编程思维》,相比五年前走马观花般略读,这次确有更深体会。原书列举了大量例子,基本上涵盖Java、Groovy、Scala和Clojure等语言,然而美中不足的是,作者和译者都没涉及.NET,更别提针对F#展开了。笔者针对原书第三章《权责让渡》,简辅以F#语言数例(非原例),仅当读书笔记,亦方便其他由命令式编程转函数式编程的开发者参考思路,望抛砖引玉,共同进步。
原书提到“函数式思维的好处之一,便是能把低层次细节的控制权移交运行时,从而消弭一大批注定会发生的程序错误。”另外,书中阐述了“五种向语言和运行时让渡控制权的途径,让开发者抛开负累,投入到更有意义的问题中去”。
迭代让位于高阶函数
对于传统的命令式编程,迭代(或循环)是主要的控制过程,通常由for
代码块实现,在C系语言中作为循环控制几乎无处不在,借用C#举例,遍历某个一维整型数组,并把所有元素的按行输出到控制台,代码如下:
var array = new [] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
for(int i = 0; i < array.Length; i++)
{
System.Console.WriteLine($"{array[i]}");
}
上述代码展现了迭代过程如何实现——引入一个名为i
的局部变量作为计数器,设定其初始值为0
,目标值为array.Length - 1
,每步迭代后计数器递增幅度为1
,而具体功能的实现则被放进{ ... }
代码块内。
函数式编程的思维则把迭代的实现抽象出来交给运行时,让开发者利用高阶函数直入主题进行具体功能的实现。这种方式在大部分只需要做简单遍历的场景是极其便利的,开发者无需关心迭代过程中的计数器,尤其对于嵌套循环(一堆对i
、j
、k
、l
的组合操作直叫人眼花缭乱失之毫厘差之千里),从而聚焦具体功能,省时省力且不易出错。
不妨用F#重写刚才的例子,如下:
[|1..10|] |> Array.iter (printfn "%d")
上述代码使用了高阶函数Array.iter
来实现迭代,计数器(初始值、目标值和步增幅度等)对于开发者而言完全是透明的。
到这里,有开发者也许会问,你用一个函数把原先用来实现迭代过程的计数器都封装起来了,但凡迭代就是千篇一律逐个元素遍历,操纵不了计数器岂不是意味着要放弃对迭代的灵活性?比如我不想从第一个元素开始,又如我不想到最后一个元素截止,或如我不想逐个而是跳跃遍历等……这些问题很实际,不过无须担心,函数式编程语言发展到现在,内建的很多高阶函数都可以控制迭代过程,它们形式相似但功能各异,足以应对现实中的复杂场景,实际上比传统的计数器更强大。
接下来我们会针对F#中用以控制迭代过程常用的高阶函数逐个进行举例。
iter
*.iter
相当于最基本的迭代了,函数名来自“迭代”对应英文单词iterate的简写,顾名思义就是对某个数据结构里的所有元素从头到尾逐个进行遍历,而且仅遍历,即遍历过程中对每个元素的单元操作不返回值。
不止列表类型List,F#中内置的其他Collection类型,包括数组类型Array、序列类型Seq(对应.NET其他语言的IEnumerable<T>)、字典类型Map和集合类型Set,都支持iter
这个高阶函数。其实你去Bing上搜索一下Microsoft.FSharp.Collections直达MSDN,会发现更多惊喜,简列表格如下:
Function | List | Array | Seq | Map | Set |
---|---|---|---|---|---|
iter | O(N) | O(N) | O(N) | O(N) | O(N) |
iteri | O(N) | O(N) | O(N) | - | - |
iter2 | O(N) | O(N) | O(N) | - | - |
iteri2 | O(N) | O(N) | - | - | - |
除了iter
函数之外,F#还提供了iteri
、iter2
、iteri2
三个函数进行元素遍历,简直都快玩出花来了。不妨简单看看它们的签名,如下:
其余三个函数具体用法与iter
相差无几,MSDN也有详例,本文就不再赘述了。
map
*.map
函数可能是函数式编程中最常用的高阶函数了,函数名本身直接取自“映射”的英文单词map,开发者可以使用map
函数在对Collection类型进行迭代的同时对其中的元素进行映射(施行变换)。
F#简例如下:
[1..10] |> List.map (pown 2)
输出的结果是一个整型列表,其中每个元素分别为2的1次幂、2次幂……10次幂:
[2; 4; 8; 16; 32; 64; 128; 256; 512; 1024]
若想得到的整型列表中每个元素值为原元素值的平方,只需对传入List.map
的函数稍作调整即可,如下:
[1..10] |> List.map (fun n -> pown n 2)
造成这个有趣结果的原因是F#中内建的pown
函数的第一个参数为底,第二个参数才是幂。
回到正题,如果对比iter
和map
函数的签名:
- List.iter:
('a -> unit) -> list<'a> -> unit
- List.map:
('a -> 'b) -> list<'a> -> list<'b>
我们会发现这两个函数非常相似,某种程度上可以理解成iter
相当于map
在'b
为unit
情况下的一个特例,只是结果把list<unit>
约简为unit
罢了。
reduce/fold
归约函数reduce
和折叠函数fold
在函数式编程中也被高频使用,它们的功能也非常通俗易懂,就是把一堆元素以某种方式揉在一起变成一个元素,至于某种方式具体是哪种方式,开发者可以通过函数的形式把它作为参数传入其中。最常见的例子(没有之一)就是求和,诸如对一个整型列表里所有的元素求和,用F#的reduce
函数很方便就能实现,如下:
[1..10] |> List.reduce (+)
用fold
函数实现则需要多传入一个作为初始状态的参数,如下:
[1..10] |> List.fold (+) 0
上面的代码纵然可行,但会被Visual Studio Code + ionide 框架里鄙视,F# Linter会友善地提醒你,这段代码可以直接用List.sum
进行重构,而List.sum
函数,恰好就是求和函数。
求和也许不是个有说服力例子,但它肯定不是唯一的例子——要是
reduce
和fold
函数只能用来求和,那未免有点太弱了。函数式编程的强大之处恰好在于函数,在F#中,函数是头等公民,函数可以直接作为参数传入其他高阶函数,求和的例子,被传入的函数为(+),倘若要求积,只需要把+
改成*
即可。无论如何,只要你能把糅合的规则用某个函数定义清楚,reduce
和fold
函数就能帮你把这堆元素糅合成一个元素,美其名曰“归约”或“折叠”。
不妨再举个例子:把一组英文单词,以空格为分隔符,按顺序组成句子。这一看就是典型的归约问题,给定一个字符串列表,通过指定规则,对列表中的字符串逐个迭代并归约成一个字符串,用F#简单实现,如下:
//using reduce
["Time";"and";"tide";"wait";"for";"no";"man"] |> List.reduce (sprintf "%s %s")
//using fold
["Time";"and";"tide";"wait";"for";"no";"man"] |> List.fold (sprintf "%s %s") ""
//result
val it : string = "Time and tide wait for no man"
通过上面的例子,我们可以发现reduce
和fold
两个函数也是高度相似,那它俩的区别是什么呢?还是直接对比函数签名吧:
- List.reduce:
('a -> 'a -> 'a) -> list<'a> -> 'a
- List.fold:
('a -> 'b -> 'a) -> 'a -> list<'b> -> 'a
这两个函数虽然都是把一堆元素糅合从一个元素,但从函数签名看,fold
比reduce
要灵活更多。首先,最直观的是类型不一样,fold
函数可以把一堆既定类型的元素糅合成一个其他类型的元素,而reduce
函数只能把一堆既定类型的元素糅合成相同类型的元素。其次,fold
函数可以指定不依赖于这一堆元素的初始状态,而reduce
函数没有所谓的初始状态。某种程度上可以理解成reduce
相当于fold
在'b
为'a
情况下的一个特例,只是初始状态为'a
类型的零值罢了。此处的“零值”因类型而异,如整型的零值是0
,字符串类型的零值是""
,是可以参与计算的有效值,跟null是两码事。
举个类似的例子更直接地展现二者的区别,如把某个字符类型的列表折叠为一个字符串,如下:
//using fold, OK
['F';'u';'n';'c';'t';'i';'o';'n';'a';'l'] |> List.fold (sprintf "%s%c") ""
//result
val it : string = "Functional"
//using reduce, KO
['F';'u';'n';'c';'t';'i';'o';'n';'a';'l'] |> List.reduce (sprintf "%c%c")
//result
error FS0001: Type mismatch. Expecting a
'char -> char -> char'
but given a
'char-> char -> string'
The type 'char' does not match the type 'string'
对于这个例子:如果直接使用fold
函数,你会得到一个预期的结果;要是直接使用reduce
函数,你会得到的只能是一个意外的错误。原因在报错信息里写得很清楚——“类型不匹配”。char
和string
是两种不同的数据类型,硬套reduce
函数是不合理的,若非要用,可以先用map
统一类型,如下:
//using map then reduce, OK
['F';'u';'n';'c';'t';'i';'o';'n';'a';'l'] |> List.map string |> List.reduce (sprintf "%s%s")
//result
val it : string = "Functional"
很多人或多或少知道map
和reduce
是好搭档,但不是所有人都知其所以然,而上例正好完美演绎了它们如何相得益彰。要是觉得效果还不够明显,我们可以再来一遍,如下:
//using map then reduce, OK, OK
[70; 117; 110; 99; 116; 105; 111; 110; 97; 108]
|> List.map (char >> string)
|> List.reduce (sprintf "%s%s")
//result
val it : string = "Functional"
map
负责转换,reduce
负责归约,两者各司其职,配合得天衣无缝。
filter
个人以为*.filter
是函数式编程思维中最最最直白的高阶函数了,就算你没有任何IT背景,不懂iter,不懂map,不懂reduce代表什么,你不可能不知道filter是什么意思。对,它就是那个意思。这个函数需要一个返回布尔类型的函数作为判断条件输入,才能对给定的Collection类型进行筛选。筛选也是极其常见的操作,比如在一个整型列表中,把小于5的所有元素筛选出来,可用F#实现如下:
[1..10] |> List.filter ((>) 5)
特别提醒:上例中,>
本身是操作符,但(>)
被表示为函数,类型为'a -> 'a -> bool
,其中函数的第一个参数为其左操作数,第二个参数为其右操作数,所以代码块x > y
的等价代码块是(>) x y
。所以,当我们需要筛选小于5的元素,我们需要传入的判断条件函数应为fun n -> 5 > n
,等价于fun n -> (>) 5 n
,柯里化后就变成了(>) 5
。关于函数式编程中的柯里化应用,笔者在本系列的后续小例中会展开细论。
小结
本文针对《函数式编程思维》第三章《权责让渡》的第一种途径——迭代让位于高阶函数,用F#语言列举了几个例子,展示了在函数式编程过程中,抛开for
循环,借用Collection类型的高阶函数同样可以实现迭代逻辑的处理,涉及的场景包括:
iter
map
-
reduce
/fold
-
filter
当然,F#中Collection类型的高阶函数还有很多,光fold
函数也还有另外三兄弟fold2
、foldBack
和foldBack2
,足以见得函数式编程语言针对不同的场景内建了极为丰富的高阶函数选择,而且通常都经过了运行时优化,大部分情况下,它们比开发者自己随手写的for
性能要更高,大可放心使用。