鸿 网 互 联 www.68idc.cn

F#与FP

来源:互联网 作者:佚名 时间:2022-06-10 15:42
F#与FP Written by Allen Lee 做回你自己 每当提到内向的性格,人们就会联想到"沉默,不爱说话"、"孤僻,不善交际"、"神秘,不够open"等个性特征。就连一些知名的心理学词典也使用了消极的

F#与FP

 

Written by Allen Lee

 

做回你自己

      每当提到内向的性格,人们就会联想到"沉默,不爱说话"、"孤僻,不善交际"、"神秘,不够open"等个性特征。就连一些知名的心理学词典也使用了消极的描述来定义内向,比如说,《心理学词典》(Dictionary of Psychology)把内向描述为"专注于自己的思想,回避社会交往,倾向于逃离现实世界",而《心理学国际词典》(The International Dictionary of Psychology)则把内向定义为"一种主要的人格特质,其特征是专注于自我,缺少社交能力,以及较为消极被动"。人们把内向的性格视为一种有问题的人格特质,而我们的社会也不断强调外向的性格才是健康发展的自然结果,无怪乎很多性格内向的人都羞于承认自己的本真,并承受着巨大的社会压力。

      类似的情况也发生在函数式编程的身上。我曾经在hubFS.net看到一篇帖子,作者说某天他告诉同事自己在业余时间里做了一些函数式编程,而得到的回应却是:"Functional programming - isn't that the long haired, dope smoking end of computing?"这个描述让我联想到一个性格非常内向的人,整天把自己关在房间里做计算,不理发、不洗澡、吃喝随便,偶尔出来买点东西就像野人出山,招来无数奇怪目光。如果这就是人们对函数式程序员的印象,那么你就不必对人们说函数式编程语言是数学家的语言或者只适合在大学里做研究时使用感到奇怪了。

      难道内向的性格真的一无是处吗?非也。最近在读《内向者优势》,它不但让我了解到内向的性格和外向的性格之间的差异只是由于不同的大脑机制,而且让我认识到内向的性格所具有的珍贵品性——"高度集中注意力的能力,对每个相关人员因变化而受到影响的体会,观察力,摆脱限制、思考问题的习性,作出不寻常决定的意志力,以及使外界放缓脚步的潜力"。我曾经在郑辛遥的《智慧快餐》上读到这样一句话:

做人累,大多是因为扮演了另一个自己。

或许,性格内向的人应该重新发现自身固有的价值,而不是试图把自己改造成性格外向的人。

      同样地,函数式编程也应该有它自己的一片天空。Michael L. Scott在《Programming Language Pragmatics》里介绍函数式编程的概念时曾经提到"Many programmers—probably most—who have written significant amounts of software in both imperative and functional styles find the latter more aesthetically appealing"。这段时间,由于F#的学习,我也如饥似渴地阅读着各种介绍函数式编程的文章,有时候我不禁在想:我们是否对函数式编程有太多先入为主的偏见,以至于我们无法更深入、更完整地了解它呢?如果是的话,那就太可惜了,因为我们否定函数式编程的同时也会错过那些本应得到重视的价值。或许,我们应该尝试了解函数式编程在哪些方面能有更出色的表现,而不是一味地排斥它。

 

今天的主角——函数

      在我印象里,map函数通常用来把一组元素按照一定的规则映射成另一组元素,例如,下面的代码把一个整数列表映射成由对应元素的平方组成的列表:

图 1

      有一次,我在《Programming Language Pragmatics》介绍高阶函数(10.5 Higher-Order Functions)的那节里看到map函数的一个"新玩法":

图 2

箭头左边是这个map函数在Scheme里的用法,而箭头右边则是输出结果。这次,它接受两个输入列表,映射规则是一个二元函数,将会应用到这两个列表里位置对应的两个元素,而运算结果将会放在输出列表的对应位置上。想想看,如果让你来实现这个map函数,编程语言不限,你会怎么做?(嘿,我建议你先想一下如何在你喜欢的语言里实现这个map函数,然后再继续读下去。)

      那天晚上,我躺在床上睡不着,突然来劲,就起来写下这段代码:

代码 1

这是什么?函数吗?哪些是参数?类型是什么?返回值呢?函数体怎么看?……冷静点!且听我慢慢道来。

      首先,map确实是一个函数,而且是一个高阶函数,你可能已在别的地方听过这个术语了,所谓的高阶函数就是指至少满足下列一个条件的函数:(1)接受一个或多个函数作为输入;(2)输出一个函数。读到这里,你可能会感到疑惑:"map函数什么时候接受函数作为输入啦?"没看出来是吧?仔细观察代码1,是不是充满了"类型不明"的符号?当你试图从头到尾阅读这段代码时,有没有头晕、胸闷、呼吸急速、手腕无力等感觉?如果有,那么你很可能患了"显式类型声明依赖综合症"。哈哈,开个玩笑而已。

      接着,把map函数的签名和图2里的代码做一个匹配,不难看出,f、l和r就是map函数的参数,其中,f是用于表示映射规则的二元函数,l和r则是两个参与运算的输入列表,然而,f、l和r具体又是什么类型呢?或许,你曾听说,F#的一大亮点就是它的类型推断系统,现在正是考验这个系统的最佳时机,看看它会把f、l和r三个参数以及函数的返回值推断成什么类型。在F# Interactive里输入map函数的代码,你将会看到如下输出:

