函数式编程另类指南3

惰性求值

惰性(或延迟)求值这一技术可能会变得非常有趣一旦我们采纳了函数式哲学。在讨论并行时已经见过下面的代码片断:

String s1 = somewhatLongOperation1();

String s2 = somewhatLongOperation2();

String s3 = concatenate(s1, s2);

在一个命令式语言中求值顺序是确定的,因为每个函数都有可能会变更或依赖于外部状态,所以就必须有序的执行这些函数:首先是somewhatLongOperation1,然后 somewhatLongOperation2,最后concatenate,在函数式语言里就不尽然了。

前面提到只要确保没有函数修改或依赖于全局变量,somewhatLongOperation1 somewhatLongOperation2 可以被并行执行。但是如果我们不想同时运行这两个函数,还有必要保证有序的执行他们呢?答案是不。我们只在其他函数依赖于s1s2时才需要执行这两个函数。我们甚至在concatenate调用之前都不必执行他们——可以把他们的求值延迟到concatenate函数内实际用到他们的位置。如果用一个带有条件分支的函数替换concatenate并且只用了两个参数中的一个,另一个参数就永远没有必要被求值。在 Haskell 语言中,不确保一切都(完全)按顺序执行,因为 Haskell 只在必要时才会对其求值。

惰性求值优点众多,但缺点也不少。我们会在这里讨论它的优点而在下一节中解释其缺点。

 

优化

惰性求值有客观的优化潜力。惰性编译器看函数式代码就像数学家面对的代数表达式————可以注销一部分而完全不去运行它,重新调整代码段以求更高的效率,甚至重整代码以降低出错,所有确定性优化(guaranteeing optimizations)不会破坏代码。这是严格用形式原语描述程序的巨大优势————代码固守着数学定律并可以数学的方式进行推理。

抽象控制结构

惰性求值提供了更高一级的抽象,它使得不可能的事情得以实现。例如,考虑实现如下的控制结构:

unless(stock.isEuropean()) {

sendToSEC(stock);

}

我们希望只在祖先不是欧洲人时才执行sendToSEC。如何实现 unless?如果没有惰性求值,我们需要某种形式的宏(macro)系统,但Haskell 这样的语言不需要它。把他实现为一个函数即可:

void unless(boolean condition, List code) {

if(!condition)

code;

}

注意如果条件为真代码将不被执行。我们不能在一个严格(strict)的语言中再现这种求值,因为 unless 调用之前会先对参数进行求值。

 

无穷(infinite)数据结构

惰性求值允许定义无穷数据结构,对严格语言来说实现这个要复杂的多。考虑一个 Fibonacci 数列,显然我们无法在有限的时间内计算出或在有限的内存里保存一个无穷列表。在严格语言如 Java 中,只能定义一个能返回 Fibonacci 数列中特定成员的 Fibonacci 函数,在 Haskell 中,我们对其进一步抽象并定义一个关于 Fibonacci 数的无穷列表,因为作为一个惰性的语言,只有列表中实际被用到的部分才会被求值。这使得可以抽象出很多问题并从一个更高的层次重新审视他们。(例如,我们可以在一个无穷列表上使用表处理函数)。缺点

当然从来不存在免费的午餐。惰性求值有很多的缺点,主要就在于,懒。有很多现实世界的问题需要严格求值。例如考虑下例:

System.out.println(”Please enter your name: “);

System.in.readLine();

在惰性求值的语言里,不能保证第一行会在第二行之前执行!那么我们就不能进行输入输出操作,不能有意义地使用本地(native)接口(因为他们相互依赖其副作用必须被有序的调用),从而与整个世界隔离。如果引入允许特定执行顺序的原语又将失去数学地推理代码的诸多好处(为此将葬送函数式编程与其相关的所有优点)。幸运的是,并非丧失了一切,数学家为此探索并开发出了许多技巧来保证在一定函数设置下(function setting)代码以一特定的顺序执行。这样我们就赢得了两个世界。这些技术包括 continuation monad uniqueness typing (一致型别)。我只会在本文中解释continuation,把 monad uniqueness typing 留到将来的文章中。有趣的是,除了确保函数求值顺序, continuation 在很多别的情况下也很有用。这点等一会儿就会提到。

 

Continuations

Continuations 对于程序设计的意义,就像《达芬奇密码》对人类历史的意义:即对人类最大秘密的惊人揭示。也许不是,但他在概念上的突破性至少和揭示了负数的平方根意义等同。

