2012年4月20日星期五

外刊IT评论:对象-函数式编程简史


外刊IT评论:对象-函数式编程简史

2010-03-11 09:41 | 8878次阅读 | 来源:外刊IT评论 【已有22条评论】发表评论
本文是一篇风格轻松的概述Scala语言诞生过程中的各种软件开发运动历史事件的文章。
前言
从前,有一种编程语言叫Scala。
人们研究这种语言,发现这是一种给人印象深刻的语言,但是由于这种语言的功能特征不断的急速进化,导致除了一些自己研究的项目外,没有其他人再使用这种语言开发了。
这种语言看起来很美,但没有人愿意冒险把自己的职业生涯依赖于这种语言上,这个语言太年轻了,谁能保证它不会夭折?
之后,发生了一些事情; Scala 长大了。 Twitter 宣布他们用Scala语言替换了以前一些用Ruby开发的后端程序,而SAP也在使用这种语言,还有EDF等。 这消息迅速传播开来,有许多新的程序开发者慕名而来,他们也都感觉到这是一种令人印象深刻的语言,同时,早期的这个语言的信徒也开始发现此语言已经凤凰涅磐,让他们眼睛一亮。
他们现在看到的这种语言已经是一个成熟的、急不可待的等人们使用它去大展宏图的语言了。 随着2.8版本的发布,Scala 终于从少年进入了青年,可以当之无愧的接受令人印象深刻的赞誉了。
编程就是人生
程序语言在进化,在繁衍,产生不同的种族。 非常类似于生命在早期地球的上的构成,编程语言最初是诞生于由CPU指令和数学概念混成的沸腾的高汤里。 跟生命的发展不同的是,它们不需要泥土。
但是它们也经历着残酷血腥的优胜劣汰、物竞天择过程,当然,你可以把它们之间的战争想像成关于Tab键和Space键,关于括弧在程序中的地位问题上的战争 。
人们就像一个优秀的饲养员只喜欢挑选一个纯种血统的良马一样选择自己喜爱的编程语言。 就像生物学上,人工育种必然存在不足,近亲连续不断地繁殖、以此来保存某一血统的令人满意的特性的做法必然潜藏基因缺陷的危险。
幸运的事,凡事都有两面, 好的饲养人和园丁会选择利用 混血杂交优势.
父母的结合产生的后代汇聚了其父母双方各自不同的特征,所以后代比前代更强大,同时父母各自的弱点也会被后代查明从而摒弃。 同样的思想也被应用到了编程语言的世界里,各种面向对象和面向函数风格的概念相互融合给予了程序员们前所未有的能力和表达方式。
Scala编程语言就是这样的语言中的一员。
言归正传!
我估计阅读我这篇文章的大部分是Java程序员,所以在我详细的解释函数和对象如何交互之前我打算先介绍一些关于针对函数编程的概念。
其实在网上已经有了很多完全超出我的写作水平的好教材,所以我愿意尽量简单的介绍一下。
什么是函数?
数学里,函数就是接受一个值(输入值)而后使用它产生另外一个值(输出)的运算。 在很长的时间里这个定义几乎适用有所有任何的情况,即使是现在,数学家们也只是在扩充这个定义里的值的概念: 复杂数值,矩阵,向量,坐标(对称坐标和笛卡尔坐标),四元数。.. 很多东西都可以被当作值,只要你用正确的方式去看待它。
这种情况持续了很久,之后程序员出现了,之后计算机被发明了。
一旦人们对计算机技术的重要性达成共识,并且使计算机技术逐步完善起来,程序员就开始用一种新的思想考虑他们了,比如:看着这计算机打印输出的长河般一排排的三个字母组成的汇编程序码,你不头痛也不行。
如果他们能把那些序列码按相同的功能分成一组一组,给它们起个名称,那么他们将会有一种简洁的方式去重复利用这些代码,那么以前花大量时间拼写这些代码的时间节省下来,终于有了去酒馆的时间。 因为很多的程序员也都是数学家,因为很多他们的程序都是用来解决数学问题的,这就决定了函数的概念非常简洁的迎合了这种给编程单元打包处理的行为,从此第二代编程语言诞生了。
完美中的不足
这种革新,完美中有些不足。 针对函数,人们发现一个问题,就是经常需要它们一次处理多个输入值,或,更令人沮丧的,多个输出值。 幸运的是一些数学家解决了如果让函数处理多个输入值的问题,这种思想很早就被人采纳了,人们按照这种思路想出来如何去返回多个输出值(通常是把输入值给抹去,替换我想要输出的值)。 但是其他的一些数学家(例如Haskell)并不喜欢多个输入值的方式,他产生了一个新的观点,用高阶函数替代多个输入值,函数可以返回其它函数,或可以用函数体当作函数参数,但这种做法很难实现,所以程序员起初都没在意这种观点。
函数编程还有一个问题,就是它有副作用。 一个函数使用一个相同输入值(例如读一个文件)却可以每次都做出不同的事情,或者它可以去做一些不专一的事情(例如处理返回一个值外还会向控制台打印一行字)。 更糟糕的是,它会把自己的输入值在使用之后改变其值! 对于那些想利用这些副作用的人来说,这是再好不过了,可是对于另外的一些数学家就不一样了,他们不喜欢喝啤酒,可是还必须要把啤酒杯拿在手上。
所以程序里的函数跟数学里的函数是不同的。 人们给出了一个新的定义(不是很精确的):一个程序,或者一批指令,具有一个名称,可以选择性的拥有一个或多个输入值和输出值,甚至同时具备多个输入值和多个输出值,同时还能做点额外的事情。
A reprive ahead of its time
自然,很多数学家并不高兴函数被定义成这样,于是一个新的语言品种被创造了出来,用来弥补其先天的不足,再一次的将它用一个稳固的理论架构确定下来。
函数体成为第一类实体,而非以前的仅是一批代码的别名。 这样Haskell的高阶函数的概念就可以应用于设计开发软件了。 编程语言的进化发展中人们越来越多的鼓励使用常量值,这样函数就不能把输入值给能脏了。 人们实现了局部套用(Currying),开始使用数组结构,这样函数终于又回到了只能接受一个输入值和一个输出值的绅士面貌。 一些有趣的方法被人们采用来限制那些讨厌的副作用:如果这些副作用不能完全避免的话,那就把它们规整起来专门找个地方放置它们。 这样的语系被人们称作为函数式编程语言,因为它们把函数的概念回归到了其数学上的根源。 这个语系里的语言包括有Lisp, Scheme, Caml, Erlang, F#, Clojure等。
作为工程学上一个优秀的典范,函数式语言具有设计优良,易理解,高效,结构稳定等优点。 与此同时,如同其他Good Ideas?经常遇到的情况一样,很长的一段时间里它们被主流团体所遗忘。 程序员们都很清楚为什么人们喜欢把函数放在首要位置; 人们需要把系统按单元功能划分,相互不依赖,可以在不同的地方重复使用它们。 这些愿望就像痒痒需要挠的感觉折磨着人们,于是面向对象的思维从此诞生了并崛起了。
目前,函数式编程只是被人们当成一种业余爱好,也被人们用在相关的演讲和论文里去灵巧的阐述一些新事物。 人们通常认为函数式语言会比命令式语言运行的慢,但这种结论也许只有上帝知道,因为从来没有人用自己的方式证明过。 人们还认为,尽管函数式语言看起来非常简洁,适合小的程序和做演示用,但它们不太适合大规模的程序,像那些成百上千行的程序,如果用函数式语言来开发,几乎是不可维护的。
重生
实际上,函数式语言并不只是一种玩物。 跟随着时代革新的大潮,它在地下酝酿了这么多年,终于等到了这个世界可是接受它的这一天。 主流程序员们越来越多的认识到,函数式语言是如此的容易使用,而这一点在其它(面向对象)代码是难以达到的。 就比如这个简单的问题处理这个字符串队列,将它们全部转化成大写后返回,用Java编写却有可能出错。 因为偶尔人们会忽略掉这个队列里的第一个字串,因为他们从1开始计数,而不是0。有时候人们会发现这个队列里的字串不是按他们要求的部分转化成大写,而是全部大写了,还有些时候程序会报出空指针异常。
逐渐的,人们开始讨论起closures和continuations,为的是让他们的程序更加的强壮和可维护。 当时这些东西并不是对象们所能具有的,于是加强型for循环被发明了,还有匿名类,visitor模式,command 模式。 当然这些没有一个能按照程序员们想象的那样的完美,但这些东西还是有用的,让很多有问题的地方变得可维护了(即使这样需要编排一些丑陋的模板式的代码)。 时机已经到了人们改变思维方式的时候了,函数式语言已经迫不及待的看到自己的宏大入场了。
让人嫉妒的特性
通过Erlang语言,爱立信演示了函数式语言如何能应用于大规模系统的。 而其开发效率高,可维护性,可测试性都很好,特别是不易犯错。 这才是真正的函数式语言的面貌,感觉比面向对象语言要成功的多。 爱立信的程序员们前所未有的有了充分的喝啤酒的空闲时间了。 生活变得轻松起来!
而在另外的阵营里的程序员看待函数式语言有点想法,也有的嫉妒。 Java变得如此臃肿,而且,每一个新出现的特征都看起来是围绕着它的模板代码风格创造出来的。 即使是很小的程序,现在也要使用annotations,模板参数,和duplicate type declarations,大程序问题就更大了。 不幸中的不幸,关于如何往Java里添加closures(闭包)功能的讨论并不像早期预期的那样顺利,还有,Java bean里的数不完的get/set方法实在是不能在忍受了。
有些事情必须要变了。
除了这些,Java还有一大堆的问题。 The Virtual Machine(虚拟机)是一个非常成熟的工具,经过了很好的优化,市场上随处可见,从洗衣机,移动电话,到数不清的web服务器和桌面电脑里都有它的身影。 Java系统在开源库和框架方面已经发展的令人瞠目结舌繁华,在一些付费系统里也火的不得了。 靠着Java这棵大树,市面上已经到处都是由各种企业投资推动的数不清的团队开发工作创造出的成功和成熟的java项目。
如果因为一些小的语言特征而放弃Java这一切基本是不可能的。
我们一起做蛋糕 也一起吃!
我们所有做的事既要继承Java所有目前的优质资产,同时也要使用函数式语言重新描绘新的编程语言版图。
Scala正好迎合了这种需要,尽管它有很多的竞争对手。 Pizza语言第一个出现的,但它跟今天的Scala比较起来更Scala当初的形式。
我们所知的能在JVM上跑的语言大概有JavaFX, JRuby, Jython, Groovy 等。 大部分都有closures 和其他的一些函数式语言具有的特征,但在Java王国里,这些新生事物并不是那么的血统纯正,它们的特征更像是外来移民,护照很新亮,但有异域口音。
动态语言的流行是无济于事的; 类型可以通过各种方法隐藏起来,让人感到它的不存在,但是这样很难编译出原生的Java代码了。 这是个很大的问题,特别是你写出的对象需要拿到第三方类库里去处理时。 有时候各种语言之间很难交互,通常需要一个解释器,就像JSR233 Scripting API 或 the Bean Scripting Framework 那样。
Scala却有与生俱来的优势,它和Java的结合是如此的紧密,它能像自己本身的类型那样处理Java类型。 它并不像一个外来移民,而是一个侨胞,而且是有护照的公民。
你从外面看,Java和Scala编译出来的代码是一模一样的,没有区别,这有点让人难以置信,但可以明确的告诉大家,Scala最初就是这样设计出来的。 当你把Scala当作一种函数式语言时,你会更惊奇的发现,它把面向对象和函数式的两种风格以其优雅的方式完全融合统一起来。
正因为它和Java是如此紧密的联系,你可以把Scala当作Java临时的替代品,它绝对不会强制你用任何的函数式风格的代码书写。 它的类型引用,简洁的属性存取,以及带有成员变量参数的构造函数,你几乎可以把它当作一种风格简洁的Java。 除了上述的优点外,我们可以称赞Scala为某些方便比Java面向对象更成功的语言:
* 一切皆为对象,包括数值和函数。
在Java中,方法不是对象,更别提基本数据类型了。 2.toString在Scala里是一个合法的语句。
* 它抛弃了静态类成员,Java的这个问题可以追溯到它所效仿的C++上,是个历史错误。 C++本身就是个混合型的语言,它的设计目标就是要兼容过程式的C语言,同时也要支持对象结构。 静态成员不是完全的可面向对象,因为他们不能实现接口,以及向普通成员那样的多形性和覆盖、过载。 当你把一个对象当作参数传入一个函数时,静态成员是不可用的。
相反,Scala提供了singleton objects, 这样这种问题就不存在了。 Scala里新的companion概念可以让你使用singleton去访问具有相同类名的实例上的一个有约束限定的成员,这样你就可以把静态成员的权限复制出来。
* 类上所有的属性都实现了behind-the-scenes,就像是个隐藏域,而且有针对它的一对Get和Set隐藏方法。 那些任何人都可以直接修改的内部属性将不再被允许公共访问。
在将来,虚拟类的概念将会在Scala里出现,那样后Scala对对象的支持将会有更惊人的表现。
函数式编程对下面的特征进行了支持:
* 对递归函数的尾调用(tail-call)优化
* 模式匹配
* 第一类函数和高级函数
* 局部函数(可以接受任何输入值)
* 局部套用(Currying)和函数局部应用
* 闭包
* 简洁的声明常量值的语法定义,很好的支持常量集合的类库
* continuations (scala 2.8 新增)
所有的这些都意味着什么?
函数式编程已经证实了它的实力,快速增长的开发者人数是最好的证明。
Scala向大家演示了如何在不牺牲面向对象思维模式下接受函数式设计模式的概念。 它同时也向大家显示了如果将这两种风格的语言如何融合到一起变成一个强壮丰满的新语言,不带任何的形式的勉强。
一旦你了解了基本语法并对闭包、第一类属性、高阶函数、traits,、immutable refs等概念有了认识,它的各种特点的相互结合会向你展示它更深层次的潜质。 语言生命里的一些设计思想的选择和确定最终导致了一个增效作用;
我们认定这种新一代的对象-函数式的设计正是使Scala今天如此成功的关键。

