背景
上次重构用到了Stream,他的延迟计算能力很酷! 于是乎查阅了一下延迟计算的原理——没想到这看似魔法一般的延迟计算,其实现原理却是是这么的简单!
本文依照其原理用Scala语言实现了一下。本文只是对延迟计算原理的探讨,在Scala里它直接在语言级提供了lazy关键字,可以用来定义延迟计算,所以实际应用时用lazy就好了。
知识点
1. 延迟计算
2. 叫名参数(Call By Name)
3. 匿名函数
实现原理
延迟计算看似魔法般的存在,实际上在函数式编程里,其实现是如此的简单直白:
① 延迟不过是一个无参匿名函数的语法糖:
def delay[A](v: => A) = {() => v}
② 而求值的过程,不过是对这个匿名函数的调用:
def compute[A](dv: () => A) = dv()
以上两个简单的函数即完成了延迟计算的全部!
用以下代码测试一下,可以看到我们的x和y是在调用compute时才真正开始计算的,达到了延迟计算的效果:
例1:
scala> import java.util.Calendar
import java.util.Calendar
scala> val x = delay(Calendar.getInstance.getTime)
x: () => java.util.Date = <function0>
scala> Calendar.getInstance.getTime
res0: java.util.Date = Wed Jul 30 14:14:43 CST 2014
scala> compute(x)
res1: java.util.Date = Wed Jul 30 14:14:55 CST 2014
例2:
scala> val y = delay((1 to 1000000000).toList.filter(_ % 3 == 0).take(4))
y: () => List[Int] = <function0>
scala> compute(y)
java.lang.OutOfMemoryError: Java heap space
delay实现详解
def delay[A](v: => A) = {() => v}
- 参数的类型要为 "=> A",这在scala里表示参数为Call By Name。与之对应的,参数类型写为 A 的时候,表示Call By Value。这两者的区别,简单来说就是:
① Call By Name: 参数在函数中用到的时候才求值
② Call By Value: 参数先求值,然后才把值传到函数中去
注:这两种方式实际上涉及到函数式编程里一个基本的概念:代换模型。在SICP这本书中有详细的介绍。Call By Name对应于SICP中的正则序求值,即先展开再规约;Call By Value对应于SICP中的应用序求值。Scala和Scheme、Lisp等一样,默认采用按应用序求值,即Call By Value的方式。在Scala里如果想使用Call By Name的方式,只需要简单的在参数类型前面加个"=>"
- 函数体为 {() => v}: 这里实际上是定义了一个无参匿名函数,这个匿名函数的返回值为v,并把这个匿名函数作为delay函数的返回值。
① 什么是匿名函数? 比如最常用的List(1,2,3).map(x => x * 2)中,x => x * 2就是一个匿名函数。这个匿名函数有一个入参x,函数体为 x * 2。在函数式编程的世界里,这个匿名函数更专业的叫法为lambda表达式,lambda表达式的返回值是返回一个新的函数。lambda演算是函数式语言的数学基础。最早的函数式语言Lisp就是John McCarthy在做lambda演算论文的时候不小心创造出来的,所以说函数式语言从一开始就更接近数学,更接近高度的抽象;而不是像C、C++这些命令式语言一开始就面向机器硬件。
② delay函数的返回值是另外一个函数!这就是实现延迟计算的奥秘!——你在定义一个延迟计算变量delay(x)的时候,其实你什么都没干,只是把这个x包装到了一个函数中而已;直到你需要用它的时候,才在它上面应用compute方法,开始真正的计算。
对象方式的实现
在Scala的世界里,函数就是对象,对象某种程度上也可以看成函数,这也是Scala的完美融合两者的强大之处吧。下面是“面向对象”的一种实现方式:
class Delay[A](v: => A) {
def compute() = v
}
应用示例:
scala> val x = new Delay(Calendar.getInstance.getTime)
x: Delay[java.util.Date] = Delay@a80d36
scala> Calendar.getInstance.getTime
res32: java.util.Date = Wed Jul 30 16:20:01 CST 2014
scala> x.compute
res33: java.util.Date = Wed Jul 30 16:20:12 CST 2014
优化:带记忆的版本
目前的实现在每次调用compute的时候,都要重新计算。对于计算量很大的运算,会损失性能;同时,如上面的例子,每次调用compute的返回值是不一样的,这也不符合我们对一个val直觉的认识。因此,大部分语言延迟计算的实现都有一个重要的优化:只做一次运算。Scala里的lazy也是这样的,只会做一次计算。
要实现只做一次运算,要求我们的Delay要有记忆:当第一次执行的时候做运算,执行后记住运算结果,下次再执行compute时直接返回上次的运行结果。实现如下:
class Delay[A](v: => A) {
private var isFirstRun = true
private var value: A = _
def compute() = {
if (isFirstRun) {
value = v
isFirstRun = false
}
value
}
}
应用示例:
val x = new Delay(Calendar.getInstance.getTime)
x: Delay[java.util.Date] = Delay@13455e7
scala> Calendar.getInstance.getTime
res36: java.util.Date = Wed Jul 30 16:35:46 CST 2014
scala> x.compute
res37: java.util.Date = Wed Jul 30 16:35:52 CST 2014
scala> x.compute
res38: java.util.Date = Wed Jul 30 16:35:52 CST 2014