我们在学习函数时,只是学到了一半的事实,因为我们基于一个错误的假定:函数只能将结果返回到它的调用函数。在这个意思上continuation 是广义的函数。函数不必要返回到其调用函数而可以返回到程序的任何地方。我们把”continuation” 作为参数传给一个函数,它指定了这个函数返回的位置。这个描述可能听起来更加复杂。看一下下面的代码:

int i = add(5, 10);

int j = square(i);

函数 add 在其被调用的位置将结果 15 赋给了 i,接下来 i 的值被用来调用square。注意所有的惰性求值编译器都不能调整这几行代码因为第二行依赖着第一行的成功求值。下面用 continuation 风格又称 CPS (Continuation Programming Style) 来重写这段代码,这里函数 add 会将结果返回到 square 而不是原来的调用函数。

int j = add(5, 10, square);

这个例子中 add 有了另一个参数 —— 一个 add 必须在它求值结束时用其返回值调用的函数。这里 square add 的一个 continuation。这两种情况下,j 都将等于 255

这就是强制使惰性语言有序地求值两个表达式的第一个技巧。考虑下面这个(熟悉的)IO代码:

System.out.println(”Please enter your name: “);

System.in.readLine();

这两行不相依赖所以编译器会自由的重新调整他们的执行顺序。然而,如果我们用 CPS 来重写这段代码,就会有一个依赖,编译器会因此而强制对这两行代码有序执行!

System.out.println(”Please enter your name: “, System.in.readLine);

这里 println 需要用自己的返回结果作为参数去调用 readLine 并将 readLine 返回值作为自己的返回值。这样就能确保这两行被有序执行而且 readLine 一定被执行(因为整个计算期望最后的结果为结果)。Java println 返回 void 但如果它返回的是一个抽象值(readLine所期待的),我们就解决了这个问题!当然这样的链接函数调用很快就会使代码难以读懂,不过这个可以避免。比如我们可以给语言添加些语法甜点(syntactic sugar)就可以简单的按顺序输入表达式,然后由编译器自动为我们链接这些函数调用。这样就可以如愿地使用期望的求值顺序并保留一切函数式编程的好处(包括数学地对我们程序进行推理的能力)!如果还是有迷惑,记住函数是只有一个成员的类的实例。重写上述代码使得 println readLine 成为类的实例,这样就对一切都清楚了。

如果我在此结束本节,那将仅仅涉及到 continuation 最浅显的应用。用 CPS 重写整个程序,那里所有的函数都增加一个额外的 continuation 参数并把函数结果传给它。也可以通过简单地把函数当作 continuation 函数(总是返回到调用者的函数)的特殊实例来将程序转为 CPS 风格。这种转换很容易被自动化(事实上,许多编译器就是这么做的)。

一旦我们将一个程序转为了CPS,那么很明显每个指令都将有些 continuation, 这是一个该指令在执行结束时会用其执行结果调用的函数,通常的程序中,这是一个它要返回的地址。从上面的例子中随便举个例子,比如 add(5, 10)。在用CPS 风格写的程序里,add continuation很明显——这是一个 add 在其执行结束时会调用的函数。那么如果在非CPS的程序里,它是什么呢?当然我们可以把程序转为 CPS ,但有这个必要吗?

 

其实没有必要。仔细看一下我们的 CPS 转换过程。如果尝试为它写一个编译器,然后经过长期的思考后,你意识到这个 CPS 的版本根本不需要栈!没有函数会以传统的意义返回,它只是用结果调用了另一个函数。我们无需在调用时将函数参数压栈再于调用结束时弹出栈,而只是简单的把他们保存在一大块内存中,然后使用跳转指令。不再需要原来的参数——他们不会再次被用到,因为没有函数会返回!

所以,用 CPS 风格写成的程序没有堆栈,但每个函数却有一个额外的参数可被调用。不是 CPS 风格的程序没有可以被调用的这个参数,但却有栈。栈中存放着什么?只是参数和一个指向函数返回地址的指针。你看到光了吗?栈中只是放着continuation 的信息!栈中指向返回指令的指针本质上和 CPS 程序里将被调用的函数是等价的。如果你想探究 add(5,10) continuation,只要简单地检查它在堆栈的执行点!

这的确很简单。continuation 和栈上指向返回地址的指针是等价的,只是continuation 是被显式传递,所以不必和函数被调用点是同一位置。如果还记得continuation 就是一个函数,并且在我们的语言里,函数被编译为一个类的实例,你就会理解指向栈中返回指令的指针实际就是传递给 continuation 的参数,因为我们的函数(就像一个类的实例)只是一个指针。这意味着给定程序中任意时间和任意位置,你都可以去请求一个当前的 continuation (current continuation)(它就是当前的栈的信息)。

 

