所谓命令式编程,是以命令为主的,给机器提供一条又一条的命令序列让其原封不动的执行。程序执行的效率取决于执行命令的数量。因此才会出现大O表示法等等表示时间空间复杂度的符号。
而函数式语言并不是通常意义上理解的“通过函数的变换进行编程”。注意到纯的函数式语言中是没有变量的(没有可以改变的东西,所有的东西在定义以后就都是不变的),那么这样的东西有什么好处呢?就比如,如果所有的东西都是不变的,那么我们又怎么进行编程呢?
实际上,我们在函数式编程中进行构建的是实体与实体之间的关系。在这种意义上,lisp虽然不是纯粹的函数式编程,但是也算是函数式编程一员。使用这种定义,大多数提供了原生的list支持的脚本语言也可以算混合了函数式语言的功能,但是这不是函数式语言的精髓。知其然,还要知其所以然。我们既然已经有了精确自然的命令式编程,又为什么还需要函数式编程呢?我们举个小例子。
int fab(int n) {
return n == 1 || n == 2 ? 1 : fab(n - 1) + fab(n - 2);
}
这是用C语言写的求斐波那契数列的第N项的程序,相应的Haskell代码是这样的:
fab :: (Num a) => a -> a
fab n = if n == 1 || n == 2 then 1 else fab(n - 1) + fab(n - 2)
看上去差不多对不对?但是这两个程序在执行的效率方面有着天差地别的差距。为什么呢?C语言是标准的命令式编程语言。因此对于你写下的每一行语句,C程序会原封不动地机械地去执行。如果想效率提高,你必须自己去分析程序,去人工地减少程序中执行的语句的数量。具体到这个C程序,我们注意到在每次函数调用时,都会产生两个新的函数调用。这时,实际产生的函数调用的数目是指数级别的!比方说,我们写fab(5),实际的执行结果是:
fab(5)
fab(4)
fab(3)
fab(2)
fab(1)
fab(2)
fab(3)
fab(2)
fab(1)
我们看到,fab(3)被求值了两遍。为了计算fab(5),我们实际执行了8次函数调用。
那么函数式语言呢?我们说过,函数式语言里面是没有变量的。换句话说,所有的东西都是不变的。因此在执行fab(5)的时候,过程是这样的:
fab(5)
fab(4)
fab(3)
fab(2)
fab(1)
fab(3)
总共只有五次应用。注意我说的是应用而不是调用。因为函数式语言里的函数本意并不是命令式语言里面的“调用”或者“执行子程序”的语义,而是“函数与函数之间的关系”的意思。比如fab函数中出现的两次fab的应用,实际上说明要计算fab函数,必须先计算后续的两个fab函数。这并不存在调用的过程。因为所有的计算都是静态的。haskell可以认为所有的fab都是已知的。因此实际上所有遇到的fab函数,haskell只是实际地计算一次,然后就缓存了结果。
本质上,这代表了我们提供给函数式语言的程序其实并不是一行一行的“命令”,而只是对数据变换的说明。这样函数式语言可以深入这些说明中,寻找这些说明中冗余的共性,从而进行优化。这就是函数式语言并不需要精心设计就会比命令式语言高效的秘密。命令式语言当然也可以进行这种优化,但是因为命令式语言是有边界效应的。而且大部分情况下都是利用边界效应进行计算,因此很难推广这种优化,只有少数几种窥孔优化能取得效果。
放到这个例子上,因为本质上我们两次的fab应用是重叠的。haskell发现了这个特点,于是将两次fab的结果缓存下来(注意,能缓存结果的必要条件是这个函数返回的值是不会变的!而这是函数式语言主要的特性)。如果后续的计算需要用到这两次fab的结果,就不需要再次重复计算,而只是直接提取结果就可以了。这就是上面几乎完全一样的两个程序效率相差如此之大的主要原因。
函数式语言有这样的优势,那么函数式语言有没有缺陷呢?当然是有的。函数式语言不如命令式语言那么纯粹。和机器一一对应,这在某些情形下会导致更差的效率和更低的开发效率。对计算模型不断深入的了解会缩短这两者之间的差距。然而,一定要注意命令式语言是植根于冯·诺依曼体系的,一旦新的体系产生了革命性的改变。那么命令式语言就不会再适用,而只能通过模拟的方法进行执行,到那个时候,函数式语言和命令式语言的地位就会完全颠倒过来,当然这并不是我们目前需要考虑的问题,但是在现在稍微了解一点函数式语言编程的思想是十分重要的。