函数式编程
本文记录笔者函数式编程学习记录,是原文《Functional Programming For The Rest of Us》的中文翻译的部分内容,只是记录下自己可以稍微明白的一点内容,主要是函数式编程的优点和函数式编程的一个实例(高阶函数),这个实例的思想可以仔细体会。还有更多内容需要继续研读,原文链接会附在相关资料,由于是 Github 上找到的资料,并未发现译者信息,以后得知会补上。
正文
函数式编程是阿隆佐思想在现实世界中的实现。不过不是全部的 lambda 演算思想都可以运用到实际中,因 lambda 演算在设计的时候就不是为了在各种现实世界中的限制下工作的。所以,就像面向对象的编程思想一样,函数式编程只是一系列想法,而不是一套严苛的规定。有很多支持函数式编程的程序语言,它们之间的具体设计都不完全一样。在这里我将用 Java 写的例子介绍那些被广泛应用的函数式编程思想(没错,如果你是受虐狂你可以用 Java 写出函数式程序)。在下面的章节中我会在 Java 语言的基础上,做一些修改让它变成实际可用的函数式编程语言。那么现在就开始吧。
Lambda 演算在最初设计的时候就是为了研究计算相关的问题。所以函数式编程主要解决的也是计算问题,而出乎意料的是,是用函数来解决的!(译者:请理解原作者的苦心,我想他是希望加入一点调皮的风格以免读者在中途睡着或是转台……)。函数就是函数式编程中的基础元素,可以完成几乎所有的操作,哪怕最简单的计算,也是用函数完成的。我们通常理解的变量在函数式编程中也被函数代替了:在函数式编程中变量仅仅代表某个表达式(这样我们就不用把所有的代码都写在同一行里了)。所以我们这里所说的‘变量’是不能被修改的。所有的变量只能被赋一次初值。在 Java 中就意味着每一个变量都将被声明为 final(如果你用 C++,就是 const )。在 FP 中,没有非 final 的变量。
final int i = 5;
final int j = i + 3;
既然 FP 中所有的变量都是 final 的,可以引出两个规定:一是变量前面就没有必要再加上 final 这个关键字了,二是变量就不能再叫做‘变量’了……于是现在开始对 Java 做两个改动:所有 Java 中声明的变量默认为 final ,而且我们把所谓的‘变量’称为‘符号’。
到现在可能会有人有疑问:这个新创造出来的语言可以用来写什么有用的复杂一些的程序吗?毕竟,如果每个符号的值都是不能修改的,那么我们就什么东西都不能改变了!别紧张,这样的说法不完全正确。阿隆佐在设计 lambda 演算的时候他并不想要保留状态的值以便稍后修改这些值。他更关心的是基于数据之上的操作(也就是更容易理解的“计算”)。而且,lambda 演算和图灵机已经被证明了是具有同样能力的系统,因此指令式编程能做到的函数式编程也同样可以做到。那么,怎样才能做到呢?
事实上函数式程序是可以保存状态的,只不过它们用的不是变量,而是函数。状态保存在函数的参数中,也就是说在栈上。如果你需要保存一个状态一段时间并且时不时的修改它,那么你可以编写一个递归函数。举个例子,试着写一个函数,用来反转一个 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。同时它也会大量的消耗内存,因为它会不断的分配创建内存对象。无论如何,它是用函数式编程思想写出来的。这时候可能有人要问了,为什么要用这种奇怪的方式编写程序呢?嘿,我正准备告诉你。
FP 之优点
你大概已经在想:上面这种怪胎函数怎么也不合理嘛。在我刚开始学习 FP 的时候我也这样想的。不过后来我知道我是错的。使用这种方式编程有很多好处。其中一些是主观的。比如说有人认为函数式程序更容易理解。这个我就不说了,哪怕街上随便找个小孩都知道‘容易理解’是多么主观的事情。幸运的是,客观方面的好处还有很多。
单元测试
因为 FP 中的每个符号都是 final 的,于是没有什么函数会有副作用。谁也不能在运行时修改任何东西,也没有函数可以修改在它作用域之外的值给其他函数继续使用(在指令式编程中可以用类成员或是全局变量做到)。这意味着决定函数执行结果的唯一因素就是它的返回值,而影响其返回值的唯一因素就是它的参数。
这正是单元测试工程师梦寐以求的啊。现在测试程序中的函数时只需要关注它的参数就可以了。完全不需要担心函数调用的顺序,也不用费心设置外部某些状态值。唯一需要做的就是传递一些可以代表边界条件的参数给这些函数。相对于指令式编程,如果 FP 程序中的每一个函数都能通过单元测试,那么我们对这个软件的质量必将信心百倍。反观 Java 或者 C++,仅仅检查函数的返回值是不够的:代码可能修改外部状态值,因此我们还需要验证这些外部的状态值的正确性。在 FP 语言中呢,就完全不需要。
调试查错
如果一段 FP 程序没有按照预期设计那样运行,调试的工作几乎不费吹灰之力。这些错误是百分之一百可以重现的,因为 FP 程序中的错误不依赖于之前运行过的不相关的代码。而在一个指令式程序中,一个 bug 可能有时能重现而有些时候又不能。因为这些函数的运行依赖于某些外部状态, 而这些外部状态又需要由某些与这个 bug 完全不相关的代码通过某个特别的执行流程才能修改。在 FP 中这种情况完全不存在:如果一个函数的返回值出错了,它一直都会出错,无论你之前运行了什么代码。
一旦问题可以重现,解决它就变得非常简单,几乎就是一段愉悦的旅程。中断程序的运行,检查一下栈,就可以看到每一个函数调用时使用的每一个参数,这一点和指令式代码一样。不同的是指令式程序中这些数据还不足够,因为函数的运行还可能依赖于成员变量,全局变量,还有其他类的状态(而这些状态又依赖于类似的变量)。FP 中的函数只依赖于传给它的参数,而这些参数就在眼前!还有,对指令式程序中函数返回值的检查并不能保证这个函数是正确运行的。还要逐一检查若干作用域以外的对象以确保这个函数没有对这些牵连的对象做出什么越轨的行为(译者:好吧,翻译到这里我自己已经有点激动了)。对于一个 FP 程序,你要做的仅仅是看一下函数的返回值。
把栈上的数据过一遍就可以得知有哪些参数传给了什么函数,这些函数又返回了什么值。当一个返回值看起来不对头的那一刻,跳进这个函数看看里面发生了什么。一直重复跟进下去就可以找到 bug 的源头!
并发执行
不需要任何改动,所有 FP 程序都是可以并发执行的。由于根本不需要采用锁机制,因此完全不需要担心死锁或是并发竞争的发生。在 FP 程序中没有哪个线程可以修改任何数据,更不用说多线程之间了。这使得我们可以轻松的添加线程,至于那些祸害并发程序的老问题,想都不用想!
既然是这样,为什么没有人在那些高度并行的那些应用程序中采用 FP 编程呢?事实上,这样的例子并不少见。爱立信开发了一种 FP 语言,名叫 Erlang,并应用在他们的电信交换机上,而这些交换机不仅容错度高而且拓展性强。许多人看到了 Erlang 的这些优势也纷纷开始使用这一语言。在这里提到的电信交换控制系统远远要比华尔街上使用的系统具有更好的扩展性也更可靠。事实上,用 Erlang 搭建的系统并不具备可扩展性和可靠性,而 Java 可以提供这些特性。Erlang 只是像岩石一样结实不容易出错而已。
FP 关于并行的优势不仅于此。就算某个 FP 程序本身只是单线程的,编译器也可以将其优化成可以在多 CPU 上运行的并发程序。以下面的程序为例:
String s1 = somewhatLongOperation1();
String s2 = somewhatLongOperation2();
String s3 = concatenate(s1, s2);
如果是函数式程序,编译器就可以对代码进行分析,然后可能分析出生成字符串 s1 和 s2 的两个函数可能会比较耗时,进而安排它们并行运行。这在指令式编程中是无法做到的,因为每一个函数都有可能修改其外部状态,然后接下来的函数又可能依赖于这些状态的值。在函数式编程中,自动分析代码并找到适合并行执行的函数十分简单,和分析 C 的内联函数没什么两样。从这个角度来说用 FP 风格编写的程序是“永不过时”的(虽然我一般不喜欢说大话空话,不过这次就算个例外吧)。硬件厂商已经没办法让 CPU 运行得再快了。他们只能靠增加 CPU 核的数量然后用并行来提高运算的速度。这些厂商故意忽略一个事实:只有可以并行的软件才能让你花大价钱买来的这些硬件物有所值。指令式的软件中只有很小一部分能做到跨核运行,而所有的函数式软件都能实现这一目标,因为 FP 的程序从一开始就是可以并行运行的。
热部署
在 Windows 早期,如果要更新系统那可是要重启电脑的,而且还要重启很多次。哪怕只是安装一个新版本的播放器。到了 XP 的时代这种情况得到比较大的改善,尽管还是不理想(我工作的时候用的就是 Windows,就在现在,我的系统托盘上就有个讨厌的图标,我不重启机子就不消失)。这一方面 Unix 好一些,曾经。只需要暂停一些相关的部件而不是整个操作系统,就可以安装更新了。虽然是要好一些了,对很多服务器应用来说这也还是不能接受的。电信系统要求的是 100%的在线率,如果一个救急电话因为系统升级而无法拨通,成千上万的人就会因此丧命。同样的,华尔街的那些公司怎么也不能说要安装软件而在整个周末停止他们系统的服务。
最理想的情况是更新相关的代码而不用暂停系统的其他部件。对指令性程序来说是不可能的。想想看,试着在系统运行时卸载掉一个 Java 的类然后再载入这个类的新的实现,这样做的话系统中所有该类的实例都会立刻不能运行,因为该类的相关状态已经丢失了。这种情况下可能需绞尽脑汁设计复杂的版本控制代码,需要将所有这种类正在运行的实例序列化,逐一销毁它们,然后创建新类的实例,将现有数据也序列化后装载到这些新的实例中,最后希望负责装载的程序可以正确的把这些数据移植到新实例中并正常的工作。这种事很麻烦,每次有新的改动都需要手工编写装载程序来完成更新,而且这些装载程序还要很小心,以免破坏了现有对象之间的联系。理论上是没问题,可是实际上完全行不通。
FP 的程序中所有状态就是传给函数的参数,而参数都是储存在栈上的。这一特性让软件的热部署变得十分简单。只要比较一下正在运行的代码以及新的代码获得一个 diff,然后用这个 diff 更新现有的代码,新代码的热部署就完成了。其它的事情有 FP 的语言工具自动完成!如果还有人认为这只存在于科幻小说中,他需要再想想:多年来 Erlang 工程师已经使用这种技术对它们的系统进行升级而完全不用暂停运行了。
机器辅助证明及优化
FP 语言有一个特性很有意思,那就是它们是可以用数学方法来分析的。FP 语言本身就是形式系统的实现,只要是能在纸上写出来的数学运算就可以用这种语言表述出来。于是只要能够用数学方法证明两段代码是一致的,编译器就可以把某段代码解析成在数学上等同的但效率又更高的另外一段代码7。 关系数据库已经用这种方法进行优化很多年了。没有理由在常规的软件行业就不能应用这种技术。
另外,还可以用这种方法来证明代码的正确性,甚至可以设计出能够自动分析代码并为单元测试自动生成边缘测试用例的工具出来!对于那些对缺陷零容忍的系统来说,这一功能简直就是无价之宝。例如心脏起搏器,例如飞行管控系统,这几乎就是必须满足的需求。哪怕你正在开发的程序不是为了完成什么重要核心任务,这些工具也可以帮助你写出更健壮的程序,直接甩竞争对手 n 条大街。
高阶函数
我还记得在了解到 FP 以上的各种好处后想到:“这些优势都很吸引人,可是,如果必须非要用这种所有变量都是 final 的蹩脚语言,估计还是不怎么实用吧”。其实这样的想法是不对的。对于 Java 这样的指令式语言来说,如果所有的变量都是必须是 final 的,那么确实很束手束脚。然而对函数式语言来说,情况就不一样了。函数式语言提供了一种特别的抽象工具,这种工具将帮助使用者编写 FP 代码,让他们甚至都没想到要修改变量的值。高阶函数就是这种工具之一。
FP 语言中的函数有别于 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 赋给另外一个符号,还可以在运行时创建 add_function_t 的实例然后在不再需要这些实例的时候由系统回收机制处理掉。这样做使得函数成为和 integer 或是 string 这样的第一类对象。对其他函数进行操作(比如说把这些函数当成参数)的函数,就是所谓的高阶函数。别让这个看似高深的名字吓倒你(译者:好死不死起个这个名字,初一看还准备搬出已经尘封的高数教材……),它和 Java 中操作其他类(也就是把一个类实例传给另外的类)的类没有什么区别。可以称这样的类为“高阶类”,但是没人会在意,因为 Java 圈里就没有什么很强的学术社团。(译者:这是高级黑吗?)
那么什么时候该用高阶函数,又怎样用呢?我很高兴有人问这个问题。设想一下,你写了一大堆程序而不考虑什么类结构设计,然后发现有一部分代码重复了几次,于是你就会把这部分代码独立出来作为一个函数以便多次调用(所幸学校里至少会教这个)。如果你发现这个函数里有一部分逻辑需要在不同的情况下实现不同的行为,那么你可以把这部分逻辑独立出来作为一个高阶函数。搞晕了?下面来看看我工作中的一个真实的例子。
假设有一段 Java 的客户端程序用来接收消息,用各种方式对消息做转换,然后发给一个服务器。
class MessageHandler {
void handleMessage(Message msg) {
// ...
msg.setClientCode("ABCD_123");
// ...
sendMessage(msg);
}
// ...
}
再进一步假设,整个系统改变了,现在需要发给两个服务器而不再是一个了。系统其他部分都不变,唯独客户端的代码需要改变:额外的那个服务器需要用另外一种格式发送消息。应该如何处理这种情况呢?我们可以先检查一下消息要发送到哪里,然后选择相应的格式把这个消息发出去:
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);
在上面的程序里,我们没有创建任何新的类型或是多层类的结构。仅仅是把相应的函数作为参数进行传递,就做到了和用面向对象编程一样的事情,而且还有额外的好处:一是不再受限于多层类的结构。这样做可以做运行时传递新的函数,可以在任何时候改变这些函数,而且这些改变不仅更加精准而且触碰的代码更少。这种情况下编译器其实就是在替我们编写面向对象的“粘合”代码(译者:又称胶水代码,粘接代码)!除此之外我们还可以享用 FP 编程的其他所有优势。函数式编程能提供的抽象服务还远不止于此。高阶函数只不过是个开始。