你可能会认为我根本无法对前面一小节那个畸形的函数给出个合理的解释。我开始学习函数式编程时就是这么认为的。不过我是错了。有很好的理由使用这种风格,当然其中一些属主观因素。例如,函数式程序被认为更容易阅读。因为连街上乱跑的娃娃都知道,是否容易理解是个见仁见智的判断,所以我将略去这些主观方面的理由。幸运的是,还有很多的客观理由。
单元测试
因为函数式编程的每一个符号都是 final 的,没有函数产生过副作用。因为从未在某个地方修改过值,也没有函数修改过在其作用域之外的量并被其他函数使用(如类成员或全局变量)。这意味着函数求值的结果只是其返回值,而惟一影响其返回值的就是函数的参数。
这是单元测试者的梦中仙境(wet dream)。对被测试程序中的每个函数,你只需在意其参数,而不必考虑函数调用顺序,不用谨慎地设置外部状态。所有要做的就是传递代表了边际情况的参数。如果程序中的每个函数都通过了单元测试,你就对这个软件的质量有了相当的自信。而命令式编程就不能这样乐观了,在 Java 或 C++ 中只检查函数的返回值还不够——我们还必须验证这个函数可能修改了的外部状态。
调试
如果一个函数式程序不如你期望地运行,调试也是轻而易举。因为函数式程序的 bug 不依赖于执行前与其无关的代码路径,你遇到的问题就总是可以再现。在命令式程序中,bug 时隐时现,因为在那里函数的功能依赖与其他函数的副作用,你可能会在和 bug 产生无关的方向探寻很久,毫无收获。函数式程序就不是这样——如果一个函数的结果是错误的,那么无论之前你还执行过什么,这个函数总是返回相同的错误结果。
一旦你将那个问题再现出来,寻其根源将毫不费力,甚至会让你开心。中断那个程序的执行然后检查调用栈,和命令式编程一样,栈里每一次函数调用的参数都呈现在你眼前。但是在命令式程序中只有这些参数还不够,函数还依赖于成员变量,全局变量和其他类的状态(它们也依赖着同样多的其他东西)。函数式程序里函数只依赖于它的参数,而那些信息就在你注视的目光下!还有,在命令式程序里,只检查一个函数的返回值不能够让你确信这个函数已经正常工作了,你还要去查看那个函数作用域外数十个对象的状态来确认。对函数式程序,你要做的所有事就是查看其返回值!
沿着堆栈检查函数的参数和返回值,只要发现一个不尽合理的结果就进入那个函数然后一步步跟踪下去,重复这一个过程,直到它让你发现了 bug 的生成点。
并行
函数式程序无需任何修改即可并行执行。不用担心死锁和临界区,因为你从未用锁!函数式程序里没有任何数据被同一线程修改两次,更不用说两个不同的线程了。这意味着可以不假思索地简单增加线程而不会引发折磨着并行应用程序的传统问题。
事实既然如此,为什么并不是所有人都在需要高度并行作业的应用中采用函数式程序?嗯,他们正在这样做。
爱立信公司设计了一种叫作 Erlang 的函数式语言并将它使用在需要极高抗错性和可扩展性的电信交换机上。还有很多人也发现了 Erlang 的优势并开始使用它。我们谈论的是电信通信控制系统,这与设计华尔街的典型系统相比对可靠性和可升级性要求高得多。实际上,Erlang 系统并不是“可伸缩”和“可靠”——Java 系统才是——Erlang 系统是“坚如磐石”。
关于并行的故事还没有就此停止,即使你的程序本身就是单线程的,那么函数式程序的编译器仍然可以优化它使其运行于多个 CPU 上。请看下面这段代码:
1 | String s1 = somewhatLongOperation1(); |
2 | String s2 = somewhatLongOperation2(); |
3 | String s3 = concatenate(s1, s2); |
在函数式是编程语言中,编译器会分析代码,辨认出潜在耗时的创建字符串 s1 和 s2 的函数,然后并行地运行它们。
这在命令式语言中是不可能的,因为在那里,每个函数都有可能修改了函数作用域以外的状态并且其后续的函数又会依赖这些修改。
在函数式语言里,自动分析函数并找出适合并行执行的候选函数简单的像自动进行的函数内联化!在这个意义上,函数式风格的程序是“不会过时的技术(future proof)”(虽然不喜欢用行业广告语,但这里要破例一次)。硬件厂商已经无法让 CPU 运行得更快了,于是他们增加了处理器核心的速度并因并行而获得了四倍的速度提升。当然他们也顺便忘了提及:我们多花的钱只对支持并行运行的软件有用,只有一小部分的命令式程序可以(不加修改地)并行运行在这些新的硬件上,而 100% 的(纯)函数式程序都可以,因为函数式程序天生支持并行处理。
代码热部署
过去要在 Windows 上安装更新,重启计算机是难免的,而且还不只一次,即使只是安装了一个新版的媒体播放器。Windows XP 大大改进了这一状态,但仍不理想(我今天工作时运行了 Windows Update,现在一个烦人的图标总是显示在托盘里除非我重启一次机器)。
Unix 系统一直以来以更好的模式运行,安装更新时只需停止系统相关的组件,而不是整个操作系统。即使如此,对一个大规模的服务器应用这还是不能令人满意的。电信系统必须 100% 时间在线运行,因为如果在系统更新时紧急拨号失效,就可能造成生命损失。华尔街的公司也没有理由必须在周末停止服务以安装更新。
理想的情况是完全不停止系统任何组件来更新相关的代码。在命令式的世界里这是不可能的。
考虑运行时上载一个 Java 类并重载一个新的定义,那么所有这个类的实例都将不可用,因为它们被保存的状态丢失了。我们可以着手写些繁琐的版本控制代码来解决这个问题,然后将这个类的所有实例序列化,再销毁这些实例,继而用这个类新的定义来重新创建这些实例,然后载入先前被序列化的数据并希望载入代码可以不出问题地将这些数据移植到新的实例。在此之上,每次更新都要重新手动编写这些用来移植的代码,而且要相当谨慎地防止破坏对象间的相互关系。理论简单,但实践可不容易。
对函数式的程序,所有的状态即传递给函数的参数都被保存在了堆栈上,这使的热部署轻而易举!实际上,所有我们需要做的就是对工作中的代码和新版本的代码做一个差异比较,然后部署新代码。其他的工作将由一个语言工具自动完成!如果你认为这是个科幻故事,请再思考一下:多年来 Erlang 工程师一直更新着他们在线上运转着的系统,而无需中断它。
机器辅助的推理和优化
函数式语言的一个有趣的属性就是他们可以用数学方式推理。因为一种函数式语言只是一个形式系统的实现,所有在纸上完成的运算都可以应用于以这种语言书写的程序。比如,编译器可以把一段代码变换为等价但更高效的代码,由于变化遵循严格的数学原理其等价性是可证明的。多年来关系型数据库一直在进行着这类优化,没有理由不能把这一技术应用到常规软件上。
另外,还能使用这些技术来证明部分程序的正确,甚至可能创建工具来分析代码并为单元测试自动生成边界用例!这对那些要求极其稳定的系统价值不可估量,比如心脏起搏器(pacemaker)或空中交通控制系统;如果你编写的不是非常关键的应用,这类工具也是让你领先于竞争对手的杀手锏。