3.5.1 流是延迟的列表

3.5.1 流是延迟的列表
正如我们在2.2.3部分中看到的,序列能够作为组合程序模块的标准接口。
为了操作序列,我人公式化了强大的抽象,例如map,filter,accumulate.
以一种优雅的方式,统一了各种操作。

不幸的是,如果我们用列表来表示序列,这种优雅带来了严重的低效率问题。
也就是我们的计算要求有大量的时间和空间。当我们用列表的转换来表示序列的
操作时,我们的程序必须在流程的每个步骤中,组装和复制数据结构(这个结构可能很巨大)。

为了说明这是真实的,让我们比较两个程序,它们是计算一个区间内的所有的素数的和。
第一个程序以标准的迭代风格写成:
(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))

 第二个程序执行相同的计算使用2.2.3部分中的序列的操作:

 (define (sum-primes a b)
    (accumulate +
                0
               (filter prime? (enumerate-interaval a b)))
 )

在执行计算中,第一个程序仅需要存储被累加的和值.相反的是
在第二个程序中过滤器不能做任何测试直到已经组装成了一个完整
的有特定间隔的数的集合.过滤器生成了另一个列表,它被传递给
累加的程序,这个程序再计算出值.第一个程序不需要如此大的存储量.
我们能认为随着间隔的累加,把每个素数加上去.

通过解释如下的表达式,来计算从1万到1百万之间的第二个素数
,如果我们使用序列的方式,在使用列表的低效率方面,将变得无法
忍受.

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

这个表达式在找到第二个素数,但是计算的天花板是令人
无法容忍的.我们组装了一个几乎有一百万个整数的列表,
通过测试每个元素的素数性来过滤这个列表,并且然后忽略
结果中的绝大部分.在一个更传统的编程风格中,我们交互着
列举枚举值和过滤,当我们到达了第二个素数时,就停了下来.

在允许我们使用序列的操作并且避免了以列表的方式操作序列时
的性能问题方面,流这种方法是很聪明的思想.使用流,我们
到达了两个世界的最佳结合点.我们能以序列操作的方式来优雅
地编写程序,并且实现了增量计算的高效率.基本的思想是只
部分地组装一个流,并且把这个流的部分组装的结果传送到
消费这个流的程序那里.如果消费者企图读取流还没有组装好
的一部分时,为了产生需求的部分,流将自动化组装好那一个
部分的内容.因此保留了整个流都存在的幻觉.换句话说,
我们将写程序好象我们正在处理整个流,我们设计我们的流的实现为
自动化地,透明地交互着流的组装和流的使用.

在表面上看,对于使用流与列表的程序来说,它们仅是名称上
的不同.有一个组装子cons-stream,两个选择子stream-car,
stream-cdr,都满足如下的约束:

(stream-car (cons-stream x y)) = x
(stream-cdr (cons-stream x y)) = y

这有一个特殊的对象,the-empty-stream,它不是任何的cons-stream
操作的结果,它仅能用来判断式stream-null? 因此为了在一个序列中,
表示合计的数据,我们能构建并且使用流,与我们能构建并且
使用列表的方式完成相同。特别是,我们能构建流,与第二章中的
列表操作类似的操作,例如list-ref,map和 for-each:

(define (stream-ref s n)
    (if (= n 0)
        (stream-car s)
        (stream-ref (stream-cdr s) (- n 1)))
)
(define (stream-map proc s)
   (if (stream-null? s)
       the-empty-stream
       (cons-stream (proc (stream-car s))
                    (stream-map proc (stream-cdr s)))
   )
)

(define (stream-for-each proc s)
   (if (stream-null? s)
       'done
       (begin (proc (stream-car s))
              (stream-for-each proc (stream-cdr s))))
)

stream-for-each对于流的显示是很有用的:

(define (display-stream s)
  (stream-for-each display-line s)
)

(define (display-line x)
   (newline)
   (display x)
)

