函数式编程另类指南2

来源:互联网 发布:淘宝白幡上町日代真假 编辑:程序博客网 时间:2024/05/21 21:36

 

函数式编程


函数式编程是对阿隆左·丘奇理论的实践应用。但也并非全部 lambda 演算都被应用到了实践中,因为 lambda 演算不是被设计为在物理局限下工作的。因此,象面向对象的编程一样,函数式编程是一系列理念,而不是严格的教条。现在有很多种函数式编程语言,他们中的大多数以不同方式完成不同任务。在本文中我将就最广泛使用的源自函数式编程的思想作一解释,并将用Java语言举例。(的确,你可以用Java写出函数式的程序如果你有显著的受虐倾向)。在下面的小节中,我将会把Java作为一种函数式语言,并对其稍加修改使它成为一种可用的函数式语言。现在开始吧。

lambda 演算被设计用来探询关于计算的问题,所以函数式编程主要处理计算,并惊人地用函数来完成这一过程。函数是函数式编程的基本单位,函数几乎被用于一切,包括最简单的计算,甚至变量都由计算取代。在函数式编程中,变量只是表达式的别名(这样我们就不必把所有东西打在一行里)。变量是不能更改的,所有变量只能被赋值一次。用 Java 的术语来说,这意味着所有单一变量都被声明为 final(或 C++ const)。在函数式编程中没有非 final 的变量。

final int i = 5;

final int j = i + 3;

因为函数式编程中所有变量都是 final 的,所以可以提出这样两个有趣的表述:没有必要总是写出关键字 final,没有必要把变量再称为变量。那么现在我们对Java 作出两个修改:在我们的函数式 Java 中所有变量默认都是 final的,我们将变量(variable)称为符号(symbol)。

就此你也许会质疑,用我们新创造的语言还能写出有些复杂度的程序吗?如果每个符号都是不可变更(non-mutalbe)的,那么就无法改变任何状态!其实事实并非完全如此。在阿隆左研究其 lambda 演算时,他并不想将某个状态维护一段时间以期未来对其进行修改。他关注的是对数据的操作(也通常被称为演算体caculating stuff”)。既然已被证明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],它还也是个嗜内存魔因为要持续分配对象。不过它的确是在用函数式风格。你可能会问,怎么有人会这样写程序?好的,我这就慢慢讲来:

 

函数式编程的优点

你可能会认为我根本无法对上面那个畸形的函数给出个合理的解释。我开始学习函数式编程时就是这么认为的。不过我是错了。有很好的理由使用这种风格,当然其中一些属主观因素。例如,函数式程序被认为更容易阅读。因为每个街区的孩子都知道,是否容易理解在旁观者的眼中,所以我将略去这些主观方面的理由。幸运的是,还有很多的客观理由。

 

单元测试

因为函数式编程的每一个符号都是 final 的,没有函数产生过副作用。因为从未在某个地方修改过值,也没有函数修改过在其作用域之外的量并被其他函数使用(如类成员或全局变量)。这意味着函数求值的结果只是其返回值,而惟一影响其返回值的就是函数的参数。

这是单元测试者的梦中仙境(wet dream)。对被测试程序中的每个函数,你只需在意其参数,而不必考虑函数调用顺序,不用谨慎地设置外部状态。所有要做的就是传递代表了边际情况的参数。如果程序中的每个函数都通过了单元测试,你就对这个软件的质量有了相当的自信。而命令式编程就不能这样乐观了,在 Java C++ 中只检查函数的返回值还不够——我们还必须验证这个函数可能修改了的外部状态。

 

调试

如果一个函数式程序不如你期望地运行,调试也是轻而易举。因为函数式程序的bug 不依赖于执行前与其无关的代码路径,你遇到的问题就总是可以再现。在命令式程序中,bug 时隐时现,因为在那里函数的功能依赖与其他函数的副作用,你可能会在和 bug 的产生无关的方向探寻很久,毫无收获。函数式程序就不是这样——如果一个函数的结果是错误的,那么无论之前你还执行过什么,这个函数总是返回相同的错误结果。

一旦你将那个问题再现出来,寻其根源将毫不费力,甚至会让你开心。中断那个程序的执行然后检查堆栈,和命令式编程一样,栈里每一次函数调用的参数都呈现在你眼前。但是在命令式程序中只有这些参数还不够,函数还依赖于成员变量,全局变量和类的状态(这反过来也依赖着这许多情况)。函数式程序里函数只依赖于它的参数,而那些信息就在你注视的目光下!还有,在命令式程序里,只检查一个函数的返回值不能够让你确信这个函数已经正常工作了,你还要去查看那个函数作用域外数十个对象的状态来确认。对函数式程序,你要做的所有事就是查看其返回值!