好的,这样我们就知道了什么是 current continuation。他有什么意义?一旦我们得到了当前的 continuation 并将它保存在某处,我们就最终将程序当前的状态保存了下来——及时地冷冻下来。这就像操作系统将其置为休眠状态。一个continuation 对象里保存了在我们获得它的地方重新启动程序的必要信息。操作系统在每次发生线程间的上下文切换时也是如此。唯一的区别是它保留着全部控制。请求一个continuation 对象(在Scheme里,可以调用call-with-current-continuation 函数)后,你就会获得一个包括了当前continuation 的对象——堆栈(或者在CPS情况下则是下一个要调用的函数)。可以把这个对象保存在一个变量(或者是磁盘)里。当你用这 continuation “重启程序时,就会转回到处你取得这个对象的那个状态。这就象切换回一个被挂起的线程或唤醒休眠着的操作系统,区别是用 continuation,你可以多次地重复这一过程。当操作系统被唤醒时,休眠信息就被销毁了。但如果那些信息没有被销毁,你也就可以一次次地将它唤醒到同一点,就象重返过去一样。有了 continuation 你就有了这个控制力!

Continuation 应该在什么情况下使用呢?通常在尝试模拟一个本质上是无状态的应用时可以简化你的任务。Continuation 很适合在Web应用程序中使用。微软公司的 ASP.NET 技术极尽苦心地模拟状态以便你在开发 Web 应用时少费周折。可如果 C# 支持了continuationASP.NET 的复杂度就可以减半——你只需要保存一个 continuation,当用户下次发出 web 请求时重启它即可。对程序员来说,web 应用程序将不再有中断——程序只是简单的从下一行重启!利用continuation 这一抽象解决问题真是令人难以置信的便利。考虑到越来越多的胖客户端应用程序正在向服务器端转移,将来 continuation 也会变得越来越重要。

 

模式匹配

模式匹配不是什么新的创新的特性。事实上,它和函数式编程的关系不大。把产生模式匹配归因于函数式编程的唯一的原因是函数式语言一度提供了模式匹配,然而现在的命令式语言还做不到。

让我们用一个例子深入了解一下模式匹配。这是一个JavaFibonacci函数:

int fib(int n) {

if(n == 0) return 1;

if(n == 1) return 1;

return fib(n - 2) + fib(n - 1);

}

让我们从Java衍生出的语言来支持模式匹配:

int fib(0) {

return 1;

}

int fib(1) {

return 1;

}

int fib(int n) {

return fib(n - 2) + fib(n - 1);

}

两者有什么区别?编译器为我们实现了分支。这有什么大不了?的确没什么。有人注意到很多函数包括了复杂的 swith 语句(尤其是在函数式程序中)所以认为这种抽象形式很好。我们把一个函数定义分离成多个,然后把模式置于参数中(有点象重载)。当这个函数被调用时,编译器使其比较参数和其运行时的定义然后选择其中正确的一个。这一般是通过选择可选的最特定的定义来完成。例如,int fib(int n) 可以以 n 等于 1 被调用,但是实际上 fib(n) 没有被调用,因为fib(1) 更加特定。

模式匹配通常要比我这个例子复杂,比如,高级模式匹配系统可以让我们这样做:

int f(int n < 10) { ... }

int f(int n) { ... }

模式匹配什么时候适用?情况太多了!每当你有一个嵌套着 if 的复杂的数据结构,这时就可以用模式匹配以更少的代码完成得更好。一个很好的例子闪现在我脑海,这就是所有 Win32 平台都提供了的标准的 WinProc 函数(即使它通常被抽象了)。通常模式匹配系统能检测集合也可以应付简单的值。例如,当传给函数一个数组后,就可以找出所有首元素为 1 第三个元素大于 3 的所有数组。

模式匹配还有一个好处即如果需要增加或修改条件,那么不必对付一个巨大的函数。只需增加或修改适合的定义即可。这消除了四人帮GoF)书中的一大类设计模式。条件越复杂,模式匹配就越有用。一旦习惯了它,你就会担心没有了模式匹配的日子如何打发。


 

Closures

