天天看点

『惰性求值』初探

首先,以一个经典的问题来引入惰性求值1的概念:能否用定义函数的方式定义出if2控制构造?例如,对于某个语言的 if 语句,有如下简单的语法:

if test then-clause else-clause

if true (1+1) (1-1)

将返回2 );那么,我们能否用定义函数的方式定义出这个if操作符?不妨先试试吧:

def new_if(test, then_clause, else_clause):
    tmp = test and then_clause
    return (tmp or else_clause)

new_if(True, +, -) # -> 2
new_if(False, +, -) # -> 0
           

如此看上去似乎效果不错。现在我们用这个新的 if 来写一个简单的阶乘函数:

def factorial(n):
    return new_if(n<=, n, n*factorial(n-))

factorial() 
# -> should be 24, but instead, we get
# RuntimeError: maximum recursion depth exceeded
           

糟糕!Python解释器告诉我们由于递归深度超过了限制,计算被中断了。这说明,这个所谓

new_if

是行不通的,为什么呢?

事实上,包括Python在内的众多语言都是使用『严格求值序』3的。这意味着,当作为实参的表达式被送入函数体时,总是会被先求值。例如,在刚刚的例子里,由于在执行

new_if

之前第三个实参总是要被求值,于是整个计算过程就变成『死循环』了。

这看上去似乎是很无奈的事情。但另一方面,一门叫「Haskell」4的语言采用了『正则求值序』——即我们今天要讨论的所谓『惰性求值』。在这样的求值序下,当且仅当表达式需要被求值时才会被求值。例如,如果 Python 采用的是『正则求值序』,那么在刚刚的

new_if

函数体里,只有当

tmp

False

的时候

else-clause

才会被求值,否则是不会被求值的。

所以,从这个角度看,『惰性求值』是个很有趣的概念。例如,它可以实现『惰性表』这样的东西。总的来说,利用『惰性求值』,可以避免掉不必要的计算。下面,我们先用 Python5 实现两个函数,它们可被视为实现『惰性表』的原语:

def lazy_first(Cons): return Cons[]

def lazy_rest(Cons): return (Cons[])()
           

熟悉

Lisp

的朋友应该都知道

Cons

,但是这里的

Cons

Lisp

Cons

关系并不大,姑且只是将其看成一个二元组好了。可以看到,

lazy_first

直接利用索引取出了第一个元素;

lazy_rest

则首先取出第二个元素,并执行了一次函数调用;而从他们的名字可以看出,

lazy_first

被用来取出『惰性表』的第一个元素,

lazy_rest

则被用来取出剩余的元素并以列表的形式返回。

接下来,我们再实现两个函数6:

1. from_n接受一个整数n,返回一个从该整数起始的『惰性表』;例如,如果n为0,则我们得到一个自然数序列;

2. take_n接受一个整数n和一个『惰性表』,返回该表的前n项。

def from_n(n):
    return [n, lambda : from_n(n+)]

def take_n(n, lazy_list):
    if n==: return []
    else: return [lazy_first(lazy_list)] + take_n(n-, lazy_rest(lazy_list))

number = from_n() # -> [0, <function <lambda> at 0x1078d3578>]

take_n(, number) # -> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

take_n(, number) # -> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
           

from_n

中,我们可以看到我们是怎么实现『惰性表』的:『惰性表』是一个二元组,第一个元素为表的内容,第二个元素为一个所谓「thunk」的东西——一个无参函数,此处用

lambda

表达式构造。这样一来,在

take_n

中的递归过程便不难理解了。

事实上,理解这里的『惰性表』的关键即在于理解thunk的作用:延迟计算。例如给定一个表达式 expr,经过

thunk

后,expr 的求值就被延迟了:

expr -> lambda : expr

;直到

thunk

被执行后,expr 才会被求值。这个概念用 Common Lisp 的宏7能很好的表达出来:

(defmacro delay (expr) `(lambda () ,expr)) ;; thunk it!

(defun force (thunk) (funcall thunk)) ;; force it to be evaluated.
           

这篇博文只是对『惰性求值』的一个简单介绍;我们看到,利用「thunk」,我们可以延迟计算从而实现『惰性表』这样的东西。下面是『惰性表』在 OCaml 与 Common Lisp 中的示例代码,以及一些引用。

OCaml8

type 'a seq = Nil
            | Cons of 'a * (unit -> 'a seq)

let first = function
  | Cons(x, _) -> x
  | Nil -> None

let rest = function
  | Cons(_, thunk) -> Some(thunk())
  | Nil -> None

let rec from_n k = Cons(k, fun() -> from_n(k+))

let rec take_n = function
  | , lst -> []
  | n, Nil -> []
  | n, Cons(x, thunk) -> x :: take_n(n-,thunk())
           

Common Lisp

(defmacro make-lazy-list (first rest)
  `(cons ,first #'(lambda () ,rest)))

(defun llst-first (llst) (car llst))

(defun llst-rest (llst) (funcall (cdr llst)))

(defun from-n (n)
  (make-lazy-list n (from-n (+ n))))

(defun take-n (n llst)
  (cond ((zerop n) nil)
    ((not (llst-first llst)) nil)
    (t (cons (llst-first llst)
         (take-n (- n) (llst-rest llst))))))

;;;; Since Common Lisp is my first language, I write two more example functions here.
;;;; 'map-llst' is just like Scheme's 'map', but not that powerful, of course.
;;;; It takes a function and a lazy list, then return another lazy list;
;;;; 'filter-llst' takes a predicate and a lazy list, then
;;;; return another lazy list which has been filtered out those 'incorrect' elements.

(defun map-llst (fn llst)
  (if (null llst)
      nil
      (make-lazy-list (funcall fn (llst-first llst))
              (map-llst fn (llst-rest llst)))))

(defun filter-llst (pred llst)
  (cond ((null llst) nil)
    ((funcall pred (llst-first llst))
     (make-lazy-list (llst-first llst)
             (filter-llst pred (llst-rest llst))))
    (t (filter-llst pred (llst-rest llst)))))

;;;; (setf numbers (from-n 0)) -> (0 . #<Closure (:INTERNAL FROM-N 0) @ #x20839d12>)
;;;; (setf squares (map-llst #'(lambda (x) (* x x)) numbers))
;;;; (setf even-numbers (filter-llst #'evenp numbers))
;;;; (take-n 10 numbers) -> (0 1 2 3 4 5 6 7 8 9)
;;;; (take-n 10 squares) -> (0 1 4 9 15 25 36 49 64 81)
;;;; (take-n 10 even-numbers) -> (0 2 4 6 8 10 12 14 16 18)
           
  1. Wikipedia, Lazy Evaluation ↩
  2. 这个例子事实上来自于《计算机程序构造与解释》(SICP)一书的练习1.6 ↩
  3. 这段实现并不完美,例如,当惰性表的长度不足「n」时,「take_n」函数将会有些难堪;这个问题在下面的另外两个实现中解决掉了。 ↩
  4. 「宏」在Lisp中是一种强大的机制。简单的说,「宏」是一个函数,它接收一组「S-expression」并将其映射为另一组「S-expression」。想了解一些关于「宏」的知识可参见Wikipedia以及Paul Graham所著的《On Lisp》。 ↩
  5. 这段实现多多少少参考了《ML for the Working Programmer》一书中的相关内容。 ↩