图 3

这就是map函数的类型,你没听错,这确实是map函数的类型,它包含了参数和返回值的类型信息:

  • f:('a -> 'a -> 'b)
  • l:'a list
  • r:'a list
  • 返回值:'b list

其中,'a和'b是类型参数,'a list也可以写成list<'a>。由此可见,F#的类型推断系统已经成功推断出l和r两个参数以及返回值的类型是F#的(泛型)列表,至于f,类型推断系统则从map的代码中分析出它是一个二元函数。由于map函数的代码并未表明f的返回值和参数的类型相同,于是类型推断系统分别用两个不同的类型参数来表示它们,换句话说,f的返回值和参数的类型可以相同,也可以不同。

      然后,我们来看看map函数的实现。如果你对F#的语法还不是很了解,看不懂map函数的代码,那不要紧,我们暂时把语法放下,用我们自有的逻辑思维去尝试理解。"match…with…",字面意思就是"把…和…做个匹配",于本例,我们可以把它理解成"把 (l, r) 和下列情况做个匹配",紧接着,代码列出了三种情况以供匹配:

  • 第一种情况:l和r皆为空列表(在F#里,"[]"用于表示空列表,事实上,类型推断系统正是看到"[]"才得知l和r的类型是F#的列表),此时,map函数将会返回一个空列表。
  • 第二种情况:这里是整个map函数最有意思的地方,我们不再停留在列表的表面特征上,而是深入到它的内部结构,我们通过"lh::lt"来描述可以进一步分解成"头"(lh)和"尾"(lt)的列表,把分解出来的"头"(lh和rh)传给f函数(f lh rh),把分解出来的"尾"(lt和rt)递归传给map函数(map f lt rt),并通过"::"运算符把这两个函数的运算结果连接起来。在这里,我们可以看到,"::"运算符既可以用来分解列表,又可以用来合成列表,"->"左边是变换之前的形态,右边则是变换之后的形态,短短的一句话却包含了"理解"、"分解"和"再合成"三个步骤,如果你看过《钢之炼金术师》这部动画,你会发现,这其实就是动画里面描述的炼金术的三大步骤。
  • 第三种情况:其实它描述了两种情况,一种是l比r长,另一种是r比l长,即一个可以继续分解,另一个不能,无论是哪种情况,都意味着运算无法继续,于是我们抛了一个异常。

实际上,这种匹配的实现方式有个正式的名字,叫做"模式匹配",你可以试着把每种情况都理解成从一种形态到另一种形态的变换,或许这样更容易接受。由于我们要以递归的方式使用map函数,于是我们需要在声明它的时候使用rec关键字。

      最后,我们在F# Interactive里仿照图2的做法试一下map函数:

图 4

乘法运算符的参数和返回值的类型是相同的,下面,我们换一个参数和返回值的类型不同的函数来看看:

代码 2

这个函数返回一个Tuple,里面包含了a、b的值和它们的积,把图4的乘法运算符换成g函数再试一下:

图 5

现在,假如我要把输出列表以"2 * 3 = 6"(拿第一个元素来举例)的方式显示,那么我应该怎样做呢?F#提供了一个List.iter函数,它用途和List<T>.ForEach方法相似,我们可以向List.iter函数传递一个匿名函数,对每个元素调用printfn函数,问题是,我如何才能从每个元素里分解出我想要的三个数值呢?细心观察输出列表,你会发现,每个元素都符合 (x, y, z) 模式,于是,我们可以通过把每个元素"匹配"到这个模式,提取我们想要的数据(假设result是输出列表):

图 6

假如我只需输出每个元素的第三个数值,那么我可以通过"_"符号忽略其余两个数值:

图 7

从这里可以看出,模式匹配的更深层意义其实是在理解数据结构的基础上对数据进行分解和提取,而非通常认为的switch的山寨版。

 

调用函数

      一般而言,在调用函数(或方法)时,我们都会提供它所需的全部参数,或许,这种做法已经作为一种常识固化到我们的行为了,以至于我在这里显式地提及它可能让人有点不可思议。

      试想一下,假如函数的参数是在不同的时间从不同地方获取的呢?比如说,map函数的三个参数分别有三个不同的用户在三个不同的时间提供,换言之,每次只能提供一个参数。

      这时候,有同学建议创建两个辅助函数来"固定"map函数的头两个参数:

代码 3

这个方案不错,够简单,但存在一个小小的约束,拿map_with_g_and_l函数来举例,它的创建要在l这个参数已经存在的情况下才能完成,换言之,我得先把这些参数以变量的形式定义在某个地方,然后用它们来创建这些辅助函数,当我需要更改函数的参数时,我只需改变这些变量的值。说到这里,熟悉面向对象的同学可能会说:"你应该把这三个参数封装到一个对象里!"这个主意不错,我们可以用F#的Record Type来试一下(如果你对它不熟悉,可以先阅读《从C# 3.0到F#》的相关章节补充一些基础知识):

代码 4

接着,我们实例化一个Map<'a, 'b>对象:

代码 5

值得提醒的是,在F#里,null字面量对于函数和列表来说并非正常的值,所以它们不允许你直接使用null字面量,包括比较和赋值,但null字面量可以作为它们在异常情况下的值,代码4和代码5示范了两种使用null字面量的变通做法(事实上,F#不推荐使用null字面量,如果你确实有需要表达"可空"值,你可以使用F#的Option Type,有兴趣的同学可以使用Option Type重构代码4试试看)。然后,我们分别设置l和r的值,并调用Invoke方法:

代码 6

      对于熟悉命令式编程和面向对象编程的同学来说,上面这个思维过程是自然而然的,但熟悉函数式编程的同学可能会觉得我们把简单的问题复杂化了。在函数式编程语言里,函数默认支持柯里化(Currying),这使得分阶段提供参数成为可能:

代码 7

说到这里,你可能会问:"代码7和代码3有什么区别?"最简单的回答是:代码7的函数是计算出来的,而代码3的函数是定义出来的。无法理解?别着急,要理解这个区别,你得先搞清楚函数为何可以接受部分参数,而柯里化又是怎么一回事。

拿g函数(代码2)来举例,要使它支持分开提供参数,我们应该把它写成这样:

代码 8

从上面代码可以看出,g函数的参数实际上只有一个,它会返回一个匿名函数,这个匿名函数的参数也是一个。换言之,如果我的函数有N个参数,我就要在里面嵌套N-1个匿名函数,这种写法显然不够直观(比较一下代码2和代码8)。那么,柯里化又是什么呢?它在这里起到什么作用?在HaskellWiki上,柯里化的定义是这样的:

Currying is the process of transforming a function that takes multiple arguments into a function that takes just a single argument and returns another function if any arguments are still needed.

读到这里,我想你已经明白了,柯里化使以代码2的方式写的g函数获得以代码8的方式写的效果。换言之,在F#里,代码2和代码8是等效的。

      那么,计算出来的函数和定义出来的函数又有什么区别呢?为了让你能够看清它们的区别,我们把代码8的g函数修改一下:

代码 9

接着,我们在F# Interactive里分别以代码7和代码3的方式使用这个g函数:

图 8

h1是计算出来的函数,h2是定义出来的函数,请留意"slot"的输出位置,当我们计算h1时,g函数里的"printfn "slot""已被执行,而往后对h1的调用将不再执行这句;当我们定义h2时,g函数里的"printfn "slot""未被执行,而往后每次调用h2都将执行这句。发现区别了吗?当我们计算h1时,g函数的一部分已被执行了!你能想象得出一个函数能被分部执行意味着什么吗?事实上,计算h1的过程有个正式而且非常贴切的名字,叫做"Partial Application"。至此,我想你应该感受到代码7和代码3有着本质的不同了。

 

组合函数

      接下来,我们考虑一个新的需求,我在一个文件里保存了这些数据:

图 9

我要在控制台输出如下结果:

图 10

对照图9和图10,我们不难发现,图10输出的是图9的两列数据的积为偶数的算式,从图9到图10经历了如下过程:

图 11

      假如上述过程的每个步骤都对应着一个函数,那么,完成整个过程将会需要如下六个函数:

代码 10

现在的问题是,你会如何调用这些函数?一个常见的做法是:

图 12

然而,F# 提供了一个很特别的运算符——"|>",它使你可以用如下方式调用这些函数:

图 13

嘿!发现什么了吗?看看图11,再看看图13,我相信你已经看到我所看到的东西了。假如我要用一个函数来表示图11的过程呢?一个常见的做法是:

代码 11

此刻,我相信你应该很想知道process_and_print函数能否像图13那样保留图11的"形状",当然可以!F#提供了另一个很特别的运算符——">>",它使你可以用如下方式组合这些函数:

代码 12

如果说供应链体现了从采购原材料到把最终产品送到消费者手中的整个过程,那么代码12的"数据链"则体现了从读取原始数据到把最终结果输出控制台的整个过程。当我们把整条链架好后,剩下的就是"提供原材料"了:

图 14

 

高级话题

      回到我们的map函数,有同学说它可能会导致堆栈溢出,嗯,的确有这种可能,怎么办?我们可以把它改成"尾递归"(Tail Recursion):

代码 13

此外,你还可以通过本地函数"隐藏"它的实现:

代码 14

如果你想更深入地理解尾递归,我推荐你去看Chris Smith的《Understanding Tail Recursion》。

      你也可以把它改成CPS(Continuation-Passing Style):

代码 15

如果你对CPS没有了解,我推荐你去看Matthew Podwysocki的《Recursing on Recursion - Continuation Passing》和wesdyer的《Continuation-Passing Style》。

      接下来干嘛呢?布置作业!假设我有一个列表的列表:

let have = [['a';'b';'1'];['c';'d';'2'];['e';'f';'3']]

我想把它变成这样:

let want = [['a';'c';'e'];['b';'d';'f'];['1';'2';'3']]

我该怎么做?这道题目是在Jomo Fisher的博客上找到的,那里有很多人给出不同语言的实现,你可以用你最擅长的语言来试一下。

 

包容"新"事物

      函数式编程已经不算什么新事物了,可它为何就是普及不起来呢?是因为它不贴近实际?是因为它的理论门槛太高?还是因为我们有了更好的选择?在给出我的看法之前,我想和你分享一个我在杰拉尔德·温伯格的《咨询的奥秘——咨询师的百宝箱》里看到的小故事:

某铁路公司的官员拒绝了民众在某处设立停靠站的要求,因为在进行一番研究后,他们发现没有任何旅客在停靠时间内在该站台等候——当然,因为那时该列车并不准备停靠该站,旅客没有任何等待的理由。

你能看出个中的矛盾吗?因为某个站台没有设立停靠站,所以旅客没有在此等候,因为没有旅客在此等候,所以该站台无需设立停靠站,这是什么逻辑?温伯格把这个"逻辑"总结为"铁路悖论":

因为服务太差,人们对更好服务的要求被予以拒绝。

试想一下,如果用人单位总以缺乏经验为由拒绝招收应届毕业生,那么他们就会失去增长工作经验的机会,从而导致更多用人单位以此为由拒绝招收他们。经验是过去的写照,它无法代表现在,更不能预示未来,很多企业一边用狭窄的眼光来看待人才,一边又大喊没有人才,每当此时,我都会不禁想起郑辛遥在《智慧快餐》里提到的一个问题:

缺乏人才,还是缺乏容纳人才的机制?

函数式编程通常被认为是"学院之物",它只适用于学术研究,所以人们认为没有必要让它进入"现实世界",它也因此失去踏出学院大门的机会。试问,你可曾静下心来了解过函数式编程?抑或是跟随众人的看法重演一次"小马过河"?

      自从微软宣布把F#产品化,很多人就开始问F#和C#哪个更好?为何他们如此关注这个问题?为何他们认为F#和C#是相斥而不是相承的?当今社会,竞争激烈之程度远胜以往,优胜劣汰的观念也深深扎根于人们的思想之中,新旧更替的现象更是见惯不怪,以至于人们在面对新事物时的第一反应几乎都是"它将要取代什么"。当今社会,发展迅猛之程度令人咂舌,人们要承受的东西似乎已经太多了,以至于稍稍停下来做点有益思考的时间都支付不起,更别说了解另一个可能有着巨大差异的世界观。无力包容多个不同的世界观会迫使你必须从中选择一个,而这又会在你的潜意识种下这样一个观念:我必须选择最好的那个。这个观念的异化会导致你选择编程语言就像奴才选主子一样,必须谨慎决定,以免选错了主子会连累自己的将来。试问,是编程语言为程序员服务,还是程序员为编程语言奴役?

      牛顿说物体具有维持原来状态的惯性,人的思维和行为又何尝不是呢?李子勋在《心灵飞舞》里说:

每一种理论给人的视觉与感知觉创建了一种关联现实,我们越是欣赏一种理论,我们受到这种理论的制约也越多,我们的视觉与感知觉也越窄。如果我们还有无意识地否定其他理论的心理倾向,那么我们基本上就可以被称为某种理论的"囚徒"。越是深刻地相信和依赖一种理论,人们的认知能力越会被理论慢慢地缩窄到一个非常可怜的境地,直到完全失去心灵与感知的自由。

还记得自己有过多少次为了符合某个理论而采取某个措施而不是为了解决某个问题而选择某个理论吗?不同的编程思想就像看待事物的不同角度,无论你今天用哪个角度来看待事物,多一个选择总是有好处的,只要你没有在这些选择中迷失方向。

网友评论
<