SICP:惰性求值、流和尾递归(Python实现)

求值器完整实现代码我已经上传到了GitHub仓库:TinySCM,感兴趣的童鞋可以前往查看。这里顺便强烈推荐UC Berkeley的同名课程CS 61A

即使在变化中,它也丝毫未变。

——赫拉克利特

吾犹昔人,非昔人也。

——僧肇

绪论

在上一篇博客《SICP:元循环求值器(Python实现)》中,我们介绍了用Python对来实现一个Scheme求值器。然而,我们跳过了部分特殊形式(special forms)和基本过程(primitive procedures)实现的介绍,如特殊形式中的delaycons-stream,基本过程中的forcestreawn-carstream-map等。事实上,以上特殊形式和基本过程都和惰性求值与流相关。这篇博客我们将介绍如何用Python来实现Scheme中的惰性求值和流,并使用惰性求值的原理来为我们的Scheme解释器增添尾递归的支持。

1 Scheme中的流简介

所谓,一言以蔽之,就是使用了惰性求值技术的表。它初始化时并没有完全生成,而是能够动态地按需构造,从而同时提升程序的计算和存储效率。

我们先来比较两个程序,它们都计算出一个区间里的素数之和。其中第一个程序用标准的迭代(尾递归)风格写出:

(define (sum-primes a b)
  (define (iter count accum)
    (cond ((> count b) accum)
          ((prime? count) (iter (+ count 1) (+ count accum)))
          (else (iter (+ count 1) accum))))
  (iter a 0))

第二个程序完成同样的计算,其中使用了我们在博客《SICP: 层次性数据和闭包性质(Python实现)》中所介绍过的序列操作:

(define (sum-primes a b)
  (reduce +
          (filter prime? (enumerate-interval a b))))

在执行计算时,第一个程序只需要维持正在累加的和;而对于第二个程序而言,只有等enumerate-interval构造完这一区间所有整数的表后,filter才能开始工作,而且等过滤区工作完后还得将结果表传给reduce得到求和。显然,第一个程序完全不需要像第二个程序这么大的中间存储。

以上情况还不是最极端的,最极端的情况是下面这种,我们枚举并过滤出了10000到1000000内的所有素数,但实际上只取第二个:

(car (cdr (filter prime?
                    (enumerate-interval 10000 1000000))))

这程序槽点很多,首先要构造与一个大约包含了一百万个整数的表,然后再通过过滤整个表的方式去检查每个元素是否是素数,而后只取第二个,几乎抛弃了全部结果,这在时间和空间上都是极大的浪费。在更传统的程序设计风格中,我们完全可以交错进行枚举和过滤,并在找到第二个素数时立即停下来。

流是一种非常巧妙的想法,使我们在保留各种序列操作的同时,不会带来将序列作为表去操作引起的代价(时间上和空间上的)。从表面上看,流也是就是表,但对它们进行操作的过程名字不同。对于流而言有构造函数cons-stream,还有两个选择函数stream-cdrstream-cdr,它们对任意的变量xy都满足如下的约束条件:

scm> (equal? (stream-car (cons-stream x y)) x)
#t
scm> (equal? (stream-cdr (cons-stream x y)) y)
#t

为了使流的实现能自动透明地完成一个流的构造与使用的交错进行,我们需要做出一种安排,使得对于流的cdr的求值要等到真正通过过程stream-cdr去访问它的时候再做,而非在用cons-stream构造流的时候就做。事实上,这一思想在原书2.1.2节中介绍实现有理数的时候也有体现。再那里简化分子与分母的工作可以在构造的时候完成,也可以在选取的时候完成,这两种方式将产生同一个数据抽象,但不同的选择可能产生效率的影响。流和常规表也存在着类似的关系、对于常规的表,其carcdr都是在构造时求值;而流的cdr则是在读取时才求值。

我们可以使用流来完成上面所说的素数筛选功能:

scm> (define (stream-enumerate-interval low high)
        (if (> low high)
            nil
            (cons-stream
            low
            (stream-enumerate-interval (+ low 1) high))))
stream-enumerate-interval
scm> (car (cdr (stream-filter prime?
                    (stream-enumerate-interval 10000 1000000))))
10009

