函数式编程的另类指南(5)

By | October 23, 2013

The following part is not maintained anymore. Please go to 函数式程序设计的另类指南 for the whole translation.

以下内容不再更新,浏览全部翻译,请访问 函数式程序设计的另类指南


原文链接:Functional Programming For The Rest of Us
原文作者:Vyacheslav Akhmechet

函数式编程的优点

你可能会认为我根本无法对上面那个变态的函数给出合理的解释。我开始学习函数式编程时,也这么想。不过事实证明我错了。有许多很好的理由来支持这样的写法,当然其中一些是主观因素。比如,有人号称函数式程序易于理解。我不会拿这些理由出来说事,因为小孩子都知道:情人眼里出西施。不过我还能找到很多客观理由。

单元测试

因为函数式编程的每一个符号都是final的,所以没有函数能产生副作用。因为你不能在某个地方修改值,也不能在一个函数中修改其作用域外别的函数使用的值(比如类成员或全局变量),所以计算(或者运行)一个函数,只能得到它的返回值,而唯一可以改变该返回值的是这个函数的参数。

对单元测试者来说,这是梦寐以求的。你可以测试程序中的每个函数,并且只关心它的输入参数。你不用理会函数的调用关系,也不用精心设置外部状态。唯一需要做的就是把需要测试的极端情况输入给函数。比起使用命令式编程,如果函数式程序中的每个函数都通过了单元测试,那么你会对整个程序的质量有更大的信心。在Java或C++中,只检查函数的返回值是不够的——我们还必须验证这个函数可能修改的外部状态。但是在函数式程序中,这种情况永远不会发生。

调试

如果一段函数式程序没有按照你所希望的那样执行,调试起来也是轻而易举。因为函数式程序中的bug与之前执行的代码无关,所以你总是可以复现遇到的问题。在命令式程序中,有些bug会时隐时现,这是由于该函数可能会依赖其他函数提供的外部状态,而那些其他的函数可能才是问题的关键。因此你必须一并检查它们。而这种调试看似和这个Bug并无直接关系。函数式程序则不同——如果一个函数的返回值错了,它永远是错的,这与你之前运行了什么代码无关。

一旦你复现了问题,寻其根源将毫不费力甚至颇有乐趣。给你的程序打个断点,然后看看栈中的情况。和命令式编程一样,你可以检查栈里每一次函数调用的参数。在命令式编程中,这是不够的,因为函数依赖于成员变量、全局变量、以及其他类的状态(这些类还可能依赖其他的成员变量、全局变量、甚至更多其他的类)。函数式程序里函数依赖于它的参数,而那些信息就在你的面前!另外,在命令式程序里,只检查一个函数的返回值不能够让你确信这个函数已经可以正常工作了。你还需要逐个检查一堆作用域外的对象来看看它们是否也处于正常的状态。而对函数式程序,你要做的所有事就是查看其返回值!

沿着堆栈检查函数的参数和返回值,只要有返回值出现问题,就进入那个函数然后一步步跟踪下去。重复这个过程,你就能发现bug的位置。

并发

函数式程序就是为并发而生的。因为用不到锁(lock),所以永远不必担心死锁和竞争条件(race condition)。在函数式程序中,没有任何数据会被同一个线程修改两次,更不用说被两个不同的线程修改了。这意味着你可以不假思索地添加线程而不用担心困扰并发程序设计的常见问题。

如果这样,那么为什么没有人在大型并发应用中运用函数式编程呢?其实是有的。爱立信公司设计了一种函数式语言Erlang,用于需要极高抗错性和可扩展性的电话交换机上。其他公司也意识到了Erlang的优势,并开始使用它。我们所说的程控交换和电信通信控制系统,需要比传统的华尔街系统更易扩展和可靠。实际上,Erlang系统并不具有很高的扩展性和可靠性——Java系统才是——但是Erlang系统坚如磐石。

