UP | HOME

Ping's Tech Notes

Lisp里的延迟执行

Ping Zhou, 2021-07-31

在图计算和协程里经常会用到的“延迟执行”,在Lisp里,只需要十几行代码就能实现!

创建和使用延迟执行对象

首先我们来定义一个宏 defer ,它的作用是把输入的代码块变成一个延迟执行的对象(其实是一个闭包):

(defmacro defer (&body body)
  (let ((executed (gensym))
        (result (gensym)))
    `(let ((,executed nil)
           (,result nil))
       (lambda ()
         (unless ,executed
           (setf ,result (progn ,@body))
           (setf ,executed t))
         ,result))))

而如果要执行之前被推迟的代码,可以用这个函数:

(defun defer-execute (deferred)
  (funcall deferred))

例如,我们有这样一块代码:

(princ "Hello, world.")
(princ (1+ 2))

defer 来包装它:

(defer
  (princ "Hello, world.")
  (princ (1+ 2)))

defer 会把输入的代码块包装成一个 闭包 返回给我们。所以通常我们要把这个闭包存在一个变量里:

(defvar *deferred-hello*
  (defer
    (princ "Hello, world.")
    (princ (1+ 2))))

等我们想要执行这个代码块的时候,只需要用 defer-execute 来调用这个闭包:

(defer-execute *deferred-hello*)

输出结果:

Hello, world.3
3 (2 bits, #x3, #o3, #b11)

怎么理解这个 defer 宏?我们把它展开一下就明白了:

(macroexpand
 '(defer
   (princ "Hello, World.")
   (princ (1+ 2))))

展开后得到这样的一段代码:

(LET ((#:G659 NIL) (#:G660 NIL))
  (LAMBDA ()
    (UNLESS #:G659
      (SETF #:G660 (PROGN (PRINC "Hello, World.") (PRINC (1+ 2))))
      (SETF #:G659 T))
    #:G660))

defer 首先用 gensym 产生两个临时变量,分别用来记录该代码块是否被执行过,以及运行后的返回值(通常是代码块中最后一个求值表达式的结果)。如果该代码块没有执行过,它就把输入的代码块放到一个 progn 块里顺序执行,然后把返回值记录下来。下次再执行这个闭包的话,就直接返回记录下来的值。

defer-execute 就是用 funcall 来执行这个闭包。

一个例子:“延迟”列表

我们可以用 defer 来包装一组列表的API。

(defmacro deferred-cons (a b)
  `(defer (cons ,a ,b)))

(defun deferred-car (x)
  (car (defer-execute x)))

(defun deferred-cdr (x)
  (cdr (defer-execute x)))

(defun deferred-nil ()
  (defer nil))

(defun deferred-null (x)
  (not (defer-execute x)))

这些API就相当于列表API( car, cdr, cons )的“延迟执行”版本。

然后呢,我们可以用递归的办法把普通列表转换成一个“延迟执行列表”:

(defun make-deferred-list (lst)
  (defer (when lst
           (cons (car lst) (make-deferred-list (cdr lst))))))

是不是就有点绕了?我们用简单的例子来分析一下。假如我们有个列表 (1 2 3) ,把它转换成延迟执行列表:

(defvar *dlist*
  (make-deferred-list '(1 2 3)))

我们会得到一个闭包:

#<CLOSURE (LAMBDA () :IN MAKE-DEFERRED-LIST) {10036DC8CB}>

这其实是一个构造列表 (1 2 3) 的函数的闭包。如果我们把参数 (1 2 3) 代入 make-deferred-list 的实现,并展开宏:

(macroexpand
 '(defer (when '(1 2 3)
           (cons (car '(1 2 3)) (make-deferred-list (cdr '(1 2 3)))))))

得到展开后的代码:

