Functional Programming For The Rest of Us 翻译,重译 (剩人们的函数式编程)

 

原作者: Slava Akhmechet(译者注:这哥们不像marting fowler那样有以自己名字命名的网站,原文链接真挺难找的,但是我还是TMD找到了,http://www.defmacro.org/,这好像是这哥们的个人网站,里面有他所有的文章链接,包括这篇)

 

原文链接:http://www.defmacro.org/ramblings/fp.html

 

译者:Zeta

 

转载请标明出处,不要改动此文章

 

 

译注:本篇文章原作者在片尾写了很多注解,为了让大家不至于在所读的段落和片尾之间滚来滚去,我把翻译后的注解放在出现的段落后面。

 

译注2翻译过程中查资料的时候发现有人翻译过(名称:函数式编程另类指南),不过本着学习原文与英语的精神重译一遍。

 

译注3已经重译过两篇别人翻译过的文章了,在翻译即将结束的时候感觉,自己翻译一遍真的有助于自己理解。并且希望自己是一个专业技术人员的同时,英语和语文的水平同样有所提高。技术文章的翻译(以及任何文章其实都是)个人理解是这样的,以自己的技术和英文理解能力吃透作者的意思,再用准确的语文水平表达出来。所以翻译一篇文章后,三者都得到了长足的发展,深感受益。持续精进中。

 

介绍 

 

         程序员全都有拖延症。进办公室,沏点儿咖啡,查查邮件,读读RSS上的回复,读读新闻,看看技术网站上最新的文章,看看技术论坛相关板块上的政治辩论,反复确认自己没落下什么。该吃午饭了,然后回来,盯着IDE看几分钟。检查邮箱,沏点咖啡。不知不觉的一天就这么过去了。

 

         你会发现一件事,隔那么一阵子的确有那么一些有挑战性的文章就会出现。如果你看对了地方,那么隔几天至少会有那么一篇。这些文章有些时候比较晦涩难懂,所以就开始堆积起来。你渐渐的有了一堆链接还有一个装着一堆PDF的文件夹,你希望能有一年的时间能呆在四周荒无人烟的丛林小屋里把它们看完。如果能有个人在每天早上你去河边散步的时候给你带点吃的并且把垃圾拿走那就更好了。

 

         我不知道你的列表是什么样的,但是我的上面有一大堆都是关于函数式编程的。这是一般来说都比较难懂。那种无聊的学术语言(Academic Language),连“在华尔街混了十年的工业达人”都不知道关于函数式编程(也简写为FP)的那堆文章写的是什么。如果你问花旗银行或者德意志银行的项目经理(原作者注1):“为什么用JMS替代Erlang?”时,他们会回答:“我们不能在工业强度的应用程序上应用学术语言(Academic Language)。”。可是问题是,一些很复杂并且有刚性需求的系统也是用函数式编程语言写的。这说不通。

 

(原作者注1:当我2005年秋天找工作的时候,我真的常常问这个问题。让人惊讶的是,我看到了很多茫然的面孔。你得想想,这帮年薪30万美金的人对于大部分他们能接触到的工具都有一个很好的理解。)

 

         FP的文章的确挺难懂的,但是他们本不应那么难懂。这些知识点之间的鸿沟纯粹是历史方面的,而FP概念本身其实并不难。把这篇文章当做“FP的亲切向导”吧,一个由我们命令式(imperative)思维通往FP世界的桥梁。沏杯咖啡,继续往下读,你的同事可能马上就要拿你对于FP的评论找乐了。

 

         那么什么是FP?它一路是怎么走过来的?能吃么?如果它真的像它的拥护者说的那么管用,那么为什么没有在工业中更经常的被使用?为什么只有那帮博士后愿意用它们?最重要的是,它怎么那么TM难学?这些什么“终止(closure)”,“继续(continuation)”,“咖喱?!(currying)”,“延迟赋值(lazy evaluation)”与“无副作用业务(no side effects business)”都是什么东西?它怎么在不牵扯某个大学的项目中使用?为什么它与我们神圣,伟大,令人敬畏的命令式(imperative)思维相差甚远?我们很快就能把这些都搞清楚。我们从“为什么真实世界与学术文章有着巨大的鸿沟”开始解释。答案就像在公园里溜达一圈那么容易。

        

公园中的散步 

 

         启动我们的时间机器。我们这次的散步在2000年以前的一个公园里。这是公元前380年一个早已被遗忘的阳光明媚的春天发生的事情,在雅典城的城墙外一片橄榄树的阴凉下,柏拉图与一个英俊的奴隶小男孩(译注:腐女们,发挥你们的想象吧。 -_-b )一起向着学院走去。天气很好,伙食不错,话题转到了哲学方面。

 

         “你看那两个学生”,柏拉图小心的选择修辞来让问题显得有教育意义。“你认为谁更高些?”奴隶男孩顺着水看到两个人站在那里。“他们差不多一样高”,他说。“你指的‘差不多一样’是什么意思呢?”柏拉图问道。“恩。。。他们在这儿看一样高,但是我确定如果我再走近点看一定能看出些不同来。”

 

         柏拉图笑了。他把男孩引向正确的思路。“那么你想说这世界上没有绝对的相等是不是?”男孩想了想后回答说:“是的,任何事情都有一点不同,即便我们看不到它们。”正中重点!“那么如果这世界上没有绝对的相等,那么你认为你是怎样知道“绝对”相等的概念的呢?”奴隶男孩有点被问住了。“我不知道”,他回答说。

 

         这样第一个探寻数学本源的尝试诞生了。柏拉图提出了我们世界上所有的事情都是对于绝对的趋近。他同时认识到虽然我们从没有接触到过,但是我们理解“绝对”的概念。他得出一个结论:绝对的数学仅存在于另一个世界,并且我们以某种方式通过对于这平行世界的连接来认知它。可以清楚地说:我们无法看到绝对完美的圆。但是我们同时知道什么是绝对的圆而且我们可以通过等式来描述它。那么什么是数学

?为什么世界以数学定律来描述?我们世界的一切现象都可以被描述为数学么?(原作者注2)

 

         (原作者注2:这是一个有争议的问题。物理学家和数学家一直被迫声明:并不完全清楚是否可以说世界的一切事物是否都可以依据数学定律来描述。)

        

         数学的哲学(Philosophy of mathematics)一直是一个复杂的课题。就像绝大多数的哲学学科,它更适合于提出问题而不是给出答案。很多得出的共识一直围绕着一个事实,那就是数学真的是个谜:我们给出一组基础的,不冲突的原理还有一组描述如何操作这些原理的定律。我们可以把这些定律叠加起来衍生出更复杂的定律。数学家把这种方法称为“形式系统”或者“微积分”。如果我们愿意,我们可以为俄罗斯方块(Tetris)写一个形式系统。事实上,一个工作的俄罗斯方块的实现就是一个形式系统,只是它被以一个不寻常的外观表现出来了而已。

 

         半人马阿尔法行星上的毛毛生物文明不能理解我们俄罗斯方块或者圆的表现形式,因为他们唯一的感知输入是一个只能感到嗅觉的器官。他们似乎永远无法理解俄罗斯方块的形式,但是他们很可能有一种表达圆的形式。我们很可能无法理解,因为我们的嗅觉没有那样复杂,但是一旦我们抛开形式化的表象(通过各种感官辅助工具和标准解码技术来理解各种语言),其下的概念就可以被任何智慧文明所理解。

 

         有趣的是,即便宇宙中从没有过智慧文明,俄罗斯方块和圆的形式仍然存在,只是没有什么去发现它们。如果一个智慧文明出现了,那他们大概就会找出某种形式来描述宇宙的定律。然而他们可能永远不会搞一个俄罗斯方块,因为宇宙里没有像这样的一个东西。俄罗斯方块是数不尽的形式系统例子中的一个,一个谜题,和现实世界没有任何关系。我们甚至无法确定自然数是否全部在这世界上有其对应,毕竟我们可以很容易的想出一个特别大的数字以至于它无法表达我们世界中的任何事物,因为我们的世界可能最终仍然是有限的。

 

一点历史(原作者注3)

 

(原作者注3:我非常讨厌那种仅给出一个无聊的只有日期,名字和事件的编年史的那种历史课。对我来说,历史是关于那些改变历史的人们的生活故事,是关于他们行动背后的个人动机,以及他们如何能影响这成千上万的灵魂。因此,这一段历史无奈的有些缺失,只有一些和我们文章有关联的人和事件会被介绍到。)

 

         让我们再次启动时间机器,加速。这次我们的旅行目的地比较近,1930年。大萧条正在新旧世界中肆虐。所有社会阶层的所有家庭都受到了不可想象的经济衰退所带来的影响。但是还有一些人逃脱了贫困的危机得以躲在为数不多的庇护所中。几乎没有人能如此幸运的跻身于这些庇护所,但是他们的确存在。我们这次的主角是普林斯顿大学一群数学家们。

 

         哥特式建筑的新办公楼让普林斯顿显得被安全的光环所笼罩。世界上各处的逻辑学家被邀请至普林斯顿以建立一个新的部门。当大多数的美国人都找不到一片面包当晚餐的时候,高高的天花板,装潢着精致木雕的墙壁,每天端着一杯茶互相讨论,还有树下的散步则是普林斯顿中的景象。

 

         这种宽松生活中的数学家中的其中一人是一个叫做Alonzo Church的年轻人。Alonzo获得了普林斯顿理科学士学位并且被说服继续留在研究院工作。Alonzo感觉这里环境太宽松了。他很少端着杯茶和其他人讨论数学而且他也没有在森林中散步。Alonzo是一个孤独的人:他自己工作的时候产出往往更高。然而Alonzo还是与其他的普林斯顿住民之间的几个人有着固定的联系,他们是Alan Turing, John von Neumann, and Kurt Godel。

 

         这四个人都对形式化系统感兴趣。他们没有花太多精力在物理研究上,他们对于抽象数学谜题更有兴趣。他们的谜题有一些共同之处:他们都致力于解答计算方面的问题。如果我们有一台计算能力无限的机器,那么什么样的问题我们能够解决?我们能自动的使它解决么?有没有什么问题仍然不能被解决,为什么?各种设计不同的机器是否在能力上相等?

 

         Alonzo Church在与其他几个人的合作中开发了一种形式系统名为兰布达演算(lambda calculus)。这个系统本质上是那些假想的机器上的编程语言。它以函数为基础并且以其他函数作为参数并且返回另一个函数作为结果。这个函数以一个希腊字母lambda标识,此后这就成为了这个系统的名称(原作者注4)。通过这个形式,Alonzo可以为上述的问题理清原因,并且给出结论性的答案。

 

(原作者注4:当我学习函数式编程时我曾为术语“lambda”感到十分恼火,因为我搞不清楚它到底代表什么意思。在这里,lambda是一个函数,用一个好写点的希腊字母表达的数学符号。每次当谈到函数式编程的时候你听到“lambda”,把它翻译成“函数”就好了。)

 

         Alan Turing则独立于Alonzo Church,做着类似的工作。他开发了一个不同的形式(现在我们称它为图灵机),并且独立的用它得出了和Alonzo类似的结论。之后便体现出图灵机和兰布达演算在能力上是相当的。

 

         这个故事到这里就结束了,我们翻开书页,来到另一页,第二次世界大战的初期。世界笼罩在战火之中。美国陆军和海军使用火炮的频率比以往大大增加。为了努力增进准确性,军方雇佣了很大一批数学家计算精确弹道射击表格所需的微分方程。对于手工计算来说这明显是一个巨大的工作,所以各种装备被开发出来以解决这一问题。第一台用于计算弹道表格的机器由IBM建造,叫做 Mark I —— 它重达5吨,有75万个零件,每秒钟可以做3次运算。

 

         竞赛,理所当然的,不会停止。1949年,艾维克(EDVAC,Electronic Discrete Variable Automatic Computer 离散变量自动电子计算机)崭露头角并且获得了巨大的成功。这是第一个von Neumann架构的例子,同时这也是一个实际的图灵机在现实世界的实现。Alonzo Church当时并不是那么的走运。

 

         1950年麻省(MIT)的一个教授John McCarthy(也是普林斯顿毕业的)开始对Alonzo Church的工作产生了兴趣。在1958年他推出了列表处理语言(List Processing language, Lisp)。Lisp是一个可以在von Neumann计算机上工作的Alonzo的lambda演算实现!很多计算机科学家都认识到了Lisp的表现力。1973年一群麻省人工智能实验室的程序员们开发了一种硬件,并命名为Lisp机——实际上这就是Alonzo的lambda演算的硬件载体实现。

 

函数式编程 

 

         函数式编程是Alonzo Church的思想的一种实现。并不是全部的lambda演算的思想都在其中被实现,因为lambda演算原本不是为有物理限制的环境设计的。就这样,就像面向对象编程一样,函数式编程是一组思想,而不是一组严格的规章。函数式编程语言有很多,并且它们以非常不同的方式处理很多事情。在这篇文章中,我会用一些例子来解释函数式编程中使用的最广泛的一些概念,例子是用Java编写的(对,就是用Java,你可以用Java写函数式程序如果你觉得你特别有受虐倾向)。在接下来的几节,我会拿Java举例,然后将Java改变成一个可用的函数式编程语言。让我们开始吧。

 

         lambda演算是为了探究计算相关的问题而被设计的。函数式编程从而也主要是处理计算,并且,意外地,使用函数来干那些事情。函数是函数式编程语言中最基本的单元。函数被用于处理几乎所有事物,即便是最简单的计算。即便变量也被函数取代了。在函数式编程中变量仅是表达式的别名(以便于我们不用把所有代码写在一行中)。他们不能被修改。所有的变量只能做一次赋值。Java术语中这意味着每一个变量都被作为final声明(如果在C++中则是const)。FP中没有不是final的变量。

 

                   final int i = 5;

                   final int j = i + 3;

 

         因为所有的变量在FP中都是final的,所以有两个有趣的结论。总得写关键字final不太说得过去,还有既然它们不可变我们为什么还叫它们“变量”呢,好吧。。。变量。我们现在对Java做两个修改:在我们的函数式Java中所有的变量都会默认为final,并且我们从现在起把变量称为“符号”。

 

         现在你可能会奇怪你怎样才能用我们刚创造的新语言来写一些合理的复杂的东西呢?如果每一个符号是不会变的我们不能改变任何东西的状态。严格来说并不是这样的。当Alonzo研发lambda演算时他还对维护状态并在一定时间后去改变它没有兴趣。他感兴趣的是对于数据的操作(也一般被称为“算东西”)。不管怎么说,lambda演算已经被证明在效果上和图灵机相等。命令式编程能做的事情它也能做。那么,我们接下来讨论:如何才能让它们达到相似的效果。

 

         函数式程序也可以存储状态,只是我们不用变量来这样做。我们使用函数。状态存储于函数参数中,在栈里。如果你想保持其状态,并且隔一段时间去修改它,那么就写一个递归函数。举个例子,我们写一个颠倒Java字符串的函数。记住,我们每一个变量都被以final声明过了。(原作者注5)

 

(原作者注5:有趣的是Java字符串本身就是不可变的。讨论为什么会这样可能会更有趣,但是这会打断我们当前的话题。)

 

                   String reverse(String arg) {

                                if(arg.length == 0) {

                                    return arg;

                                }

                                else {

                                    return reverse(arg.substring(1, arg.length)) + arg.substring(0, 1);

                                }

                   }

 

         这函数很慢,因为它不断的调用自己。(原作者注6)而且它特吃内存,因为它不断重复的分配对象。但是它是函数式的形式。你可能好奇为什么有人会以这种方式编程呢。恩,我这就告诉你。

 

         (原作者注6:很多函数式语言编译器尽可能的通过交替迭代算法对递归函数做了优化。这通称为tail call optimization)

 

FP的好处

 

         你可能会想我永远没办法理解上面那段变态的函数。当我学习函数式编程时,我也这么想。我当时错了。有很多很好的论据来支持这样的写法。他们其中的一些很主观。比如,有人号称函数式程序很好理解。我不会拿这些出来说事,因为小孩子都知道:情人眼中出西施。很幸运的,我有足够的客观理由来论述这件事情。

 

         n.单元测试

 

                   因为每一个符号在FP中都是final的,没有可能带来副作用的函数。你永远不可能修改任何东西,也不能在一个函数中修改函数作用域外别的函数使用的值(就像一个类成员或者全局变量)。这意味着一个函数所带来的影响仅限于它的返回值,并且唯一可以影响函数的返回值的则是函数的参数。

 

                   这对于单元测试人员来说简直是一场春梦。你可以在你的程序中测试任何函数并且尽关心它参数的输入。你不用担心按照正确的顺序去调用函数,或者确保设置全部的外部状态。你所需要做的就是把代表边缘情况的参数传入函数。比起命令式编程,如果函数式程序中的每一个函数都通过了测试,那么你有理由对于你程序的质量有更大的信心。在Java或者C++中仅检查返回值是不充分的——函数还有可能改变外部状态,所以我们还要验证这些情况。FP则不需要。

 

         n.调试

 

                   如果函数式程序没有按照你希望的那样执行,那么调试起来也是小事一桩。你总能重现问题因为在函数式编程中Bug与之前执行的看似无关的代码流程无关。在命令式编程中有些Bug时隐时现。因为命令式编程的函数还有可能依赖于其他函数更改外部状态所带来的副作用,所以你甚至必须检查看似和这个Bug无关的代码和步骤。FP中这就不是问题——如果一个函数的返回值错了,它永远是错的,这与你之前运行了什么代码是无关的。

 

                   一旦你复现了问题,接下来就是一堆琐事了。给你的程序打个断点,看看栈中的情况。堆栈中每个函数的每个参数都可以供你检查,就和在命令式编程中一样。但是在命令式编程中这是不够的,因为函数依赖于每一个成员变量,全局变量,还有各个类的状态(这些类还可能依赖于同样的一些东西)。FP中的函数仅依赖于它的参数,而且它们就在你眼前!此外,光检查命令式程序的返回值不能告诉你这个函数是不是正常工作的。你还需要挨着个检查一堆作用域外的对象来看看它们是不是处于正常的状态。FP中你唯一需要做的就是看看返回值是什么。

 

                   跟踪堆栈信息然后看看什么参数被传入以及它们的返回值。直到有一个返回值出现问题,然后你跟进这个有问题的函数逐行的看一遍。重复这一步骤直到你找到问题的所在。

 

         n.并发

 

                   函数式程序就是为并发而生的。你永远不必担心死锁和竞争条件(race condition),因为你根本不用使用lock!没有任何数据会会被同一个线程更改第二次,更不用说被两个不同的线程更改了。这意味着你可以简单的添加线程并且不用担心那些常规程序中经常发生的烦人的并发问题。

 

                如果是这样,为什么没有人在大型并发应用中运用FP呢?恩,其实是有的。爱立信设计了一个函数式语言名为Erlang,并且应用于具有高可用性及高可扩展性的电话交换机上。很多其他的企业认识到了Erlang的优势并且开始使用它。我们说的是程控交换和交通控制系统,它们比典型的华尔街系统更具扩展性和可靠性。事实上,Erlang系统并不具有扩展性和可靠性,Java系统才是。Erlang系统只是跟石头一样硬。

 

                   并发的故事并未这样结束。如果你的应用本来就是单线程的,编译器仍然能保证在多CPU的情况下优化FP。我们来看看下面这段代码。

 

                            String s1 = somewhatLongOperation1();

                            String s2 = somewhatLongOperation2();

                            String s3 = concatenate(s1, s2);

 

                   在函数式编程中编译器可以对代码进行分析,从而将得出s1和s2的函数划分为潜在可能耗费时间的操作,然后将它们同时执行。这个在命令式语言中是不可能的,因为每一个函数都有可能修改外部状态并且接下来的函数有可能依赖于它们。在FP,自动分析哪些函数可以并发执行就像自动内联一样简单。这样的话,函数式编程可以说是“永不过时的技术”(尽管我很讨厌这样夸张的词汇,不过这次我就用一用吧)。硬件厂商无法再让CPU快一些了。他们只能增加内核数量以成倍的提高并发速度。当然他们很轻松的忘了提醒我们,我们花的钱只增加了处理并发问题的能力。这是命令式编程中很小的一部分,但是是函数式编程的100%,因为函数式编程有很多意想不到的并发手段。

 

         n.热部署(Hot Code Deployment)

 

                   N年前Windows为了要安装它的那堆更新需要重启机器,N多遍,而且仅是是在装了一个新的媒体播放器之后。在Windows XP中,这个问题得到了很大的解决,但是仍然不理想(我今天工作的时候更新了一下我的Windows,一个烦人的图标一直在我的系统托盘上,直到我重启我的系统。)。Unix系统一直以来有一个更好的模型。你只需要停止相关的程序就可以安装一个更新,而不是整个OS。尽管这是一个更好的解决方案,但是对于大型的服务器仍然是不可接受的。程控交换系统需要100%的时间都在运行,因为如果在升级的时候出现紧急呼叫,那是很可能要人命的。所以不能像华尔街商行那样每周末停止系统去安装软件更新。

 

                   一个理想的状态就是,完全不需要影响的系统的其他部分就可以对需要升级的代码进行更新。在命令式世界,这是不可能的。想想在Java中卸载一个类并且加载一个新的定义。如果我们这么做每一个类的实例都不能用了,因为这个类的状态也跟着丢失了。我们需要去写复杂的版本控制代码。我们需要把实例的数据序列化,销毁实例,创建新实例,加载序列化后的数据,并祈祷着加载代码能确实的将数据迁移到新实例中。在那之前,每一次变动之前我们必须手动写迁移代码。并且我们的迁移代码还必须十分小心,不能让它们破坏对象间的关系。理论上没问题,但是实践起来很难搞。

 

                   在函数式编程中所有的状态是通过函数的参数存储在栈中的。这大大的简化了热部署!实际上,我们需要做的只是对比一下生产环境和新版本之间代码,并且部署新代码。剩下的工作语言工具自动就帮你做了!如果你觉得这是科幻小说,我建议你再想想。Erlang工程师直接升级正在运行的系统已经很多年了。

 

         n.计算机辅助求证与优化

 

                   FP的一个有趣的特性是它们可以以数学的方式被处理。因为FP就是一个形式系统的实现,所以所有的能在纸上进行的数学运算都适用于对FP语言的处理。编译器可是,举个例子,把一段代码转变成另一段效果一样但是效率更高的代码,并且用数学求证它们的效果是相等的。(原作者注7)关系型数据库已经应用这种优化很多年了。没有理由说这种技术无法应用于常规软件。

 

(原作者注7:相对的并不永远是这样,尽管有时证明两段代码效果相等是可能的,但并不是所有的情况都能证明。)

 

                   另外,你可以通过这些技术来求证你的程序是没问题的。甚至可以写一个工具来分析代码并自动生成单元测试的边缘情况!这个功能对于一个坚挺的系统来说是无价的。如果你要设计一个心脏起搏器或者交通控制系统,这样的话这样的一些工具永远是必要的。或者即便你没有在如此人命关天的工业应用中写应用程序,这些工具也能很大的提升你与对手间的竞争力。

 

高阶函数

 

         我记得当我得知我上述的那些优点的时候我想“恩,这些的确不错,但是如果让我在一个蹩脚的什么都是final的语言中编程的话,什么优点也不能打动我。”这是一个错误的概念。令所有的变量声明为final的确会让一个像Java一样的命令式语言显得蹩脚,但是函数式语言则不会。函数式语言提供各种不同的抽象工具来让你忘记你曾经喜欢过修改变量。其中的一个工具就是用高级函数的能力。

 

         这种语言中的函数和C或者Java中的函数有些不同。它属于它们的超集——Java能做的事它也能做,不能做的事也能做。我们来像我们在C语言中那样来定义一个函数:

 

                   int add(int i, int j) {

                                return i + j;

                   }

 

         这与同样的C语言代码有些不同。我们来扩展我们的Java编译器来支持这种模式。当我们输入上面这类的东西编译器会将它们转换成下述Java代码(别忘了,所有变量是final的):

 

                   class add_function_t {

                                int add(int i, int j) {

                                    return i + j;

                                }

                   }

 

                   add_function_t add = new add_function_t();

 

         add并不是一个真正的函数。它是一个小型的类,并且有一个函数作为它的成员。我们现在可以将add作为其他函数的参数传来传去了,我也可以将它赋给其他的符号。我们可以在运行时创建一个add_function_t并且当我们不再需要它们的时候进行垃圾回收。这样函数就可以与整型或者字符型一样被作为一等对象(first class objects)。而操作其他函数(将它们作为参数)的函数就被称为高阶函数。别让这个名词吓着你,这和Java对象之间互相操作没什么区别(我们可以把对象实例传递至其他对象中)。我们可以叫他们“高阶类”,但是没人关心这个,因为Java背后没有一个强力的学术社区。

 

         如何并在什么时候使用高阶函数?恩,我很高兴你问到这个。你不用在你写的一大堆代码中担心类层次结构。当你看到一段重复的代码时,你将它们重组成函数(幸运的是学校里还是教这个的)。如果你看到一段逻辑在不同的状况下行为不同的话,你就将它们重组成高阶函数。有点晕?我工作中就有一个活生生的例子。

 

         假设我们有一段Java代码,它接收一段信息,将信息以各种方式变更后,转发至其他的服务器上。

 

                   class MessageHandler {

                                void handleMessage(Message msg) {

                                    // ...

                                    msg.setClientCode("ABCD_123");

                                    // ...

       

                                    sendMessage(msg);

                                }

   

                                // ...

                   }

 

         现在想象一下我们的系统变成了要将信息转发至两个服务器。除了client Code以外的所有其他的逻辑都一样——第二个服务器想要另一个格式的代码。我们怎么处理这种情况?我们可以检查我们发送信息的目标,并且按照目标给出不同的代码格式,就像这样:

 

                   class MessageHandler {

                                void handleMessage(Message msg) {

                                    // ...

                                    if(msg.getDestination().equals("server1") {

                                                 msg.setClientCode("ABCD_123");

                                    } else {

                                                 msg.setClientCode("123_ABC");

                                    }

                                    // ...

       

                                    sendMessage(msg);

                                }

   

                                // ...

                   }

 

         这种手段,不管怎么说,扩展性太差了。如果更多的服务器被加入的话,我们的函数会变得很大,我们以后更新它就很麻烦了。面向对象处理这种问题的时候会定义一个MessageHandler基类,然后再在派生类中实现生成client code的行为。

 

                   abstract class MessageHandler {

                                void handleMessage(Message msg) {

                                    // ...

                                    msg.setClientCode(getClientCode());

                                    // ...

       

                                    sendMessage(msg);

                                }

   

                                abstract String getClientCode();

   

                                // ...

                   }

 

                   class MessageHandlerOne extends MessageHandler {

                                String getClientCode() {

                                    return "ABCD_123";

                                }

                   }

 

                   class MessageHandlerTwo extends MessageHandler {

                                String getClientCode() {

                                    return "123_ABCD";

                                }

                   }

 

         我们现在为各个服务器派生出了合适的类。添加服务器变得更加可维护了。可是这简单的修改却需要这么多的代码。我们必须创建两个新类型而仅仅是为了支持不同的client code!那么现在我们来在我们支持高阶函数的语言中作同样的事情。:

 

                   class MessageHandler {

                                void handleMessage(Message msg, Function getClientCode) {

                                    // ...

                                    Message msg1 = msg.setClientCode(getClientCode());

                                    // ...

       

                                    sendMessage(msg1);

                                }

   

                                // ...

                  }

 

                   String getClientCodeOne() {

                                return "ABCD_123";

                   }

 

                   String getClientCodeTwo() {

                                return "123_ABCD";

                   }

 

                   MessageHandler handler = new MessageHandler();

                   handler.handleMessage(someMsg, getClientCodeOne);

 

         我们没有建立新类型与类结构。我们只是把适当的函数作为参数传入。我实现了和OO一样的事情,并且我们的方案还有很多优势。我们并不将我们局限于类结构:我们可以在运行时传入新函数并且随时以更少的代码与最大的粒度更改它们。实际上编译器提供了我们与OO语言“粘合”的代码。在这个基础上我们获得了FP的所有好处。FP提供的抽象性还远不止于此。高阶函数仅仅是个开始。

 

Currying(译者注:此法由Haskell Curry定义,所以命名也由人名得来,至于Curry是什么意思?搜索出来的不是咖喱,就是咖喱粉。)

 

         我见多的很多人都读过四人帮(GOF)的设计模式。很多自恋的程序员会告诉你那本书是和语言无关的并且模式适用于所有的软件工程,无论你使用什么语言。这是一个堂而皇之的宣言。不幸的是它离真相有点远。

 

         FP非常具有表现力。你不需要在FP中使用设计模式,因为这种语言很高级,你用来完成你程序的概念都是用来终结设计模式的。曾经有个模式叫适配器(Adapter)模式(它和外观模式(Facade)有什么区别?听起来某人需要多花几页来满足我们的消费者。)。它就被一个支持Currying技术的语言终结了。

 

         适配器模式被广泛应用于Java的“默认”抽象单元——类。在FP中,这个模式被应用于函数。这个模式使一个接口转换为另一个人希望的另一个接口。这里有一个适配器模式的例子:

 

                   int pow(int i, int j);

                   int square(int i)

                   {

                                return pow(i, 2);

                   }

 

         上面的代码将一个对整数做乘方的函数适配于一个对一个整数做二次方的函数。在学术圈中,这样的用法被称之为Currying(一个展示了数学技巧的必要性的逻辑学家Haskell Curry规范的定义了它)。因为在FP中,函数(与类相对的)作为参数传递,Currying经常被使用于将一个函数适配于令一个其他人需要的接口。因为接口对于函数来说是它的参数,所以Currying被用来减少参数的数量。(就像上面那段代码一样。)

 

         FP中就内建有这个机制。你不必手工创建一个包含原始函数的函数,FP会为你这样做。就像平常那样,我们来扩展一下我们的语言来支持这种机制:

 

                   square = int pow(int i, 2);

 

         这样会自动的为我们创建一个只有一个参数的square函数。它会在第二个参数为2的情况下调用pow函数。这段代码会编译成如下Java代码:

 

                   class square_function_t {

                                int square(int i) {

                                    return pow(i, 2);

                                }

                   }

 

                   square_function_t square = new square_function_t();

 

         就像你看到的,我们简单的为原函数创建了一个外壳。FP中的Currying就是这样——快速便捷的创建外壳的捷径。你专注于你的工作,而编译器为你生成具体的代码。什么时候用Currying?很简单,任何你想用适配器模式(外壳)的时候。

 

延迟赋值(Lazy Evaluation)

 

         延迟赋值是一个我们采用函数式哲学后出现的有趣的技术。我们已经在讲并发的时候看到过这段代码:

 

                   String s1 = somewhatLongOperation1();

                   String s2 = somewhatLongOperation2();

                   String s3 = concatenate(s1, s2);

 

         在命令式编程中赋值顺序一目了然。因为每一个函数有可能影响或依赖外部状态,所以需要让它们按顺序执行:首先somewhatLongOperation1,其次somewhatLongOperation2,然后是concatenate。FP中则不是这样。

 

         就像我们之前提到的somewhatLongOperation1和somewhatLongOperation2可以并发执行,因为我们保证没有函数依赖或影响全局状态。但是如果我们不想让它们并发执行,我们想按顺序执行它们呢?答案是不行。我们仅仅需要在另一个函数依赖于s1和s2的时候,我们才需要执行那两个函数。在concatenate被调用之前,我们甚至不需要执行那两个函数——我们可以将它们的赋值延迟到concatenate需要它们的时候。如果我们将concatenate替换成一个有一个条件的其他函数,并且它只需要原来两个参数中的其中一个,那么我们可能永远不会为另一个赋值了!Haskell就是一个延迟赋值的例子。在Haskell中,你不能被保证每一行代码都会按顺序执行(或者是否会被执行)因为Haskell仅在需要的时候执行代码。

 

         延迟赋值有很多优点,也有一些缺点。我们现在来说说它的优点,下一节我们会说说如何规避那些缺点。

 

         n. 优化

 

                   延迟赋值提供了巨大的优化潜力。惰性编译器处理函数化编码完全像数学家处理代数方程那样——它会约掉一些部分并且避免那些部分的执行,重新排列代码以提升效率,而且重新排列代码甚至可以减少错误,这些全都保证不会破坏代码原本的逻辑。这就是严格使用老式形式系统来表达程序的最大好处——代码黏着于数学法则并且可以用数学来解释代码。

 

         n. 抽象控制结构(Abstracting Control Structures)

 

                   延迟赋值提供了特有的高阶的抽象方法。举个例子,看看下面的控制结构实现:

 

                            unless(stock.isEuropean()) {

                                         sendToSEC(stock);

                            }

 

                   我们希望只有在stock为isEuropean的情况下才执行sendToSEC。我们如何实现unless?如果没有延迟赋值,那么我需要宏来实现,但是像Haskell这样的语言中没必要这样做。我们可以将unless实现为一个函数:

 

                            void unless(boolean condition, List code) {

                                         if(!condition)

                                             code;

                            }

 

                   注意,当状态是true的时候code永远不会执行。在严格顺序执行的语言中这样的行为是无法实现的,因为参数会在传进来以前就被赋值了。

 

         n. 无限数据结构(Infinite Data Structures)

 

                   延迟语言允许定义无限数据结构,一种在严格顺序语言中更难实现的一种机制。举个例子,斐波那契数列。我们明显无法在一个可接受的时间范围内计算出一个无限的数列或者把它存储在内存中。在像Java那样的严格顺序语言仅是定义一个斐波那契函数来返回数列中一定数量的数字。在像Haskell这种语言中我们可以将它抽象为一个无限斐波那契数列。因为语言是有延迟特性的,只有数列被程序用到的那部分才被计算并赋值。这样就可以对很多问题进行抽象并从一个更高的层级来看问题。(比如,我们可以用一个处理数列的函数来分析数列。)

 

         n. 劣势

 

                   当然天下没有免费的午餐(tm)。随延迟赋值而来的还有一些劣势。主要来说就是,恩,延迟。很多现实问题都需要严格按顺序执行。例如下面这段代码:

 

                            System.out.println("Please enter your name: ");

                            System.in.readLine();

 

                   在延迟语言中你无法保证第一行会比第二行先执行!这意味着我们没法做IO操作,我们无法以任何有用的方式调用系统内置函数(它们需要被顺序执行,因为他们依赖于附属状态),并且无法与外界互动!如果我们采取顺序执行的方式的话,我们将失去所有将我们的代码以数学方式解释的优势(很有可能FP的优势就全没了)。幸运的是损失是有限的。数学家找到了一系列技巧确保一个函数集合以特定的顺序执行。我们找到了两个世界的完美平衡。这些技巧包括:继续操作(continuations),单一体(monads)还有唯一类型(uniqueness typing)。这篇文章我们仅阐述继续操作。我们把单一体和唯一类型留到以后讨论。有趣的是,继续操作除了能让代码以特定的顺序执行以外还有很多用处。我们也可以说说那些。

 

接续指令(continuations)

 

         continuations对于编程来说就像达文西(Da Vinci,文西?零零柒?)密码对于人类历史一样:揭露了人类有史以来最大的假象。恩,也许没那么牛,但是他们的确揭示了负数开平方的骗局(译者注:不知道啥意思?其实我也不知道,搜索一下,搜索一下)。

 

         当我们学习函数时,我们只学了一半的事实,这个事实是建立在函数只能将它们的值返回给调用者这个错误的假设的基础上的。这样的话,continuation就是一个函数的继承。一个函数不一定必须要返回值给它的调用者,而是可以返回到程序的任何地方。continuation是我们函数的一个参数,我们通过这个参数指定当前的函数的值返回到什么地方。这个描述肯定比它听起来复杂多了,我们来看看下面的代码:

 

                   int i = add(5, 10);

                   int j = square(i);

 

         函数add返回15赋值给i,原始调用add的地方。然后用i的值调用square。注意,延迟特性的编译器无法重新排列这些行的代码,因为第二行代码依赖于第一行成功的执行。我们可以用Continuation传递方式(Continuation Passing Style)或者缩写为CPS重新写这段代码,用它来指定add函数直接返回到square调用,而不是返回到原始调用者:

 

                   int j = add(5, 10, square);

 

         在这里add有了另一个参数——必须在add完成后调用的函数。在这里square就是add的continuation。这两段代码的结果都是225.

 

         我们展示了第一个顺序执行两行代码的技巧。接下来我们看看这个(熟悉的)IO代码:

 

                   System.out.println("Please enter your name: ");

                   System.in.readLine();

 

         这两行代码没有彼此依赖,那么编译器则根据自己希望的去排列他们执行的顺序。那么,如果我们将它们重写成CPS,那么它们之间便有了依赖,编译器会按照顺序执行这两段代码:

 

                   System.out.println("Please enter your name: ", System.in.readLine);

 

         在这里,println需要在结束后调用readLine并且返回readLine的结果。这样允许我们确保两行代码被顺序执行,并且readLine总被执行(因为整个运算以最后一个值作为返回值)。Java println返回的是void,但是如果它返回的是抽象值(readline可以接受的),我们总之解决了我们的问题。当然,像这样把函数串起来的话,我们的代码的可读性直线下降,但是把它们串起来不是必要的。我们可以加上语法修饰来允许我们简单的按顺序输入代码,并由编译器为我们自动把它们串起来。我们这样就可以按我们希望的顺序执行代码并且不会放弃任何FP提供的便利(包括按数学逻辑来解释我们的代码。)。如果说到这里还有点晕的话,那么记住,一个函数就是一个有一个成员的类的实例。如果将上面两行重写成println和readline的类实例的话,那样就清楚多了。

 

         我们其实才刚刚说了一些continuation用法的皮毛,不然的话这节就到此为止了。为每一个函数都传入一个附加的continuation参数,并且将当前函数的执行结果传进去,我们可以像这样用CPS来写整个程序。我们也可以用一种特殊形式来将那些常规写法的程序(函数总是返回值给调用者)转换成CPS。这种转换是自动的(事实上,很多编译器已经这样做了)。

 

         当我们将一个程序转换为CPS以后,每一个指令都会有一个当前函数执行后继续用自身的结果调用的continuation,也就是在通常程序中函数通常返回值的地方。让我从上面的代码中挑一个指令,add(5,10)。在CPS写法的程序中很容易看到add的continuation是什么——就是那个add调用完毕后接下来要执行的函数。但是如果在一个不是CPS的程序中又会如何?我们可以,当然,把它转换成CPS,但是我们必须这样做么?

 

         实际上我们不必非要这样做。我们来仔细看看CPS转换。如果你试着去为它写一个编译器,并且好好的思考如何去做的话,你会意识到CPS不需要计入堆栈!其实没有函数像传统程序那样“返回”值,它们只是在一个执行完了之后用自己的结果去调用另一个函数。我们不用在每次调用后都去把每一个函数参数都压到栈中,然后再把它们弹出。而仅需要把它们存到内存块中利用跳转指令操作它们就可以了。我们永远不需要原始参数——它们永远不会被用到,因为没有函数被返回。

 

         所以,以CPS方式写的程序没有堆栈但是有一个额外的参数代表即将调用的函数,而不以CPS方式写的程序没有额外的参数,但是有堆栈。栈中包含了什么?只有一些参数,还有一个指向函数应返回的内存指针。你脑袋上的灯泡亮了没?栈中包含的就是continuation信息!在栈中储存的指向返回指令的指针本质上和CPS中调用的那个函数是一码子事!如果你想知道add(5,10)的下一步是什么,那么你只需要检查它在栈中的指针即可。

 

         接下来就简单了。continuation和栈中指向返回指令的指针实际上是一码子事,只是continuation指定了一个确定的目标,所以它不一定必须是函数原来被调用的地方。如果你还记得,continuation是一个函数,并且这个函数在我们的编译器中被转换成一个类的实例的话,你会更清楚栈中的一个指向返回指令的指针和continuation参数是一码事,因为我们的函数(就像类的实例那样)就是个指针。这意味着在程序中任何时间任何位置你都可以启动一个“当前continuation”(它仅仅携带栈中的一些信息)。

 

         好的,那么我们知道什么是当前continuation了。那它是什么意思呢?当我们将一个当前continuation存在一个地方,我们等于将当前程序的状态存储了下来——及时将它们冻结起来。这类似于OS进入冬眠状态。一个continuation对象包含着当重启程序时所需要的信息。OS在当你的程序上下文在线程间切换的时候经常这么干。唯一的不同就是它仍然继续保持所有的控制。如果你需要一个continuation对象(使用上你可以通过call-with-current-continuation函数这样做),你就会得到一个包含当前continuation的对象——堆栈(或者是在CPS中那个接下继续调用的函数)。你可以将这个对象存到变量中(或者磁盘上)。当你用这个continuation对象选择“重启”你的程序的时候,只要你成功得到了continuation对象,就可以将程序“变”成你想要的状态啦。当回到悬停的线程,或者叫醒冬眠OS的时候,事情基本上一样,除非你想一遍又一遍的这样做。当OS醒来的时候,冬眠信息就销毁了。如果没有,那你就可以从同一个点一次又一次的被叫醒,很像原地转圈。我们可以自由控制continuation。

 

         continuation在什么情况下有用?一般当我们希望在一个先天就无状态的应用中模拟状态的时候用,这让我们的生活轻松很多。continuation适用的最佳场合就是web应用。微软的ASP.NET花了很大的功夫来模拟状态,以让大家不必那么麻烦。如果C#支持continuation,那么ASP.NET一半的复杂度将会消失——你只需要将continuation储存起来,并且当客户再次做web请求的时候重新启动它就可以了。对于程序员来说,这样的web程序将没有“中断(interruption)”这样的概念——程序只需要从下一行开始执行就可以了。对于一些问题来说continuation是一个太有用的抽象工具了。考虑到很多传统的富客户端走向网络,continuation在未来将变得越来越重要。

 

模式匹配(Pattern Matching)

 

         模式匹配不是一个新出现的或者多创新的功能。事实上,这跟FP的关系不大。至于为什么每次它都被当做FP的一个特性,那是因为在现代命令式语言仍然没有支持这种方式的情况下,FP已经支持模式匹配一段时间了。

 

         让我们来随一个例子来深入了解一下模式匹配。下面是一个Java实现的斐波那契函数:

 

                   int fib(int n) {

                                if(n == 0) return 1;

                                if(n == 1) return 1;

       

                                return fib(n - 2) + fib(n - 1);

                   }

 

         那么接下来是一个基于Java并且支持模式匹配的语言的例子:

 

                   int fib(0) {

                                return 1;

                   }

                   int fib(1) {

                                return 1;

                   }

                   int fib(int n) {

                                return fib(n - 2) + fib(n - 1);

                   }

 

         有什么不同?编译器为我们实现了分支。

 

         这有什么大不了的?其实也没啥。有人注意到很多的函数都包含有非常复杂的switch语句(函数式程序尤其如此)并且觉得这是一种很好的抽象方式。我们将函数的定义拆分成多个,然后将参数的形式写到相应的位置(有点像重载(overloading))。当函数调用的时候,编译器便会在运行时将传入的参数与定义做对比,然后挑选一个匹配的执行。通常来说,越具体的定义越先被选择。比如,int fib(int n)可以在当n为1时被调用,但是其实不是这样,因为这种情况int fib(1)更具体一些。

 

         模式匹配通常比例子中写的更加复杂。例如,一个更先进的模式匹配系统允许我们这样做:

 

                   int f(int n < 10) { ... }

                   int f(int n) { ... }

 

         模式匹配什么时候有用?在需要一大堆case的时候!每次当你需要在函数中写一大堆if的时候,模式匹配都可以以最少的代码达成更好的效果。我第一个想到的好的函数就是一个标准的win32程序必须提供的WndProc函数(尽管有时候它是抽象后的)。一般来说一个好的模式匹配系统既可以检查简单值,也可以对集合进行判断。比如,如果你传入了一个数组,那么你可以找到与第一个元素为1并且第三个元素大于3的那个数组匹配的定义。

 

         模式匹配的另一个好处就是,当你需要添加或者改变条件时,你不用去检查一个庞大的函数。你只需要添加(或修改)响应的那个定义就好。这样Gof书上的一大部分设计模式就又没用了。你的条件越多,模式匹配可以帮你做的就越多。一旦你熟悉了它,你就开始奇怪:没有它的这些年你是怎么挨过来的。

 

封闭体(Closure)

 

         到现在我们讨论了在“纯”函数式语言环境中的很多功能——所谓“纯”函数式语言就是实现了lambda演算并且不包含违背Church的范式的功能。不管怎么说,很多FP中的功能对于lambda演算框架以外的语言也同样有用。尽管一个实现为能支持自我证明的系统是很好的,因为它允许将程序以数学方式来理解,但是它可能在实践中可行,也有可能不可行。很多语言选择与函数式元素合并,而不是严格的依附于函数式教条。很多这样的语言(像Common Lisp这样的)的变量不一定是final的——你可以修改它们。它们的函数也可以不必仅依赖于函数的参数——函数可以被允许访问其边界外的外部状态。但是它们确实有函数式功能——像高阶函数。在非“纯”语言中来回传递函数与在lambda演算的约束下传递有一些不同,并且需要支持一种通常叫做lexical closure(译者注:即语句封闭体,简称closure。)的有趣功能。让我们来看看这段例子代码。记住,这回变量不是final的,并且函数可以引用其作用域外的变量:

 

                   Function makePowerFn(int power) {

                        int powerFn(int base) {

                                     return pow(base, power);

                        }

 

                        return powerFn;

                   }

 

                   Function square = makePowerFn(2);

                   square(3); // returns 9

 

         函数makePowerFn返回的是一个函数,这个被返回的函数也有一个参数,并且将这个参数做一个由makePowerFn指定的乘方。当我们对square(3)求值的时候会发生什么?power这个变量其实已经不在powerFn的作用域中了,因为makePowerFn已经执行完并且返回了,它的堆栈早就消失了。那么square是怎么执行的?如果我们创建另一个函数,cube,为一个参数乘3次方会怎么样?运行时一定存了两个power的拷贝,每一个makePowerFn创建的函数都保留一个。存储这些值的现象就叫做封闭体(closure)。closure不仅仅用来存储宿主函数的变量。举个例子,closure也可以这么用:

 

                   Function makeIncrementer() {

                        int n = 0;

 

                        int increment() {

                                     return ++n;

                        }

                   }

 

                   Function inc1 = makeIncrementer();

                   Function inc2 = makeIncrementer();

 

                   inc1(); // returns 1;

                   inc1(); // returns 2;

                   inc1(); // returns 3;

                   inc2(); // returns 1;

                   inc2(); // returns 2;

                   inc2(); // returns 3;

 

         运行时负责存储n,所以像inc1这些函数可以访问到它。它存储各种各样的拷贝,每一个incrementer都有,尽管它们应该在makeIncrementer返回的时候就消失了。那么这些代码又被编译成什么了呢?closure在幕后又是怎么工作的呢?幸运的是,我们有个后台通道,跟我来。

 

         一点小常识可以起大作用。我们的第一印象是本地变量不再被作用域所束缚并且没有定义具体的生命周期。那么直观的结论就是它们不再存在栈中——而它们一定是存在堆(heap)中(原作者注8)。那么,closure,一个实现起来像我们前面说的函数那样的东西,除了它会对一些变量有一些额外的引用:

 

(原作者注8:这其实不比存在栈中慢,因为如果你引入了垃圾回收器,那么内存分配便成为了一个O(1)操作。)

 

                   class some_function_t {

                        SymbolTable parentScope;

  

                        // ...

                   }

 

         当一个closure引用的变量不在本地作用域时,它便会参考父作用域的引用。就是这样。closure让FP与OO的世界更紧密了。每次当你想创建一个类,并且把它装满状态来回来去的传的时候,想想closure吧。closure就是一个可以在运行时创建并获取“成员变量”的对象,所以你并不一定要用类来处理问题。

 

接下来如何?

 

         这篇文章仅介绍了FP的一些皮毛。即是所谓的抛砖引玉吧。未来我打算写一写关于分类理论(category theory),单一体(monad),函数数据结构(functional data structure),FP中的类型系统,FP并发,函数式数据库等等等等。如果我能(在学习的过程中)写出这些主题的一半,我想我的人生就完整了。与此同时,Google是我们的好朋友。

 

有意见或建议?

 

         如果你有任何问题,意见或建议,请发送到以下邮箱coffeemug@gmail.com。很期待你们的反馈。(译者注:大家都辛辛苦苦看到这里了。。。也点个推荐顶我吧。。。也很期待你们的反馈)

 

 

posted on 2011-02-04 22:59  ZetaZ  阅读(1818)  评论(1编辑  收藏  举报