为了让流的实现自动化并且透明地交互流的组装与使用,我们将安排
流的取尾部的操作被执行,是在流的读取尾部的时候,而不是在流的
组装的时候。这种实现的选择,是在2.1.2部分中的有理数的讨论的翻版,
在那一部分中,我们看到了我们能选择实现有理数的分子与分母化简到最
简形式是可以执行在组装的时候,还可以是在读取的时候。这两个有理数
的实现方式,生成了相同的数据抽象,但是选择却在效率上有效果。
在流与普通的列表之间这有相似的关系。作为一个数据抽象,流与列表相同。
不同的是元素被解释执行的时机。对于普通的列表,读取的操作都被执行
在组装的时候,对于流,读取尾部是在读取的操作的时候。

我们的流的实现是将基于一个特殊的操作符叫做delay.
解释(delay <exp>) 没有解释表达式<exp>而是返回了一个叫做"延迟的对象",
我们能认为它是在将来某个时刻时解释<exp>的一个承诺。与delay相伴的是,
有一个叫做force的程序,它以一个延迟的对象为参数,执行解释过程。
从效果上看,它强制delay程序兑现承诺。我们在下面将看到delay 和force
是如何被实现的,但是首先让我们使用它们来组装流。

cons-stream是一个特殊的关键字定义如下:

(cons-stream <a> <b>)
等价于
(cons <a>  (delay <b>))

这的意思是我们使用数对组装流。然而,不是把流的其它部分的值
放入数对的尾部,而是如果被请求的时候,我们把计算其它部分的
承诺放入数对的尾部。stream-car 和stream-cdr 现在被定义为
如下的程序:

(define (stream-car stream)  (car stream))
(define (stream-cdr stream)  (force (cdr stream)))

stream-car选择数对的头部,
stream-cdr选择数对的尾部,解释从流的其它部分中找到的延迟对象。

*在实际中流的实现
为了看一看如何应用这个实现,让我们分析一下我们之前看到的
令人无法容忍的素数的计算,用流的方式重构它:

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

我们能看到它的确工作得很有效率。

我们开始于调用stream-enumerate-interval 以10000 和1000000为参数,
stream-enumerate-interval 是在流中与enumerate-interval(见2.2.3部分)
类似的操作。

(define (stream-enumerate-interval low high)
   (if (> low high)
        the-empty-stream
       (cons-stream low (stream-enumerate-interval (+ low 1) high))
   )   
)
并且因此由stream-enumerate-interval返回的结果是由cons-stream生成的,
是如下的内容:

(cons 10000 (delay (stream-enumerate-interval 10001 1000000)))

也就是stream-enumerate-interval返回了一个由数对表示的流,它的头部是
10000,它的尾部是如果被请求的话,那列举区间中的更多部分的一个承诺。这
个流现在过滤素数,使用在流中与过滤程序相似的操作(见2.2.3部分):

(define  (stream-filter pred stream)
    (cond ((stream-null? stream) the-empty-stream)
          ((pred (stream-car stream))
             (cons-stream (stream-car stream)
                   (stream-filter pred (stream-cdr stream))))
          (else (stream-filter pred (stream-cdr stream)))
    )
)

stream-filter测试流的 stream-car部分,(数对的头部,它是10000)
因为它不是素数,stream-filter然后检查它的输入流的尾部。对stream-cdr
的调用,强制解释延迟的stream-enumerate-interval,现在返回了

(cons 10001 (delay (stream-enumberate-interval 10002 1000000)))

stream-filter 现在检查这个流的stream-car部分,10001,看到这个数也不是素数,
强制下一个stream-cdr,等等。直到stream-enumerate-interval 列出了素数10007,
根据stream-filter,根据它的定义,返回

(cons-stream (stream-car stream)
                      (stream-filter pred (stream-cdr stream)))

在这个例子中是:

(cons 10007
   (delay  (stream-filter  prime?
                                    (cons 10008 
                                              (delay (stream-enumerate-interval 10009 1000000)))))
)

在我们的原始的表达式中,这个结果现在被传递给stream-cdr. 这强制解释延迟的stream-filter,
这进而保持着强制解释延迟的stream-enumerate-interval,直到找到下一个素数,它是10009。
最后,结果传递给了在我们的原始的表达式中的stream-car的是:

(cons 10009
   (delay  (stream-filter  prime?
                                    (cons 10010 
                                              (delay (stream-enumerate-interval 10011 1000000)))))
)

stream-car 返回10009,计算就完成了。为了找到第二个素数,仅有足够多的整数
被测试素数性,这是必要的。并且仅有必要的整数区间被列举出来,并喂给素数过滤器。

总之,我们能认为延迟解释是作为要求驱动的编程,流的过程的任何一个时期被激活,
仅对满足下一个时期是足够的。我们已经完成的事是从我们的程序的明显的结构中,
解释在计算过程的事件的实际的顺序。我们写程序,好像是整已经完整地存在了,实际上,
计算以增量的方式在执行着,正如传统的编程风格的方式一样。

*实现延迟和强制
尽管延迟和强制可能是看起来有点神秘的操作,它们的实现是真的很自然的。
延迟必须是打包一个表达式,为了让它能按要求在之后的时候被解释,我们能完成
这个任务,通过把表达式当成是程序的程序体。延迟是一个关键字如下:

(delay <exp>)
是如下的表达式的语法糖
(lambda () <exp>)

强制仅是简单调用被延迟生成的没有参数的程序,所有我们能实现强制,作为一个程序:

(define (force delayed-object)
   (delayed-object)
)

作为广告宣传来说,这个实现已经满足了延迟与强制的工作要求,但是这个有一个
我们能包含的重要的优化。 在许多的应用程序中,我们需要强制相同的延迟对象
许多次。在引用流的递归程序中,这能导致很严重的性能问题(见练习3.57)。
为了构建延迟的对象的解决方案是在它们被第一次强制执行时,存储它的被计算的值。
在以后的强制执行时,仅简单地返回存储的值,而不是重复地计算。换句话说,我们实现
延迟作为一个特定目的存储程序,与练习3.27中的描述是相似的。完成这个任务的一个方式是
使用如下的程序。有记忆的程序在第一次运行时,它保存计算结果,在接下来的解释中,
它简单地返回结果。

(define (memo-proc proc)
   (let  ((already-run? false)  (result false))
        (lambda ()
                     (if  (not already-run?)
                          (begin (set! result (proc))
                                     (set!  already-run? true) result)
                          result))
   )
)

延迟然后定义,(delay <exp>)等价于

(memo-proc (lambda   ()   <exp>))

并且强制的定义如同之前的定义。

练习3.50
完成下面的定义,它生成了stream-map,允许程序有多个参数,与2.2.3部分中的map相似。
(define (stream-map proc . argstreams)
      (if (<??>   argstreams) 
           the-empty-stream
           (<??>
                 (apply proc (map <??> argstreams))
                 (apply stream-map
                           (cons       proc (map <??> argstreams)))
            )
       )
)

练习3.51
为了更详细地了解延迟的解释,我们将使用如下的程序,它简单地返回打印后的它的参数:

(define (show x)
     (display-line x)
)

在如下 的序列中,解释器在解释每个表达式时,它打印的内容是什么?

(define x (stream-map show (stream-enumerate-interval 0 10)))
(stream-ref x 5)
(stream-ref x 7)

练习3.52
考虑如下的表达式:

(define sum 0)
(define  (accum x)
(set! sum (+ x sum))
sum)

(define seq (stream-map accum (stream-enumerate-interval 1 20)))
(define y (stream-filter even?  seq))
(define z (stream-filter (lambda (x)  (= (remainder x 5)  0))   
                                     seq))

(stream-ref  y 7)

(display-stream  z)

在上面的每个表达式被解释后,sum的值是什么? 在解释了stream-ref 和display-stream
之后,打印的内容是什么? 如果我们实现 (delay <exp>)为简单的(lambda () <exp>)
而没有使用memo-proc提供的优化,那么这些响应的结果会有什么不同吗?请解释。

猜你喜欢

转载自blog.csdn.net/gggwfn1982/article/details/82386669
今日推荐