沿着堆栈检查函数的参数和返回值,只要发现一个不尽合理的结果就进入那个函数然后一步步跟踪下去,重复这一个过程,直到它让你发现了 bug 的生成点。

 

并行

函数式程序无需任何修改即可并行执行。不用担心死锁和临界区,因为你从未用锁!函数式程序里没有任何数据被同一线程修改两次,更不用说两个不同的线程了。这意味着可以不假思索地简单增加线程而不会引发折磨着并行应用程序的传统问题。

事实既然如此,为什么并不是所有人都在需要高度并行作业的应用中采用函数式程序?嗯,他们正在这样做。爱立信公司设计了一种叫作 Erlang 的函数式语言并将它使用在需要极高抗错性和可扩展性的电信交换机上。还有很多人也发现了Erlang 的优势并开始使用它。我们谈论的是电信通信控制系统,这与设计华尔街的典型系统相比对可靠性和可升级性要求高了得多。实际上,Erlang 系统并不可靠和易扩展,Java 才是。Erlang 系统只是坚如磐石。

关于并行的故事还没有就此停止,即使你的程序本身就是单线程的,那么函数式程序的编译器仍然可以优化它使其运行于多个CPU上。请看下面这段代码:

String s1 = somewhatLongOperation1();

String s2 = somewhatLongOperation2();

String s3 =concatenate(s1, s2);

在函数编程语言中,编译器会分析代码,辨认出潜在耗时的创建字符串s1s2的函数,然后并行地运行它们。这在命令式语言中是不可能的,因为在那里,每个函数都有可能修改了函数作用域以外的状态并且其后续的函数又会依赖这些修改。在函数式语言里,自动分析函数并找出适合并行执行的候选函数简单的像自动进行的函数内联化!在这个意义上,函数式风格的程序是不会过时的技术(future proof)”(即使不喜欢用行业术语,但这回要破例一次)。硬件厂商已经无法让CPU运行得更快了,于是他们增加了处理器核心的速度并因并行而获得了四倍的速度提升。当然他们也顺便忘记提及我们的多花的钱只是用在了解决平行问题的软件上了。一小部分的命令式软件和 100% 的函数式软件都可以直接并行运行于这些机器上。

代码热部署过去要在 Windows上安装更新,重启计算机是难免的,而且还不只一次,即使是安装了一个新版的媒体播放器。Windows XP 大大改进了这一状态,但仍不理想(我今天工作时运行了Windows Update,现在一个烦人的图标总是显示在托盘里除非我重启一次机器)。Unix系统一直以来以更好的模式运行,安装更新时只需停止系统相关的组件,而不是整个操作系统。即使如此,对一个大规模的服务器应用这还是不能令人满意的。电信系统必须100%的时间运行,因为如果在系统更新时紧急拨号失效,就可能造成生命的损失。华尔街的公司也没有理由必须在周末停止服务以安装更新。

 

理想的情况是完全不停止系统任何组件来更新相关的代码。在命令式的世界里这是不可能的。考虑运行时上载一个Java类并重载一个新的定义,那么所有这个类的实例都将不可用,因为它们被保存的状态丢失了。我们可以着手写些繁琐的版本控制代码来解决这个问题,然后将这个类的所有实例序列化,再销毁这些实例,继而用这个类新的定义来重新创建这些实例,然后载入先前被序列化的数据并希望载入代码可以恰到地将这些数据移植到新的实例。在此之上,每次更新都要重新手动编写这些用来移植的代码,而且要相当谨慎地防止破坏对象间的相互关系。理论简单,但实践可不容易。

 

对函数式的程序,所有的状态即传递给函数的参数都被保存在了堆栈上,这使的热部署轻而易举!实际上,所有我们需要做的就是对工作中的代码和新版本的代码做一个差异比较,然后部署新代码。其他的工作将由一个语言工具自动完成!如果你认为这是个科幻故事,请再思考一下。多年来 Erlang工程师一直更新着他们的运转着的系统,而无需中断它。

 

机器辅助的推理和优化

函数式语言的一个有趣的属性就是他们可以用数学方式推理。因为一种函数式语言只是一个形式系统的实现,所有在纸上完成的运算都可以应用于用这种语言书写的程序。编译器可以用数学理论将转换一段代码转换为等价的但却更高效的代码[7]。多年来关系数据库一直在进行着这类优化。没有理由不能把这一技术应用到常规软件上。

另外,还能使用这些技术来证明部分程序的正确,甚至可能创建工具来分析代码并为单元测试自动生成边界用例!对稳固的系统这种功能没有价值,但如果你要设计心房脉冲产生器 (pace maker)或空中交通控制系统,这种工具就不可或缺。如果你编写的应用程序不是产业的核心任务,这类工具也是你强于竞争对手的杀手锏。

 

高阶函数

