译文:函数式编程另类指南
Functional Programming For The Rest of Us 是一篇很特别的函数式编程入门简介,它用 Java 的语法清晰的说明了函数式编程中最难解的一组概念,可能对很多人越过学习 FP 初期的峭壁会有不小的帮助。原文由当时在 Stony Brook 大学念 CS 的博士生 Slava Akhmechet(此人是分布式 JSON 数据库 RethinkDB 的发起人和开发者之一)写于 2006 年,早先国内有 @lihaitao 很不错的译文,可惜原译文链接已失效,各处的转载都有各种质量缺陷,所以我整理并修订了原译文的一些错误并转载于此,作为归档和更好传播之用。在此对原文作者和译者致以真诚谢意。
原文:Functional Programming For The Rest of Us
作者:Slava Akhmechet
原译:函数式编程另类指南
译者:lihaitao
修订:Neo Lee a.k.a @soulhacker
前言
程序员拖沓成性,每天到了办公室后,泡咖啡、检查邮箱、阅读 RSS feed、到技术站点查阅最新的文章、在编程论坛的相关版面浏览公共讨论,并一次次地刷新以免漏掉一条信息。然后是午饭,回来后盯了 IDE 没几分钟,就再次检查邮箱、倒咖啡。最后在不知不觉中,结束了一天。
不平凡的事是每隔一段时间会跳出一些很有挑战性的文章。如果没错,这些天你至少发现了一篇这类文章——很难快速通读它们,于是就将其束之高阁,直到突然你发现自己已经有了一个长长的链接列表和一个装满了 PDF 文件的目录,然后你梦想着到一个人迹罕至的森林里的小木屋苦读一年以期赶上,要是每天清晨你沿着那里的林中小溪散步时会有人带来食物和带走垃圾就更好了。
虽然我对你的列表一无所知,但我的列表却是一大堆关于函数式编程的文章,而这些基本上是最难阅读的了。它们用枯燥的学院派语言写成,即使"在华尔街浸淫十年的计算专家(veterans)“也不能理解函数式编程(也写作 FP)都在探讨些什么。如果你去问花旗集团(Citi Group)或德意志银行(Deutsche Bank)的项目经理1,为什么选择了 JMS 而不是 Erlang,他们可能回答不能在产业级的应用中使用学院派语言。问题是,一些最为复杂、有着最严格需求的系统却是用函数式编程元素写成的。这种说法不能让人信服。
的确,关于函数式编程的文章和论文难于理解,但他们本来不必这么晦涩。这一知识隔阂的形成完全是历史原因。函数式编程的概念本身并不困难。这篇文章可以作为"简易的函数式编程导引”。是一座从命令式(imperative)思维模式到函数式编程的桥梁。去取杯咖啡回来继续读下去吧,不定啥时候你的同事就会开始取笑你对函数式编程发表的观点了。
那么什么是函数式编程呢?它怎么产生?它可以被掌握吗(Is it edible)?如果它真如其倡导者所言,为什么没有在行业中得到更广泛的使用?为什么好像只有那些拿着博士学位的人才使用它?最要紧的是,为什么它就[哔]这么难学?这些 closure、continuation、currying、lazy evaluation 和 side effects 等等究竟是些什么东西?没有大学参与的项目怎么使用它?相比命令式思想友好、圣洁和亲近的一切的一切,为什么它看上去这么诡异?我们将于不久扫清这些疑问。首先让我来解释形成实际生活和学界文章之间巨大隔阂的缘起,简单得像一次公园的散步。
信步游园
启动时间机器,我们漫步在两千多年以前一个被遗忘了太久的春光明媚的日子,那是公元前 380 年。雅典城墙外的橄榄树荫里,柏拉图和一个英俊的奴隶小男孩朝着学院走去。“天气真好”,“饮食不错”,然后话题开始转向哲思。
“瞧那两个学生,“为了使问题更容易理解,柏拉图仔细地挑选着用词,“你认为谁更高呢?”
小男孩看着那两个人站着的水漕说,“他们差不多一样高”。
柏拉图说:“你的差不多一样是什么意思?” “我在这里看他们是一样高的,不过我肯定如果走近些就会看出他们高度的差别。”
柏拉图笑了,他正把这个孩子带到正确的方向。“那么你是说,我们这个世界没有完全的等同了?”
小男孩想了一会儿回答,“对,我不这样认为,任何事物总有一些区别,即使我们看不到它。”
这句话非常到位!“那么如果这世上没有完全的相等,你又是如何理解‘完全’相等这个概念的呢?”
小男孩迷惑得说:“我不知道。”
最初尝试着理解数学的本源(nature)时也会产生这种疑惑。
柏拉图暗示这个世上的万物都只是一个对完美的近似。他还认识到我们即使没有接触到完美但依然可以理解这一概念。所以他得出结论,完美的数学形式只能存在于另一个世界,我们通过和那个世界的某种联系在一定程度上知晓他们。很明显我们不能看到完美的圆,但我们可以理解什么是完美的圆并用数学公式将它表达出来。那么,什么是数学?为什么宇宙可以用数学定理描述?数学可以描述宇宙中的所有现象吗?2
数学哲学是一个很复杂的课题。像大多数哲学学科一样它更倾向于提出问题而不是给出解答。这些意见中很多都循回绕转于一个事实,即数学实际上是一个谜语:我们设置了一系列基本的、不冲突的原理和一些可以施加于这些原理的操作规则,然后我们就能堆砌这些规则以形成更复杂的规则。数学家把这种方法叫做"形式系统"或"演算”。如果愿意,我们可以很快写出一个关于 Tetris(译者注:经典的俄罗斯方块游戏)的形式系统。实际上,工作中的 Tetris 实现就是一个形式系统,只是被指定使用了个不常见的表现形式。
人马座 α 星的某个生物文明也许不能理解我们的 Tetris 和圆的范式,因为可能他们唯一能感知输入的是带有嗅觉的某个器官。他们也许永远不会发现 Tetris 范式,但很可能会有一个圆的范式,我们很可能无法阅读它,因为我们的嗅觉没有那么复杂,可是一旦我们理解了(past)这一范式的表示形式(通过这种传感器和标准解码技术来理解这种语言),其底层的概念就可被任何智能文明所理解。
有趣的是如果从来没有智能文明存在,Tetris 和圆的范式仍然严密合理,只是没有人注定将会发现他们。如果产生了一种智能文明,他就会发现一些形式系统来帮助描述宇宙的规律。但他还是不大可能发现 Tetris 因为宇宙中再没有和它相似的事物。在现实世界中这类无用的形式系统或迷题的例子数不胜数,Tetris 只是其中的一个典型。我们甚至不能确定自然数是否是对客观世界的完全近似,比如我们可以简单的设想一个很大的数,它不能用来描述我们的宇宙中任何东西,因为它(足够大)但又不是无穷大。
历史一瞥3
再次启动时间机器,这一次的旅行近了很多,我们回到 1930 年代。大萧条正在蹂躏着这个半新不旧的时代。空前的经济下挫影响着几乎所有阶层的家庭生活,只有少数人还能够保持着饥谨危机前的安逸。一些人就如此幸运地位列其中,我们关心的是普林斯顿大学的数学家们。
采用了歌特式风格设计建造的新办公室给普林斯顿罩上天堂般的幸福光环,来自世界各地的逻辑学家被邀请到普林斯顿建设一个新的学部。虽然彼时的美国民众已很难弄到一餐面包,普林斯顿的条件则是可以在高高的穹顶下,精致雕凿的木质墙饰边上整日的品茶讨论或款款漫步于楼外的林荫之中。
阿隆佐·丘奇就是一个在这种近于奢侈的环境中生活着的数学家。他在普林斯顿获得本科学位后被邀留在研究生院继续攻读。阿隆佐认为那里的建筑实属浮华,所以他很少一边喝茶一边与人讨论数学,他也不喜欢到林中散步。阿隆佐是一个孤独者:因为只有一个人时他才能以最高的效率工作。虽然如此,他仍与一些普林斯顿人保持着定期联系,其中包括阿伦·图灵、约翰·冯·诺依曼和库尔特·哥德尔。
这四个人都对形式系统很感兴趣,而不太留意现实世界,以便致力于解决抽象的数学难题。他们的难题有些共同之处:都是探索关于计算的问题。如果我们有了无限计算能力的机器,哪些问题可以被解决?我们可以使他们自动地得以解决吗?是否还是有些问题无法解决?为什么?不同设计的各种机器是否具有相同的计算能力?
通过和其它人的合作,阿隆佐·丘奇提出了一个被称为 λ 演算(lambda calculus)的形式系统。这个系统本质上是一种虚拟的机器的编程语言,他的基础是一些以函数为参数和返回值的函数。函数用希腊字母 λ 标识,这个形式系统因此得名4。利用这一形式系统,阿隆佐就可以对上述诸多问题推理并给出结论性的答案。
独立于阿隆佐,阿伦·图灵也在进行着相似的工作,他提出了一个不同的形式系统(现在被称为图灵机),并使用这一系统独立地给出了和阿隆佐相似的结论。后来人们证明图灵机和 λ 演算能力等同。
如果第二次世界大战没有在那时打响,我们的故事本可以到此结束,我会就此歇笔,而你也将浏览到下一个页面。彼时整个世界笼罩在战争的火光和硝烟之中,美国陆军和海军前所未有的大量使用炮弹,为了改进炮弹的精确度,部队组织了大批的科学家持续地计算微分方程以解出弹道发射轨迹。在渐渐意识到这个任务用人力手工完成太耗精力后,人们开始着手开发各种设备来攻克这个难关。第一个解出了弹道轨迹的机器是 IBM 制造的 Mark I,它重达 5 吨,有 75 万个组件,每秒可以完成三次操作。
竞争当然没有就此结束,1949 年,EDVAC(Electronic Discrete Variable Automatic Computer,爱达瓦克)推出并获得了极大的成功。这是对冯·诺依曼架构的第一个实践实例,实际上也是图灵机的第一个现实实现。那一年开始好运与阿隆佐·丘奇无缘。
直到 1950 年代将尽,一位 MIT 的教授 John McCarthy(也是普林斯顿毕业生)对阿隆佐·丘奇的工作产生了兴趣。1958年,他公开了表处理语言 Lisp。Lisp 是对阿隆佐·丘奇的 λ 演算系统的实现,但同时它工作在冯·诺依曼计算机上!很多计算机科学家认识到了 Lisp 的表达能力。1973 年,MIT 人工智能实验室的一组程序员开发了被称为 Lisp 机器的硬件-阿隆佐 λ 演算的硬件实现!
函数式编程
函数式编程是对阿隆佐·丘奇理论的实践应用。但也并非全部 λ 演算都被应用到了实践中,因为 λ 演算不是被设计为在物理局限下工作的。因此,象面向对象的编程一样,函数式编程是一系列理念,而不是严格的教条。现在有很多种函数式编程语言,他们中的大多数以不同方式完成不同任务。在本文中我将就最广泛使用的源自函数式编程的思想作一解释,并将用 Java 语言举例(是的,你完全可以用 Java 写出函数式程序,如果你有显著的受虐倾向)。在下面的小节中,我将会把 Java 作为一种函数式语言,并对其稍加修改使它成为一种可用的函数式语言。现在开始吧。
λ 演算被设计用来探询关于计算的问题,所以函数式编程主要处理计算,并用 函数 来完成这一过程。函数是函数式编程的基本单位,函数几乎被用于一切,包括最简单的计算,甚至变量都由计算取代。在函数式编程中,变量只是表达式的别名(这样我们就不必把所有东西打在一行里)。变量是不能更改的,所有变量只能被赋值一次。用 Java 的术语来说,这意味着所有单一变量都被声明为 final(或 C++ 的 const)。在函数式编程中没有非 final 的变量。
final int i = 5;
final int j = i + 3;
因为函数式编程中所有变量都是 final 的,所以可以提出这样两个有趣的表述:没有必要总是写出关键字 final,没有必要把变量再称为变量。于是现在我们对 Java 作出两个修改:在我们的函数式 Java 中所有变量默认都是 final 的,我们将变量(variable)称为符号(symbol)。
你也许会质疑,用我们新创造的语言还能写出有些复杂度的程序吗?如果每个符号都是不可变更(non-mutable)的,那么就无法改变任何状态!其实事实并非完全如此。在阿隆佐研究其 λ 演算时,他并不想将某个状态维护一段时间以期未来对其进行修改。他关注的是对数据的操作(也通常被称为"演算体 caculating stuff”)。既然已经证明 λ 演算与图灵机等价,它可以完成所有命令式编程语言能够完成的任务。那么,我们怎么才能做到呢?
答案是函数式程序能保存状态,只是它并非通过变量而是使用函数来保存状态。状态保存在函数的参数中,保存在堆栈上。如果你要保存某个状态一段时间并时不时地对其进行一些修改,可以写个递归函数。举个例子,我们写个函数来翻转 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。多年来关系型数据库一直在进行着这类优化,没有理由不能把这一技术应用到常规软件上。
另外,还能使用这些技术来证明部分程序的正确,甚至可能创建工具来分析代码并为单元测试自动生成边界用例!这对那些要求极其稳定的系统价值不可估量,比如心脏起搏器(pacemaker)或空中交通控制系统;如果你编写的不是非常关键的应用,这类工具也是让你领先于竞争对手的杀手锏。
高阶函数
我记得自己在了解了上面列出的种种优点后曾想:“这都很棒,可是如果我不得不用天生残缺的语言对着全是 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);
}
// …
}
假设现在要更改这个系统,我们要把信息转发到两个服务器而不是一个,一切基本都像刚才一样,但第二个服务器接受另一种客户代码(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 作为基类,在子类中定制客户代码操作:
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 的技术,这一模式就可以被消除。
适配器模式最有名的是被应用在 Java 的"默认"行为抽象上。在函数式编程里,模式被应用到函数,模式带有一个接口并将它转换成另一个对他人有用的接口。这有一个适配器模式的例子:
int pow(int i, int j);
int square(int i)
{
return pow(i, 2);
}
上面的代码把一个整数幂运算接口转换成为了一个平方接口。在学术文章里,这个雕虫小技被叫作 currying(得名于逻辑学家 Haskell Curry,他曾将相关的数学理论形式化)。因为在函数式编程中函数(反之如 class)被作为参数来回传递,currying 很频繁地被用来把函数调整为更适宜的接口。因为函数的接口是他的参数,使用 currying 可以减少参数的数目(如上例所示)。
函数式语言内建了这一技术。不用手动地创建一个包装了原函数的函数,函数式语言可以为你代劳。同样地,扩展我们(假想)的 Java 语言,让他支持这个技术:
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)接口(因为他们相互依赖其副作用必须被有序的调用),从而与整个世界隔离。如果引入允许特定执行顺序的原语又将失去数学地推理代码的诸多好处(为此将葬送函数式编程与其相关的所有优点)。幸运的是,我们并非丧失了一切,数学家为此探索并开发出了许多技巧来保证在一定函数式设置下(functional 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,那么很明显每个指令都将有些 continuation, 这是一个该指令在执行结束时会用其执行结果调用的函数(在通常的非 CPS 程序中,就是跳转到调用端的指令)。从上面随便选个例子,比如 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,它就是当前的栈的信息)。
这样我们就知道了什么是"当前 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);
}
两者有什么区别?编译器为我们实现了分支。这有什么大不了?的确没什么,有人注意到很多函数包括了复杂的 switch 语句(尤其是在函数式程序中)所以认为这种抽象形式很好。我们把一个函数定义分离成多个,然后把模式置于参数中(有点象重载)。当这个函数被调用时,编译器比较传入参数和函数定义然后选择其中正确的一个,这一般是通过选择可选的最特定的定义来完成。例如,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
到此我们已经讨论了纯函数式语言——实现了 λ 演算又不包括与丘奇形式系统矛盾的语言——里的特性,可是还有很多在 λ 演算框架之外的函数语言的有用特征。虽然一个公理系统的实现可以让我们象数学表达式那样思考程序但它未必是实际可行的。许多语言选择去合并一些函数式的元素而没有严格的坚持函数式的教条。很多象这样的语言(如 Common Lisp)不要求变量是 final 的——可以对其修改。他们还不要求函数只依赖于其参数——允许函数访问外部状态。但这些语言也的确包含着函数式的特征——如高阶函数,在非纯粹的函数式语言里传递函数作为参数和限制在 λ 演算系统中的作法有些不同,它需要一种常被称为词法闭包(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 是我们的朋友。
如果你有任何问题,意见或建议,请发到邮箱 coffeemug@gmail.com。很高兴收到你的反馈。
-
2005 年秋天我找工作时常常提出这个问题,当时我得到的是数量可观的一脸茫然,考虑到这些人基本上年薪都在 30 万美元以上,他们理应对他们可以得到的工具有更深入的理解。 ↩︎
-
这看上去像是个悖论:物理学家和数学家被迫接受这个现实:他们完全无法确认宇宙万物是否遵循着某种数学家们可以定义的规则。 ↩︎
-
我一直厌恶提供了一堆枯燥的日期,人名和地点的纪年式历史课,对我而言,历史是改变了这个世界的人的生活,是他们行为之后的个人动机,是他们得以影响亿万生灵的体制。所以这个关于历史的小节注定无法完整,只讨论了于本文关系密切的人物与事件。 ↩︎
-
我在学习函数式编程的时候,很不喜欢术语 λ,因为我没有真正理解它的意义。在这个上下文里,λ 是一个函数,那个希腊字母只是方便书写的数学记法,每当你听到 λ 时,只要在脑中把它翻译成函数即可。 ↩︎
-
有趣的是 Java 的字符串是不可变更的,探讨这一离经叛道的设计的原因也非常有趣,不过在这里会分散我们对原目标的注意力。 ↩︎
-
大多数函数式编程语言的编译器能通过将递归尽可能转为迭代来进行优化,这被称为尾递归。 ↩︎
-
反之未必成立,虽然有时可以证明某两段代码等价,但并不是对随意的两段代码都能做到的。 ↩︎
-
这实际上不比存储在栈上慢,因为一旦引入了垃圾回收器,内存分配就成为了一个 O(1) 的操作。 ↩︎