(LET ((#:G665 NIL) (#:G666 NIL))
  (LAMBDA ()
    (UNLESS #:G665
      (SETF #:G666
              (PROGN
               (WHEN '(1 2 3)
                 (CONS (CAR '(1 2 3)) (MAKE-DEFERRED-LIST (CDR '(1 2 3)))))))
      (SETF #:G665 T))
    #:G666))

这段代码包含的这个lambda函数,返回的是一个cons对(类似于Python里的二元组):

(CONS (CAR '(1 2 3)) (MAKE-DEFERRED-LIST (CDR '(1 2 3))))

其中第一个元素是 car '(1 2 3) ,也就是1 ,第二个元素调用是 make-deferred-list 形成的另一个闭包,而这个闭包产生的是 (2 3) 的延迟列表。

所以如果我们执行这个闭包:

(defer-execute *dlist*)

得到的是这样一个东西:

(1 . #<CLOSURE (LAMBDA () :IN MAKE-DEFERRED-LIST) {100840F14B}>

我们如果对它作car操作,得到的是列表的第一个元素1,而如果我们想要后续的元素,就需要进一步执行后面那个闭包来得到。

这个过程可以这么理解:我这个闭包假装是一个列表,其实我具体只有第一个元素,后面的元素都在另一个闭包里,你问我要第一个元素,我马上就可以给你,但如果你问我要第2或第3个元素,我就去执行后面的闭包来给你。

类似的,还可以给这个延迟列表API加上 mapcar 的延迟版本,等等。

另一个有趣的例子:“无限”整数列表

接下来看一个有趣的例子:我们可以用前面的“延迟列表”API,创建一个包含1到无穷大的“无限”整数列表!

所需要的代码就这几行:

(defparameter *ints*
  (labels ((f (n)
             (deferred-cons n (f (1+ n)))))
    (f 1)))

这个 *ints* 变量,也是一个闭包,里面是我们定义的这个内部函数f,而这个内部函数f,返回的就是一个生成延迟列表(二元组)的闭包:

(deferred-cons n (f (1+ n)))

这里 deferred-cons 返回的这个闭包,会产生一个与前面类似的二元组,第一个元素是参数n,第二个元素则是递归调用f的闭包。所以如果我们执行这个 *ints* 对象:

(defer-execute *ints*)

得到的是这样一个二元组:

(1 . #<CLOSURE (LAMBDA () :IN F) {100855314B}>)

同样,如果我们用 deferred-cdr 取二元组的后面项,然后执行它:

(defer-execute (deferred-cdr *ints*))

得到的时候以2开头的一个二元组:

(2 . #<CLOSURE (LAMBDA () :IN F) {100899D1EB}>)

以此类推,可以一直这样 car, cdr 下去,看起来就好象得到了一个从1开始到无穷大的“无限”整数列表。

这个无穷列表可以这么想象:表面上看,我这个“列表”从1开始包含了到无穷大的数,你可以不停的 car, cdr 从我这里取数。其实呢,我这里只有第一个数1,后面的是一个闭包,你要第2个数,我就去执行闭包得到2;你要第三个数,我就继续执行2后面的闭包,给你3……

进一步讨论

从这个 defer 宏的实现可以看出,它保证输入的代码块只会被执行一次,如果这个延迟执行闭包被多次调用,后续的调用只会返回相同的返回值,而不会真的执行里面的代码块。所以我前面的例子里用 princ 其实并不是最恰当,因为如果你再次执行闭包的话,会发现它不会再打印“Hello, World”了。

另一个考虑, defer 包装出的闭包如果要用在图计算里,那么势必要有输入输出(例如某个节点从前一节点输入,经过计算输出结果,作为下一节点的输入)。我初步的想法是随着计算图创建各个闭包,并且把图中节点的连接性也打包进闭包,例如在创建某个节点的闭包时,引用它的输入节点闭包,作为代码块的一部分一起打包到闭包里。下次有空试试这个应用。

最后还是感叹一下,Lisp真有意思啊!

注:部分示例参考Land of Lisp(landoflisp.com)