2 惰性求值

接下来我们来看如何在求值器中实现流。流的实现将基于一种称为delay的特殊形式,对于(delay <expr>)的求值将不对表达式<expr>求值,而是返回一个称为延时对象(delayed object) 的对象,它可以看做是对在未来(future)求值<expr>允诺(promise)。这种直到需要时才求值的求值策略我们称之为惰性求值(lazy evaluation)按需调用(call-by-need)[2][3][4]。与之相反的是所谓的急切求值(eager evaluation),也即表达式立即进行求值(除非被包裹在特殊形式中)。

:事实上,futurepromisedelaydeferred等来自函数式编程的特性已经被许多语言的并发模块所吸纳[5]。在并发编程中,我们常常会对程序的执行进行同步,而由于某些计算(或者网络请求)尚未结束,我们需要一个对象(也即futurepromise)来代理这个未知的结果。

我们求值器中的延时对象定义为:

class Promise:
    def __init__(self, expr, env):
        self.expr = expr
        self.env = env

    def __str__(self):
        return "#[promise ({0}forced)]".format(
            "not " if self.expr is not None else "")

可以看到,该对象保持了表达式expr及其对应的环境env,但未对其进行求值。

特殊形式delay对应的的求值过程如下,可以看到它返回了一个Promise延时对象:

def eval_delay(expr, env):
    validate_form(expr, 1, 1)
    return Promise(expr.first, env)

delay一起使用的还有一个称为force的基本过程,它以一个延时对象为参数,执行相应的求值工作,也即迫使delay完成它所允诺的求值。

@ primitive("force")
def scheme_force(obj):
    from eval_apply import scheme_eval

    validate_type(obj, lambda x: is_scheme_promise(x), 0, "stream-force")
    return scheme_eval(obj.expr, obj.env)

我们接下来测试下delayforce

scm> (define pms1 (delay (+ 2 3)))
pms1
scm> pms1
#[promise (not forced)]
scm> (force pms1)
5
scm> (define pms2 (delay (delay (+ 2 3))))
pms2
scm> (force pms2)
#[promise (not forced)]
scm> (force (force pms2))
5

可见对于(delay (delay (+ 2 3)))这种嵌套的延时对象,也需要像剥洋葱一样一层一层地对其进行force

3 流的实现

3.1 构造流

在实现了最基本的延时对象后,我们用它们来构造流。流由特殊形式cons-stream来构造,该特殊形式对应的求值过程如下:

def eval_cons_stream(expr, env):
    validate_form(expr, 2, 2)
    return scheme_cons(scheme_eval(expr.first, env), Promise(expr.rest.first, env))

可见,在实际使用中(cons-stream <a> <b>)等价于(cons <a> (delay <b>)),也即用序对来构造流,不过序对的cdr并非流的剩余部分的求值结果,而是把需要就可以计算的promise放在那里。

现在,我们就可以继续定义基本过程stream-carstream-cdr了:

@primitive("stream-car")
def stream_car(stream):
    validate_type(stream, lambda x: is_stream_pair(x), 0, "stream-car")
    return stream.first

@primitive("stream-cdr")
def stream_cdr(stream):
    validate_type(stream, lambda x: is_stream_pair(x), 0, "stream-cdr")
    return scheme_force(stream.rest)

stream-car选取有关序对的first部分,stream-cdr选取有关序对的cdr部分,并求值这里的延时表达式,以获得这个流的剩余部分。

3.2 流的行为方式

我们接下来看上述实现的行为方式,我们先来分析一下我们上面提到过的区间枚举函数stream-enumerate-interval的例子,不过它现在是以流的方式重新写出:

scm> (define (stream-enumerate-interval low high)
        (if (> low high)
            nil
            (cons-stream
            low
            (stream-enumerate-interval (+ low 1) high))))
stream-enumerate-interval

我们来看一下它如何工作。首先,我们使用该过程定义一个流integers,并尝试直接对其进行求值:

