3.5 Streams - CloneableX/SICP-learning GitHub Wiki
虽然赋值有利于对建模的理解,但同样带来了一些复杂的问题。现在是时候探究其他的实现路径,既保证同样的实现效果,又能避免这些复杂的问题。在本节中,我们将探索另一种对状态建模的方法,它基于一种称为 流(stream) 的数据结构。并且,我们将看到流能够有效降低状态建模的复杂度。
让我们回到状态建模复杂性的根源。在对现实世界建模时,将现实世界中带有本地状态的对象通过局部变量的方式建模。并且通过计算机中的时间变化对现实世界的时间变化进行鉴别。接着通过为带有局部变量的建模对象赋值的方式实现状态随时间变化的建构对象。
是否还有其他实现方式呢?是否能在对世界建模时避免在计算机中对时间的鉴别?是否对现实世界随时变化的事物建模时必须使对象也随着时间变化呢?我们可以基于数学函数思考这些问题。首先通过与时间相关的函数 x(t) 表示随着时间变化的数量 x。如果随着时间的流逝一直观察 x,我们可以认为此数量是变化的。不过,如果我们关注的是数值变化的整个历史时间段,并且不把关注点放在变化上,会发现函数本身是不变的。
如果时间是由多个分离的片段组成,我们便可以将时间相关函数通过序列建模。在本节,我们将看到在表示整个系统历史时间的序列基础上对变化建模的方法。为了实现这一目标,需要引入新的数据结构——流。从抽象的角度看,流就是一个序列。不过,在实现过程中会发现,直接使用列表实现流并不能完全发挥它的全部实力。接着会介绍另一种实现方式—— 延迟计算(delayed evaluation),它为我们提供了通过大型(甚至可达到无限)序列实现流的能力。
流的引入使系统建模可以脱离状态赋值和可变数据。无论对于理论还是实践,这都具有重大意义,因为这既赋予了我们建模的能力,又避免了赋值带来的缺陷。不过,流也有自身的复杂性,所以目前还没有既能提高模块化、使系统更易维护又没有缺陷的完美建模技术。
在之前的章节我们已经学习过,序列可以通过标准接口的方式在程序模块中提供服务。通过这种方式,我们对序列操作进行了抽象,比如 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))
第二段程序通过序列的方法实现。
(define (sum-primes a b)
(accumulate +
0
(filter prime?
(enumerate-interval a b))))
运算过程中,第一个程序只需要存储素数累计的和,但第二个程序需要过滤由 enumerate-interval
构建的一个完整列表。过滤后又将产生另一个新的列表,最终由 accumulate
累计总和。相比之下,第一个程序不需要大量的中间存储,只需要对当前的数字进行素数判断并累加,然后继续迭代即可。
所以,如果按照序列范式通过下列表达式计算 10000 至 1000000 之间的第二个素数将会变得格外麻烦。
(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))
为了实现流的自动创建功能,我们计划通过 cdr
程式进行访问时进行构建运算而不是在 cons-stream
被调用时。我们已经在第 2 章进行了类似实现方式的讨论,当时有理数的约分操作既可以在构造器中实现,也可以在选择器中实现。尽管这两种不同的实现方式都会产生正确的结果,但不同的实现会生成效率上的差异,在列表和序列的实现中也存在类似的情况。作为一种数据抽象,流与列表极其相似,唯一的区别在于计算元素的时机。对于列表,在构建时便需要将 car
和 cdr
的结果计算完成;对于流,只需要在获取元素时计算 cdr
的结果即可。
流的实现将基于特殊形式 delay
。(delay <exp>)
并不会计算表达式 <exp>
,而是返回 延迟对象(delay object),可以将其视作能够在未来时间进行 <exp>
运算的承诺。经常伴随 delay
出现的程式 force
能够接收延迟对象作为参数并执行其中的表达式,实际上是要求 delay
实现它的承诺。后面会剖析 delay
和 force
的实现方式,不过现在先通过这些程式应用流。
cons-stream
是一种特殊形式,语法为 (cons-stream <a> <b>)
,实际上等于 (cons <a> (delay <b>))
。
也就是说,需要通过序对构建流。不过此时并没有将流余下的结果都放置在序对的 cdr
中,而是在序对的 cdr
中放置了一个承诺,当流余下元素被请求时才计算结果返回。所以,stream-car
和 stream-cdr
的实现如下。
(define (stream-car stream) (car stream))
(define (stream-cdr stream) (force (cdr stream)))
stream-car
获取序对的 car
结果,stream-cdr
在获取序对的 cdr
结果后还需要计算被设置延迟计算的表达式,并产生流剩下的元素。
为了实现流相关的操作,先分析一下之前的素数计算例子,我们将用流改写。
(stream-car
(stream-cdr
(stream-filter prime?
(stream-enumerate-interval
10000 1000000))))
表达式中的 stream-enumerate-interval
将 10000 和 1000000 作为参数,并实现与 enumerate-interval
类似的功能。
(define (stream-enumerate-interval low high)
(if (> low high)
the-empty-stream
(cons-stream
low
(stream-enumerate-interval (+ low 1) high))))
上述实现中的 cons-stream
部分等同于下列形式。
(cons 10000
(delay (stream-enumerate-interval 10001 1000000)))
也就是说,stream-enumerate-interval
返回一个通过序对实现的流,其中的 car
值为 10000,而 cdr
值为被请求时将枚举更多结果的一个承诺。接着需要对流进行过滤,使用方式与 filter
类似。
(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
值(也就是序对的 car
值 10000)。如果当前值不是素数,stream-filter
将对 stream-cdr
的结果进行检验。在调用 stream-cdr
时将使 stream-enumerate-interval
的延迟操作强制执行,此时返回的结果为
(cons 10001
(delay (stream-enumerate-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 10010
(delay (stream-enumerate-interval
10011
1000000))))))
其中 stream-car
的值为 10009,此时计算完成。整个筛选中的检测会在必要时运行(比如查找第二个素数结果时),枚举运算也会在素数过滤需要时再次运行。
其实我们可以认为延迟计算就是一种需求驱动的编程,流运算过程中只计算至当前阶段,只有在必要时才进行下一阶段。这种方式能够使事件计算顺序与程式结构解耦。在编写程式时好似流完全存在一样,但实现上整个计算是增量式的,就像传统编程风格一样。
尽管 delay
和 force
看起来很神秘,但它们的实现十分易懂。delay
需要包装一个程式,并在之后按需执行它,其实可以简单地将表达式处理成程式体实现。所以 (delay <exp>)
相当于 (lambda () <exp>)
的语法糖。force
能够调用由 delay
产生的程式,所以可以按如下方式实现 force
。
(define (force delayed-object) (delayed-object))
上述实现已经能够满足 delay
和 force
的使用,不过我们还可以进行一些优化。在许多应用中,需要对同一个延迟对象进行多次调用,这将导致流中的递归程序效率降低。所以需要使延迟对象只在第一次执行时被构建,然后再将计算结果进行存储。后续对同延迟对象的强制执行不再重复计算而是返回存储的结果。也就是说,delay
需要实现为一种特殊目的的缓存程式,要实现此功能需要通过下列程式。它接收一个程式(此程式没有参数)作为参数,并返回该程式的缓存版本。如果缓存的程式是第一次运行则将计算结果进行缓存,在后续的同一程式运算中只需返回缓存结果即可。
(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
的实现改写为 (memo-proc (lambda () <exp>))
。
上一节,我们已经实现了基本的流,它看过来拥有完整的元素,但实际上只计算当前访问时需要的元素。在这种情况下,即使序列很长也可以通过流进行高效的计算。更引人注目的是还可以通过流表示无限长的序列。例如,思考下列正整数流。
(define (integers-starting-from n)
(cons-stream n (integers-starting-from (+ n 1))))
(define integers (integers-starting-from 1))
上述程式看起来很有道理,integers
将产生一个 car
是 1,cdr
是一个以 2 开始的延迟对象。这是一个无限长的流,不过计算时只能对它其中有限的部分操作。所以,程序本身也不会知道整个流并不存在。
通过 integers
还可以定义其他无限流,比如不能被 7 整除的流。
(define (divisible? x y) (= (remainder x y) 0))
(define no-sevens
(stream-filter (lambda (x) (not (divisible? x 7)))
integers))
接着可以按照如下方式访问不被 7 整除的流中的元素。
(stream-ref no-sevens 100)
117
在 integers
的启发下,还可以定义斐波那契无限流。
(define (fibgen a b) (cons-stream a (fibgen b (+ a b))))
(define fibs (fibgen 0 1))
fibs
是一个序对,它的 car
值为 0,cdr
值是一个 (fibge 1 1)
的延迟对象。当计算延迟表达式 (fibgen 1 1)
时,将产生一个新的序对,其中 car
值为 1,而 cdr
是 (fibgen 1 2)
的延迟对象,如此循环下去。
为了见识更加令人激动的无限流,可以将 no-sevens
示例泛化为素数的无限流,这种方式称为 Eratosthenes 筛选。首先需要一个以 2 开头的整数集合,其中 2 作为第一个素数。为了获取余下的素数,首先从余下的整数中过滤掉能够整除 2 的。接着产生一个以 3 开头的流,它便是下一个素数。现在,要过滤掉流中余下数字能够整除 3 的部分,接着产生一个以 5 开头的流,它便是下一个素数,如此反复。也就是说,需要通过筛选的过程产生素数,具体步骤为:对流 S 进行筛选时,需要构建一个以流 S 的首个元素为首个元素,以 S 首个元素之外不被 S 首个元素整除的其他元素组成的流,具体实现如下。
(define (sieve stream)
(cons-stream
(stream-car stream)
(sieve (stream-filter
(lambda (x)
(not (divisible? x (stream-car stream))))
(stream-cdr stream)))))
(define primes (sieve (integers-starting-from 2)))
具体使用如下。
(stream-ref primes 50)
233
将 sieve
视作通信过程系统考虑将十分有趣,如下图。输入流被一个分隔操作将首个元素和流余下元素进行序对分隔。然后流的首个元素被用于构建整除过滤器,它负责过滤流的余下元素,并将结果进行下一轮的素数筛选运算。所以,不止流是无限的,整个信号处理器也是无限的,因为素数筛选中又包含着素数筛选。
integers
和 fibs
都是通过逐步计算流元素的特殊生成器定义的流。除此之外,通过延迟计算的方式还可以定义隐式流。例如,下列表达式定义关于 ones
的无限流。
(define ones (cons-stream 1 ones))
上述表达式的运行方式与递归程式十分相似。ones
是一个 car
值为 1,cdr
为 ones
延迟对象的序对,每次计算 cdr
都会产生一个 1 与 ones
延迟对象组成的序对。
add-streams
能够计算两个流逐个元素的和,通过 add-streams
将产生更加有趣的情况。
(define (add-stream s1 s2) (stream-map + s1 s2))
现在按下列方式实现整数流。
(define integers
(cons-stream 1 (add-streams ones integers)))
integers
是一个由首个元素为 1,其他元素由 ones
与 integers
的和组成的流。所以,integers
的第二个元素是 1 加上 integers
第一个元素,结果为 2;integers
的第三个元素是 1 加上 integers
的第二个元素,结果为 3,以此类推。这个定义之所以有效,是因为在任何时候,都有足够的 integers
流返回以供产生下一个整数。
也可以用同样的方式定义斐波那契数列。
(define fibs
(cons-stream
0
(cons-stream 1 (add-streams (stream-cdr fibs) fibs))))
上述表达式表示 fibs
是一个以 0 和 1 开头的流,流余下的部分通过 fibs
与这自身错位相加产生。
1 1 2 3 5 8 13 21 ...= (stream-cdr fibs)
0 1 1 2 3 5 8 13 ...= fibs
0 1 1 2 3 5 8 13 21 34 ...= fibs
scale-stream
也是一个在定制流定义方面十分有用的程式。它能够将流的每个元素与一个常量相乘。
(define (scale-stream stream factor)
(stream-map (lambda (x) (* x factor))
stream))
具体使用如下:
(define double (cons-stream 1 (scale-stream double 2)))
上述程式的计算结果为 2 的 n 次幂。
素数流也可以通过整数与素数检测过滤器实现。具体实现如下。
(define primes
(cons-stream
2
(stream-filter prime? (integers-starting-from 3))))
上述程式并不像表面上这样简单,因为检测素数的方法是靠判断 n 能否整除小于等于 的素数实现。
(define (prime? n)
(define (iter ps)
(cond ((> (square (stream-car ps)) n) true)
((divisible? n (stream-car ps)) false)
(else (iter (stream-cdr ps)))))
(iter primes))
由于 primes
被 prime?
程式调用,所以这是一个递归定义,也就是说在 primes
流中会调用自身。此程式能够正常运作的原因是无论在何时,primes
流都能够提供满足下一个素数检测所需要的素数。所以,每个进行素数检测的数字 n,只能是素数(此时有一个小于 n 大于 的素数产生)或不是素数(此时有一个能够被它整除的素数产生)。
通过延迟计算实现的流是一种强大的建模工具,它能够带来与本地状态和赋值操作一样的好处。它甚至能够避免一些由于引入赋值操作带来的理论上的问题。
流之所以能够出类拔萃是因为它能够通过模块化构建系统,而不需要系统围绕状态的赋值操作进行构建。例如,我们可以专注于某个时间序列,而不需要专注于某个时刻的状态变量值。这使得在不同时间组合和比较组件成为可能。
在第一章,我们介绍了迭代过程,它能够不断更新状态变量。不过,现在可以通过流表示与时间无关的状态,而不需要不断更新一组状态值。让我们回到平方根的示例中,平方根程式不断生成迫近真实结果的猜想数值的方式计算平方根结果。
(define (sqrt-improve guess x)
(average guess (/ x guess)))
在最初的 sqrt
程式中,猜想数值被实现为连续变化的状态变量。现在,我们通过无限流实现猜想数值。
(define (sqrt-stream x)
(define guesses
(cons-stream
1.0
(stream-map (lambda (guess) (sqrt-improve guess x))
guesses)))
guesses)
(display-stream (sqrt-stream 2))
1.
1.5
1.4166666666666665
1.4142156862745097
1.4142135623746899
...
通过流可以生成越来越逼近平方根结果的猜想值。只要我们愿意,完全可以编写一个程式一直生成新的猜想数值,直到结果满足期望为止。
生成 π 结果的过程也可以使用类似方式改写,它基于下面的等式。
首先要创建计算数列之和的流,然后通过流不断产生的结果与 4 相乘。
(define (pi-summands n)
(cons-stream (/ 1.0 n)
(stream-map - (pi-summands (+ n 2)))))
(define pi-stream
(scale-stream (partial-sums (pi-summands 1)) 4))
(display-stream pi-stream)
4.
2.666666666666667
3.466666666666667
2.8952380952380956
3.3396825396825403
2.9760461760461765
3.2837384837384844
3.017071817071818
...
上述程式提供了能够计算逐渐逼近 π 的流,虽然逼近过程显得有些缓慢。第 8 个计算结果将 π 的结果锁定在 3.284 至 3.017 之间。
根据以上两个示例,通过流实现的结果与更新状态变量并无不同,不过流是通过一个有趣的技巧实现的。例如,我们能够将流转化为序列加速器,它能够将一整个序列转换为一个更适宜的序列,该新序列收敛结果与原始值转换结果相同,只是具有更快的速度。
其中一种加速器由 18 世纪瑞士数学家 Leonhard Euler 实现,它对需要计算和的序列十分适用。在 Euler 的实现中,如果 Sn 是第 n 个原始序列之和的结果,则加速序列满足下列算式。
所以如果原始序列通过流实现,则可以将序列改写为。
(define (euler-transform s)
(let ((s0 (stream-ref s 0)) ; Sn −1
(s1 (stream-ref s 1)) ; Sn
(s2 (stream-ref s 2))) ; Sn+1
(cons-stream (- s2 (/ (square (- s2 s1))
(+ s0 (* -2 s1) s2)))
(euler-transform (stream-cdr s)))))
我们可以对 Euler 的加速公式将产生逼近于 π 的结果进行验证。
(display-stream (euler-transform pi-stream))
3.166666666666667
3.1333333333333337
3.1452380952380956
3.13968253968254
3.1427128427128435
3.1408813408813416
3.142071817071818
3.1412548236077655
...
我们甚至可以在加速序列的基础上不断进行递归加速。也就是说在流的基础上创建一个流,每个流都基于前一个生成,这种结构也可以称为 tableau。
(define (make-tableau transform s)
(cons-stream s (make-tableau transform (transform s))))
tableau 的结构如下。
s00 s01 s02 s03 s04 ...
s10 s11 s12 s13 ...
s20 s21 s22 ...
...
最后,再将 tableau 中每行的第一个元素组合为序列。
(define (accelerated-sequence transform s)
(stream-map stream-car (make-tableau transform s)))
验证进行了更多加速操作的 π 序列。
(display-stream
(accelerated-sequence euler-transform pi-stream))
4.
3.166666666666667
3.142105263157895
3.141599357319005
3.1415927140337785
3.1415926539752927
3.1415926535911765
3.141592653589778
...
计算结果让人印象深刻,因为在第 8 项是已经产生了 π 的 14 位正确小数,如果使用原始的计算程式,需要第 10 的 13 次方个计算结果才能产生与当前精确度相同的结果。
虽然可以不通过流实现加速技术,但流更加优雅也更加方便,因为整个状态序列被完整地提供给我们,并且可以通过统一的操作进行维护。
在第二章讲述了序列范式将传统的嵌套循环定义为序对序列的处理方式。如果将这项技术泛化为无限流,我们编写的程序将不再是简单的循环,而是关于无限集合的循环。
例如,假设符合 i 小于等于 j 并且 i 与 j 之和为素数的所有整数对 (i j)
的计算程式 prime-sum-pairs
通过序对流实现。如果 ini-pairs
是所有整数序对的序列,接下来可以按如下方式实现。
(stream-filter
(lambda (pair) (prime? (+ (car pair) (cadr pair))))
int-pairs)
接下来要解决的问题是创建 int-pairs
流。更通俗地说,假设现在存在两个流 S = (Si),T = (Tj),然后有如下数组矩阵。
(S0,T0)(S0,T1)(S0,T2)...
(S1,T0)(S1,T1)(S1,T2)...
(S2,T0)(S2,T1)(S2,T2)...
...
不过我们希望产生的流是一个包含所有数组序对的斜线排列形式。
(S0,T0)(S0,T1)(S0,T2)...
(S1,T1)(S1,T2)...
(S2,T2)...
...
现在称 (pairs S T)
为常规的序对流,它由三部分组成,分别是序对 (S0, T0)
、第一行除第一个元素外的其他序对,以及除前两部分之外的其他序对。
观察在分解图中的第三部分,它由 (stream-cdr S)
和 (stream-cdr T)
构成,而且根据分解图可以将第一行的余下部分表示为下列表达式。
(stream-map (lambda (x) (list (stream-car s) x))
(stream-cdr t))
因此,我们可以将序对流构建为如下形式。
(define (pairs s t)
(cons-stream
(list (stream-car s) (stream-car t))
(<combine-in-some-way>
(stream-map (lambda (x) (list (stream-car s) x))
(stream-cdr t))
(pairs (stream-cdr s) (stream-cdr t)))))
为了实现整个程式,必须考虑将两个内部流结合起来的方法。一种方法是将流应用于类似 append
的程式。
(define (stream-append s1 s2)
(if (stream-null? s1)
s2
(cons-stream (stream-car s1)
(stream-append (stream-cdr s1) s2))))
不过这对无限流并不适用,因为在结合第二流之前需要获得第一个流的所有元素。特别是通过 (pairs integers integers)
生成所有正整数的序对时,stream-append
将产生序对第一个元素等于 1 的所有序对,因此永远无法将第一个整数值的序对结果产生完。
为了解决无限流的问题,需要制定两个流结合顺序的规则,以此确定在程序运行足够久时最终会获取的每个元素。实现此功能最优雅的方法是使用 interleave
程式。
(define (interleave s1 s2)
(if (stream-null? s1)
s2
(cons-stream (stream-car s1)
(interleave s2 (stream-cdr s1)))))
当 interleave
从两个流中交替获取元素时,第二个流的每个元素都有方法进入交错的流中,即使第一个流是无限流。
于是序对流可以进行如下改写。
(define (pairs s t)
(cons-stream
(list (stream-car s) (stream-car t))
(interleave
(stream-map (lambda (x) (list (stream-car s) x))
(stream-cdr t))
(pairs (stream-cdr s) (stream-cdr t)))))
首先我们将流视作电子信号系统中的电子信号,然后在此基础上进行关于流的讨论。实际上,能够直接使用流建模电子信号处理系统的原因是电子信号数值在时间上的连续与流的元素一样。例如,我们可以实现一个积分器或加法器,输入参数有流 x = (xi),初始值 C 及极小的增量 dt,它们符合下列等式。
等式运算结果为 S = (Si)。下列程式 integral
中嵌入了整数流。
(define (integral integrand initial-value dt)
(define int
(cons-stream initial-value
(add-stream (scale-stream integrand dt)
int)))
int)
下图是表示 integral
的一个电子信号过程。输入流与 dt 相乘,然后通过加法器,并且加法器的输出并返回进入同一个加法器。在 int
中的自引用就是电子信号系统中加法器输出又转回输入的方式。
integral
程式中展示了通过流对电子信号系统循环回调建模的方法。加法器的循环回调由 int
实现。
(define int
(cons-stream
initial-value
(add-streams (scale-stream integrand dt)
int)))
解释器之所以能够如此解析此表达式,是由于 cons-stream
内的 delay
操作。如果没有 delay
操作,解释器便无法在完成 cons-stream
的参数计算前构模 int
。一般来说,delay
是使用流对包含循环的电子信号系统建构的关键,如果没有 delay
电子信号系统在接收输入流时会为输出流完成全量计算,这样便无法实现电子信号系统的循环。
不幸的是,流建模含有循环的系统时需要直接使用 delay
而不是将其隐含于 cons-stream
中使用。例如,下图展示了微分等式 的电子信号系统。图中展示了一个转换组件 f,它的输入流来自于积分器的循环回调。
假设此时提供初始值 y0 作为 y,可以将系统实现为下列程式。
(define (solve f y0 dt)
(define y (integral dy y0 dt))
(define dy (stream-map f y))
y)
上述程式无法正常运作,因为在调用 integral
时需要 dy
已经被定义,但 dy
在 solve
程式的第二行代码才被定义。
另一方面来看,我们的程式定义也符合图示,因为在我们的思维中可以在没有 dy 的情况下生成 y 流。而且 integral
和其它许多流操作一样,都与 cons-stream
类似,只需要提供部分信息就能够生成部分结果。对于 integral
转出流的第一个元素是 initial-value
,所以可以在没有 dy
的情况下生成输出流的第一个元素。一旦 y 的第一个元素产生,stream-map
便能够产生 dy
流的第一个元素,接着生成 y
的第二个元素,如此循环往复。
要按照上述描述实现,需要重新定义 integral
,使积分流变成一个延迟参数。integral
在需要生成输入流的第一个元素时强制积分流进行运算。
(define (integral delayed-integrand initial-value dt)
(define int
(cons-stream
initial-value
(let ((integrand (force delayed-integrand)))
(add-streams (scale-stream integrand dt) int))))
int)
现在通过 dy
的延迟计算重新实现 solve
程式。
(define (solve f y0 dt)
(define y (integral (delay dy) y0 dt))
(define dy (stream-map f y))
y)
一般来说,每次调用 integral
都必须对当前的被积分参数进行延迟。下面我们验证一下 solve
程式在 y = 1
时将向 收敛。
(stream-ref (solve (lambda (y) y)
1
0.001)
1000)
2.716924
本章的示例说明了如何通过 delay
和 force
提供更灵活的程序,但同样也展示了这种方式将使程序更加复杂。在 integral
程式中,展示了对循环系统的建模方法,不过代价是需要记住 integral
中的被积分参数为延迟操作,并且每个程式在调用 integral
时都不得不注意这点。实际上,我们已经创建了两种程式,一种是初始程式,另一种是参数进行延迟操作的程式。通常来说,创建不同种类程式的能力迫使我们能够创建不同种类的高阶程式。
如果要避免两种不同种类程式的需求可以考虑使所有程式都能够接收延迟参数。不过我们需要适应所有程式的参数都自动施加延迟,并在计算需要时自动强制运算的能力。这将转变当前语言中的正常序运算方式。为正常序运算提供一个统一优雅的进行延迟计算的方式,在我们只关注流运算过程时是种常规策略。在第四章,在我们学习了计算器后,我们将懂得如何按上述方式转换我们的语言。不过,将延迟操作包含在程式将大肆破坏依靠事件顺序设计程序的能力,比如程序中的赋值能力、可变数据或对执行输入输出的能力。即使只是在 cons-stream
中含有 delay
也导致很多疑惑,众所周知可变性和延迟计算无法在编程语言中很好的整合,设计同时处理这两种情况的方法是目前研究的活跃领域。
如同之前谈到的,引入赋值操作的好处在于它能够提高系统模块化。流也能够在不使用赋值操作的情况下提供类似的模块化能力。为了说明这种情况,我们打算实现 Monte Carlo 关于 π 的估算。
模块化的关键是需要将生成随机数的程序将每次生成的随机数作为内部状态隐藏起来。首先从 rand-update
程式开始,它能够根据提供的数字产生新随机数,通过它可以实现随机数生成器。
(define rand
(let ((x random-init))
(lambda ()
(set! x (rand-update x))
x)))
在流的实现方式中,并不存在随机数生成器,它只是一个通过连续调用 rand-update
产生的随机数流。
(define random-numbers
(cons-stream
random-init
(stream-map rand-update random-numbers)))
接着利用上述表达式构建 Cesaro 实验的输出流,这个实现需要基于连续随机数对进行。
(define cesaro-stream
(map-successive-pairs
(lambda (r1 r2) (= (gcd r1 r2) 1))
random-numbers))
(define (map-successive-pairs f s)
(cons-stream
(f (stream-car s) (stream-car (stream-cdr s)))
(map-successive-pairs f (stream-cdr (stream-cdr s)))))
cesaro-stream
由 monte-carlo
程式调用,它将产生可能性估算的流,然后再转换为关于 π 估算的流。这个版本的程序不需要传入参数指定实验次数,最终的估算结果通过 pi
流获取即可。
(define (monte-carlo experiment-stream passed failed)
(define (next passed failed)
(cons-stream
(/ passed (+ passed failed))
(monte-carlo
(stream-cdr experiment-stream) passed failed)))
(if (stream-car experiment-stream)
(next (+ passed 1) failed)
(next passed (+ failed 1))))
(define pi
(stream-map
(lambda (p) (sqrt (/ 6 p)))
(monte-carlo cesaro-stream 0 0)))
通过这种方式实现也可以进行合理的模块化,因为 monte-carlo
能够处理任意实验流,而且它并没有对本地状态进行赋值操作。
现在回到本节开头提出的关于对象和状态的问题,我们要从另一角度研究它。通过引入赋值和可变对象,我们拥有了对状态建模系统进行模块化构建的原理。通过本地状态变量构建可计算对象,并使用赋值操作修改这些变量值。然后通过计算对象中的时效性行为对现实世界中的时效性行为建模。
现在,我们通过流提供了另一种对有状态对象建模的方式。通过流表示状态的连续变化结果对对象中的变化属性建模。从本质上来说,我们通过流明确地表示了时间,所以能够使时间从现实世界计算事件发生顺序的建模中解耦。另外,由于 delay
的出现,在时间的模拟和计算事件的顺序间还保持着少量的联系。
为了比较两种不同的建模方式,思考关于提款机的实现。之前我们实现的程式如下。
(define (make-simplified-withdraw balance)
(lambda (amount)
(set! balance (- balance amount))
balance))
调用 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
是用户不断输入的取款数额,计算产生的流是展示的余额。从输入金额的用户角度出发,它观察到的结果与 make-simplified-withdraw
的结果相同。但是在流实现的版本中,没有参数,也没有本地状态变量,更没有引入赋值时遇到的理论上的困难,但是系统还是拥有状态。
这需要格外注意,即使 stream-withdraw
是按照数学函数式实现的,但从用户的角度出发与系统的交互会导致状态的变化。解决这个悖论的方式是将其理解为用户的暂时性存在导致有状态强加于系统。如果用户从交互视角跳出,并且从余额流的角度而不是单个交易的角度看待问题,整个系统便是无状态的。
从复杂流程的某部分看来,其他部分都在随着时间变化,不过随时间变化的状态被它隐藏了起来。如果我们打算编写对现实世界某类事物(从我们的角度出发它就是现实世界的一部分)建模的程序并结构化在计算机中,我们会将其建模为计算对象而不是函数式,因为它们总是随着时间变化。我们通过本地状态对状态建模,将状态的变化通过赋值进行操作。通过这些方式,我们将计算对象运算过程中的时间与我们所处世界的时间对应,因此我们在计算机中得到了对象。
对象建模是强大且直观的,因为这符合我们与当前所处世界的交互。不过,再次思考本章内容时,会发现这种建构方式引出了许多棘手的问题,包括事件顺序的限制和多流程同步顺序的限制。要避免这些问题需要通过函数式编程语言进行建模,这种编程语言不会提供任何赋值操作或可变数据。在这种语言中,所有程式都以数学函数的方式实现,只要所有入参不变化程式的结果也不会变化。这种函数式方法在解决并发系统问题上格外引入注目。
从另一方面来看,仔细观察会发现时间相关的问题也潜伏在函数式建模中。特别是在需要设计一个交互式系统时问题会在独立实体的交互建模上浮现。例如,再次思考存在关联账户的银行系统设计。在传统系统中会使用赋值和对象,将 Peter 和 Paul 的交易请求都通过同一个账户处理以此实现共享账户功能。但从流的视角来看,并不存在对象,银行账户将被建模为一个过程,这个过程会操作交易请求的流,并产生响应的流。最终,通过将 Peter 和 Paul 交易请求流合并,并将合并结果应用于银行账户流便能实现关联账户功能,如下图。
这种实现方式的问题在于 merge
节点,它并不是简单的将 Peter 的交易请求流与 Paul 的交易请求流整合即可。假设 Paul 很少访问账户,我们并不能让 Peter 等到 Paul 访问账户时再进行第二笔交易。而且,在合并操作的实现中,通过将两个交易流交错生成的方式将被 Peter 和 Paul 感知到的实时性限制,就像如果 Peter 和 Paul 开会,它们既能够同意开会前发生的交易,也能够同意开会后发生的交易。这与我们处理同步操作时需要确认并发进程中对象的状态是一样的限制。因此,为了以函数式编程实现此功能,需要为合并不同代理输入的操作中再次引入函数式编程,以期望解决问题。
本章的目标是构建符合我们对现实世界感知的可计算模型。现在,我们能够将现实世界的事物建模为一组分离的、与时间绑定的、通过状态进行交互的对象,也可以将现实世界建模为一个独立的、无时间性的、无状态的单元。每种方式都有自身的优势,但也都不完美,大一统的方法依然尚未出现。