我记得自己在了解了上面列出的种种优点后曾想:那都是非常好的特性,可是如果我不得不用天生就不健全的语言编程,把一切变量声明为final 产生的代码将是垃圾一堆。这其实是误解。在如Java 这般的命令式语言环境里,将所有变量声明为 final 没有用,但是在函数式语言里不是这样。函数式语言提供了不同的抽象工具它会使你忘记你曾经习惯于修改变量。高阶函数就是这样一种工具。

函数式语言中的函数不同于 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 = newadd_function_t();

这里的符号 add 并不是一个函数。这是一个有一个成员函数的很小的类。我们现在可以把 add 作为函数参数放入我们的代码中。还可以把它赋给另一个符号。我们在运行时创建的 add_function_t 的实例如果不再被使用就将会被垃圾回收掉。这些使得函数成为第一级的对象无异于整数或字符串。(作为参数)操作函数的函数被称为高阶函数。别让这个术语吓着你,这和 Java class 操作其它 class (把它们作为参数)没有什么区别。我们本可以把它们称为高阶类但没有人注意到这个,因为 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 classMessageHandler {
void handleMessage(Message msg) {
// …
msg.setClientCode(getClientCode());
// …

sendMessage(msg);
}

abstract StringgetClientCode();

// …

}

class MessageHandlerOneextends MessageHandler {String getClientCode() {return “ABCD_123″;}

}

class MessageHandlerTwoextends MessageHandler {
String getClientCode() {
return “123_ABCD”;
}

}

现在就可以对每一个服务器实例化一个适合的类。添加服务器的操作变得容易维护了。但对于这么一个简单的修改仍然要添加大量的代码。为了支持不同的客户代码我们创建了两个新的类型!现在我们用高阶函数完成同样的功能:

class MessageHandler {
void handleMessage(Message msg, Function getClientCode) {
// …
Message msg1 = msg.setClientCode(getClientCode());
// …

sendMessage(msg1);
}

// …

}

StringgetClientCodeOne() {return “ABCD_123″;

}

StringgetClientCodeTwo() {
return “123_ABCD”;

}

MessageHandler handler =new MessageHandler();
handler.handleMessage(someMsg, getClientCodeOne);

没有创建新的类型和新的class层次,只是传入合适的函数作为参数,完成了面向对象方式同样的功能,同时还有一些额外的优点。没有使自己囿于类的层次之中:可以在运行时传入函数并在任何时候以更高的粒度更少的代码修改他们。编译器高效地为我们生成了面向对象的粘合代码!除此之外,我们还获得了所有函数式编程的其他好处。当然函数式语言提供的抽象不只这些,高阶函数只是一个开始:

 

currying

我认识的大多数人都读过四人帮的那本设计模式,任何自重的程序员都会告诉你那本书是语言中立的(agnostic),模式在软件工程中是通用的,和使用的语言无关。这个说法颇为高贵,故而不幸的是,有违现实。

函数式编程极具表达能力。在函数式语言中,语言既已达此高度,设计模式就不再是必需,最终你将设计模式彻底消除而以概念编程。适配器(Adapter)模式就是这样的一个例子。(究竟适配器和 Facade 模式区别在哪里?可能有些人需要在这里再多费些篇章)。一旦语言有了叫作 currying 的技术,这一模式就可以被消除。

 

currying.

适配器模式最有名的是被应用在 Java 默认抽象单元——class 上。在函数式编程里,模式被应用到函数。模式带有一个接口并将它转换成另一个对他人有用的接口。这有一个适配器模式的例子:

int pow(int i, int j);

int square(int i)

{

return pow(i, 2);

}

上面的代码把一个整数幂运算接口转换成为了一个平方接口。在学术文章里,这个雕虫小技被叫作currying(得名于逻辑学家Haskell Curry,他曾将相关的数学理论形式化 )。因为在函数式编程中函数(反之如class) 被作为参数来回传递,currying 很频繁地被用来把函数调整为更适宜的接口。因为函数的接口是他的参数,使用 currying 可以减少参数的数目(如上例所示)

函数式语言内建了这一技术。不用手动地创建一个包装了原函数的函数,函数式语言可以为你代劳。同样地,扩展我们的语言,让他支持这个技术:

square = int pow(int i,2);

这将为我们自动创建出一个有一个参数的函数 square。他把第二个参数设置为 2 再调用函数 pow。这行代码会被编译为如下的 Java 代码:

class square_function_t {

int square(int i) {

return pow(i, 2);

}

}

square_function_t square= new square_function_t();

正如你所见,通过简单地创建一个对原函数的包装,在函数式编程中,这就是currying —— 快速简易创建包装的捷径。把精力集中在你的业务上,让编译器为你写出必要的代码!什么时候使用 currying?这很简单,任何时候你想要使用适配器模式(包装)时。

 

原创粉丝点击