首先让我们从一段最简单的Java代码开始。
class HelloWorld {
public static void main(String[] args) {
HelloWorld hello = new HelloWorld();
hello.greeting("World");
}
void greeting(String name){
System.out.println("Hello, " + name);
}
}
这是再简单不过的一段代码,相信绝大多数的人都是从类似这样的代码开始学习编程的。如果我们仅仅从功能的角度来看的话,这段代码本身并不包含逻辑上的错误,但是我们今天要介绍的是泛函编程,那么这段代码就有很多可以商榷的地方了。我将尽可能避免将泛函编程宏大的世界观一次性地展开,以免读者感觉太复杂而难以适从。相反,我将从一些小的方面,一点一点地将每一个问题逐步介绍出来,以便于我们由浅入深地去慢慢品味那别样的编程哲学。
现在我们以泛函的角度来重新构建这段代码,依然使用Java的话,它看上去大致会是这样的:
import java.util.function.Consumer;
public class HelloWorld {
Consumer<String> consumer;
public HelloWorld(Consumer<String> consumer) {
this.consumer = consumer;
}
void run(String name) {
consumer.accept(name);
}
public static void main(String[] args) {
new HelloWorld(name -> {
System.out.println("Hello, " + name + "!");
}).run("World");
}
}
比较这两段代码,最大的不同在我们构建 HelloWorld 这个类的实例的时候,第一段代码执行了一个缺省的空构建,因为这个 HelloWorld 实例在创建之前它就已经知道了自己将会做什么,它具有与生俱来的某些动作,比如 greeting() 方法;而第二段代码中,我们则是将一个包含运算的 Lambda 传递给了它,于是这个HelloWorld 被注入了某种外来的“内涵”。当我们执行下一条指令的时候,第一个实例忠实的执行了在它被定义时就注定的动作,打印出 “Hello, World!”,而第二个例子则执行的是被延迟到构建的时候才定义的 Lambda 中的动作。
我们不难发现,在第二个例子中,HelloWorld 只是被当作一个容器来使用,它本身除了一个作为“触发器”作用的 run() 方法和它的参数类型外,并不具有任何有实质意义的动作,它的计算完全取决于那个 Lambda。到此我们产生了两个疑问:1)我们为什么需要这样一个容器?2)既然函数的参数和打印动作几乎发生在同一时间,为什么还需要将运算交给 Lambda,而不是立刻就开始打印呢?
回答这两个问题之前,我们需要先明确两个简单的概念:“定义时”和“执行时”。如果我们把 System.out.println() 动作抽象成某种“运算”(实际上在计算机中也确实就是运算)来看的话,那么很显然,第一个例子中这个运算的过程在定义时就已经决定了,执行时只是忠实地执行了既定的计算策略而已。而第二个例子中,对计算的定义被推迟到了最终的那一刻。也就是说,虽然我们在定义时就知道了将会为计算提供什么样的参数,但是究竟会如何对待这个参数,却是在执行时才知道的。这种延迟对于计算来讲究竟具有什么样非凡的意义呢?
它至少给我们带来以下三个方面的好处:
第一:有利于优化。
这几天我恰好在辅导上中学的儿子复习代数,加拿大的中学数学虽然比国内的在题型上要简单很多,但是就理论层面来说却是差不多的。我们首先从一道简单的代数开始,假设我们有这样一道一元二次方程:
F(x) = x^2 + 2x + 1
求:
F(2) = ?
很显然我们的解题过程不会立刻就将 2 带入表达式得到 2^2 + 2*2 + 1
,然后再一步一步解出答案的。相反我们会先对表达式进行一定程度的简化,比如变形成 (x+1)^2
的形式,直到不能进一步简化后,才会将x=2带入,然后求出最终解。是的,在这里我们“延迟”了带入求解的时机,而是采取了“分析->化简->带入”三个步骤来简化我们的计算,并且,这种解题方式不仅仅体现在代数领域,实际上它程贯穿了几乎所有的数学领域,甚至在泛函编程领域,我们也沿用了这样的基本思路。理解了这个思路,那么之前的两个问题也就迎刃而解了。
现在让我们回头来看看这个例子,HelloWorld 就好比是函数 F,而打印动作则是函数体,它们分别在等号的两边,F本身除了签名外,不拥有任何实体,而等号的右边所代表的运算允许我们灵活地进行变形,只要保证变形前后是“等效”的即可。设想如果我们在定义命题F的时候就固化了函数体的所有既定动作的话,那么我们就会失去很多对它进行优化的机会。我们可以将执行时,也就是等号右边所发生的事情看作是“活”的,它能够解决许多编译时不确定的问题,各种满足计算的最终条件都会在执行时被确定下来,比如我们有这样一个运算:F() = a-b+c
,很显然如果 a=b,那么这个运算就可以简化成只有c,而这个信息在编译时是很难确定的,延迟这个过程将允许运行时编译器根据当时的上下文做出最有利于计算的优化,从而取得更高的计算效率。
其次:有利于CPU执行效率的统筹
虽然运行时编译器未必一定会对因为 a=b 而将计算直接简化成c,它还取决于许多其他条件(比如函数的“纯净度”和即时优化的级别等),但是紧凑的代码可以更有效地利用CPU的时空局限性,我们知道当 CPU 在执行运算的时候,因为高速缓存的存在和多内核并行,我们应该尽可能将指令连贯地放在一起,不仅如此,CPU在执行的过程中也会主动尝试将指令重新排序,因此如果我们将计算拆分成可以自由组合的小的计算单元,然后将它们实际发生运算的时机和位置延迟到运行时来决定,那么将大大有利于时空局限性带来的效率提升。
更甚至,当我们将程序装入容器后,我们就可以以容器为单位有目的地编排代码的执行。如果我们将CPU现象成一条货轮,而指令就是一件件即将被转载上船的货物。最有效地安排船上的空间的方法就是先将货物分门别类地装入集装箱,然后我们就可以安排吊车以或者平行、或者循序渐进地方式将它们一个一个的吊装到货轮上去。在传统的编程中,代码必须自己来管理自己的执行顺序和执行条件,每一条代码都处于各自为阵的状态,这实际上无利于CPU的执行效率。并且使得业务代码和控制代码混在一起,难以管理。如果我们将业务代码(货物)和控制代码(集装箱及其吊装)分开看待,那么我们将可能为程序的执行提供更专业和更有效率的调动。
第三:这可能也是最重要的一条:专注类型,而不是过程可以让程序变得更加安全。
这一条可能很难通过罗列代码来演示,只能靠你去理解。在阿拉丁的神话中,我们常常看到这样一个场景:某人得到了一个莫名其妙的瓶子,他不小心拧开了瓶盖,于是魔鬼现世了。每当看到这个场景的时候,我总是会有一个疑问,如果我在瓶子的外面再套一层瓶子的话,那会怎样呢?如果我有一个又一个的瓶子,让魔鬼从一个瓶子中出来的同时又装进另一个瓶子的话,哪故事又会是怎样的呢?当魔鬼装在瓶子(容器)中的时候,它始终是安全的(哪怕过了一千年),我们可以仔细地去观察它,评估它可能带来的破坏力,及早做好准备。是的,程序就如同魔鬼,如果你不小心地加以控制,那么它可能具有可怕的破坏力,最安全程序就是不执行程序。所以,我们应该尽可能让程序“静止”下来。将代码装入容器中,是一个有效的让程序“静止”的办法。当一个程序处于静止状态的时候,我们所能做的就是通过它的输入输出类型,包括可能抛出的异常,来期待它的可能的表现。这样一来,我们就从动态地于魔鬼共舞的世界里解脱出来,静态地为魔鬼准备跳舞的容器。在泛函的世界里有一句非常著名的话:“to the end of the wold
”(和本博客的标题巧合相同)指的就是这么一种思想:当你没有准备好运行程序的时候,就不要执行它,一直到拆开最后一个容器的那一刻。
现在让我们以 Scala 语言来重新编写以上第二段代码,之所以使用 Scala 是因为Java 语言并不是一门泛函语言,它本身的表达方式并不那么直观,如果我们以泛函语言 Scala 来编写的话,那么它的形式会是这样的:
object HelloWorld{
def main(args: Array[String])= {
HelloWorld("World")(name => println("Hello," + name))
}
def apply(name:String)(f: String => Unit)=f(name)
}
我们可以看到当我们使用了正真的泛函语言来进行编程的话,代码的篇幅大大地缩短了,甚至比第一个例子还短。不仅如此,而且它的表达方式也更贴近数学字面意义上的“函数”。(这种贴近也是这门编程思想被称为泛“函”的原因之一)。
综上所述,当你开始尝试将代码装入“容器”里,并将它延迟到实际需要运算的时候才取出来执行时,你就解锁了泛函编程的正确打开姿势。