欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

《编程机制探析》第十七章 函数式编程

程序员文章站 2022-05-17 13:44:06
...
《编程机制探析》第十七章 函数式编程

当我们能够像掌握循环一样熟练地掌握递归之后,我们就可以正式向函数式编程进军了。当然,即使我们还没有熟练掌握递归,我们还是可以向函数式进军。我们可以在学习函数式编程的过程中,逐步习惯递归的写法。
函数式编程并非主流编程模型,函数式语言亦非主流编程语言。但我们在描述某些具体问题时,不可避免地要涉及到一些代码。本书会尽量选择有代表性的函数式语言。本书的选择是Haskell和ErLang这两门函数式语言。
Haskell是一门学术性很强的、特性很全面、形式很正规的函数式语言,很适合用来描述函数式编程的一些重要核心概念。因此,Haskell是一种常见的教学语言。本书也不能免俗。
ErLang是一门比较实用、对分布式计算支持比较好的函数式语言。本书选择这门语言,出于两个理由。一是ErLang简单易懂,学习成本比较低。二是ErLang在函数式编程中还算是应用比较广的,学习这门语言的回报率也相对大一些。
本书在讲解函数式编程的时候,仍然遵守一贯的原则:尽量使用文字——而不是代码——来描述基本原理;遇到具体代码时,只对特殊的语法现象进行解释,不讲解语法方面的基础入门知识。
我们首先讲解函数式编程语言的两种核心数据类型——Tuple(元组)和List(列表)。
Tuple类型类似于命令式语言的数组,其中元素数据类型不定。Tuple类型用圆括号()来表达。比如,(1,”abc”, 2.5, ‘c’ )就是一个长度为4的Tuple。
比如,(1, (2, 3))就是一个长度为2的Tuple,其中第二个元素也是一个长度为2的元组(2, 3)。
在函数式语言中,同其他数据一样,Tuple只能在创建时赋值一次,以后就再也不能改变了。因此,Tuple一旦创建,其长度和内容都已经固定了。
在有些函数式语言中,Tuple用大括号{}来表达,含义和用法是一致的。
长度为2的Tuple叫做二元组。在有些函数式编程语言中,二元组也叫做Pair,即数据对。二元组在函数式编程模型中的地位十分重要。因为,在函数式编程模型中,二元组是另一个核心数据类型——List(列表)——的构成基石。
List是一种链表结构,每一个链表结点都是一个二元组,其中第一个元素叫做head(头),存放数据内容,第二个元素叫做tail(尾),存放着后面的二元组。所以,List本身就是一种特殊的二元组。
先让我们明确一个概念的用法和写法。在Java、Python、Ruby等语言中有一种表示“无”、“空”的概念,写做null。在函数式编程中,也有同样的概念。在有的语言中写做null,在有的语言中写做Nil。为了统一起见,在没有明确指定具体语言时,我们都用null。
我们从最基本的List例子看起。(15, null)这个二元组就是一个长度为1的List。这是怎么说的呢?在这里,List的数据只有一个——15。表示下一个List元素的尾部数据为null,就是说这个List已经结束了。因此,这个List的长度为1,其中只有一个元素15。
在函数式语言中,List并不是一种特殊的类型,而是Tuple的一种——二元组。本来,使用Tuple结构——即圆括号()——来表示,已经足够。但是,List在函数式编程中应用如此普遍,函数式语言为List结构提供了很多语法上的直接支持,提供了很多List的简写方法。
这种简化语法形式并非代表着实质的结构,而是为了读写方便。这种做法有个名字,叫做语法糖。
在函数式语言中,List的简写形式就是方括号[]。前面的例子中,(15, null)就可以写成[15]。
下面我们再来看两个元素的List。(14, (15, null))这个嵌套的二元组,就是一个拥有两个元素的List。这是怎么说的呢?在这里,最外层的二元组中,头元素(head)用来存放数据内容14,尾元素(tail)用来存放下一个二元组(15, null)。在下一个二元组中,,头元素(head)用来存放数据内容15,尾元素(tail)用来存放下一个二元组null。由于tail是null,就表示List结束。于是,List的长度就是二元组的嵌套深度——2。这个List可以简写为[14, 15]。
同理,我们可以写出长度为3的List。(13, (14, (15)))可以简写为[13, 14,15]。更长的List可以同理类推,不再赘述。
通过上面的例子,我们可以发现List的一个重要特性——List是从后向前,即从尾到头来构造的。
我们无法从头到尾来构造List,我们只能从尾到头来构造List。这是为什么呢?这是因为,List是由嵌套的二元组构成的,而且是头元素二元组,嵌套尾元素二元组。在函数式语言中,变量只能在声明时赋值一次,之后就不能改编。我们不可能先创建一个二元组A,再把另一个B二元组设置到A二元组的尾元素中去。被包含的二元组永远只能先被创建。而尾元素才是被包含的二元组。因此,List只能从尾到头来构建。
但是,当我们访问List的时候,却只能从头到尾来访问。因为,我们读取一个List的时候,只能从最外层的头元素来访问。一个List的遍历方法只能是从头到尾。
针对这个特性,函数式编程语言又引入了一个语法糖——[head | tail],用来标志一个List的头元素和之后的尾元素(也是一个List)。其含义与二元组表达方式(head, tail)完全一致。
函数式编程语言都支持这个[head | tail]语法糖,而且这个语法糖远远比(head, tail)这种最原始的表达方式应用得广。
其中的道理,我不是很明白。也许是为了表达上的一致性,也许是为了强调说明这是一种List类型,增加可读性。
有了这个语法糖,List的表达方式又多了一种。
[13, 14, 15]可以表达为[13 | [14, 15]],也可以表达为[13 | [14 | [15]]],这和最原始的二元组表达为(13, (14, (15, null))),已经非常接近了。另,这个List还可以写成[13 | 14 | 15]。
在有些函数式语言中,这种语法糖与最原始形式的元组比较相近,使用()圆括号包裹List元素,使用 : 分隔头元素和尾元素。比如,(1 : 2 : 3 : [ 4, 5, 6])。这种表达方式等价于[1, 2, 3, 4, 5, 6]。
无论是[head : tail],还是[head | tail],其真实结构都是二元组(head, tail)。
函数式语言的初学者经常遇到的问题之一,就是面对各式各样的List表达方式而无所适从,不明所以。
只要我们时时刻刻记住List的二元组本质,就不会被函数式语言中眼花缭乱的List表达方式迷昏了头。
现在,我们来看一个颇有迷惑性的例子。[ [1, 2] | [3, 4]]。这个List是什么意思呢?这是一个长度为3的List。头元素数据本身就是一个List——[1, 2]。整个List用二元组原始形式来表达就是((1, (2, null)), (3, (4,null)))。
明确了List表达方式之后,我们就可以来研究List这个数据结构的本质特性了。前面分析过了,构造的时候,List只能从尾到头构造;访问的时候,List只能从头到尾访问。这是个什么数据结构呢?
答案已经呼之欲出了。对了,这就是一个栈结构(Stack)——我们经常遇到的、先入后出、后入先出的栈结构。只不过,List这个栈结构并不是基于数组结构,而是基于二元组链表结构来建立的。
我们要牢牢地记住,函数式编程中的List是一个栈结构,而且仅仅是一个栈结构,只能当作一个栈结构来用,不能当别的数据结构(比如队列Queue)来用。
我为什么要不厌其烦地强调这个问题呢?因为List这个名字很有迷惑性。命令式程序员一看这个名字,通常就产生一种误解,以为这是一种可以在尾部添加数据元素的队列。
我在这里明确地断定:不能。函数式List不能在尾部添加数据元素,因此,不能当作先入先出、后入后出的队列结构来用。函数式List只能在头部添加数据元素,因此,只能当作栈结构来用
本书有一个原则,尽可能多讲基本原理,尽可能将必须涉及到语法和代码的部分向后推。毕竟,一门陌生的新语言看起来总是面目可憎的。
在讲述函数式语言基本语法和代码之前,我们先把List相关的基本操作算法过一遍,这样心里才有数,后面接触到具体语法和代码时,也不会感到有太大的障碍。
List操作在函数式语言中非常重要,所有的函数式语言都有一套专门针对List操作的List函数库。
我们自己设想一下,List函数库中都有那些函数?
懂得比较多的读者,可能会说,当然是MapReduce了,大名鼎鼎的Google搜索引擎用的就是C++语言的MapReduce库。
这个答案是正确的。Map和Reduce是两种不同的List基础操作,List函数库必然会支持Map和Reduce这两种算法。不过,Map和Reduce这两个算法并非最基础的List操作函数。
我们继续问问题,最基础的List操作函数是什么呢?
比较实际的读者,深思熟虑片刻之后,脸上露出了笑容,道:“我知道了,是长度函数。用来求List的长度的函数。”
这个答案,咳,也没错,确实非常基础。所有的List函数库都支持。我们就来看这个函数应该如何实现。
这还用说吗?有读者已经不耐烦了。当然是从头到尾遍历一遍,统计元素的个数了。
没错,就是这样。如果List比较长,并且计算长度的操作很多的时候,我们可以自己做一些优化,另外用一个数值来存放当前List的长度。比如,这样一个二元组,(length, list)。其中length就是list的长度。当list增加一个头元素的时候,就形成一个新的二元组,(length + 1, [head | list])。当list减少一个头元素的时候,也形成一个新的二元组,(length – 1, tail list)。
关于计算长度的内容,也就这些。计算长度,确实是非常基础的List操作,但却不是我想问的。还有呢?还有没有其他的、非常基础的List操作?好好想想?
哎,算了,我不卖关子了。直接给出答案吧。List函数库中一个非常非常基本的操作就是,从头到尾,反转List。
这是为什么呢?因为List只能从尾到头构造。而通常来说,我们获取数据的时候,都是从头到尾获取的。
这里可以采取两种方案——尾递归算法和非尾递归算法。
当采用尾递归算法时,我们从头到尾获取数据,并根据数据构造List时,我们构造出来的List是反顺序的。最先读出的头数据实际上放进了List中最内部的尾结点(二元组中)。当这个List构造完毕之后,我们必须把这个List倒过来。
List反转操作的实现也非常简单和直观。List虽然构造的时候是从尾到头的,但是,访问的时候,却是从头到尾的。我们只需要从头到尾再访问这个List一遍,然后,根据读出来的元素,再重新构造一个List,得到的新List自然是反转过来的。
当我们采用非尾递归算法时,我们就可以根据一个List从头到尾地构造另一个List。但这种算法的实质是这样:先从前往后遍历,递归一步步深入进栈,把遇到的所有元素都处理一遍,所有的中间结果都保存在运行栈中,直到遇到最后一个尾部数据,递归开始退栈,从尾部向头部开始构造List。这种算法虽然避免了尾递归算法的List反转,但是,空间上的开销却多于尾递归算法,而且,进栈出栈也有时间开销。
对于我们来说,我们考虑List算法时,还是首先要考虑尾递归算法,养成这种习惯。这就不可避免地要遇到List反转的情况。
以下的算法分析,除非特别声明,全都采用尾递归的解决方案,也就是说,全都涉及到List反转操作。
当我们在函数式编程使用尾递归时,一定要对List的元素数据顺序保持高度的关注和敏感。
现在,我们来考虑另外一个常见的List操作——两个List拼接——的算法。
假设有两个List,一个是[1, 2, 3],另一个是[4, 5, 6]。我们想把这两个List拼接起来,变成一个List,[1, 2, 3, 4, 5, 6]。我们应该如何做?
对于任何List操作问题,我们要时刻在头脑中记住一个原则——所有的List都是从尾到头来构造的。我们考虑List算法的时候,也要根据结果List从尾到头来考虑。
在这个例子中,我们想要达到的结果是[1, 2, 3, 4, 5, 6]。按照List的构造顺序,这个List应该这样构造。首先要构造[6],然后构造[5, 6],然后构造[4, 5, 6]。当然,我们已经拥有[4, 5, 6]这个List了。我们可以在这个基础上继续构造,过程仍然是从后往前的。我们首先要得到[3, 4, 5, 6],然后再得到[2, 3, 4, 5, 6],最后得到[1, 2, 3, 4, 5, 6]。
根据这个过程,整个算法的流程就应该是这样。首先,我们要把[1, 2, 3]这个List反转过来,变成[3, 2, 1]。然后,我们从头遍历[3, 2, 1],依次把3、2、1三个元素添加到[4, 5, 6]的头部,最后得到结果List。
这个例子可以继续深化为把多个List拼接在一起的算法。比如,把[1, 2, 3]、[4, 5, 6]、[7, 8, 9]、[10, 11, 12]。
由于这些List的个数是不定的,我们可以把它们都放到一个数组里。这个问题就转换成,一个List中的每一个元素都是一个List。我们如何把这个List中的所有List都取出来,拼在一起,形成一个List?具体到这个例子,就是。
all_lists = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]
我们要把all_lists里的所有List都取出来,并拼在一起,组成一个大的List,[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]。
同样,我们需要根据结果List,从尾到头来考虑整个算法。我们已经拥有了[10, 11, 12],我们需要在此基础上依次构造[9, 10, 11, 12],[8, 9, 10, 11, 12]…直到最后的结果,[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]。
我们先要把[7, 8, 9] 这个List反转过来,变成[9, 8, 7],然后,把9、8、7依次放入到[10, 11, 12]的头部,依次形成[9, 10, 11, 12]、[8, 9, 10, 11, 12]、[7, 8, 9, 10, 11, 12]。
之后,我们再把[4, 5, 6]反转成[6, 5, 4],把6、5、4依次贴到中间结果List的头部,然后再把[1, 2, 3]反转成[3, 2, 1],把3、2、1依次贴到中间结果List的头部。最后得到最终结果。
综上所述,此种问题的通用算法应该是这样。首先,把整个List反转。这时候,List中的最后一个子List(在这个例子中,就是[10, 11, 12])就变成了第一个List(即List头部)。这个List头部是我们构建大List的基础,我们取出这个List头部,不动它。剩下的tail List中,就是那些需要依次反转、依次粘贴到中间结果List头部的其它List。
我们看到,这个算法中存在多次List反转。
我们再把这个例子扩充为这样:一个List的树形结构,全部展开成一个扁平的List。
这个操作在List函数库中叫做flatten(扁平化)操作。
这个算法就更复杂了,涉及到的List反转操作就更多了。不过,无论数据结构多么复杂,原则都是一样的,我们首先要考虑List的尾部。
由此可见,List反转操作在各种List操作中是多么的重要。复杂情况下,List反转操作如此之多,一不小心,就很容易进行重复的、不必要的List反转操作。我们在头脑中必须绷紧这根弦,时刻谨记着List反转操作,并避免在算法中进行重复的List反转操作。
对于函数式程序员来说,List反转操作,是除了尾递归之外的另一个重要的基本功。这也是为什么我不厌其烦地强调List反转操作重要性的原因。
我是一个命令式程序员,对函数式编程完全是半路出家。我的这些经验体会,都是在一头雾水中摸爬滚打中积累出来的。我希望,这些经验体会能有效地帮助想学习函数式编程的命令式程序员,达到少走弯路、节省时间的目的。
List函数库中的其他的一些List操作,就是一些老生常谈了。我们简单地过一遍。
Map操作,这个名字和Java里面的Map接口一样,但两者除了名字中都有映射的含义,其它没有太多的共同点。函数式编程的Map操作是这样一种操作,对一个List中的所有元素都进行某一种操作,(比如,加1,乘2之类的操作),并根据计算结果生成一个新的List。
Map操作的算法很简单,就是从头遍历List,对每个元素进行计算,把计算结果添加到一个新List中去。遍历结束后,新List也构建完了。
等等,这就完了吗?不,还没有完。不要忘记,这时候构造出来的新List是反顺序的,并不能和原来的List形成映射关系。所以,我们还要把构造出来的List反转过来。可见,List反转操作无处不在,不可轻忽。
Reduce操作是一种统计操作,遍历List中的所有元素,进行一种统计计算(比如,计数,求和,求乘积等),最后得到一个结果值。Reduce的含义是缩减。用在这里,其寓意就是把一个List缩减为一个结果值。
在很多函数式语言的List函数库中,Reduce算法被写成fold(折叠)。其寓意也是相同的。把一个List折叠成一个结果值。
fold算法(即Reduce算法)有两种版本,一种是从头到尾遍历(从左到右),进行统计,这种算法写成foldl(即fold left,从左到右进行fold);一种是从尾到头遍历(从左到右),进行统计,这种算法写成foldr(即fold right,从右到左进行fold)。
foldr算法自然是要先把List反转一遍。这里,我们又遇到了List反转这个老熟人。
有一个英语翻译的笑话是这样讲的。
“How are you?”
中文翻译:怎么是你?
“How old are you?”
中文翻译:怎么老是你?
每次遇到List反转,我都会有这种感觉,“怎么老是你?”
Map和Reduce算法都接受一个操作函数作为参数,用来对List中的元素进行操作。这是一个很明显的Visitor模式。那个作为参数的操作函数就是Visitor函数。
注意,在命令式的面向对象编程中,Visitor是一个对象,算法调用的是Visitor的visit方法。但是,在函数式编程中,函数本身就是一种作为参数和返回值的对象。所以,这里我们作为参数传递给算法的就是Visitor函数本身。
不仅Map和Reduce算法是Visitor Pattern,List函数库里的几乎所有算法都是Visitor Pattern。这是显而易见的。因为List就是一种集合结构,其算法基本上就是遍历算法。而遍历算法只有两种设计模式,一种是Iterator Pattern,一种就是Visitor Pattern。既然函数式编程不支持循环和Iterator Pattern,那唯一的选择就是递归和Visitor Pattern。
List函数库中,有一个Filter算法,类似于Map算法,也是接受一个List参数,返回一个List结果。
Map算法和Filter算法的不同之处如下。
Map算法接受一个Visitor函数,返回的List长度与原来的List相同,两个List之间存在着一一对应的关系。
Filter算法也接受一个Visitor函数;不过,这个Visitor函数是一个筛选函数,根据当前元素的值来决定是否保留这个元素;这个Visitor筛选函数的返回值为真,则保留该元素,这个元素将被加入到结果List中;这个Visitor筛选函数的返回值为假,则过滤掉该元素,不把这个元素加入到结果List中;因此,Filter算法返回的结果List的长度和原来的List通常不相同,而且,两个List之间也通常失去了一一映射的关系。
Filter算法不难理解,前面的讲解设计模式(组合模式,Composite Pattern)的章节中,有一个FileFilter的例子,那就是一个典型的Filter算法。
同样,Filter算法是Visitor Pattern。
同样,Filter算法的最后,需要一个List反转的操作。(唉,怎么老是你?)
如果我们不想老是遇到List反转,我们可以采用非尾递归算法。对于List来说,非尾递归算法写起来简单直观,这里就不多费口舌了。等我们学习了函数式语言具体语法之后,我们再来看具体的代码。
Map、Filter、Reduce是List函数库的三种基础算法。用得多,也容易理解。List函数库中还存在着很多其他算法,比如,排序算法,查找算法,统计算法(最小值,最大值,平均值),取出头N个元素,取出第N个元素,取出最后一个元素,等等。还有前面前面举的List合并的例子,还有树形List结构“扁平化”(flatten)的情况,都属于List函数库中的算法。还有其他林林总总的List算法。这些算法中,有些是Visitor Pattern,有些不是。
有些算法过于简单和常见,比如,排序算法,查找算法,统计算法(最小值,最大值,平均值),取出头几个元素,取出某个位置的元素,等等。有些算法比较复杂,而且比较少用。这些算法我们都不讲。
前面讲述的List算法——Map、Reduce、Filter——全都是List函数库中最有代表性、最能体现函数式编程思路的List算法。掌握了这几个基本的List算法之后,我们就可以比较彻底地理解函数式语言针对List的一种更加强大的语法糖——List Comprehension。
Comprehension这个词的本意是理解、领会的意思。用在这里,我也不知道该怎么翻译。有些资料翻译成List推导,但我不能领会其深意。这里,我们就直接用英文原词。
List Comprehension的语法结构是这样的,一个双竖线分隔符 || 把结果List和源List分开。双竖线分隔符 || 前面的部分,定义的是结果List内容的运算表达式,实际上相当于一个Visitor函数。双竖线分隔符 || 后面的部分,定义的是源List的内容,实际上相当于List遍历算法,其中还可能包括过滤条件,相当于一个起过滤作用的Filter Visitor。
我们从最简单的List Comprehension例子看起。
[ 2 * x  | | x <- [1, 2, 3] ]
这个List Comprehension表达式的含义是,x来自于源List,即[1, 2, 3]。而结果List中的每个元素是2 * x。得到的结果List是 [2, 4, 6]。
很明显,这是一个Map操作。2 * x 就是 Visitor 函数的内容。
我们再看一个例子。
[ 2 * x  | | x <- [1, 2, 3] ,  x > 1]
这个List Comprehension表达式里面添加了一个过滤条件,x > 1。得到的结果List是[4, 6]。
这是一个Map操作和Filter操作的组合。首先进行的Filter操作,只保留源List——即[1, 2, 3]中大于1的元素,得到的中间结果List为[2, 3]。这里,x > 1 就是 Filter Visitor函数的内容。得到了过滤结果 [2, 3]之后,再针对这个[2, 3]进行Map操作,得到[4, 6]。
这是最简单的情况,源List只有一个。List Comprehension还可以支持两个源List。比如:
[ x + y || x <- [1, 2], y <-[10, 20] ]
这个List Comprehension表达式里面有两个源List。第一个源List的所有元素,都需要和第二个源List中的任何一个元素配对,组成一对参数,实际上,是一个二元组结构。这就形成了一个类似于笛卡尔乘积的大List。我们姑且称之为笛卡尔List。
产生这个笛卡尔List的操作,实际上是两个Reduce操作的嵌套。第一层Reduce操作里面,
针对第一个源List的每一个元素,都进行第二层Reduce操作。在第二层Reduce操作里面,来自于第一个源List的元素,和第二个源List中的每一个元素配对,形成一个二元组,添加到Reduce的中间结果里,即一个不断积累的元素数据内容为二元组(x, y)的List。
然后,针对这个笛卡尔List进行Map操作,这里的Visitor函数就需要操作一对参数——二元组形式的(x, y)。
注:笛卡尔乘积是集合论中的简单概念。这里不赘述,就当作读者已经掌握这个基础知识了。如果不知道这个知识,请自行查阅。
这个List Comprehension表达式的结果是[11, 21, 12, 22]。
我们还可以给给这个List Comprehension表达式加上条件。
[ x + y || x <- [1, 2], y <-[10, 20], x > 1 ]
得到的结果是[12, 22]。
x > 1是其中的过滤条件。现在,有一个问题。这个过滤条件放置的位置存在两种方案。
第一种方案,先求出笛卡尔List,然后对笛卡尔List进行过滤。
这里,我们就需要先得到笛卡尔List,即[(1, 10), (1, 20), (2, 10), (2, 20)]。
然后,对该List进行Filter操作。这时候,Filter Visitor函数接受一个二元组(x, y)作为参数,该函数返回 x > 1 的结果。
最后,对过滤后的List,进行Map操作。
第二种方案,先过滤[1, 2] 这个List,得到[2],然后再进行笛卡尔乘积操作,最后再进行Map操作。
很明显,第二种方案更优。但是,第一种方案更加通用,适用于过滤条件同时涉及到两个源List的时候。
List Comprehension语法处理器可能会采用这样的优化策略。如果过滤条件同时涉及到两个源List,那么,就采用第一种方案。如果过滤条件只涉及到一个源List,就采用第二种方案。
需要特别说明的是,这里用的“笛卡尔”List只是一种为了方便描述而生造出来的词汇。前面描述的过程中,笛卡尔List的生成,和最后的Map操作分成了两步。但这是不必要的。这两步完全可以合成一步,笛卡尔List并不需要被真正创建。
在实际的算法中,笛卡尔List可能只代表着一种两层的嵌套递归结构,具体来说,就是两层嵌套的Reduce操作。在这两层Reduce操作中,Map操作中的Visitor函数可以放在第二层Reduce操作中直接进行——根据(x, y)计算结果,添加到结果List中。这样,笛卡尔List就不用真正被创建。
这里描述的List Comprehension实现是我自己的设想思路。并不一定与真实情况一致。对这方面感兴趣的读者,可以对函数式语言的编译结果进行反编译,从中可以看到解开语法糖衣之后的真实算法。
在有些函数式语言中,List Comprehension表达式中的双竖线分隔符 || ,有时也写成单竖线分隔符 | ,其含义和用法是一样的。
相关标签: 编程