并发的故事并未就此结束。即使你的程序本身就是单线程的,函数式程序的编译器仍然可以对其进行优化,使其运行于多个CPU上。来看下面这段代码。

String s1 = somewhatLongOperation1();
String s2 = somewhatLongOperation2();
String s3 = concatenate(s1, s2);

在函数式编程中,编译器可以对代码进行分析,发现生成字符串s1s2的函数可能非常耗时,于是对其并行运行。这种情况在命令式语言中是不可能出现的,因为每一个函数都有可能修改外部状态,而后续函数有可能依赖这些外部状态。在函数式语言里,自动分析哪些函数可以并发执行就像自动内联一样简单。从这个意义上说,具有函数式风格的程序是“不会过时的技术(future proof)”(尽管我很讨厌流行用语,但这回也要破例使用一次)。硬件厂商已经无法让CPU运行得更快了。他们只能靠增加内核数量,用并发来成倍的提高速度。当然他们没有提醒我们,只有当处理并发问题的时候,我们花的钱才会物有所值。命令式程序中只有很小一部分是可以并发的,但是在函数式程序中,所有函数天生都是可并发的。

代码热部署

过去,如果要在Windows上安装更新,就必须不断地重启计算机。即使只是安装了一款新版媒体播放器,也必须重启。Windows XP大大改进了这个问题,但仍不理想(今天工作的时候,我运行了一下Windows Update,结果除非我重启系统,那个烦人的图标会一直出现在我的系统托盘上)。Unix系统一直以来有一个更好的架构:安装更新时只需停止与其相关的组件,而不是整个操作系统。即使如此,对于大型的服务器应用程序来说,这仍旧无法让人接受。程控交换系统需要100%的时间都在运行。因为如果由于升级而无法接通紧急电话,那很可能会要人命的。同样,华尔街的公司也没有理由必须在周末暂停系统来更新软件。

理想的情况是,在完全不停止系统任何组件的情况下来更新相关的代码。在命令式程序的世界里,这是不可能的。想想在Java运行过程中卸载一个类并且加载一个新的类。即使我们真的可以这样做,这个类的所有实例也都不能用了,因为这个类的状态丢失了。我们需要复杂的版本控制代码来恢复这些状态:需要把运行中实例的都序列化,销毁它们,用新的类创建新的实例,最后载入先前被序列化的数据,并祈祷着加载代码确实能将数据迁移到新的实例中。更痛苦的是,每一次改变代码的时候,我们都必须手动编写这些迁移程序。这些迁移代码不仅要迁移实例,而且还不能破坏对象间的关系。这些听来理论上可行,但实践起来可不容易。

在函数式程序中,所有的状态都存储在栈中,并且通过参数传递给函数。这使得热部署轻而易举!实际上,我们需要做的只是比较一下工作中的代码和新版本的代码,然后部署变化的部分。剩下的工作将由一个语言工具自动完成!如果你觉得这是科幻小说,我建议你再想想。这么多年来,Erlang工程师始终在运转着的系统上直接升级。

机器辅助证明和优化

函数式语言的一个有趣的特性就是它们可以用数学方式进行推理。因为函数式语言只是一个形式系统的实现,所以只要是这个形式系统能够完成的数学运算(即使只是写在纸上),用其函数式语言也可以完成。举个例子来说,编译器可以把一段代码转变成另一段运行结果相同但是更高效的代码,然后在数学上证明二者是等价的。多年来关系数据库一直在进行着类似的优化。没有理由说这种技术无法应用在常规软件上。

另外,你可以通过这些技术来证明你的一部分程序理论上是正确的。甚至可以写一个工具来分析代码并自动生成单元测试的边界用例!这个功能对于需要设计一个坚如磐石的系统来说是无价的。如果你要设计一个心脏起搏器或者交通控制系统,这种代码分析工具是必不可少的。即便你的程序不是这样人命关天,这些工具也是你击败竞争对手的杀手锏。

Leave a Reply

Your email address will not be published. Required fields are marked *