scm> (define integers (stream-enumerate-interval 10000 1000000))
integers
scm> integers
(10000 . #[promise (not forced)])

可见,对于这个流而言,其car100,而其cdr则是Promise延时对象,其意为如果需要,就能枚举出这个区间里更多的东西。

接下来我们尝试连续使用stream-cdr递归地访问流的cdr部分,以枚举区间里的更多数:

scm> (stream-cdr integers)
(10001 . #[promise (not forced)])
scm> (stream-cdr (stream-cdr integers))
(10002 . #[promise (not forced)])

这个过程实际上就像是剥洋葱的过程,相当于一层一层地对嵌套的Promise对象进行force。就像下图[5]所示的那样:

图中的每个红色箭头表示对Promise对象使用一次force

上面展示的是用流去表示有限长度的序列。但令人吃惊的是,我们甚至可以用流去表示无穷长的序列,比如下面我们定义了一个有关正整数的流,这个流就是无穷长的:

scm> (define (integers-starting-from n)
        (cons-stream n (integers-starting-from (+ n 1))))
integers-starting-from
scm> (define integers (integers-starting-from 1))
integers

在任何时刻,我们都只检查到它的有穷部分:

scm> integers
(1 . #[promise (not forced)])
scm> (stream-cdr integers)
(2 . #[promise (not forced)])
scm> (stream-cdr (stream-cdr integers))
(3 . #[promise (not forced)])
...

3.3 针对流的序列操作

目前我们已经完成了流的构造,但想实现第一节提到的sum-primes程序我们还需要针对流的map/filter/reduce操作。我们下面即将介绍针对流的stream-map/stream-filter/stream-reduce过程,它们除了操作对象是流之外,其表现和普通的map/filter/reduce完全相同。

stream-map是与过程map类似的针对流的过程,其定义如下:

@primitive("stream-map", use_env=True)
def stream_map(proc, stream, env):
    from eval_apply import complete_apply
    validate_type(proc, is_scheme_procedure, 0, "map")
    validate_type(stream, is_stream_pair, 1, "map")

    def stream_map_iter(proc, stream, env):
        if is_stream_null(stream):
            return nil
        return scheme_cons(complete_apply(proc, scheme_list(stream_car(stream)
                                                            ), env),
                           stream_map_iter(proc, stream_cdr(stream), env))

    return stream_map_iter(proc, stream, env)

stream_map将对流的car应用过程proc,然后需要进一步将过程proc应用于输入流的cdr,这里对stream_cdr的调用将迫使系统对延时的流进行求值。注意,这里我们为了方便延时,使stream_map函数直接返回用scheme_cons函数构造的普通表,在Scheme的实际实现中返回的仍然是流。

同理,我们可将stream-filterstream-reduce函数定义如下:

@primitive("stream-filter", use_env=True)
def stream_filter(predicate, stream, env):
    from eval_apply import complete_apply
    validate_type(predicate, is_scheme_procedure, 0, "filter")
    validate_type(stream, is_stream_pair, 1, "filter")

    def scheme_filter_iter(predicate, stream, env):
        if is_stream_null(stream):
            return nil
        elif complete_apply(predicate, scheme_list(stream_car(stream)), env):
            return scheme_cons(stream_car(stream),
                               scheme_filter_iter(predicate,
                                                  stream_cdr(stream), env))
        else:
            return scheme_filter_iter(predicate, stream_cdr(stream), env)

    return scheme_filter_iter(predicate, stream, env)


@primitive("stream-reduce", use_env=True)
def stream_reduce(op, stream, env):
    from eval_apply import complete_apply
    validate_type(op, is_scheme_procedure, 0, "reduce")
    validate_type(stream, lambda x: x is not nil, 1, "reduce")
    validate_type(stream, is_stream_pair, 1, "reduce")

    def scheme_reduce_iter(op, initial, stream, env):
        if is_stream_null(stream):
            return initial
        return complete_apply(op, scheme_list(stream_car(stream),
                                              scheme_reduce_iter(op,
                                                                 initial,
                                                                 stream_cdr(
                                                                     stream),
                                                                 env)), env)

    return scheme_reduce_iter(op, stream_car(stream), stream_cdr(stream), env)

以下是对stream-map的一个测试:

scm> (stream-map (lambda (x) (* 2 x))  (stream-enumerate-interval 1 10))
(2 4 6 8 10 12 14 16 18 20)

4 时间的函数式程序观点

流的使用可以让我们用一种新的角度去看对象和状态的问题(参见我的博客《SICP:赋值和局部状态(Python实现)》)。流为模拟具有内部状态的对象提供了另一种方式。可以用一个流去模拟一个变化的量,例如某个对象的内部状态,用流表示其顺序状态的时间史。从本质上说,这里的流将时间显示地表示出来,因此就将被模拟世界里的时间与求值过程中事件发生的顺序进行了解耦(decouple)。

为了进一步对比这两种模拟方式,让我们重新考虑一个“提款处理器”的实现,它管理者一个银行账户的余额。在往期博客中,我们实现了这一处理器的一个简化版本:

scm> (define (make-simplified-withdraw balance)
       (lambda (amount)
         (set! balance (- balance amount))
         balance))
make-simplified-withdraw
scm> (define W (make-simplified-withdraw 25))
w
scm> (W 20)
5
scm> (W 10)
-5

调用make-simplified-withdraw将产生出含有局部状态变量balance的计算对象,其值将在对这个对象的一系列调用中逐步减少。这些对象以amount为参数,返回一个新的余额值。我们可以设想,银行账户的用户送一个输入序列给这种对象,由它得到一系列返回值,显示在某个显示屏幕上。

换一种方式,我们也可以将一个提款处理器模拟为一个过程,它以余额值和一个提款流作为参数,生成账户中顺序余额的流:

(define (stream-withdraw balance amount-stream)
  (cons-stream
   balance
   (stream-withdraw (- balance (stream-car amount-stream))
                    (stream-cdr amount-stream))))

这里stream-withdraw实现了一个具有良好定义的数学函数,其输出完全由输入确定(即不会出现同一个输入输出不一致的情况)。当然,这里假定了输入amount-stream是由用户送来的顺序提款值构成的流,作为结果的余额流将被显示出来。如下展示了根据一个用户的提款流来完成提款的过程:

scm> (define amount (cons-stream 20 (cons-stream 10 nil)))
amount
scm> (define W (stream-withdraw 25 amount))
w
scm> (stream-cdr W)
(5 . #[promise (not forced)])
scm> (stream-cdr (stream-cdr W))
(-5 . #[promise (not forced)])

可见,从送入这些值并观看结果的用户的角度看,这一流过程的行为与由make-simplified-withdraw创建的对象没有什么不同。当然,在这种流方式里没有赋值,没有局部状态变量,因此也就不会有我们在博客《SICP:赋值和局部状态(Python实现)》中所遇到的种种理论困难。但是这个系统也有状态!

这确实是惊人的,虽然stream-withdraw实现了一个定义良好的(well-defined)数学函数,其行为根本不会变化,但用户看到的却是在这里与一个改变着状态的系统交互。事实上,在物理学中也有类似的思想:当我们观察一个正在移动的粒子时,我们说该粒子的位置(状态)正在变化。然而,从粒子的世界线[6]的观点看,这里根本就不涉及任何变化。

我们知道,虽然用带有局部状态变量的对象来对现实世界进行模拟是威力强大且直观的,但对象模型也产生了对于事件的顺序,以及多进程同步的棘手问题。避免这些问题的可能性推动着函数式程序设计语言(functional programming languages) 的开发,这类语言里根本不提供赋值或者可变的(mutable) 数据。在这样的语言里,所有过程实现的都是它们的参数上的定义良好的数学函数,其行为不会变化。FP对于处理并发系统特别有吸引力。事实上Fortran之父John Backus在1978年获得图灵奖的授奖演讲[7]中就曾强烈地推崇FP,而在分布式计算中广泛应用的Map-Reduce并行编程模型[8]以及Spark中的弹性分布式数据集(Resilient Distributed Dataset, RDD)[9]也都受到了FP的影响(关于分布式计算可以参见我的博客《Hadoop:单词计数(Word Count)的MapReduce实现》《Spark:单词计数(Word Count)的MapReduce实现(Java/Python)》)。

但是在另一方面,如果我们贴近观察,就会看到与时间有关的问题也潜入到了函数式模型之中,特别是当我们去模拟一些独立对象之间交互的时候。举个例子,我们再次考虑允许公用账户的银行系统的实现。普通系统系统里将使用赋值和状态,在模拟Peter和Paul共享一个账户时,让他们的交易请求送到同一个银行账户对象。从流的观点看,这里根本就没有什么“对象”,我们已经说明了可以用一个计算过程去模拟银行账户,该过程在一个请求交易的流上操作,生成一个系统响应的流。我们也同样能模拟Peter和Paul有着共同账户的事实,只要将Peter的交易请求流域Paul的请求流归并,并把归并后的流送给那个银行账户过程即可,如下图所示:

这种处理方式的麻烦就在于归并的概念。通过交替地从Peter和Paul的请求中取一个根本不想。假定Paul很少访问这个账户,我们很难强迫Peter去等待Paul。但无论这种归并如何实现,都必须要在某种Peter和Paul看到的“真实时间”之下交错归并这两个交流,这也就类似原书3.4.1节中引入显式同步来确保并发处理中的事件是按照“正确”顺序发生的。这样,虽然这里试图支持函数式的风格来解决问题,但在需要归并来自不同主体的输入时,又会将问题重新引入。

总结一下,如果我们要构造出一些计算模型,使其结构能够符合我们对于被模拟的真实世界的看法,那我们有两种方法:

  • 将这一世界模拟为一集相互分离的、受时间约束的、具有状态的相互交流的对象。

  • 将它模拟为单一的、无时间也无状态的统一体(unity)。

以上两种方案各有其优势,但有没有一种该方法能够令人完全满意。我们还等待着一个大一统的出现。

事实上,对象模型对世界的近似在于将其分割为独立的片段,函数式模型则不沿着对象的边界去做模块化。当“对象”之间不共享的状态远远大于它们所共享的状态时,对象模型就特别好用。这种对象观点失效的一个地方就是量子力学,再那里将物体看作独立的粒子就会导致悖论和混乱。将对象观点与函数式观点合并可能与程序设计的关系不大,而是与基本认识论有关的论题。

5 用惰性求值实现尾递归

所谓尾递归,就是当计算是用一个递归过程描述时,使其仍然能够在常量空间中执行迭代型计算的技术。

我们先来考虑下面这个经典的用递归过程描述的阶乘计算:

(define (factorial n)
  (if (= n 1)
      1
      (* n (factorial (- n 1)))))

我们可以利用原书1.1.5节中介绍的代换模型(substitution model),观看这一过程在计算\(6!\)时所表现出的行为:

可以看出它的代换模型揭示出一种先逐步展开而后收缩的的形状,如上图中的箭头所示。在展开阶段里,这一过程构造起一个推迟进行的操作所形成的链条(在这里是一个乘法*的链条),收缩阶段表现为这些运算的实际执行。这种类型的计算过程由一个推迟执行的运算链条刻画,称为一个递归计算过程。要执行这种计算过程,解释器就需要维护好以后将要执行的操作的轨迹。在这个例子中,推迟执行的乘法链条的长度也就是为保存其轨迹需要的信息量,这个长度和计算中所需的步骤数目一样,都会随着\(n\)线性增长。这样的计算过程称为一个线性递归过程

然而,如果递归调用是整个函数体中最后执行的语句,且它的返回值不属于表达式的一部分,这样就无需保存将要执行的操作轨迹,从而在常数空间内执行迭代型计算,比如下面这个过程:

(define (factorial n)
  (fact-iter 1 1 n))

(define (fact-iter product counter max-count)
  (if (> counter max-count)
      product
      (fact-iter (* counter product)
                 (+ counter 1)
                 max-count)))

我们也用代换模型来查看这一过程在计算\(6!\)时所表现出的行为:

可以看到,该计算过程虽然是用递归描述的,但并没有任何增长或者收缩。对于任何一个\(n\),在计算过程中的每一步,在我们需要保存的轨迹里,所有的东西就是变量productcountermax-count的当前值。我们称这种过程为一个迭代计算过程。一般来说,迭代计算过程就是那种其状态可以用固定数目的状态变量描述的计算过程;而与此同时,又存在着一套固定的规则,描述了计算过程在从一个状态到下一个状态转换时,这些变量的更新方式;还有一个(可能有的)结束检测,它描述了这一计算过程应该终止的条件。在计算\(n!\)时,所需的计算步骤随着\(n\)线性增长,而其使用的空间却是常量的,这种过程称为线性迭代过程

当然,这种当计算用递归过程描述时,仍能够在常量空间中执行迭代型计算的特性依赖于底层解释器的实现,我们将具有这一特性的实现称为尾递归的。有了一个尾递归的实现,我们就可以利用常规的过程调用机制表述迭代,这也会使各种复杂的专用迭代结构变成不过是一些语法糖(syntactic sugar) 了。

接下来我们看如何用前文提到的惰性求值技术来为我们的求值器实现尾递归特性。

乍一看,我们Scheme求值器的scheme_eval()求值函数是用Python语言来递归定义的:

@primitive("eval", use_env=True)
def scheme_eval(expr, env, _=None):
    # Evaluate self-evaluating expressions
    if is_self_evaluating(expr):
        return expr
    # Evaluate variables
    elif is_scheme_variable(expr):
        return env.lookup_variable_value(expr)

    ...
    # Evaluate special forms
    if is_scheme_symbol(first) and first in SPECIAL_FORMS:
        return SPECIAL_FORMS[first](rest, env)
    # Evaluate an application
    else:
        operator = scheme_eval(first, env)
        # Check if the operator is a macro
        if isinstance(operator, MacroProcedure):
            return scheme_eval(complete_apply(operator, rest, env), env)
        operands = rest.map(lambda x: scheme_eval(x, env))
        return scheme_apply(operator, operands, env)

而我们知道,Python是不支持尾递归的,但是求值器又必须要依靠Python以这种递归的方法来编写,那怎么在此基础上为我们的源语言——Scheme语言实现尾递归呢?答案就在于我们之前提到的Promise延时对象。为了和之前的Promise对象做区分(避免干扰到流的工作),我们将其定义为TailPromise对象,它直接继承了Promise类,其表现和Promise对象完全相同:

class TailPromise(Promise):
    """An expression and an environment in which it is to be evaluated."""

这里实现尾递归的诀窍就在于,我们需要使scheme_eval这个过程每次进行递归调用时,都不马上去进行递归,而是返回一个Promise对象,将当前需要求值的表达式expr和环境env暂存起来。之后,我们再在另一个while迭代的循环里去求值这个Promise对象中含有的表达式,此时的求值需要我们再次调用scheme_eval,如果遇到递归又返回一个Promise对象,然后回到之前的那个while迭代循环里再次求值,以此往复。这样,我们就用延时对象+迭代的循环在常量空间里去模拟了递归的求值过程。如下所示:

def optimize_tail_calls(original_scheme_eval):
    def optimized_eval(expr, env, tail=False):
        # If tail is True and the expression is not variable or self-evaluated,
        # return Promise directly, Note that for `optimized_eval`, argument
        # `tail` defaults to False, which means that it is impossible to
        # return Promise at the first call, that is, when the recursion depth
        # is 1
        if tail and not is_scheme_variable(expr) and not is_self_evaluating(
                expr):
            return TailPromise(expr, env)

        # If tail is False or the expression is variable or self-evaluated (
        # which includes the first call of `scheme_eval`), it will be
        # evaluated until the actual value is obtained (instead of Promise)
        result = TailPromise(expr, env)
        while (isinstance(result, TailPromise)):
            # A call to `original_scheme_eval` actually can simulate the
            # recursion depth plus one.
            result = original_scheme_eval(result.expr, result.env)
        return result

    return optimized_eval


# Uncomment the following line to apply tail call optimization
scheme_eval = optimize_tail_calls(scheme_eval)

这里为了不直接修改scheme_eval的内容,使用一个Python闭包的技巧,也就是使optimized_eval成为原始scheme_eval的函数装饰器,从而在其基础上添加尾递归功能并对其进行替代。上述代码实际上就等同于:

from functools import wraps
def optimize_tail_calls(original_scheme_eval):
    @wraps(original_scheme_eval)
    def optimized_eval(expr, env, tail=False):
        ...
        return result

    return optimized_eval

@optimize_tail_calls
@primitive("eval", use_env=True)
def scheme_eval(expr, env, _=None):
    ...

接下来我们测试一下我们求值器的尾递归功能:

scm> (define (sum n total)
            (if (zero? n) total
              (sum (- n 1) (+ n total))))
sum
scm> (sum 1000001 0)
500001500001

可以看到尾递归特性已经成功地实现了。

OK,我们已经实现好了尾递归功能,这依赖于底层惰性求值的实现。但是别忘了,我们有时候是不需要惰性求值,而是需要急切求值的(也即求值结果不能是TailPromise对象)。比如在对MacroProcedure过程对象(该过程对象由宏的定义产生)进行实际应用前,我们需要先将宏的内容进行进行展开,而这就需要我们另外定义一个complete_apply函数:

def complete_apply(procedure, args, env):
    val = scheme_apply(procedure, args, env)
    if isinstance(val, TailPromise):
        return scheme_eval(val.expr, val.env)
    else:
        return val

该函数可在给定环境env下将过程procedure应用到实参arguments,知道结果不是TailPromise对象为止。然后就得到了我们在scheme_eval()函数中对宏的处理方式:

if isinstance(operator, MacroProcedure):
    return scheme_eval(complete_apply(operator, rest, env), env)

注意,scheme-map/scheme-filter/scheme-reducestream-map/stream-filter/stream-reduce这几个基本过程函数要传入一个过程对象为参数,而在这几个函数中对该过程对象的应用就必须得是急切的。这是因为optimize_tail_calls函数中的while迭代循环只能保证map/filter/reduce等基本过程表达式本身得到求值,而对这些基本过程所调用的高阶过程的实际应用是得不到保障的。以map基本过程为例,如果仍使用惰性求值的scheme_apply来完成过程对象的应用:

@ primitive("map", use_env=True)
def scheme_map(proc, items, env):
    ...
    def scheme_map_iter(proc, items, env):
        if is_scheme_null(items):
            return nil
        return scheme_cons(scheme_apply(proc, scheme_list(items.first), env),
                           scheme_map_iter(proc, items.rest, env))

    return scheme_map_iter(proc, items, env)

那么我们将得到如下结果:

scm> (map (lambda (x) (* 2 x))  (list 1 2 3))
(#[promise (not forced)] #[promise (not forced)] #[promise (not forced)])

可以看到map这个基本过程表达式是得到求值了,但其所调用的高阶过程(lambda (x) (* 2 x))并未得到实际应用。

解决之道很简单,在scheme-map/scheme-filter/scheme-reduce几个函数中,对过程对象进行求值时使用complete-apply即可。比如对scheme-map而言,就需要使用complete-apply做如下修改:

@ primitive("map", use_env=True)
def scheme_map(proc, items, env):
    ...
    def scheme_map_iter(proc, items, env):
        if is_scheme_null(items):
            return nil
        return scheme_cons(complete_apply(proc, scheme_list(items.first), env),
                           scheme_map_iter(proc, items.rest, env))

    return scheme_map_iter(proc, items, env)

这样,再对map基本过程进行测试,就能够得到正确的求值结果了:

scm> (map (lambda (x) (* 2 x))  (list 1 2 3))
(2 4 6)

参考

  • [1] Abelson H, Sussman G J. Structure and interpretation of computer programs[M]. The MIT Press, 1996.
  • [2] 8.6 Lazy evaluation
  • [3] Wiki: Lazy evaluation
  • [4] Yet Another Scheme Tutorial: 17. Lazy evaluation
  • [5] Wiki: Futures and promises
  • [6] Wiki: World line
  • [7] Backus J. Can programming be liberated from the von Neumann style? A functional style and its algebra of programs[J]. Communications of the ACM, 1978, 21(8): 613-641.
  • [8] Dean J, Ghemawat S. MapReduce: simplified data processing on large clusters[J]. Communications of the ACM, 2008, 51(1): 107-113.
  • [9] Zaharia M, Chowdhury M, Das T, et al. Resilient distributed datasets: A fault-tolerant abstraction for in-memory cluster computing[C]//Presented as part of the 9th {USENIX} Symposium on Networked Systems Design and Implementation ({NSDI} 12). 2012: 15-28.
posted @ 2023-05-21 22:14  orion-orion  阅读(282)  评论(0编辑  收藏  举报