到此我们已经讨论了纯的函数式语言——实现了lambda演算又不包括与丘奇形式系统矛盾的语言——环境里的特性,可是还有很多在lambda演算框架之外的函数语言的有用特征。虽然一个公理系统的实现可以让我们象数学表达式那样思考程序但它未必是实际可行的。许多语言选择去合并一些函数式的元素而没有严格的坚持函数式的教条。很多象这样的语言(如Common Lisp)不要求变量是 final ——可以即处对其修改。他们还不要求函数只依赖于其参数——允许函数访问外部状态。但这些语言也的确包含着函数式的特征——如高阶函数,在非纯粹的函数式语言里传递函数作为参数和限制在 lambda 演算系统中的作法有些不同,它需要一种常被称为词法(lexicalclosure 的有趣特性。下面我给出几个例子。记住,这里变量不再是final的,函数可以引用其作用域外的变量:

Function makePowerFn(int power) {
int powerFn(int base) {
return pow(base, power);
}
return powerFn;
}

Function square = makePowerFn(2);
square(3); // returns 9

函数 make-power-fn 返回了一个函数,它有一个参数,并对这个参数进行一定阶的幂运算。如果对 square(3) 求值会有什么结果?变量 power 不在 powerFn 的作用域中,因为 makePowerFn 已经返回它的栈桢而不复存在。那么square如何工作?一定是这个语言以某种方式将power的值保存了起来以便 square 使用。如果我们再新建一个函数cube,用来计算参数的立方又会怎样?运行环境必须存储两个power的拷贝,每个我们用 make-power-fn 生成的函数都用一个拷贝。保存这些值的现象就被称为 closure closure 不只保存宿主函数的参数。例如,closure 可能会是这样:

Function makeIncrementer() {
int n = 0;

int increment() {
return ++n;
}
}

Function inc1 = makeIncrementer();
Function inc2 = makeIncrementer();

inc1(); // returns 1;
inc1(); // returns 2;
inc1(); // returns 3;
inc2(); // returns 1;
inc2(); // returns 2;
inc2(); // returns 3;

运行时已保存了n,所以递增器可以访问它,而且运行时为每个递增器都保存了一个 n 的拷贝,即使这些拷贝本应在 makeIncrementer 返回时消失。这些代码被如何编译?closure 在底层是如何工作的?很幸运,我们可以去幕后看看。

一点常识会很有帮助,首先会注意到的是局部变量的生命期不再由简单的作用域限定而是不确定的。那么显然可以由此得出结论它们不再被保存在栈上——反之必须被保存在堆上[8]。这样一来,closure 的实现就象我们前面讨论的函数一样了,只是它还有一个指向周围变量的引用。

class some_function_t {

SymbolTable parentScope;

// …

}

当一个 closure 引用了一个不在其作用域的变量时,它会在其祖先作用域中查找这个引用。就是这样!Closure 将函数式和面向对象的世界紧密结合。当你创建了一个包含了一些状态的类并把它传到别处时,考虑一下 closureClosure 就是这样在取出作用域中的变量的同时创建成员变量,所以你不必亲自去做这些!

下一步的计划?

关于函数式编程,本文作了浅显地讨论。有时候一次粗浅的射猎可能会进展为重大的收获与我也受益匪浅。将来我还计划写写 category 理论,monad,函数式数据结构,函数式语言中的类型(type)体系,函数式并发,函数式数据库等等还有很多。如果我得以(在学习的过程中)写出了上述诸多主题中的一半,我的生命就会完整了。还有,Google 是我们的朋友。

评论?

如果你有任何问题,意见或建议,请发到邮箱 coffee…@gmail.com。很高兴收到你的反馈

===========================

 [1] 我在2005年找工作时常常提出这个问题,当时我得到的是数量可观的一脸茫然。想像一下,这些人至少每人会得到30万美元,如果他们理解了他们可以得到的大部分工具。

 [2] 这像是个悖论。物理学家和数学家被迫确认他们还不完全清楚是否宇宙万物遵循着可以被数学描述的规则。

 [3] 我一直厌恶提供了一堆枯燥的日期,人名和地点的纪年式历史课。对我而言,历史是改变了这个世界的人的生活,是他们行为之后的个人动机,是他们得以影响亿万生灵的体制。所以这个关于历史的小节注定无法完整,只讨论了于此关系及其密切的人物与事件。

 [4] 我在学习函数式编程的时候,很不喜欢术语 lambda,因为我没有真正理解它的意义。在这个环境里,lambda 是一个函数,那个希腊字母只是方便书写的数学记法。每当你听到 lambda 时,只要在脑中把它翻译成函数即可。

 [5] 有趣的是 Java 的字符串是不可变更的,探讨这一离经叛道的设计的原因也非常有趣,不过在这里会分散我们对原目标的注意力

 [6] 大多数函数式编程语言的编译器能通过将递归尽可能转为迭代来进行优化,这被称为尾递归。

 [7] 相反未必成立,虽然有时可以证明两端代码等价,但这不是所有情况下都成立。

 [8] 这实际上不比存储在栈上慢,因为一旦引入了垃圾回收器,内存分配就成为了一个O(1)的操作。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值