函数式编程另类指南
March 27th, 2007 :: jackyz
原文:Functional Programming For The Rest of Us
原文作者:Vyacheslav Akhmechet
翻译:lihaitao (电邮: lihaitao在gmail.com)
翻译原帖:函数式编程另类指南
校对:刘凯清
程序员拖沓成性,每天到了办公室后,泡咖啡,检查邮箱,阅读 RSS feed,到技术站点查阅最新的文章,在编程论坛的相关版面浏览公共讨论,并一次次地刷新以免漏掉一条信息。然后是午饭,回来后盯了IDE没几分钟,就再 次检查邮箱,倒咖啡。最后在不知不觉中,结束了一天。
不平凡的事是每隔一段时间会跳出一些很有挑战性的文章。如果没错,这些天你至少发现了一篇这类文章——很难快速通读它们,于是就将之束之高阁,直到 突然你发现自己已经有了一个长长的链接列表和一个装满了PDF文件的目录,然后你梦想着到一个人迹罕至的森林里的小木屋苦读一年以期赶上,要是每天清晨你 沿着那里的林中小溪散步时会有人带来食物和带走垃圾就更好了。
虽然我对你的列表一无所知,但我的列表却是一大堆关于函数式编程的文章。而这些基本上是最难阅读的了。它们用枯燥的学院派语言写成,即使“在华尔街 行业浸淫十年的专家(veterans)”也不能理解函数式编程(也写作FP)都在探讨些什么。如果你去问花旗集团(Citi Group)或德意志银行(Deutsche Bank)的项目经理[1],为什么选择了 JMS 而不 Erlang,他们可能回答不能在产业级的应用中使用学院派语言。问题是,一些最为复杂的,有着最严格需求的系统却是用函数式编程元素写成。有些说法不能 让人信服。
的确,关于函数式编程的文章和论文难于理解,但他们本来不必这么晦涩。这一知识隔阂的形成完全是历史原因。函数式编程的概念本身并不困难。这篇文章 可以作为“简易的函数式编程导引”。是一座从我们命令式(imperative)的思维模式到函数式编程的桥梁。去取杯咖啡回来继续读下去吧。可能你的同 事很快就会开始取笑你对函数式编程发表的观点了。
那么什么是函数式编程呢?它怎么产生?它可以被掌握吗(Is it edible)?如果它真如其倡导者所言,为什么没有在行业中得到更广泛的使用?为什么好像只有那些拿着博士学位的人才使用它?最要紧的是,为什么它就 TMD 这么难学?这些 closure, continuation, currying,惰性求值和无副作用等等究竟是些什么东西?没有大学参与的项目怎么使用它?为什么它看上去这么诡异于和我们命令式思想友好,圣洁和亲近 的一切的一切?我们将于不久扫清这些疑问。首先让我来解释形成实际生活和学界文章之间巨大隔阂的缘起,简单得像一次公园的散步。
信步游园
启动时间机器,我们散步在两千多年以前的一个被遗忘了太久的春季明媚的日子,那是公元前380年。雅典城墙外的橄榄树树荫里,柏拉图和一个英俊的奴隶小男孩朝着学院走去。“天气真好”,“饮食不错”,然后话题开始转向哲思。
“瞧那两个学生,”为了使问题更容易理解,柏拉图仔细地挑选着用词,“你认为谁更高呢?”
小男孩看着那两个人站着的水漕说,“他们差不多一样高”。
柏拉图说:“你的差不多一样是什么意思?”。“我在这里看他们是一样高的,不过我肯定如果走近些就会看出他们高度的差别。”
柏拉图笑了,他正把这个孩子带到正确的方向。“那么你是说,我们这个世界没有完全的等同了?”
小男孩想了一会儿回答,“对,我不这样认为,任何事物总有一些区别,即使我们看不到它。”
这句话非常到位!“那么如果这世上没有完全的相等,你又是如何理解‘完全’相等这个概念的呢?”
小男孩迷惑得说:“我不知道。”最初尝试着理解数学的本源(nature)时也会产生这种疑惑。
柏拉图暗示这个世上的万物都只是一个对完美的近似。他还认识到我们即使没有接触到完美但依然可以理解这一概念。所以他得出结论,完美的数学形式只能 存在于另一个世界,我们通过和那个世界的某种联系在一定程度上知晓他们。很明显我们不能看到完美的圆,但我们可以理解什么是完美的圆并用数学公式将它表达 出来。那么,什么是数学?为什么宇宙可以用数学定理描述?数学可以描述宇宙中的所有现象吗?[2]
数学哲学是一个很复杂的课题。像大多数哲学学科一样它更倾向于提出问题而不是给出解答。这些意见中很多都循回绕转于一个事实,即数学实际上是一个谜 语:我们设置了一系列基本的不冲突的原理和一些可以施加于这些原理的操作规则,然后我们就能堆砌这些规则以形成更复杂的规则。数学家把这种方法叫做“形式 系统”或“演算”。如果愿意,我们可以很快写出一个关于 Tetris(译者注:一种通常被称为俄罗斯方块的游戏)的形式系统。实际上,工作中的 Tetris 实现就是一个形式系统,只是被指定使用了个不常见的表现形式。
人马座的那个生物文明也许不能理解我们的 Tetris 和圆的范式,因为可能他们唯一的感知输入是气味香橙的橘子。他们也许永远不会发现 Tetris 范式,但很可能会有一个圆的范式。我们也可能将无法阅读它,因为我们的嗅觉没有那么复杂,可是一旦我们理解(pass)了那一范式的表示形式(通过这种传 感器和标准解码技术来理解这种语言),其底层的概念就可被任何智能文明所理解。
有趣的是如果从来没有智能文明存在,Tetris 和圆的范式仍然严密合理,只是没有人注定将会发现他们。如果产生了一种智能文明,他就会发现一些形式系统来帮助描述宇宙的规律。但他还是不大可能发现 Tetris 因为宇宙中再没有和它相似的事物。在现实世界中这类无用的形式系统或迷题的例子数不胜数,Tetris 只是其中的一个典型。我们甚至不能确定自然数是否是对客观世界的完整近似,至少我们可以简单的想像一个很大的数它不能用宇宙中任何东西描述,因为它以近乎 无穷。
历史一瞥[3]
再次启动时间机器,这一次的旅行近了很多,我们回到 1930 年代。大萧条正在蹂躏着那个或新或就的时代。空前的经济下挫影响着几乎所有阶层的家庭生活,只有少数人还能够保持着饥谨危机前的安逸。一些人就如此幸运地位列其中,我们关心的是普林斯顿大学的数学家们。
采用了歌特式风格设计建造的新办公室给普林斯顿罩上天堂般的幸福光环,来自世界各地的逻辑学家被邀请到普林斯顿建设一个新的学部。虽然彼时的美国民 众已难能弄到一餐的面包,普林斯顿的条件则是可以在高高的穹顶下,精致雕凿的木质墙饰边上整日的品茶讨论或款款慢步于楼外的林荫之中。
阿隆左·丘奇就是一个在这种近于奢侈的环境中生活着的数学家。他在普林斯顿获得本科学位后被邀留在研究生院继续攻读。阿隆左认为那里的建筑实属浮 华,所以他很少一边喝茶一边与人讨论数学,他也不喜欢到林中散步。阿隆左是一个孤独者:因为只有一个人时他才能以最高的效率工作。虽然如此,他仍与一些普 林斯顿人保持的定期的联系,其中包括阿兰·图灵,约翰·冯·诺依曼,和 kurt Grodel。
这四个人都对形式系统很感兴趣,而不太留意现实世界,以便致力于解决抽象的数学难题。他们的难题有些共同之处:都是探索关于计算的问题。如果我们有 了无限计算能力的机器,哪些问题可以被解决?我们可以使他们自动地得以解决吗?是否还是有些问题无法解决,为什么?不同设计的各种机器是否具有相同的计算 能力?
通过和其它人的合作,阿隆左·丘奇提出了一个被称为 lambda 演算的形式系统。这个系统本质上是一种虚拟的机器的编程语言,他的基础是一些以函数为参数和返回值的函数。函数用希腊字母 lambda 标识,这个形式系统因此得名[4]。利用这一形式系统,阿隆左就可以对上述诸多问题推理并给出结论性的答案。
独立于阿隆左,阿兰·图灵也在进行着相似的工作,他提出了一个不同的形式系统(现在被称为图灵机),并使用这一系统独立得给出了和阿隆左相似的结论。后来被证明图灵机和 lambda 演算能力等同。
我们的故事本可以到此结束,我会就此歇笔,而你也将浏览到下一个页面,如果第二次世界大战没有在那时打响。整个世界笼罩在战争的火光和硝烟之中,美 国陆军和海军前所未有的大量使用炮弹,为了改进炮弹的精确度,部队组织了大批的科学家持续地计算微分方程以解出弹道发射轨迹。渐渐意识到这个任务用人力手 工完成太耗精力后,人们开始着手开发各种设备来攻克这个难关。第一个解出了弹道轨迹的机器是 IBM 制造的 Mark I —— 它重达5吨,有75万个组件,每秒可以完成三次操作。
竞争当然没有就此结束,1949年,EDVAC(Electronic Discrete Variable Automatic Computer,爱达瓦克)被推出并获得了极大的成功。这是对冯·诺依曼架构的第一个实践实例,实际上也是图灵机的第一个现实实现。那一年好运与阿隆左 ·丘奇无缘。
直到1950年代将尽,一位 MIT 的教授John McCarthy(也是普林斯顿毕业生)对阿隆左·丘奇的工作产生了兴趣。1958年,他公开了表处理语言 Lisp。Lisp 是对阿隆左·丘奇的 lambda 演算的实现但同时它工作在冯·诺依曼计算机上!很多计算机科学家认识到了 Lisp 的表达能力。1973年,MIT人工智能实验室的一组程序员开发了被称为Lisp机器的硬件-阿隆左 lambda 演算的硬件实现!
函数式编程
函数式编程是对阿隆左·丘奇理论的实践应用。但也并非全部 lambda 演算都被应用到了实践中,因为 lambda 演算不是被设计为在物理局限下工作的。因此,象面向对象的编程一样,函数式编程是一系列理念,而不是严格的教条。现在有很多种函数式编程语言,他们中的大 多数以不同方式完成不同任务。在本文中我将就最广泛使用的源自函数式编程的思想作一解释,并将用Java语言举例。(的确,你可以用Java写出函数式的 程序如果你有显著的受虐倾向)。在下面的小节中,我将会把Java作为一种函数式语言,并对其稍加修改使它成为一种可用的函数式语言。现在开始吧。
lambda 演算被设计用来探询关于计算的问题,所以函数式编程主要处理计算,并惊人地用函数来完成这一过程。函数是函数式编程的基本单位,函数几乎被用于一切,包括 最简单的计算,甚至变量都由计算取代。在函数式编程中,变量只是表达式的别名(这样我们就不必把所有东西打在一行里)。变量是不能更改的,所有变量只能被 赋值一次。用 Java 的术语来说,这意味着所有单一变量都被声明为 final(或 C++ 的 const)。在函数式编程中没有非 final 的变量。
final int i = 5;
final int j = i + 3;
因为函数式编程中所有变量都是 final 的,所以可以提出这样两个有趣的表述:没有必要总是写出关键字 final,没有必要把变量再称为变量。那么现在我们对Java作出两个修改:在我们的函数式 Java 中所有变量默认都是 final的,我们将变量(variable)称为符号(symbol)。
就此你也许会质疑,用我们新创造的语言还能写出有些复杂度的程序吗?如果每个符号都是不可变更(non-mutalbe)的,那么就无法改变任何状 态!其实事实并非完全如此。在阿隆左研究其 lambda 演算时,他并不想将某个状态维护一段时间以期未来对其进行修改。他关注的是对数据的操作(也通常被称为”演算体 caculating stuff”)。既然已被证明lambda演算与图灵机等价,它可以完成所有命令式编程语言能够完成的任务。那么,我们怎么才能做到呢?
答案是函数式程序能保存状态,只是它并非通过变量而是使用函数来保存状态。状态保存在函数的参数中,保存在堆栈上。如果你要保存某个状态一段时间并 时不时地对其进行一些修改,可以写个递归函数。举个例子,我们写个函数来翻转 Java 的字符串。记住,我们声明的每个变量默认都是 final 的。[5]
String reverse(String arg) {
if(arg.length == 0) {
return arg;
}
else {
return reverse(arg.substring(1, arg.length)) + arg.substring(0,1);
}}
这个函数很慢因为它不断地调用自己[6],它还也是个嗜内存魔因为要持续分配对象。不过它的确是在用函数式风格。你可能会问,怎么有人会这样写程序?好的,我这就慢慢讲来:
函数式编程的优点
你可能会认为我根本无法对上面那个畸形的函数给出个合理的解释。我开始学习函数式编程时就是这么认为的。不过我是错了。有很好的理由使用这种风 格,当然其中一些属主观因素。例如,函数式程序被认为更容易阅读。因为每个街区的孩子都知道,是否容易理解在旁观者的眼中,所以我将略去这些主观方面的理 由。幸运的是,还有很多的客观理由。
单元测试
因为函数式编程的每一个符号都是 final 的,没有函数产生过副作用。因为从未在某个地方修改过值,也没有函数修改过在其作用域之外的量并被其他函数使用(如类成员或全局变量)。这意味着函数求值的结果只是其返回值,而惟一影响其返回值的就是函数的参数。
这是单元测试者的梦中仙境(wet dream)。对被测试程序中的每个函数,你只需在意其参数,而不必考虑函数调用顺序,不用谨慎地设置外部状态。所有要做的就是传递代表了边际情况的参 数。如果程序中的每个函数都通过了单元测试,你就对这个软件的质量有了相当的自信。而命令式编程就不能这样乐观了,在 Java 或 C++ 中只检查函数的返回值还不够——我们还必须验证这个函数可能修改了的外部状态。
调试
如果一个函数式程序不如你期望地运行,调试也是轻而易举。因为函数式程序的 bug 不依赖于执行前与其无关的代码路径,你遇到的问题就总是可以再现。在命令式程序中,bug 时隐时现,因为在那里函数的功能依赖与其他函数的副作用,你可能会在和 bug 的产生无关的方向探寻很久,毫无收获。函数式程序就不是这样——如果一个函数的结果是错误的,那么无论之前你还执行过什么,这个函数总是返回相同的错误结 果。
一旦你将那个问题再现出来,寻其根源将毫不费力,甚至会让你开心。中断那个程序的执行然后检查堆栈,和命令式编程一样,栈里每一次函数调用的参数都 呈现在你眼前。但是在命令式程序中只有这些参数还不够,函数还依赖于成员变量,全局变量和类的状态(这反过来也依赖着这许多情况)。函数式程序里函数只依 赖于它的参数,而那些信息就在你注视的目光下!还有,在命令式程序里,只检查一个函数的返回值不能够让你确信这个函数已经正常工作了,你还要去查看那个函 数作用域外数十个对象的状态来确认。对函数式程序,你要做的所有事就是查看其返回值!
沿着堆栈检查函数的参数和返回值,只要发现一个不尽合理的结果就进入那个函数然后一步步跟踪下去,重复这一个过程,直到它让你发现了 bug 的生成点。
并行
函数式程序无需任何修改即可并行执行。不用担心死锁和临界区,因为你从未用锁!函数式程序里没有任何数据被同一线程修改两次,更不用说两个不同的线程了。这意味着可以不假思索地简单增加线程而不会引发折磨着并行应用程序的传统问题。
事实既然如此,为什么并不是所有人都在需要高度并行作业的应用中采用函数式程序?嗯,他们正在这样做。爱立信公司设计了一种叫作 Erlang 的函数式语言并将它使用在需要极高抗错性和可扩展性的电信交换机上。还有很多人也发现了 Erlang 的优势并开始使用它。我们谈论的是电信通信控制系统,这与设计华尔街的典型系统相比对可靠性和可升级性要求高了得多。实际上,Erlang 系统并不可靠和易扩展,Java 才是。Erlang 系统只是坚如磐石。
关于并行的故事还没有就此停止,即使你的程序本身就是单线程的,那么函数式程序的编译器仍然可以优化它使其运行于多个CPU上。请看下面这段代码:
String s1 = somewhatLongOperation1();
String s2 = somewhatLongOperation2();
String s3 = concatenate(s1, s2);
在函数编程语言中,编译器会分析代码,辨认出潜在耗时的创建字符串s1和s2的函数,然后并行地运行它们。这在命令式语言中是不可能的,因为在那 里,每个函数都有可能修改了函数作用域以外的状态并且其后续的函数又会依赖这些修改。在函数式语言里,自动分析函数并找出适合并行执行的候选函数简单的像 自动进行的函数内联化!在这个意义上,函数式风格的程序是“不会过时的技术(future proof)”(即使不喜欢用行业术语,但这回要破例一次)。硬件厂商已经无法让CPU运行得更快了,于是他们增加了处理器核心的速度并因并行而获得了四 倍的速度提升。当然他们也顺便忘记提及我们的多花的钱只是用在了解决平行问题的软件上了。一小部分的命令式软件和 100% 的函数式软件都可以直接并行运行于这些机器上。
代码热部署
过去要在 Windows上安装更新,重启计算机是难免的,而且还不只一次,即使是安装了一个新版的媒体播放器。Windows XP 大大改进了这一状态,但仍不理想(我今天工作时运行了Windows Update,现在一个烦人的图标总是显示在托盘里除非我重启一次机器)。Unix系统一直以来以更好的模式运行,安装更新时只需停止系统相关的组件,而 不是整个操作系统。即使如此,对一个大规模的服务器应用这还是不能令人满意的。电信系统必须100%的时间运行,因为如果在系统更新时紧急拨号失效,就可 能造成生命的损失。华尔街的公司也没有理由必须在周末停止服务以安装更新。
理想的情况是完全不停止系统任何组件来更新相关的代码。在命令式的世界里这是不可能的。考虑运行时上载一个Java类并重载一个新的定义,那么所有 这个类的实例都将不可用,因为它们被保存的状态丢失了。我们可以着手写些繁琐的版本控制代码来解决这个问题,然后将这个类的所有实例序列化,再销毁这些实 例,继而用这个类新的定义来重新创建这些实例,然后载入先前被序列化的数据并希望载入代码可以恰到地将这些数据移植到新的实例。在此之上,每次更新都要重 新手动编写这些用来移植的代码,而且要相当谨慎地防止破坏对象间的相互关系。理论简单,但实践可不容易。
对函数式的程序,所有的状态即传递给函数的参数都被保存在了堆栈上,这使的热部署轻而易举!实际上,所有我们需要做的就是对工作中的代码和新版本的 代码做一个差异比较,然后部署新代码。其他的工作将由一个语言工具自动完成!如果你认为这是个科幻故事,请再思考一下。多年来 Erlang工程师一直更新着他们的运转着的系统,而无需中断它。
机器辅助的推理和优化
函数式语言的一个有趣的属性就是他们可以用数学方式推理。因为一种函数式语言只是一个形式系统的实现,所有在纸上完成的运算都可以应用于用这种语言 书写的程序。编译器可以用数学理论将转换一段代码转换为等价的但却更高效的代码[7]。多年来关系数据库一直在进行着这类优化。没有理由不能把这一技术应 用到常规软件上。
另外,还能使用这些技术来证明部分程序的正确,甚至可能创建工具来分析代码并为单元测试自动生成边界用例!对稳固的系统这种功能没有价值,但如果你 要设计心房脉冲产生器 (pace maker)或空中交通控制系统,这种工具就不可或缺。如果你编写的应用程序不是产业的核心任务,这类工具也是你强于竞争对手的杀手锏。
高阶函数
我记得自己在了解了上面列出的种种优点后曾想:“那都是非常好的特性,可是如果我不得不用天生就不健全的语言编程,把一切变量声明为
final 产生的代码将是垃圾一堆。” 这其实是误解。在如Java 这般的命令式语言环境里,将所有变量声明为 final 没有用,但是在函数式语言里不是这样。函数式语言提供了不同的抽象工具它会使你忘记你曾经习惯于修改变量。高阶函数就是这样一种工具。
函数式语言中的函数不同于 Java 或 C 中的函数,而是一个超集——它有着 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 的实例如果不再被使用就将会被垃圾回收掉。这些使得函数成为第一级的对象无异于整数或字符串。(作为参数)操作函数的函数被称为高阶函数。别让这个术语吓 着你,这和 Java 的 class 操作其它 class(把它们作为参数)没有什么区别。我们本可以把它们称为“高阶类”但没有人注意到这个,因为 Java 背后没有一个强大的学术社区。
那么怎样,何时应该使用高阶函数呢?我很高兴你这样问。如果你不曾考虑类的层次,就可能写出了一整团堆砌的代码块。当你发现其中一些行的代码重复出 现,就把他们提取成函数(幸运的是这些依然可以在学校里学到)。如果你发现在那个函数里一些逻辑动作根据情况有变,就把他提取成高阶函数。糊涂了?下面是 一个来自我工作的实例:假如我的一些 Java 代码接受一条信息,用多种方式处理它然后转发到其他服务器。
class MessageHandler {
void handleMessage(Message msg) {
// …
msg.setClientCode(”ABCD_123″);
// …
sendMessage(msg);
}
// …
}
现在假设要更改这个系统,现在我们要把信息转发到两个服务器而不是一个。除了客户端的代码一切都像刚才一样——第二个服务器希望这是另一种格式。怎么处理这种情况?我们可以检查信息的目的地并相应修改客户端代码的格式,如下:
class MessageHandler {
void handleMessage(Message msg) {
// …
if(msg.getDestination().equals(”server1″) {
msg.setClientCode(”ABCD_123″);
} else {
msg.setClientCode(”123_ABC”);
}
// …
sendMessage(msg);
}
// …
}
然而这不是可扩展的方法,如果加入了更多的服务器,这个函数将线性增长,更新它会成为我的梦魇。面向对象的方法是把MessageHandler作为基类,在导出类中专业化客户代码操作:
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”;
}
}
现在就可以对每一个服务器实例化一个适合的类。添加服务器的操作变得容易维护了。但对于这么一个简单的修改仍然要添加大量的代码。为了支持不同的客户代码我们创建了两个新的类型!现在我们用高阶函数完成同样的功能:
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);
没有创建新的类型和新的class层次,只是传入合适的函数作为参数,完成了面向对象方式同样的功能,同时还有一些额外的优点。没有使自己囿于类的 层次之中:可以在运行时传入函数并在任何时候以更高的粒度更少的代码修改他们。编译器高效地为我们生成了面向对象的“粘合”代码!除此之外,我们还获得了 所有函数式编程的其他好处。当然函数式语言提供的抽象不只这些,高阶函数只是一个开始:
currying
我认识的大多数人都读过“四人帮”的那本设计模式,任何自重的程序员都会告诉你那本书是语言中立的(agnostic),模式在软件工程中是通用的,和使用的语言无关。这个说法颇为高贵,故而不幸的是,有违现实。
函数式编程极具表达能力。在函数式语言中,语言既已达此高度,设计模式就不再是必需,最终你将设计模式彻底消除而以概念编程。适配器 (Adapter)模式就是这样的一个例子。(究竟适配器和 Facade 模式区别在哪里?可能有些人需要在这里再多费些篇章)。一旦语言有了叫作 currying 的技术,这一模式就可以被消除。
currying.
适配器模式最有名的是被应用在 Java 的“默认”抽象单元——class 上。在函数式编程里,模式被应用到函数。模式带有一个接口并将它转换成另一个对他人有用的接口。这有一个适配器模式的例子:
int pow(int i, int j);
int square(int i)
{
return pow(i, 2);
}
上面的代码把一个整数幂运算接口转换成为了一个平方接口。在学术文章里,这个雕虫小技被叫作currying(得名于逻辑学家Haskell
Curry,他曾将相关的数学理论形式化 )。因为在函数式编程中函数(反之如class)被作为参数来回传递,currying 很频繁地被用来把函数调整为更适宜的接口。因为函数的接口是他的参数,使用 currying 可以减少参数的数目(如上例所示)。
函数式语言内建了这一技术。不用手动地创建一个包装了原函数的函数,函数式语言可以为你代劳。同样地,扩展我们的语言,让他支持这个技术:
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();
正如你所见,通过简单地创建一个对原函数的包装,在函数式编程中,这就是 currying —— 快速简易创建包装的捷径。把精力集中在你的业务上,让编译器为你写出必要的代码!什么时候使用 currying?这很简单,任何时候你想要使用适配器模式(包装)时。
惰性求值
惰性(或延迟)求值这一技术可能会变得非常有趣一旦我们采纳了函数式哲学。在讨论并行时已经见过下面的代码片断:
String s1 = somewhatLongOperation1();
String s2 = somewhatLongOperation2();
String s3 = concatenate(s1, s2);
在一个命令式语言中求值顺序是确定的,因为每个函数都有可能会变更或依赖于外部状态,所以就必须有序的执行这些函数:首先是
somewhatLongOperation1,然后 somewhatLongOperation2,最后 concatenate,在函数式语言里就不尽然了。
前面提到只要确保没有函数修改或依赖于全局变量,somewhatLongOperation1 和 somewhatLongOperation2 可以被并行执行。但是如果我们不想同时运行这两个函数,还有必要保证有序的执行他们呢?答案是不。我们只在其他函数依赖于s1和s2时才需要执行这两个函 数。我们甚至在concatenate调用之前都不必执行他们——可以把他们的求值延迟到concatenate函数内实际用到他们的位置。如果用一个带 有条件分支的函数替换concatenate并且只用了两个参数中的一个,另一个参数就永远没有必要被求值。在 Haskell 语言中,不确保一切都(完全)按顺序执行,因为 Haskell 只在必要时才会对其求值。
惰性求值优点众多,但缺点也不少。我们会在这里讨论它的优点而在下一节中解释其缺点。
优化
惰性求值有客观的优化潜力。惰性编译器看函数式代码就像数学家面对的代数表达式————可以注销一部分而完全不去运行它,重新调整代码段以求更高的 效率,甚至重整代码以降低出错,所有确定性优化(guaranteeing optimizations)不会破坏代码。这是严格用形式原语描述程序的巨大优势————代码固守着数学定律并可以数学的方式进行推理。
抽象控制结构
惰性求值提供了更高一级的抽象,它使得不可能的事情得以实现。例如,考虑实现如下的控制结构:
unless(stock.isEuropean()) {
sendToSEC(stock);
}
我们希望只在祖先不是欧洲人时才执行sendToSEC。如何实现 unless?如果没有惰性求值,我们需要某种形式的宏(macro)系统,但
Haskell 这样的语言不需要它。把他实现为一个函数即可:
void unless(boolean condition, List code) {
if(!condition)
code;
}
注意如果条件为真代码将不被执行。我们不能在一个严格(strict)的语言中再现这种求值,因为 unless 调用之前会先对参数进行求值。
无穷(infinite)数据结构
惰性求值允许定义无穷数据结构,对严格语言来说实现这个要复杂的多。考虑一个 Fibonacci 数列,显然我们无法在有限的时间内计算出或在有限的内存里保存一个无穷列表。在严格语言如 Java 中,只能定义一个能返回 Fibonacci 数列中特定成员的 Fibonacci 函数,在 Haskell
中,我们对其进一步抽象并定义一个关于 Fibonacci 数的无穷列表,因为作为一个惰性的语言,只有列表中实际被用到的部分才会被求值。这使得可以抽象出很多问题并从一个更高的层次重新审视他们。(例如,我们可以在一个无穷列表上使用表处理函数)。
缺点
当然从来不存在免费的午餐。惰性求值有很多的缺点,主要就在于,懒。有很多现实世界的问题需要严格求值。例如考虑下例:
System.out.println(”Please enter your name: “);
System.in.readLine();
在惰性求值的语言里,不能保证第一行会在第二行之前执行!那么我们就不能进行输入输出操作,不能有意义地使用本地(native)接口(因为他们相 互依赖其副作用必须被有序的调用),从而与整个世界隔离。如果引入允许特定执行顺序的原语又将失去数学地推理代码的诸多好处(为此将葬送函数式编程与其相 关的所有优点)。幸运的是,并非丧失了一切,数学家为此探索并开发出了许多技巧来保证在一定函数设置下(function setting)代码以一特定的顺序执行。这样我们就赢得了两个世界。这些技术包括 continuation, monad 和 uniqueness typing
(一致型别)。我只会在本文中解释continuation,把 monad 和 uniqueness typing 留到将来的文章中。有趣的是,除了确保函数求值顺序, continuation 在很多别的情况下也很有用。这点等一会儿就会提到。
Continuations
Continuations 对于程序设计的意义,就像《达芬奇密码》对人类历史的意义:即对人类最大秘密的惊人揭示。也许不是,但他在概念上的突破性至少和揭示了负数的平方根意义等同。
我们在学习函数时,只是学到了一半的事实,因为我们基于一个错误的假定:函数只能将结果返回到它的调用函数。在这个意思上continuation 是广义的函数。函数不必要返回到其调用函数而可以返回到程序的任何地方。我们把”continuation” 作为参数传给一个函数,它指定了这个函数返回的位置。这个描述可能听起来更加复杂。看一下下面的代码:
int i = add(5, 10);
int j = square(i);
函数 add 在其被调用的位置将结果 15 赋给了 i,接下来 i 的值被用来调用 square。注意所有的惰性求值编译器都不能调整这几行代码因为第二行依赖着第一行的成功求值。下面用 continuation 风格又称 CPS (Continuation Programming Style) 来重写这段代码,这里函数 add 会将结果返回到 square 而不是原来的调用函数。
int j = add(5, 10, square);
这个例子中 add 有了另一个参数 —— 一个 add 必须在它求值结束时用其返回值调用的函数。这里 square 是 add 的一个 continuation。这两种情况下,j 都将等于 255。
这就是强制使惰性语言有序地求值两个表达式的第一个技巧。考虑下面这个(熟悉的)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所期待的),我们就解决了这个问题!当然这样的链接函数调用很快就会使代码难以读懂,不过这个可以避免。比 如我们可以给语言添加些语法甜点(syntactic sugar)就可以简单的按顺序输入表达式,然后由编译器自动为我们链接这些函数调用。这样就可以如愿地使用期望的求值顺序并保留一切函数式编程的好处 (包括数学地对我们程序进行推理的能力)!如果还是有迷惑,记住函数是只有一个成员的类的实例。重写上述代码使得 println 和 readLine 成为类的实例,这样就对一切都清楚了。
如果我在此结束本节,那将仅仅涉及到 continuation 最浅显的应用。用 CPS 重写整个程序,那里所有的函数都增加一个额外的 continuation 参数并把函数结果传给它。也可以通过简单地把函数当作 continuation 函数(总是返回到调用者的函数)的特殊实例来将程序转为 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 (current continuation)(它就是当前的栈的信息)。
好的,这样我们就知道了什么是 current continuation。他有什么意义?一旦我们得到了当前的 continuation 并将它保存在某处,我们就最终将程序当前的状态保存了下来——及时地冷冻下来。这就像操作系统将其置为休眠状态。一个 continuation 对象里保存了在我们获得它的地方重新启动程序的必要信息。操作系统在每次发生线程间的上下文切换时也是如此。唯一的区别是它保留着全部控制。请求一个 continuation 对象(在Scheme里,可以调用 call-with-current-continuation 函数)后,你就会获得一个包括了当前 continuation
的对象——堆栈(或者在CPS情况下则是下一个要调用的函数)。可以把这个对象保存在一个变量(或者是磁盘)里。当你用这 continuation “重启”程序时,就会转回到处你取得这个对象的那个状态。这就象切换回一个被挂起的线程或唤醒休眠着的操作系统,区别是用 continuation,你可以多次地重复这一过程。当操作系统被唤醒时,休眠信息就被销毁了。但如果那些信息没有被销毁,你也就可以一次次地将它唤醒 到同一点,就象重返过去一样。有了 continuation 你就有了这个控制力!
Continuation 应该在什么情况下使用呢?通常在尝试模拟一个本质上是无状态的应用时可以简化你的任务。Continuation 很适合在Web应用程序中使用。微软公司的 ASP.NET 技术极尽苦心地模拟状态以便你在开发 Web 应用时少费周折。可如果 C# 支持了continuation,ASP.NET 的复杂度就可以减半——你只需要保存一个 continuation,当用户下次发出 web 请求时重启它即可。对程序员来说,web 应用程序将不再有中断——程序只是简单的从下一行重启!利用 continuation 这一抽象解决问题真是令人难以置信的便利。考虑到越来越多的胖客户端应用程序正在向服务器端转移,将来 continuation 也会变得越来越重要。
模式匹配
模式匹配不是什么新的创新的特性。事实上,它和函数式编程的关系不大。把产生模式匹配归因于函数式编程的唯一的原因是函数式语言一度提供了模式匹配,然而现在的命令式语言还做不到。
让我们用一个例子深入了解一下模式匹配。这是一个Java的Fibonacci函数:
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);
}
两者有什么区别?编译器为我们实现了分支。这有什么大不了?的确没什么。有人注意到很多函数包括了复杂的 swith 语句(尤其是在函数式程序中)所以认为这种抽象形式很好。我们把一个函数定义分离成多个,然后把模式置于参数中(有点象重载)。当这个函数被调用时,编译 器使其比较参数和其运行时的定义然后选择其中正确的一个。这一般是通过选择可选的最特定的定义来完成。例如,int fib(int n) 可以以 n 等于 1 被调用,但是实际上 fib(n) 没有被调用,因为 fib(1) 更加特定。
模式匹配通常要比我这个例子复杂,比如,高级模式匹配系统可以让我们这样做:
int f(int n < 10) { … }
int f(int n) { … }
模式匹配什么时候适用?情况太多了!每当你有一个嵌套着 if 的复杂的数据结构,这时就可以用模式匹配以更少的代码完成得更好。一个很好的例子闪现在我脑海,这就是所有 Win32 平台都提供了的标准的 WinProc 函数(即使它通常被抽象了)。通常模式匹配系统能检测集合也可以应付简单的值。例如,当传给函数一个数组后,就可以找出所有首元素为 1 第三个元素大于 3 的所有数组。
模式匹配还有一个好处即如果需要增加或修改条件,那么不必对付一个巨大的函数。只需增加或修改适合的定义即可。这消除了“四人帮”(GoF)书中的一大类设计模式。条件越复杂,模式匹配就越有用。一旦习惯了它,你就会担心没有了模式匹配的日子如何打发。
Closures
到此我们已经讨论了纯的函数式语言——实现了lambda演算又不包括与丘奇形式系统矛盾的语言——环境里的特性,可是还有很多在lambda演算 框架之外的函数语言的有用特征。虽然一个公理系统的实现可以让我们象数学表达式那样思考程序但它未必是实际可行的。许多语言选择去合并一些函数式的元素而 没有严格的坚持函数式的教条。很多象这样的语言(如Common Lisp)不要求变量是 final 的——可以即处对其修改。他们还不要求函数只依赖于其参数——允许函数访问外部状态。但这些语言也的确包含着函数式的特征——如高阶函数,在非纯粹的函数 式语言里传递函数作为参数和限制在 lambda 演算系统中的作法有些不同,它需要一种常被称为词法(lexical)closure 的有趣特性。下面我给出几个例子。记住,这里变量不再是final的,函数可以引用其作用域外的变量:
Function makePowerFn(int power) {
int powerFn(int base) {
return pow(base, power);
}
return powerFn;
}
Function square = makePowerFn(2);
square(3); // returns 9
函数 make-power-fn 返回了一个函数,它有一个参数,并对这个参数进行一定阶的幂运算。如果对 square(3) 求值会有什么结果?变量 power 不在 powerFn 的作用域中,因为 makePowerFn 已经返回它的栈桢而不复存在。那么square如何工作?一定是这个语言以某种方式将power的值保存了起来以便 square 使用。如果我们再新建一个函数cube,用来计算参数的立方又会怎样?运行环境必须存储两个power的拷贝,每个我们用 make-power-fn 生成的函数都用一个拷贝。保存这些值的现象就被称为 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,所以递增器可以访问它,而且运行时为每个递增器都保存了一个 n 的拷贝,即使这些拷贝本应在 makeIncrementer
返回时消失。这些代码被如何编译?closure 在底层是如何工作的?很幸运,我们可以去幕后看看。
一点常识会很有帮助,首先会注意到的是局部变量的生命期不再由简单的作用域限定而是不确定的。那么显然可以由此得出结论它们不再被保存在栈上——反 之必须被保存在堆上[8]。这样一来,closure 的实现就象我们前面讨论的函数一样了,只是它还有一个指向周围变量的引用。
class some_function_t {
SymbolTable parentScope;
// …
}
当一个 closure 引用了一个不在其作用域的变量时,它会在其祖先作用域中查找这个引用。就是这样!Closure 将函数式和面向对象的世界紧密结合。当你创建了一个包含了一些状态的类并把它传到别处时,考虑一下 closure。Closure 就是这样在取出作用域中的变量的同时创建“成员变量”,所以你不必亲自去做这些!
下一步的计划?
关于函数式编程,本文作了浅显地讨论。有时候一次粗浅的射猎可能会进展为重大的收获与我也受益匪浅。将来我还计划写写 category 理论,monad,函数式数据结构,函数式语言中的类型(type)体系,函数式并发,函数式数据库等等还有很多。如果我得以(在学习的过程中)写出了上 述诸多主题中的一半,我的生命就会完整了。还有,Google 是我们的朋友。
评论?
如果你有任何问题,意见或建议,请发到邮箱 coffee…@gmail.com。很高兴收到你的反馈
===========================
[1] 我在2005年找工作时常常提出这个问题,当时我得到的是数量可观的一脸茫然。想像一下,这些人至少每人会得到30万美元,如果他们理解了他们可以得到的大部分工具。
[2] 这像是个悖论。物理学家和数学家被迫确认他们还不完全清楚是否宇宙万物遵循着可以被数学描述的规则。
[3] 我一直厌恶提供了一堆枯燥的日期,人名和地点的纪年式历史课。对我而言,历史是改变了这个世界的人的生活,是他们行为之后的个人动机,是他们得以影响亿万生灵的体制。所以这个关于历史的小节注定无法完整,只讨论了于此关系及其密切的人物与事件。
[4] 我在学习函数式编程的时候,很不喜欢术语 lambda,因为我没有真正理解它的意义。在这个环境里,lambda 是一个函数,那个希腊字母只是方便书写的数学记法。每当你听到 lambda 时,只要在脑中把它翻译成函数即可。
[5] 有趣的是 Java 的字符串是不可变更的,探讨这一离经叛道的设计的原因也非常有趣,不过在这里会分散我们对原目标的注意力
[6] 大多数函数式编程语言的编译器能通过将递归尽可能转为迭代来进行优化,这被称为尾递归。
[7] 相反未必成立,虽然有时可以证明两端代码等价,但这不是所有情况下都成立。
[8] 这实际上不比存储在栈上慢,因为一旦引入了垃圾回收器,内存分配就成为了一个O(1)的操作。

没有评论:

发表评论