nus-cs1101s-sicp-js-merge-3

NUS CS1101S:SICP JavaScript 描述(四)

原文:SICP JavaScript

译者:飞龙

协议:CC BY-NC-SA 4.0

如何保持两个帐户的本地状态不同?acc和acc2之间共享环境结构的哪些部分?

更多关于块的内容

正如我们所看到的,sqrt中声明的名称的作用域是sqrt的整个主体。这解释了为什么相互递归可以工作,就像这种(相当浪费的)检查非负整数是否为偶数的方式一样。

function f(x) {
    function is_even(n) {
        return n === 0
               ? true
               : is_odd(n - 1);
    }
    function is_odd(n) {
        return n === 0
               ? false
               : is_even(n - 1);
    }
    return is_even(x);
}

当在调用f期间调用is_even时,环境图看起来像调用sqrt_iter时的图 3.11 中的图。函数is_even和is_odd在 E2 中绑定到指向 E2 的环境中调用这些函数的函数对象。因此,is_even中的is_odd指的是正确的函数。尽管is_odd在is_even之后定义,但这与sqrt_iter的主体中improve和sqrt_iter本身指向正确的函数没有区别。

有了处理块内声明的方法,我们可以重新审视顶层的名称声明。在 3.2.1 节中,我们看到在顶层声明的名称被添加到程序框架中。更好的解释是整个程序被放置在一个隐式块中,在全局环境中进行求值。上面描述的块的处理然后处理顶层:全局环境通过包含隐式块中声明的所有名称的绑定的框架进行扩展。该框架是程序框架,结果环境是程序环境。

我们说一个块的主体在一个包含在块主体中直接声明的所有名称的环境中进行求值。当进入块时,局部声明的名称被放入环境中,但没有关联的值。在求值块主体时,其声明的求值将名称分配给右边的表达式的结果,就好像声明是一个赋值一样。由于名称添加到环境中是与声明的求值分开的,整个块都在名称的范围内,一个错误的程序可能会在其声明被求值之前尝试访问名称的值;未分配名称的求值会发出错误信号。¹⁸

3.3 用可变数据建模

第 2 章讨论了复合数据作为构建计算对象的手段,这些对象有几个部分,以模拟具有多个方面的现实世界对象。在该章中,我们介绍了数据抽象的学科,根据这一学科,数据结构是根据构造函数来指定的,构造函数创建数据对象,选择器访问复合数据对象的部分。但是现在我们知道第 2 章没有涉及的数据的另一个方面。希望模拟由具有不断变化状态的对象组成的系统,这导致我们需要修改复合数据对象,以及构造和从中选择。为了模拟具有不断变化状态的复合对象,我们将设计数据抽象,以包括除选择器和构造函数之外的操作,称为变异器,这些操作修改数据对象。例如,模拟银行系统需要我们改变账户余额。因此,用于表示银行账户的数据结构可能允许一个操作

set_balance(account, new-value)

更改指定帐户的余额为指定的新值的操作。定义了突变器的数据对象称为可变数据对象。

第 2 章介绍了对偶对作为合成复合数据的通用“粘合剂”。我们从定义对偶对的基本突变器开始这一部分,以便对偶对可以作为构造可变数据对象的构建块。这些突变器极大地增强了对偶对的表示能力,使我们能够构建除了我们在第 2.2 节中使用的序列和树之外的数据结构。我们还提供了一些模拟的示例,其中复杂系统被建模为具有局部状态的对象集合。

3.3.1 可变列表结构

对对的基本操作——pair、head和tail——可以用来构造列表结构和从列表结构中选择部分,但它们无法修改列表结构。到目前为止,我们使用的列表操作也是如此,比如append和list,因为这些可以用pair、head和tail来定义。要修改列表结构,我们需要新的操作。

对于对来说,原始的修改器是set_head和set_tail。函数set_head接受两个参数,第一个参数必须是对。它修改这个对,用set_head的第二个参数的指针替换head指针。¹⁹

例如,假设x绑定到list(list("a", "b"), "c", "d"),y绑定到list("e", "f"),如图 3.12 所示。求值表达式set_head(x, y)修改了x绑定的对,用y的值替换了它的head。操作的结果如图 3.13 所示。结构x已被修改,现在等价于list(list("e", "f"), "c", "d")。代表列表list("a", "b")的对,由被替换的指针标识,现在已从原始结构中分离。²⁰

c3-fig-0012.jpg

图 3.12 列表x:list(list("a", "b"), "c", "d")和y:list("e", "f")。

c3-fig-0013.jpg

图 3.13 set_head(x, y)对图 3.12 中的列表的影响。

将图 3.13 与图 3.14 进行比较,它说明了执行的结果

const z = pair(y, tail(x));

x和y绑定到图 3.12 中的原始列表。现在,名称z绑定到由pair操作创建的新对;x绑定的列表保持不变。set_tail操作类似于set_head。唯一的区别是用tail指针替换对的head指针。在图 3.12 中执行set_tail(x, y)的效果如图 3.15 所示。这里,x的tail指针已被替换为指向list("e", "f")。此外,曾经是x的tail的列表list("c", "d")现在已从结构中分离。

c3-fig-0014.jpg

图 3.14 const z = pair(y, tail(x));对图 3.12 中的列表的影响。

c3-fig-0015.jpg

图 3.15 set_tail(x, y)对图 3.12 中的列表的影响。

函数pair通过创建新的对来构建新的列表结构,而set_head和set_tail修改现有的对。事实上,我们可以使用这两个修改器来实现pair,再加上一个get_new_pair函数,它返回一个不属于任何现有列表结构的新对。我们获得新对,将其head和tail指针设置为指定的对象,并将新对作为pair的结果返回。²¹

function pair(x, y) {
    const fresh = get_new_pair();
    set_head(fresh, x);
    set_tail(fresh, y);
    return fresh;
}
练习 3.12

在 2.2.1 节中引入了以下用于追加列表的函数:

function append(x, y) {
    return is_null(x)
           ? y
           : pair(head(x), append(tail(x), y));
}

函数append通过将x的元素依次添加到y的前面来形成一个新的列表。函数append_mutator类似于append,但它是一个修改器而不是构造器。它通过将它们拼接在一起来追加列表,修改x的最后一个对,使其tail现在是y。(使用空的x调用append_mutator是一个错误。)

function append_mutator(x, y) {
    set_tail(last_pair(x), y);
    return x;
}

这里last_pair是一个返回其参数中的最后一个对的函数:

function last_pair(x) {
    return is_null(tail(x))
          ? x
          : last_pair(tail(x));
}

考虑交互

const x = list("a", "b");

const y = list("c", "d");

const z = append(x, y);

z;
["a", ["b", ["c", ["d, null]]]]

tail(x);
response

const w = append_mutator(x, y);

w;
["a", ["b", ["c", ["d", null]]]]

tail(x);
response

缺少的response是什么?绘制框和指针图来解释你的答案。

练习 3.13

考虑以下make_cycle函数,它使用了练习 3.12 中定义的last_pair函数:

function make_cycle(x) {
    set_tail(last_pair(x), x);
    return x;
}

绘制一个框和指针图,显示由z创建的结构

const z = make_cycle(list("a", "b", "c"));

如果我们尝试计算last_pair(z)会发生什么?

练习 3.14

以下功能非常有用,尽管有些晦涩:

function mystery(x) {
    function loop(x, y) {
        if (is_null(x)) {
            return y;
        } else {
            const temp = tail(x);
            set_tail(x, y);
            return loop(temp, x);
        }
    }
    return loop(x, null);
}

函数loop使用“临时”名称temp来保存x的tail的旧值,因为下一行的set_tail会破坏tail。解释mystery一般是做什么的。假设v由以下定义

const v = list("a", "b", "c", "d");

绘制代表v绑定的列表的框和指针图。假设我们现在求值

const w = mystery(v);

绘制框和指针图,显示在求值此程序后v和w的结构。v和w的值将打印为什么?

共享和身份

我们在 3.1.3 节中提到了由赋值引入的“相同”和“改变”的理论问题。当不同的数据对象之间共享个别成对时,这些问题在实践中会出现。例如,考虑以下结构形成的结构

const x = list("a", "b");
const z1 = pair(x, x);

如图 3.16 所示,z1是一个head和tail都指向同一个x的成对。z1的head和tail共享x是pair实现的直接方式的结果。一般来说,使用pair构造列表将导致成对的交织结构,其中许多单个成对被许多不同的结构共享。

c3-fig-0016.jpg

图 3.16 由pair(x, x)形成的列表z1。

与图 3.16 相比,图 3.17 显示了由此创建的结构

const z2 = pair(list("a", "b"), list("a", "b"));

在这个结构中,两个list("a", "b")列表中的成对是不同的,尽管它们包含相同的字符串。²²

c3-fig-0017.jpg

图 3.17 由pair(list("a", "b"), list("a", "b"))形成的列表z2。

当被视为列表时,z1和z2都代表“相同”的列表:

list(list("a", "b"), "a", "b")

一般来说,如果我们只使用pair,head和tail在列表上操作,共享是完全不可检测的。但是,如果我们允许在列表结构上使用变异器,共享就变得重要。作为共享可能产生的差异的一个例子,考虑以下函数,该函数修改了应用于它的结构的head:

function set_to_wow(x) {
    set_head(head(x), "wow");
    return x;
}

尽管z1和z2是“相同”的结构,但将set_to_wow应用于它们会产生不同的结果。对于z1,改变head也会改变tail,因为在z1中head和tail是相同的成对。对于z2,head和tail是不同的,因此set_to_wow只修改head:

z1;
[["a", ["b", null]], ["a", ["b", null]]]

set_to_wow(z1);
[["wow", ["b", null]], ["wow", ["b", null]]]

z2;
[["a", ["b", null]], ["a", ["b", null]]]

set_to_wow(z2);
[["wow", ["b", null]], ["a", ["b", null]]]

检测列表结构中的共享的一种方法是使用原始谓词===,我们在 1.1.6 节中引入了它来测试两个数字是否相等,并在 2.3.1 节中扩展了它来测试两个字符串是否相等。当应用于两个非原始值时,x === y测试x和y是否是相同的对象(即x和y是否作为指针相等)。因此,对于图 3.16 和 3.17 中定义的z1和z2,head(z1) === tail(z1)为真,head(z2) === tail(z2)为假。

如下一节所示,我们可以利用共享来大大扩展可以由成对表示的数据结构的范围。另一方面,共享也可能是危险的,因为对结构所做的修改也会影响其他恰好共享修改部分的结构。变异操作set_head和set_tail应该谨慎使用;除非我们对数据对象的共享有很好的理解,否则变异可能会产生意想不到的结果。²³

练习 3.15

绘制框和指针图,解释set_to_wow对上述z1和z2结构的影响。

练习 3.16

Ben Bitdiddle 决定编写一个函数来计算任何列表结构中的成对数。“很容易”,他推理道。“任何结构中的成对数是head中的数加上tail中的数再加一来计算当前的成对数。”于是 Ben 写下了以下函数

function count_pairs(x) {
    return ! is_pair(x)
           ? 0
           : count_pairs(head(x)) +
             count_pairs(tail(x)) + 1;
}

展示这个函数是不正确的。特别是,绘制盒和指针图,表示由恰好三对组成的列表结构,Ben 的函数将返回 3;返回 4;返回 7;根本不返回。

练习 3.17

设计练习 3.16 中count_pairs函数的正确版本,该函数返回任何结构中不同对的数量。(提示:遍历结构,维护一个辅助数据结构,用于跟踪已经计数的对。)

练习 3.18

编写一个函数,检查列表并确定它是否包含循环,也就是说,一个试图通过连续的tail找到列表末尾的程序会进入无限循环。练习 3.13 构建了这样的列表。

练习 3.19

使用仅占用恒定空间的算法重新执行练习 3.18。(这需要一个非常聪明的想法。)

突变只是赋值

当我们引入复合数据时,我们在 2.1.3 节中观察到,对可以纯粹用函数表示:

function pair(x, y) {
    function dispatch(m) {
    return m === "head"
           ? x
           : m === "tail"
           ? y
           : error(m, "undefined operation – pair");
    }
    return dispatch;
}
function head(z) { return z("head"); }
function tail(z) { return z("tail"); }

对于可变数据,同样的观察是正确的。我们可以使用赋值和本地状态将可变数据对象实现为函数。例如,我们可以扩展上面的对实现,以处理set_head和set_tail,类似于我们在 3.1.1 节中使用make_account实现银行账户的方式:

function pair(x, y) {
    function set_x(v) { x = v; }
    function set_y(v) { y = v; }
    return m => m === "head"
                ? x
                : m === "tail"
                ? y
                : m === "set_head"
                ? set_x
                : m === "set_tail"
                ? set_y
                : error(m, "undefined operation – pair");
}
function head(z) { return z("head"); }
function tail(z) { return z("tail"); }
function set_head(z, new_value) {
    z("set_head")(new_value);
    return z;
}
function set_tail(z, new_value) {
    z("set_tail")(new_value);
    return z;
}

理论上,只需要赋值就可以解释可变数据的行为。一旦我们承认在我们的语言中进行赋值,我们就提出了所有问题,不仅是赋值的问题,而且是可变数据的问题。²⁴

练习 3.20

绘制环境图来说明语句序列的求值

const x = pair(1, 2);
const z = pair(x, x);
set_head(tail(z), 17);

head(x);
17

使用上面给出的对的函数实现。(比较练习 3.11。)

3.3.2 表示队列

改变器set_head和set_tail使我们能够使用对来构建不能仅用pair、head和tail构建的数据结构。本节展示了如何使用对来表示称为队列的数据结构。3.3.3 节将展示如何表示称为表的数据结构。

队列是一个序列,其中项目被插入到一端(称为队列的后端),并从另一端(前端)删除。图 3.18 显示了一个最初为空的队列,其中插入了项目a和b。然后移除了a,插入了c和d,并移除了b。因为项目总是按照插入的顺序移除,所以队列有时被称为FIFO(先进先出)缓冲区。

c3-fig-0018.jpg

图 3.18 队列操作。

在数据抽象方面,我们可以将队列视为以下一组操作定义:

  • 一个构造器:

    make_queue()

    返回一个空队列(不包含任何项目的队列)。

  • 一个谓词:

    is_empty_queue(queue)

    测试队列是否为空。

  • 一个选择器:

    front_queue(queue)

    返回队列前端的对象,如果队列为空则发出错误信号;它不修改队列。

  • 两个改变器:

    insert_queue(queue, item)

    在队列的后端插入项目,并将修改后的队列作为其值返回。

    delete_queue(queue)

    移除队列前端的项目,并返回修改后的队列作为其值,如果在删除前队列为空,则发出错误信号。

因为队列是一系列项目,我们当然可以将其表示为普通列表;队列的前端将是列表的head,在队列中插入项目将相当于在列表末尾添加一个新元素,从队列中删除项目只是取列表的tail。然而,这种表示是低效的,因为为了插入一个项目,我们必须扫描列表直到达到末尾。由于我们扫描列表的唯一方法是通过连续的tail操作,因此对于n个项目的列表,这种扫描需要Θ(n)步骤。通过对列表表示的简单修改,可以克服这个缺点,使得队列操作可以实现为需要Θ(1)步骤;也就是说,需要的步骤数与队列的长度无关。

列表表示的困难之处在于需要扫描以找到列表的末尾。我们需要扫描的原因是,尽管将列表表示为一对对的链表是标准的方法,它很容易为我们提供指向列表开头的指针,但它并没有为我们提供指向末尾的指针。避免这个缺点的修改是将队列表示为列表,以及一个额外的指针,指示列表中的最后一对。这样,当我们要插入一个项目时,我们可以查看后指针,从而避免扫描列表。

然后,队列表示为一对指针,front_ptr和rear_ptr,分别指示普通列表中的第一对和最后一对。由于我们希望队列是一个可识别的对象,我们可以使用pair来组合这两个指针。因此,队列本身将是这两个指针的pair。图 3.19 说明了这种表示。

c3-fig-0019.jpg

图 3.19 将队列实现为具有前端和后端指针的列表。

为了定义队列操作,我们使用以下函数,这些函数使我们能够选择和修改队列的前端和后端指针:

function front_ptr(queue) { return head(queue); }
function rear_ptr(queue) { return tail(queue); }
function set_front_ptr(queue, item) { set_head(queue, item); }
function set_rear_ptr(queue, item) { set_tail(queue, item); }

现在我们可以实现实际的队列操作。如果队列的前端指针是空列表,我们将考虑队列为空:

function is_empty_queue(queue) { return is_null(front_ptr(queue)); }

make_queue构造函数返回一个最初为空的队列,其head和tail都是空列表:

function make_queue() { return pair(null, null); }

要选择队列前端的项目,我们返回由前端指针指示的对的head:

function front_queue(queue) {
    return is_empty_queue(queue)
           ? error(queue, "front_queue called with an empty queue")
           : head(front_ptr(queue));
}

要在队列中插入一个项目,我们遵循图 3.20 中指示的结果的方法。我们首先创建一个新的对,其head是要插入的项目,其tail是空列表。如果队列最初为空,我们将队列的前端和后端指针设置为这个新对。否则,我们修改队列中的最后一对,使其指向新对,并且将后端指针设置为新对。

function insert_queue(queue, item) {
    const new_pair = pair(item, null);
    if (is_empty_queue(queue)) {
        set_front_ptr(queue, new_pair);
        set_rear_ptr(queue, new_pair);
    } else {
        set_tail(rear_ptr(queue), new_pair);
        set_rear_ptr(queue, new_pair);
    }
    return queue;
}

c3-fig-0020.jpg

图 3.20 在图 3.19 的队列上使用insert_queue(q, "d")的结果。

要删除队列前端的项目,我们只需修改前端指针,使其现在指向队列中的第二个项目,可以通过跟随第一个项目的tail指针找到(参见图 3.21):²⁵

function delete_queue(queue) {
    if (is_empty_queue(queue)) {
        error(queue, "delete_queue called with an empty queue");
    } else {
        set_front_ptr(queue, tail(front_ptr(queue)));
        return queue;
    }
}

c3-fig-0021.jpg

图 3.21 在图 3.20 的队列上使用delete_queue(q)的结果。

练习 3.21

Ben Bitdiddle 决定测试上述队列实现。他将函数输入 JavaScript 解释器,并开始尝试它们:

const q1 = make_queue();

insert_queue(q1, "a");
[["a", null], ["a", null]]

insert_queue(q1, "b");
[["a", ["b", null]], ["b", null]]

delete_queue(q1);
[["b", null], ["b", null]]

delete_queue(q1);
[null, ["b", null]]

“这全都错了!”他抱怨道。“解释器的响应显示最后一个项目被插入队列两次。当我删除两个项目时,第二个b仍然存在,所以队列不是空的,尽管它应该是。”Eva Lu Ator 建议 Ben 误解了发生了什么。“不是项目被插入队列两次,”她解释道。“只是标准的 JavaScript 打印机不知道如何理解队列表示。如果你想正确打印队列,你必须为队列定义自己的打印函数。”解释 Eva Lu 所说的。特别是,说明 Ben 的示例产生了它们所产生的打印结果。定义一个函数print_queue,该函数以队列作为输入并打印队列中的项目序列。

练习 3.22

我们可以将队列表示为具有本地状态的函数,而不是将队列表示为一对指针。本地状态将包括指向普通列表的开头和结尾的指针。因此,make_queue函数将具有以下形式

function make_queue() {
    let front_ptr = ...;
    let rear_ptr = ...;
    〈declarations of internal functions〉
    function dispatch(m) {...}
    return dispatch;
}

完成make_queue的定义,并使用此表示提供队列操作的实现。

练习 3.23

双端队列(“deque”)是一个序列,其中项目可以在前端或后端插入和删除。双端队列的操作包括构造函数make_deque,谓词is_empty_deque,选择器front_deque和rear_deque,以及变异器front_insert_deque,front_delete_deque,rear_insert_deque和rear_delete_ deque。展示如何使用对表示 deque,并给出操作的实现。²⁶所有操作应在Θ(1)步骤中完成。

3.3.3 表示表

当我们在第 2 章研究了各种表示集合的方式时,在第 2.3.3 节中提到了通过识别键索引的记录表的维护任务。在第 2.4.3 节中的数据导向编程的实现中,我们广泛使用了二维表,其中使用两个键存储和检索信息。在这里,我们看到如何将表构建为可变列表结构。

首先考虑一维表,其中每个值都存储在单个键下。我们将表实现为记录的列表,每个记录都实现为一个由键和相关值组成的对。这些记录通过将head指向连续记录的对粘合在一起形成列表。这些粘合对被称为表的支柱。为了在向表中添加新记录时有一个可以更改的位置,我们将表构建为头列表。头列表在开头有一个特殊的支柱对,其中包含一个虚拟的“记录”——在这种情况下是任意选择的字符串"table"。图 3.22 显示了表的盒子和指针图。

a: 1
b: 2
c: 3

c3-fig-0022.jpg

图 3.22 以头列表形式表示的表。

为了从表中提取信息,我们使用lookup函数,该函数以键作为参数并返回相关值(如果在该键下没有存储值,则返回undefined)。lookup函数是根据assoc操作定义的,该操作期望键和记录列表作为参数。请注意,assoc从不看到虚拟记录。assoc函数返回具有给定键作为head的记录。²⁷然后lookup函数检查assoc返回的结果记录是否不是undefined,并返回记录的值(tail)。

function lookup(key, table) {
    const record = assoc(key, tail(table));
    return is_undefined(record)
           ? undefined
           : tail(record);
}
function assoc(key, records) {
    return is_null(records)
           ? undefined
           : equal(key, head(head(records)))
           ? head(records)
           : assoc(key, tail(records));
}

要在指定的键下向表中插入一个值,我们首先使用assoc来查看表中是否已经存在具有该键的记录。如果没有,我们通过将键与值进行pair形成一个新记录,并将其插入到表的记录列表的头部(在虚拟记录之后)。如果已经存在具有该键的记录,我们将该记录的tail设置为指定的新值。表的标题为我们提供了一个固定的位置,以便插入新记录。²⁸

function insert(key, value, table) {
    const record = assoc(key, tail(table));
    if (is_undefined(record)) {
        set_tail(table,
                 pair(pair(key, value), tail(table)));
    } else {
        set_tail(record, value);
    }
    return "ok";
}

要构造一个新表,我们只需创建一个包含字符串"table"的列表:

function make_table() {
    return list("table");
}
二维表

在二维表中,每个值都由两个键索引。我们可以将这样的表构造为一个一维表,其中每个键都标识一个子表。图 3.23 显示了该表的框和指针图。

"math":
    "+": 43
    "-": 45
    "*": 42
"letters":
    "a": 97
    "b": 98

该对象有两个子表。(子表不需要特殊的标题字符串,因为标识子表的键就起到了这个作用。)

c3-fig-0023.jpg

图 3.23 二维表。

当我们查找一个项目时,我们使用第一个键来标识正确的子表。然后我们使用第二个键来标识子表中的记录。

function lookup(key_1, key_2, table) {
    const subtable = assoc(key_1, tail(table));
    if (is_undefined(subtable)) {
        return undefined;
    } else {
        const record = assoc(key_2, tail(subtable));
        return is_undefined(record)
               ? undefined
               : tail(record);
    }
}

要在一对键下插入一个新项目,我们使用assoc来查看是否已经存储了第一个键下的子表。如果没有,我们构建一个包含单个记录(key_2,value)的新子表,并将其插入到第一个键下的表中。如果有一个

如果第一个键的子表已经存在,我们将使用上面描述的一维表的插入方法将新记录插入到该子表中:

function insert(key_1, key_2, value, table) {
    const subtable = assoc(key_1, tail(table));
    if (is_undefined(subtable)) {
        set_tail(table,
                 pair(list(key_1, pair(key_2, value)), tail(table)));
    } else {
        const record = assoc(key_2, tail(table));
        if (is_undefined(record)) {
            set_tail(subtable,
                     pair(pair(key_2, value), tail(subtable)));
        } else {
            set_tail(record, value);
        }
    }
    return "ok";
}
创建本地表

上面定义的lookup和insert操作将表作为参数。这使我们能够使用访问多个表的程序。处理多个表的另一种方法是为每个表单独拥有lookup和insert函数。我们可以通过过程化地表示一个表来实现这一点,将其作为一个对象,该对象将内部表作为其本地状态的一部分。当发送适当的消息时,这个“表对象”提供用于在内部表上操作的函数。以下是以这种方式表示的二维表的生成器:

function make_table() {
    const local_table = list("table");
    function lookup(key_1, key_2) {
        const subtable = assoc(key_1, tail(local_table));
        if (is_undefined(subtable)) {
            return undefined;
        } else {
            const record = assoc(key_2, tail(subtable));
            return is_undefined(record)
                   ? undefined
                   : tail(record);
        }
    }
    function insert(key_1, key_2, value) {
        const subtable = assoc(key_1, tail(local_table));
        if (is_undefined(subtable)) {
            set_tail(local_table,
                     pair(list(key_1, pair(key_2, value)),
                          tail(local_table)));
        } else {
            const record = assoc(key_2, tail(subtable));
            if (is_undefined(record)) {
                set_tail(subtable,
                         pair(pair(key_2, value), tail(subtable)));
            } else {
                set_tail(record, value);
            }
        }
    }
    function dispatch(m) {
        return m === "lookup"
               ? lookup
               : m === "insert"
               ? insert
               : error(m, "unknown operation – table");
    }
    return dispatch;
}

使用make_table,我们可以实现第 2.4.3 节中用于数据导向编程的get和put操作,如下所示:

const operation_table = make_table();
const get = operation_table("lookup");
const put = operation_table("insert");

函数get以两个键作为参数,put以两个键和一个值作为参数。这两个操作都访问同一个本地表,该表封装在通过调用make_table创建的对象中。

练习 3.24

在上面的表实现中,使用equal(由assoc调用)来测试键的相等性。这并不总是适当的测试。例如,我们可能有一个具有数字键的表,在这种情况下,我们不需要与我们查找的数字完全匹配,而只需要在某个公差范围内的数字。设计一个表构造函数make_table,它以一个same_key函数作为参数,该函数将用于测试键的“相等性”。函数make_table应返回一个dispatch函数,该函数可用于访问本地表的适当lookup和insert函数。

练习 3.25

将一维和二维表泛化,展示如何实现一个表,其中值存储在任意数量的键下,并且不同数量的键下可能存储不同的值。lookup和insert函数应以用于访问表的键列表作为输入。

练习 3.26

上面实现的搜索表需要扫描记录列表。这基本上是第 2.3.3 节的无序列表表示。对于大表,可能更有效地以不同的方式构造表。描述一个表实现,其中(键,值)记录使用二叉树组织,假设键可以以某种方式排序(例如,按数字或字母顺序)。 (比较第 2 章的练习 2.66。)

练习 3.27

记忆化(也称为制表法)是一种使函数能够记录先前计算过的值的技术。这种技术可以极大地改善程序的性能。记忆化函数维护一个表,其中存储了以产生值的参数为键的先前调用的值。当记忆化函数被要求计算一个值时,它首先检查表,看看值是否已经存在,如果是,就返回该值。否则,它以普通方式计算新值,并将其存储在表中。作为记忆化的一个例子,回想一下第 1.2.2 节中用于计算斐波那契数的指数过程:

function fib(n) {
    return n === 0
           ? 0
           : n === 1
           ? 1
           : fib(n - 1) + fib(n - 2);
}

相同函数的记忆化版本是

const memo_fib = memoize(n => n === 0
                              ? 0
                              : n === 1
                              ? 1
                              : memo_fib(n - 1) +
                                memo_fib(n - 2)
                        );

其中记忆器定义为

function memoize(f) {
    const table = make_table();
    return x => {
               const previously_computed_result =
                   lookup(x, table);
               if (is_undefined(previously_computed_result)) {
                   const result = f(x);
                   insert(x, result, table);
                   return result;
               } else {
                   return previously_computed_result;
               }
           };
}

绘制一个环境图来分析memo_fib(3)的计算。解释为什么memo_fib计算第 n 个斐波那契数的步骤数量与 n 成比例。如果我们简单地将memo_fib定义为memoize(fib),这种方案是否仍然有效?

3.3.4 数字电路模拟器

设计复杂的数字系统,如计算机,是一项重要的工程活动。数字系统是通过连接简单元素构建的。尽管这些单独元素的行为很简单,但它们的网络可能具有非常复杂的行为。计算机模拟提议的电路设计是数字系统工程师使用的重要工具。在本节中,我们设计了一个用于执行数字逻辑模拟的系统。这个系统代表了一种称为事件驱动模拟的程序类型,其中动作(“事件”)触发以后发生的更多事件,这些事件又触发更多事件,依此类推。

我们的电路的计算模型将由与构成电路的基本组件对应的对象组成。有电线,它们携带数字信号。数字信号在任何时刻只能有两个可能值之一,0 和 1。还有各种类型的数字功能框,它们将携带输入信号的电线连接到其他输出电线。这些框从它们的输入信号计算输出信号。输出信号的延迟时间取决于功能框的类型。例如,反相器是一个原始功能框,它反转其输入。如果反相器的输入信号变为 0,则一个反相器延迟后,反相器将把其输出信号更改为 1。如果反相器的输入信号变为 1,则一个反相器延迟后,反相器将把其输出信号更改为 0。我们以图 3.24 中的符号来绘制反相器。与门也显示在图 3.24 中,它是一个具有两个输入和一个输出的原始功能框。它将其输出信号驱动到与输入的逻辑与值相同的值。也就是说,如果其两个输入信号都变为 1,则一个与门延迟时间后,与门将强制其输出信号为 1;否则输出将为 0。或门是一个类似的两输入原始功能框,它将其输出信号驱动到与输入的逻辑或值相同的值。也就是说,如果至少一个输入信号为 1,则输出将变为 1;否则输出将变为 0。

c3-fig-0024.jpg

图 3.24 数字逻辑模拟器中的原始函数。

我们可以将原始函数连接在一起,以构建更复杂的函数。为了实现这一点,我们将一些功能框的输出连接到其他功能框的输入。例如,图 3.25 中显示的半加器电路由一个或门、两个与门和一个反相器组成。它接收两个输入信号A和B,并有两个输出信号S和C。当A和B中恰好有一个为 1 时,S将变为 1,当A和B都为 1 时,C将变为 1。从图中我们可以看到,由于涉及到的延迟,输出可能在不同的时间生成。数字电路设计中的许多困难都源于这一事实。

c3-fig-0025.jpg

图 3.25 一个半加器电路。

我们现在将构建一个用于建模我们希望研究的数字逻辑电路的程序。该程序将构建计算对象,对信号进行建模。功能框将由强制执行信号之间正确关系的函数进行建模。

我们模拟的一个基本元素将是一个名为make_wire的函数,用于构建信号线。例如,我们可以按照以下方式构建六根信号线:

const a = make_wire();
const b = make_wire();
const c = make_wire();
const d = make_wire();
const e = make_wire();
const s = make_wire();

我们通过调用一个构造该类型框的函数将一个函数框连接到一组线上。构造函数的参数是要连接到框的线。例如,鉴于我们可以构建与门、或门和反相器,我们可以将图 3.25 中显示的半加器连接在一起:

or_gate(a, b, d);
"ok"

and_gate(a, b, c);
"ok"

inverter(c, e);
"ok"

and_gate(d, e, s);
"ok"

更好的是,我们可以通过定义一个名为half_ adder的函数来显式命名这个操作,该函数构建这个电路,给定要连接到半加器的四根外部线:

function half_adder(a, b, s, c) {
    const d = make_wire();
    const e = make_wire();
    or_gate(a, b, d);
    and_gate(a, b, c);
    inverter(c, e);
    and_gate(d, e, s);
    return "ok";
}

制定这个定义的优势在于,我们可以使用half_adder本身作为创建更复杂电路的构建块。例如,图 3.26 展示了由两个半加器和一个或门组成的全加器。我们可以按照以下方式构建一个全加器:

function full_adder(a, b, c_in, sum, c_out) {
    const s = make_wire();
    const c1 = make_wire();
    const c2 = make_wire();
    half_adder(b, c_in, s, c1);
    half_adder(a, s, sum, c2);
    or_gate(c1, c2, c_out);
    return "ok";
}

定义了full_adder作为一个函数后,我们现在可以将其用作创建更复杂电路的构建块。(例如,参见练习 3.30。)

c3-fig-0026.jpg

图 3.26 一个全加器电路。

实质上,我们的模拟器为我们提供了构建电路语言的工具。如果我们采用了我们在第 1.1 节中研究 JavaScript 时所采用的关于语言的一般观点,我们可以说原始功能框构成了语言的原始元素,将框连接在一起提供了一种组合的手段,指定框作为函数的连线模式作为抽象的手段。

原始功能框

原始功能框实现了一根线上的信号变化如何影响其他线上的信号的“力量”。为了构建功能框,我们使用以下操作:

  • get_signal(wire)

    返回信号线上的当前值。

  • set_signal(wire, new-value):

    将信号线上的信号值更改为新值。

  • add_action(wire, function-of -no-arguments):

    断言指定的函数应该在线上的信号值发生变化时运行。这些函数是信号值变化传递给其他线的工具。

此外,我们将使用一个名为after_delay的函数,该函数接受一个时间延迟和一个要运行的函数,并在给定延迟后执行给定的函数。

使用这些功能,我们可以定义原始的数字逻辑功能。要通过反相器将输入连接到输出,我们使用add_action将输入线与一个函数关联起来,每当输入线上的信号值发生变化时,该函数就会运行。该函数计算输入信号的logical_not,然后在一个inverter_delay之后,将输出信号设置为这个新值:

function inverter(input, output) {
    function invert_input() {
        const new_value = logical_not(get_signal(input));
        after_delay(inverter_delay,
                    () => set_signal(output, new_value));
    }
    add_action(input, invert_input);
    return "ok";
}
function logical_not(s) {
    return s === 0
           ? 1
           : s === 1
           ? 0
           : error(s, "invalid signal");
}

与门稍微复杂一些。如果门的任一输入发生变化,则必须运行动作函数。它计算输入电线上的信号值的logical_and(使用类似于logical_not的函数),并设置在and_gate_delay之后在输出电线上发生新值的变化。

function and_gate(a1, a2, output) {
    function and_action_function() {
        const new_value = logical_and(get_signal(a1),
                                      get_signal(a2));
        after_delay(and_gate_delay,
                    () => set_signal(output, new_value));
    }
    add_action(a1, and_action_function);
    add_action(a2, and_action_function);
    return "ok";
}
练习 3.28

将或门定义为原始函数框。您的or_gate构造函数应类似于and_gate。

练习 3.29

构建或门的另一种方法是作为一个复合数字逻辑设备,由与门和反相器构建而成。定义一个函数or_gate来实现这一点。或门的延迟时间是多少,用and_gate_delay和inverter_delay来表示?

练习 3.30

图 3.27 显示了由串联n个全加器形成的链式进位加法器。这是用于加法两个n位二进制数的最简单形式的并行加法器。输入A[1]、A[2]、A[3],...,A[n]和B[1]、B[2]、B[3],...,B[n]是要相加的两个二进制数(每个A[k]和B[k]都是 0 或 1)。电路生成 S[1], S [2], S [3], ..., S [n],和C,加法的进位。编写一个函数ripple_carry_adder来生成这个电路。该函数应该接受三个n个电线的列表作为参数——A[k]、B[k]和S[k],还有另一个电线C。链式进位加法器的主要缺点是需要等待进位信号传播。以与门、或门和反相器的延迟来表示,获得n位链式进位加法器的完整输出所需的延迟是多少?

c3-fig-0027.jpg

图 3.27 一个用于n位数字的链式进位加法器。

代表电线

在我们的模拟中,电线将是一个计算对象,具有两个本地状态变量:signal_value(最初为 0)和要在信号变化时运行的action_functions集合。我们使用消息传递样式实现电线,作为一组本地函数以及选择适当的本地操作的dispatch函数,就像我们在第 3.1.1 节中的简单银行账户对象中所做的那样:

function make_wire() {
    let signal_value = 0;
    let action_functions = null;
    function set_my_signal(new_value) {
        if (signal_value !== new_value) {
            signal_value = new_value;
            return call_each(action_functions);
        } else {
            return "done";
        }
    }
    function accept_action_function(fun) {
        action_functions = pair(fun, action_functions);
        fun();
    }
    function dispatch(m) {
        return m === "get_signal"
               ? signal_value
               : m === "set_signal"
               ? set_my_signal
               : m === "add_action"
               ? accept_action_function
               : error(m, "unknown operation – wire");
    }
    return dispatch;
}

本地函数set_my_signal测试新的信号值是否改变了电线上的信号。如果是,它将运行每个动作函数,使用以下函数call_each,该函数调用无参数函数列表中的每个项目:

function call_each(functions) {
    if (is_null(functions)) {
        return "done";
    } else {
        head(functions)();
        return call_each(tail(functions));
    }
}

本地函数accept_action_function将给定的函数添加到要运行的函数列表中,然后运行新函数一次。(参见练习 3.31。)

设置本地dispatch函数后,我们可以提供以下函数来访问线上的本地操作:³⁰

function get_signal(wire) {
    return wire("get_signal");
}
function set_signal(wire, new_value) {
    return wire("set_signal")(new_value);
}
function add_action(wire, action_function) {
    return wire("add_action")(action_function);
}

电线具有时变信号,可以逐步连接到设备,这是可变对象的典型特征。我们将它们建模为具有本地状态变量的函数,这些状态变量通过赋值进行修改。创建新电线时,将分配一组新的状态变量(通过make_wire中的let语句),并构造并返回一个新的dispatch函数,捕获具有新状态变量的环境。

电线被各种设备共享,这些设备已连接到它们。因此,通过与一个设备的交互所做的更改将影响连接到电线的所有其他设备。电线通过在建立连接时提供的动作函数来将更改通知给其邻居。

议程

完成模拟器所需的唯一事情是after_delay。这里的想法是我们维护一个数据结构,称为agenda,其中包含要执行的计划。议程定义了以下操作:

  • make_agenda():

    返回一个新的空议程。

  • is_empty_agenda(agenda)

    如果指定的议程为空,则为真。

  • first_agenda_item(agenda)

    返回日程表上的第一项。

  • remove_first_agenda_item(agenda)

    通过删除第一项来修改日程表。

  • add_to_agenda(time, action, agenda)

    通过添加给定的动作函数来修改日程表,以便在指定时间运行。

  • current_time(agenda)

    返回当前模拟时间。

我们使用的特定日程表由the_agenda表示。函数after_delay向the_agenda添加新元素:

function after_delay(delay, action) {
    add_to_agenda(delay + current_time(the_agenda),
                  action,
                  the_agenda);
}

模拟由propagate函数驱动,该函数按顺序执行the_agenda上的每个函数。一般来说,随着模拟的运行,新的项目将被添加到日程表中,只要日程表上还有项目,propagate就会继续模拟:

function propagate() {
    if (is_empty_agenda(the_agenda)) {
        return "done";
    } else {
        const first_item = first_agenda_item(the_agenda);
        first_item();
        remove_first_agenda_item(the_agenda);
        return propagate();
    }
}
一个示例模拟

以下函数在动作上放置一个“探针”,展示了模拟器的运行。探针告诉导线,每当其信号值发生变化时,它应该打印新的信号值,以及当前时间和标识导线的名称。

function probe(name, wire) {
    add_action(wire,
               () => display(name + " " +
                             stringify(current_time(the_agenda)) +
                             ", new value = " +
                             stringify(get_signal(wire))));
}

我们首先初始化日程表,并为原始函数框架指定延迟:

const the_agenda = make_agenda();
const inverter_delay = 2;
const and_gate_delay = 3;
const or_gate_delay = 5;

现在我们定义了四根导线,并在其中两根上放置了探针:

const input_1 = make_wire();
const input_2 = make_wire();
const sum = make_wire();
const carry = make_wire();

probe("sum", sum);
"sum 0, new value = 0"

probe("carry", carry);
"carry 0, new value = 0"

接下来,我们连接半加器电路中的导线(如图 3.25 所示),将input_1上的信号设置为 1,并运行模拟:

half_adder(input_1, input_2, sum, carry);
"ok"

set_signal(input_1, 1);
"done"

propagate();
"sum 8, new value = 1"
"done"

sum信号在时间 8 时变为 1。现在距离模拟开始已经过去了八个时间单位。此时,我们可以将input_2上的信号设置为 1,并允许值传播:

set_signal(input_2, 1);
"done"

propagate();
"carry 11, new value = 1"
"sum 16, new value = 0"
"done"

在时间 11 时,carry变为 1,而在时间 16 时,sum变为 0。

练习 3.31

在make_wire中定义的内部函数accept_action_function指定了当新的动作函数被添加到导线时,立即运行该函数。解释为什么这种初始化是必要的。特别是,通过上面段落中的半加器示例追踪,并说出如果我们将accept_action_function定义为何,系统的响应会有何不同

function accept_action_function(fun) {
    action_functions = pair(fun, action_functions);
}
实施日程表

最后,我们详细介绍了日程表数据结构,该结构保存了计划用于将来执行的函数。

日程表由时间段组成。每个时间段都是一个数字(时间)和一个队列(参见练习 3.32),该队列保存了计划在该时间段内运行的函数。

function make_time_segment(time, queue) {
    return pair(time, queue);
}
function segment_time(s) { return head(s); }
function segment_queue(s) { return tail(s); }

我们将使用 3.3.2 节中描述的队列操作来操作时间段队列。

日程表本身是一个时间段的一维表。它与 3.3.3 节中描述的表不同之处在于,时间段将按照时间递增的顺序进行排序。此外,我们在日程表的头部存储当前时间(即,上次处理的动作的时间)。新构建的日程表没有时间段,并且当前时间为 0:

function make_agenda() { return list(0); }
function current_time(agenda) { return head(agenda); }
function set_current_time(agenda, time) {
    set_head(agenda, time);
}
function segments(agenda) { return tail(agenda); }
function set_segments(agenda, segs) {
    set_tail(agenda, segs);
}
function first_segment(agenda) { return head(segments(agenda)); }
function rest_segments(agenda) { return tail(segments(agenda)); }

如果日程表没有时间段,则为空:

function is_empty_agenda(agenda) {
    return is_null(segments(agenda));
}

要向日程表添加一个动作,我们首先检查日程表是否为空。如果是,我们为该动作创建一个时间段,并将其安装在日程表中。否则,我们扫描日程表,检查每个时间段的时间。如果我们找到了一个与我们指定时间相符的时间段,我们就将该动作添加到相关队列中。如果我们到达了晚于我们指定时间的时间,我们就在它之前插入一个新的时间段到日程表中。如果我们到达了日程表的末尾,我们必须在末尾创建一个新的时间段。

function add_to_agenda(time, action, agenda) {
    function belongs_before(segs) {
        return is_null(segs) || time < segment_time(head(segs));
    }
    function make_new_time_segment(time, action) {
        const q = make_queue();
        insert_queue(q, action);
        return make_time_segment(time, q);
    }
    function add_to_segments(segs) {
        if (segment_time(head(segs)) === time) {
            insert_queue(segment_queue(head(segs)), action);
        } else {
            const rest = tail(segs);
            if (belongs_before(rest)) {
                set_tail(segs, pair(make_new_time_segment(time, action),
                                    tail(segs)));
            } else {
                add_to_segments(rest);
            }
        }
    }
    const segs = segments(agenda);
    if (belongs_before(segs)) {
        set_segments(agenda,
                    pair(make_new_time_segment(time, action), segs));
    } else {
        add_to_segments(segs);
    }
}

删除日程表中的第一项的函数会删除第一个时间段中的队列中的项目。如果这个删除使时间段为空,我们就把它从时间段列表中移除。

function remove_first_agenda_item(agenda) {
    const q = segment_queue(first_segment(agenda));
    delete_queue(q);
    if (is_empty_queue(q)) {
        set_segments(agenda, rest_segments(agenda));
    } else {}
}

第一个日程表项位于第一个时间段的队列的头部。每当我们提取一个项目时,我们也会更新当前时间:

function first_agenda_item(agenda) {
    if (is_empty_agenda(agenda)) {
        error("agenda is empty – first_agenda_item");
    } else {
        const first_seg = first_segment(agenda);
        set_current_time(agenda, segment_time(first_seg));
        return front_queue(segment_queue(first_seg));
    }
}
练习 3.32

待在议程的每个时间段内运行的函数被保存在一个队列中。因此,每个时间段的函数按照它们被添加到议程的顺序被调用(先进先出)。解释为什么必须使用这个顺序。特别是,追踪一个与门的行为,当它的输入在同一个时间段内从0,1变为1,0,并说出如果我们将一个时间段的函数存储在一个普通列表中,只在前面添加和删除函数时,行为会有何不同。

3.3.5 约束的传播

计算机程序通常以单向计算的方式组织,它们对预先指定的参数执行操作以产生期望的输出。另一方面,我们经常以量之间的关系来建模系统。例如,机械结构的数学模型可能包括这样的信息:金属杆的挠度d与杆上的力F、杆的长度L、横截面积A和弹性模量E之间通过方程

dAE = FL

这样的方程不是单向的。给定这些量中的任意四个,我们可以使用它来计算第五个。然而,将方程转化为传统的计算机语言会迫使我们选择其中一个量来根据其他四个计算。因此,一个用于计算面积A的函数不能用于计算挠度d,即使A和d的计算都来自同一个方程。³⁴

在本节中,我们概述了一种能够让我们直接使用关系本身的语言的设计。语言的原始元素是 原始约束,它们陈述了某些量之间的关系。例如,adder(a, b, c)指定了量a、b和c必须满足方程a + b = c,multiplier(x, y, z)表达了约束xy = z,constant(3.14, x)表示x的值必须是 3.14。

我们的语言提供了一种将原始约束组合以表达更复杂关系的方法。我们通过构建 约束网络 来组合约束,其中约束由 连接器 连接。连接器是一个“持有”一个值的对象,可以参与一个或多个约束。例如,我们知道华氏温度和摄氏温度之间的关系是

9C = 5(F – 32)

这样的约束可以被看作是一个由原始加法器、乘法器和常量约束构成的网络(图 3.28)。在图中,我们可以看到左边有一个带有三个端口的乘法器盒子,标有m[1]、m[2]和p。这些将乘法器与网络的其余部分连接起来:m[1]端口连接到一个连接器C,它将持有摄氏温度。m[2]端口连接到一个连接器w,它也连接到一个持有 9 的常量盒子。乘法器盒子约束的p端口连接到另一个乘法器盒子的p端口,后者的m[2]连接到一个常量 5,m[1]连接到一个求和中的一个项。

c3-fig-0028.jpg

图 3.28 表达为约束网络的关系9C = 5(F – 32)。

这样的网络进行计算的过程如下:当连接器被赋予一个值(由用户或与其链接的约束框),它会唤醒其所有相关约束(除了刚刚唤醒它的约束),通知它们它有一个值。然后每个唤醒的约束框轮询其连接器,看是否有足够的信息来确定连接器的值。如果是,该框将设置该连接器,然后唤醒其所有相关约束,依此类推。例如,在摄氏度和华氏度之间的转换中,w、x和y立即由常量框设置为 9、5 和 32。连接器唤醒乘法器和加法器,确定没有足够的信息来继续。如果用户(或网络的其他部分)将C设置为一个值(比如 25),最左边的乘法器将被唤醒,它将把u设置为25*9=225。然后u唤醒第二个乘法器,将v设置为 45,v唤醒加法器,将F设置为 77。

使用约束系统

要使用约束系统执行上面概述的温度计算,我们首先调用构造函数make_connector来创建两个连接器C和F,然后将它们链接到一个适当的网络中:

const C = make_connector();
const F = make_connector();
celsius_fahrenheit_converter(C, F);
"ok"

定义创建网络的函数如下:

function celsius_fahrenheit_converter(c, f) {
    const u = make_connector();
    const v = make_connector();
    const w = make_connector();
    const x = make_connector();
    const y = make_connector();
    multiplier(c, w, u);
    multiplier(v, x, u);
    adder(v, y, f);
    constant(9, w);
    constant(5, x);
    constant(32, y);
    return "ok";
}

这个函数创建内部连接器u、v、w、x和y,并使用原始约束构造函数adder、multiplier和constant将它们链接如图 3.28 所示。就像 3.3.4 节中的数字电路模拟器一样,用函数表达这些原始元素的组合自动为我们的语言提供了复合对象的抽象手段。

观察网络的运行,我们可以在连接器C和F上放置探针,使用类似于我们在 3.3.4 节中用来监视电线的probe函数。在连接器上放置探针将导致在给连接器赋值时打印消息:

probe("Celsius temp", C);
probe("Fahrenheit temp", F);

接下来我们将C的值设置为 25。(set_value的第三个参数告诉C这个指令来自user。)

set_value(C, 25, "user");
"Probe: Celsius temp = 25"
"Probe: Fahrenheit temp = 77"
"done"

C上的探针醒来并报告值。C也通过网络传播其值,如上所述。这将F设置为 77,探针上报告了这一点。

现在我们可以尝试将F设置为一个新值,比如 212:

set_value(F, 212, "user");
"Error! Contradiction: (77, 212)"

连接器抱怨它已经感知到矛盾:它的值是 77,有人试图将其设置为 212。如果我们真的想要使用新的值重新使用网络,我们可以告诉C忘记它的旧值:

forget_value(C, "user");
"Probe: Celsius temp = ?"
"Probe: Fahrenheit temp = ?"
"done"

C发现"user",最初设置其值的人,现在正在撤回该值,因此C同意失去其值,如探针所示,并通知网络的其余部分。这些信息最终传播到F,现在F发现没有理由继续相信自己的值是 77。因此,F也放弃了它的值,如探针所示。

现在F没有值,我们可以自由地将其设置为 212:

set_value(F, 212, "user");
"Probe: Fahrenheit temp = 212"
"Probe: Celsius temp = 100"
"done"?

这个新值在网络中传播,强制C的值为 100,并由C上的探针注册。请注意,同一个网络被用来计算C给定F和计算F给定C。这种计算的非定向性是约束系统的显著特征。

实现约束系统

约束系统是通过具有局部状态的过程对象实现的,与 3.3.4 节中的数字电路模拟器非常相似。尽管约束系统的原始对象有些复杂,但整个系统更简单,因为不必担心议程和逻辑延迟。

连接器的基本操作如下:

  • has_value(connector)

    告诉连接器是否有值。

  • get_value(connector)

    返回连接器的当前值。

  • set_value(connector, new-value, informant)

    表示通知者正在请求连接器将其值设置为新值。

  • forget_value(connector, retractor)

    告诉连接器,撤回者正在请求它忘记其值。

  • connect(connector, new-constraint)

    告诉连接器参与新的约束。

连接器通过函数inform_ about_value与约束进行通信,该函数告诉给定约束连接器具有值,并且inform_about_no_value告诉约束连接器已经失去了它的值。

Adder在加数连接器a1和a2以及一个sum连接器之间构造一个加法器约束。加法器实现为具有本地状态的函数(下面的函数me):

function adder(a1, a2, sum) {
    function process_new_value() {
        if (has_value(a1) && has_value(a2)) {
            set_value(sum, get_value(a1) + get_value(a2), me);
        } else if (has_value(a1) && has_value(sum)) {
            set_value(a2, get_value(sum) - get_value(a1), me);
        } else if (has_value(a2) && has_value(sum)) {
            set_value(a1, get_value(sum) - get_value(a2), me);
        } else {}
    }
    function process_forget_value() {
        forget_value(sum, me);
        forget_value(a1, me);
        forget_value(a2, me);
        process_new_value();
    }
    function me(request) {
        if (request === "I have a value.") {
            process_new_value();
        } else if (request === "I lost my value.") {
            process_forget_value();
        } else {
            error(request, "unknown request – adder");
        }
    }
    connect(a1, me);
    connect(a2, me);
    connect(sum, me);
    return me;
}

函数adder将新的加法器连接到指定的连接器并将其作为其值返回。代表加法器的函数me充当本地函数的分派。与分派一起使用以下“语法接口”(参见第 3.3.4 节中的脚注 30):

function inform_about_value(constraint) {
    return constraint("I have a value.");
}
function inform_about_no_value(constraint) {
    return constraint("I lost my value.");
}

当加法器被告知其连接器之一具有值时,将调用加法器的本地函数process_new_value。加法器首先检查看看a1和a2是否都有值。如果是这样,它会告诉sum将其值设置为两个加数的和。set_value的informant参数是me,即加法器对象本身。如果a1和a2都没有值,那么加法器会检查看看也许a1和sum有值。如果是这样,它会将a2设置为这两者的差。最后,如果a2和sum有值,这就为加法器提供了足够的信息来设置a1。如果加法器被告知其连接器之一失去了值,它会要求所有连接器现在都失去它们的值。(实际上只有这些值是由此加法器设置的才会丢失。)然后运行process_new_value。这最后一步的原因是一个或多个连接器可能仍然具有值(即,连接器可能具有一个不是最初由加法器设置的值),并且这些值可能需要通过加法器传播回去。

乘法器与加法器非常相似。如果因子中的任何一个为 0,即使另一个因子未知,它也会将其product设置为 0。

function multiplier(m1, m2, product) {
    function process_new_value() {
        if ((has_value(m1) && get_value(m1) === 0)
         || (has_value(m2) && get_value(m2) === 0)) {
            set_value(product, 0, me);
        } else if (has_value(m1) && has_value(m2)) {
            set_value(product, get_value(m1) * get_value(m2), me);
        } else if (has_value(product) && has_value(m1)) {
            set_value(m2, get_value(product) / get_value(m1), me);
        } else if (has_value(product) && has_value(m2)) {
            set_value(m1, get_value(product) / get_value(m2), me);
        } else {}
    }
    function process_forget_value() {
        forget_value(product, me);
        forget_value(m1, me);
        forget_value(m2, me);
        process_new_value();
    }
    function me(request) {
        if (request === "I have a value.") {
            process_new_value();
        } else if (request === "I lost my value.") {
            process_forget_value();
        } else {
            error(request, "unknown request – multiplier");
        }
    }
    connect(m1, me);
    connect(m2, me);
    connect(product, me);
    return me;
}

“常量”构造函数只是设置指定连接器的值。发送到常量框的任何“我有一个值。”或“我失去了我的价值。”消息都会产生错误。

function constant(value, connector) {
    function me(request) {
        error(request, "unknown request – constant");
    }
    connect(connector, me);
    set_value(connector, value, me);
    return me;
}

最后,探针打印有关设置或取消指定连接器的消息:

function probe(name, connector) {
    function print_probe(value) {
        display("Probe: " + name + " = " + stringify(value));
    }
    function process_new_value() {
        print_probe(get_value(connector));
    }
    function process_forget_value() {
        print_probe("?");
    }
    function me(request) {
        return request === "I have a value."
               ? process_new_value()
               : request === "I lost my value."
               ? process_forget_value()
               : error(request, "unknown request – probe");
    }
    connect(connector, me);
    return me;
}
表示连接器

连接器表示为具有本地状态变量value的过程对象,即连接器的当前值;informant,设置连接器值的对象;和constraints,连接器参与的约束列表。

function make_connector() {
    let value = false;
    let informant = false;
    let constraints = null;
    function set_my_value(newval, setter) {
        if (!has_value(me)) {
            value = newval;
            informant = setter;
            return for_each_except(setter,
                                   inform_about_value,
                                   constraints);
        } else if (value !== newval) {
            error(list(value, newval), "contradiction");
        } else {
            return "ignored";
        }
    }
    function forget_my_value(retractor) {
        if (retractor === informant) {
            informant = false;
            return for_each_except(retractor,
                                   inform_about_no_value,
                                   constraints);
        } else {
            return "ignored";
        }
    }
    function connect(new_constraint) {
        if (is_null(member(new_constraint, constraints))) {
            constraints = pair(new_constraint, constraints);
        } else {}
        if (has_value(me)) {
            inform_about_value(new_constraint);
        } else {}
        return "done";
    }
    function me(request) {
        if (request === "has_value") {
            return informant !== false;
        } else if (request === "value") {
            return value;
        } else if (request === "set_value") {
            return set_my_value;
        } else if (request === "forget") {
            return forget_my_value;
        } else if (request === "connect") {
            return connect;
        } else {
            error(request, "unknown operation – connector");
        }
    }
    return me;
}

当有请求设置连接器的值时,将调用连接器的本地函数set_my_value。如果连接器当前没有值,它将设置其值并记住请求设置值的约束作为informant。然后,连接器将通知除了请求设置值的约束之外的所有参与约束。这是通过以下迭代器实现的,该迭代器将指定的函数应用于列表中除给定项之外的所有项:

function for_each_except(exception, fun, list) {
    function loop(items) {
        if (is_null(items)) {
            return "done";
        } else if (head(items) === exception) {
            return loop(tail(items));
        } else {
            fun(head(items));
            return loop(tail(items));
        }
    }
    return loop(list);
}

如果要求连接器忘记其值,则运行forget_my_value,这是一个本地函数,首先检查请求是否来自最初设置值的相同对象。如果是这样,连接器会通知其关联的约束丢失了值。

本地函数connect将指定的新约束添加到约束列表中,如果它尚未在该列表中。然后,如果连接器具有值,它会告知新约束这一事实。

连接器的函数me用作对其他内部函数的调度,并且也代表连接器作为一个对象。以下函数为调度提供了语法接口:

function has_value(connector) {
    return connector("has_value");
}
function get_value(connector) {
    return connector("value");
}
function set_value(connector, new_value, informant) {
    return connector("set_value")(new_value, informant);
}
function forget_value(connector, retractor) {
    return connector("forget")(retractor);
}
function connect(connector, new_constraint) {
   return connector("connect")(new_constraint);
}
练习 3.33

使用原始乘法器、加法器和常量约束,定义一个名为averager的函数,该函数以三个连接器a,b和c作为输入,并建立约束,使得c的值是a和b的平均值。

练习 3.34

Louis Reasoner 想要构建一个平方器,这是一个具有两个端子的约束设备,使得第二个端子上的连接器 b 的值始终是第一个端子上连接器 a 的值的平方。他提出了以下由乘法器制成的简单设备:

function squarer(a, b) {
    return multiplier(a, a, b);
}

这个想法存在一个严重的缺陷。请解释。

练习 3.35

Ben Bitdiddle 告诉 Louis,避免练习 3.34 中的麻烦的一种方法是将平方器定义为一个新的原始约束。填写 Ben 的轮廓中用于实现这种约束的函数的缺失部分:

function squarer(a, b) {
    function process_new_value() {
        if (has_value(b)) {
            if (get_value(b) < 0) {
                error(get_value(b), "square less than 0 – squarer");
            } else {
                alternative[1]
            }
        } else {
            alternative[2]
        }
    }
    function process_forget_value() {
        body[1]
    }
    function me(request) {
        body[2]
    }
    statements
    return me;
}
练习 3.36

假设我们在程序环境中求值以下语句序列:

const a = make_connector();
const b = make_connector();
set_value(a, 10, "user");

在set_value的求值过程中的某个时间,将求值连接器的本地函数中的以下表达式:

for_each_except(setter, inform_about_value, constraints);

绘制一个环境图,显示上述表达式的求值环境。

练习 3.37

与更注重表达式的定义风格相比,celsius_fahrenheit_converter函数显得很繁琐,例如

function celsius_fahrenheit_converter(x) {
   return cplus(cmul(cdiv(cv(9), cv(5)), x), cv(32));
}

const C = make_connector();
const F = celsius_fahrenheit_converter(C);

在这里,cplus,cmul等是算术操作的“约束”版本。例如,cplus接受两个连接器作为参数,并返回一个与这些连接器相关的连接器,通过加法器约束:

function cplus(x, y) {
    const z = make_connector();
    adder(x, y, z);
    return z;
}

定义类似的函数cminus,cmul,cdiv和cv(常量值),使我们能够像上面的转换器示例一样定义复合约束。³⁷

3.4 并发性:时间至关重要

我们已经看到了具有局部状态的计算对象作为建模工具的强大力量。然而,正如 3.1.3 节所警告的那样,这种力量是有代价的:失去了引用透明性,引发了关于相同性和变化的一系列问题,并且需要放弃替换模型的求值,转而采用更复杂的环境模型。

隐藏在状态、相同性和变化的复杂性下的核心问题是,通过引入赋值,我们被迫将时间引入我们的计算模型中。在引入赋值之前,我们所有的程序都是无时间的,即任何具有值的表达式始终具有相同的值。相比之下,回想一下在 3.1.1 节开头介绍的从银行账户中提取和返回结果余额的建模示例:

withdraw(25);
75

withdraw(25);
50

这里对同一表达式的连续求值产生了不同的值。这种行为是由于赋值语句(在本例中是对变量balance的赋值)的执行划定了值发生变化的时间点。求值表达式的结果不仅取决于表达式本身,还取决于求值是在这些时间点之前还是之后发生的。以计算对象的局部状态构建模型迫使我们面对时间作为编程中的一个基本概念。

我们可以进一步构建计算模型,使其与我们对物理世界的感知相匹配。世界中的对象不是按顺序一个接一个地变化。相反,我们将它们视为同时并发执行。因此,通常自然地将系统建模为同时执行的线程(计算步骤序列)的集合。正如我们可以通过以对象的方式组织模型来使程序更加模块化,将计算模型分成部分以便分别和同时演变也是合适的。即使程序将在顺序计算机上执行,将程序编写成并发执行的方式也会迫使程序员避免不必要的时间约束,从而使程序更加模块化。

除了使程序更加模块化外,并发计算还可以比顺序计算提供速度优势。顺序计算机一次只执行一个操作,因此执行任务所需的时间与执行的总操作数成正比。然而,如果可以将问题分解为相对独立并且只需要偶尔通信的部分,那么可能可以将这些部分分配给单独的计算处理器,从而产生与可用处理器数量成正比的速度优势。

不幸的是,赋值引入的复杂性在并发存在时变得更加棘手。并发执行的事实,无论是因为世界是并行运行的,还是因为我们的计算机是,并发执行都会增加我们对时间理解的复杂性。

3.4.1 并发系统中时间的本质

表面上,时间似乎很简单。它是对事件施加的一种排序。对于任何事件A和B,要么A发生在B之前,A和B同时发生,或者A发生在B之后。例如,回到银行账户的例子,假设彼得从一个初始含有 100 美元的联合账户中提取了 10 美元,而保罗从中提取了 25 美元,留下 65 美元在账户中。根据两次提取的顺序,账户中的余额序列要么是 100 90 65,要么是 100 75 65。在银行系统的计算机实现中,这种不断变化的余额序列可以通过对变量balance进行连续赋值来建模。

然而,在复杂情况下,这样的观点可能会有问题。假设彼得和保罗,以及其他人,通过遍布全球的银行机器网络访问同一个银行账户。账户中的实际余额序列将严重依赖于访问的详细时间和机器之间通信的细节。

事件顺序的不确定性可能会在并发系统的设计中带来严重问题。例如,假设彼得和保罗的提取是作为两个共享一个公共变量balance的独立线程实现的,每个线程由第 3.1.1 节中给出的函数指定:

function withdraw(amount) {
    if (balance >= amount) {
        balance = balance - amount;
        return balance;
    } else {
        return "Insufficient funds";
    }
}

如果两个线程独立操作,那么彼得可能会测试余额并尝试提取合法金额。然而,保罗可能会在彼得检查余额和彼得完成提取之间提取一些资金,从而使彼得的测试无效。

事情可能会变得更糟。考虑这个声明

balance = balance - amount;

作为每个提取过程的一部分执行。这包括三个步骤:(1) 访问balance变量的值;(2) 计算新的余额;(3) 将balance设置为这个新值。如果彼得和保罗的提取同时执行这个语句,那么两个提取可能会交错访问balance和将其设置为新值的顺序。

图 3.29 中的时序图描述了一系列事件的顺序,其中balance从 100 开始,Peter 取款 10,Paul 取款 25,最终balance的值却是 75。正如图中所示,这种异常的原因是 Paul 将 75 赋给balance的假设是要减少的balance的值为 100。然而,当 Peter 将balance改为 90 时,这个假设变得无效。这对银行系统来说是一个灾难性的失败,因为系统中的总金额没有得到守恒。交易之前,系统中的总金额是 100 美元。之后,Peter 有 10 美元,Paul 有 25 美元,银行有 75 美元。^41

c3-fig-0029.jpg

图 3.29 时序图显示了两笔银行取款事件的交错顺序可能导致最终余额不正确。

这里展示的一般现象是,多个线程可以共享一个公共状态变量。使这变得复杂的是,可能有多个线程同时尝试操作共享状态。对于银行账户的例子,在每笔交易中,每个客户都应该能够假设其他客户不存在。当客户以依赖于余额的方式改变余额时,他们必须能够假设在改变的那一刻之前,余额仍然是他们认为的那样。

并发程序的正确行为

上面的例子典型地说明了可能潜入并发程序的微妙错误。这种复杂性的根源在于不同线程之间共享的变量的赋值。我们已经知道,在编写使用赋值的程序时必须小心,因为计算的结果取决于赋值发生的顺序。^42 在并发线程中,我们必须特别小心赋值,因为我们可能无法控制不同线程所做的赋值的顺序。如果可能同时进行几个这样的更改(例如两个存款人访问联合账户),我们需要某种方式来确保我们的系统行为正确。例如,在联合银行账户的取款情况下,我们必须确保金钱是守恒的。为了使并发程序行为正确,我们可能需要对并发执行施加一些限制。

对并发的一种可能限制是规定不能同时发生改变任何共享状态变量的两个操作。这是一个非常严格的要求。对于分布式银行业务,这将要求系统设计者确保只能一次进行一笔交易。这既低效又过于保守。图 3.30 展示了 Peter 和 Paul 共享一个银行账户,Paul 也有一个私人账户。该图说明了从共享账户中取款(Peter 和 Paul 各取一笔)以及向 Paul 的私人账户存款。^43 从共享账户中取款的两笔操作不能同时进行(因为两者都访问并更新同一个账户),Paul 的存款和取款也不能同时进行(因为两者都访问并更新 Paul 钱包中的金额)。但是允许 Paul 向他的私人账户存款与 Peter 从共享账户中取款同时进行应该没有问题。

c3-fig-0030.jpg

图 3.30 在 Bank1 的联合账户和 Bank2 的私人账户中同时存款和取款。

对并发的限制较少会确保并发系统产生与线程按某种顺序顺序运行时相同的结果。这一要求有两个重要方面。首先,它不要求线程实际上按顺序运行,而只要求产生与它们按顺序运行时相同的结果。例如,在图 3.30 的例子中,银行账户系统的设计者可以安全地允许保罗的存款和彼得的取款同时发生,因为最终结果将与这两个操作按顺序发生时的结果相同。其次,一个并发程序可能产生多个可能的“正确”结果,因为我们只要求结果与某些顺序的结果相同。例如,假设彼得和保罗的联合账户一开始有 100 美元,彼得存入 40 美元,同时保罗取出账户中的一半。然后,顺序执行可能导致账户余额为 70 美元或 90 美元(见练习 3.38)⁴⁴。

对并发程序的正确执行还有更弱的要求。用于模拟扩散(比如物体中的热量流动)的程序可能由大量线程组成,每个线程代表一小块空间,它们同时更新自己的值。每个线程反复将自己的值更改为自己的值和邻居的值的平均值。这种算法收敛到正确的答案,不受操作顺序的影响;对共享值的并发使用没有任何限制的必要。

练习 3.38

假设彼得、保罗和玛丽共享一个最初包含 100 美元的联合银行账户。同时,彼得存入 10 美元,保罗取出 20 美元,玛丽取出账户中的一半,执行以下命令:

| 彼得:| balance = balance + 10 |

| --- | --- |

| 保罗:| balance = balance - 20 |

| 玛丽:| balance = balance - (balance / 2) |

  1. a. 假设银行系统强制这三个线程按某种顺序顺序运行,请列出这三个交易完成后balance的所有不同可能值。

  2. b. 如果系统允许线程交错,还可能产生哪些其他值?画出类似图 3.29 中的时间图,解释这些值是如何产生的。

3.4.2 控制并发的机制

我们已经看到处理并发线程的困难根源在于需要考虑不同线程中事件顺序的交错。例如,假设我们有两个线程,一个有三个有序事件(a,b,c),另一个有三个有序事件(x,y,z)。如果两个线程同时运行,而不限制它们的执行交错方式,那么与两个线程的各自顺序一致的 20 种不同可能的事件顺序:

|(a,b,c,x,y,z)|(a,x,b,y,c,z)|(x,a,b,c,y,z)|(x,a,y,z,b,c)|

| --- | --- | --- | --- |

|(a,b,x,c,y,z)|(a,x,b,y,z,c)|(x,a,b,y,c,z)|(x,y,a,b,c,z)|

|(a,b,x,y,c,z)|(a,x,y,b,c,z)|(x,a,b,y,z,c)|(x,y,a,b,z,c)|

|(a,b,x,y,z,c)|(a,x,y,b,z,c)|(x,a,y,b,c,z)|(x,y,a,z,b,c)|

|(a,x,b,c,y,z)|(a,x,y,z,b,c)|(x,a,y,b,z,c)|(x,y,z,a,b,c)|

作为设计这个系统的程序员,我们必须考虑这 20 种顺序的影响,并检查每种行为是否可接受。随着线程和事件数量的增加,这种方法很快变得难以控制。

设计并发系统的更实际的方法是设计通用机制,允许我们限制并发线程的交错,以确保程序行为是正确的。为此目的已经开发了许多机制。在本节中,我们描述其中之一,即序列化程序。

对共享状态进行序列化访问

序列化实现了以下思想:线程将同时执行,但将有一定的函数集合不能同时执行。更确切地说,序列化创建了一组特殊的函数集,以便每次只允许在每个序列化集中执行一个函数。如果正在执行集合中的某个函数,则试图执行集合中任何函数的线程将被迫等待,直到第一次执行完成。

我们可以使用序列化来控制对共享变量的访问。例如,如果我们想要基于该变量的先前值更新共享变量,我们将变量的先前值的访问和对变量的新值的赋值放在同一个函数中。然后,我们通过使用相同的序列化程序对所有这些函数进行序列化,以确保没有其他分配给变量的函数可以与此函数同时运行。这保证了变量的值在访问和相应的赋值之间不能被更改。

序列化程序

为了使上述机制更具体,假设我们已经扩展了 JavaScript,包括一个名为concurrent_execute的函数:

concurrent_execute(f[1], f[2], ..., f[k])

每个f必须是一个没有参数的函数。函数concurrent_execute为每个f创建一个单独的线程,该线程将f(无参数)应用于f。这些线程都同时运行。[^45]

作为如何使用它的示例,考虑

let x = 10;
concurrent_execute(() => { x = x * x; },
                   () => { x = x + 1; });

这创建了两个并发线程——T[1],将x设置为x乘以x,以及T[2],增加x。执行完成后,x将保留五种可能的值之一,具体取决于T[1]和T[2]的事件交错:

| 101: | T[1]将x设置为 100,然后T[2]将x增加到 101。 |

| --- | --- |

| 121: | T[2]将x增加到 11,然后T[1]将x设置为x乘以x。 |

| 110: | T[2]在T[1]之间将x从 10 更改为 11 |

| | 在求值x * x期间访问x的值。 |

| 11: | T[2]访问x,然后T[1]将x设置为 100,然后T[2]设置x。 |

| 100: | T[1]访问x(两次),然后T[2]将x设置为 11,然后T[1]设置x。 |

我们可以通过使用序列化函数来限制并发,这些函数是由序列化程序创建的。序列化程序是由make_serializer构造的,其实现如下所示。序列化程序接受一个函数作为参数,并返回一个行为类似于原始函数的序列化函数。对给定序列化程序的所有调用都返回相同集合中的序列化函数。

因此,与上面的示例相比,执行

let x = 10;
const s = make_serializer();
concurrent_execute(s(() => { x = x * x; }),
                   s(() => { x = x + 1; }));

可以产生x的两个可能值,101 或 121。其他可能性被消除,因为T[1]和T[2]的执行不能交错。

这是从 3.1.1 节中的make_account函数的一个版本,其中存款和取款已经被序列化:

function make_account(balance) {
    function withdraw(amount) {
        if (balance > amount) {
            balance = balance - amount;
            return balance;
        } else {
            return "Insufficient funds";
        }
    }
    function deposit(amount) {
        balance = balance + amount;
        return balance;
    }
    const protect = make_serializer();
    function dispatch(m) {
        return m === "withdraw"
               ? protect(withdraw)
               : m === "deposit"
               ? protect(deposit)
               : m === "balance"
               ? balance
               : error(m, "unknown request – make_account");
    }
    return dispatch;
}

通过这种实现,两个线程不能同时从单个帐户中提取或存款。这消除了图 3.29 中所示错误的来源,即 Peter 在 Paul 访问余额以计算新值和 Paul 实际执行分配之间更改帐户余额的时间。另一方面,每个帐户都有自己的序列化程序,因此不同帐户的存款和取款可以同时进行。

练习 3.39

如果我们改为按照以下方式对执行进行序列化,上述并发执行中的五种可能性中哪些仍然存在:

let x = 10;
const s = make_serializer();
concurrent_execute( () => { x = s(() => x * x)(); },
                   s(() => { x = x + 1; }));
练习 3.40

给出执行后可能的所有x的值

let x = 10;
concurrent_execute(() => { x = x * x; },
                   () => { x = x * x * x; });

如果我们使用序列化函数,那么这些可能性中还剩下哪些呢:

let x = 10;
const s = make_serializer(); concurrent_execute(s(() => { x = x * x; }),
                   s(() => { x = x * x * x; }));
练习 3.41

Ben Bitdiddle 担心最好按照以下方式实现银行账户(已更改的部分已在注释行中):

function make_account(balance) {
    function withdraw(amount) {
        if (balance > amount) {
            balance = balance - amount;
            return balance;
        } else {
            return "Insufficient funds";
        }
    }
    function deposit(amount) {
        balance = balance + amount;
        return balance;
    }
    const protect = make_serializer();
    function dispatch(m) {
        return m === "withdraw"
               ? protect(withdraw)
               : m === "deposit"
               ? protect(deposit)
               : m === "balance"
               ? protect(() => balance)(undefined) // serialized
               : error(m, "unknown request – make_account");
    }
    return dispatch;
}

因为允许对银行余额进行未序列化访问可能会导致异常行为。你同意吗?有没有任何情景可以证明 Ben 的担忧?

练习 3.42

Ben Bitdiddle 建议,针对每个withdraw和deposit消息创建一个新的序列化函数是浪费时间。他说make_account可以被改变,这样对protect的调用就在dispatch函数之外完成。也就是说,一个账户每次要求提取函数时都会返回相同的序列化函数(该函数是在创建账户时同时创建的)。

function make_account(balance) {
    function withdraw(amount) {
        if (balance > amount) {
            balance = balance - amount;
            return balance;
        } else {
            return "Insufficient funds";
        }
    }
    function deposit(amount) {
        balance = balance + amount;
        return balance;
    }
    const protect = make_serializer();
    const protect_withdraw = protect(withdraw);
    const protect_deposit = protect(deposit);
    function dispatch(m) {
        return m === "withdraw"
               ? protect_withdraw
               : m === "deposit"
               ? protect_deposit
               : m === "balance"
               ? balance
               : error(m, "unknown request – make_account");
    }
    return dispatch;
}

这样改变安全吗?特别是,这两个版本的make_account允许的并发性有什么区别吗?

使用多个共享资源的复杂性

序列化器提供了一个强大的抽象,有助于隔离并发程序的复杂性,以便可以小心地(希望)正确地处理。然而,当只有一个共享资源(如单个银行账户)时,使用序列化器相对来说是相对简单的,但是当存在多个共享资源时,并发编程可能会非常困难。

为了说明可能出现的困难之一,假设我们希望交换两个银行账户的余额。我们访问每个账户以查找余额,计算余额之间的差额,从一个账户中提取这个差额,并将其存入另一个账户。我们可以这样实现:

function exchange(account1, account2) {
    const difference = account1("balance") - account2("balance");
    account1("withdraw")(difference);
    account2("deposit")(difference);
}

当只有一个线程尝试进行交换时,这个函数运行良好。然而,假设 Peter 和 Paul 都可以访问账户a[1]、a[2]和a[3],Peter 交换a[1]和a[2],同时 Paul 并发地交换a[1]和a[3]。即使对于单个账户的存款和取款都是串行化的(就像本节中上面显示的make_account函数一样),exchange仍然可能产生不正确的结果。例如,Peter 可能计算a[1]和a[2]的余额差,但是 Paul 可能在 Peter 完成交换之前改变a[1]的余额。为了正确的行为,我们必须安排exchange函数在整个交换过程中锁定对账户的任何其他并发访问。

我们可以通过使用两个账户的序列化器来实现这一点,以序列化整个exchange函数。为此,我们将安排访问账户的序列化器。请注意,我们故意打破了银行账户对象的模块化,通过消息传递来暴露序列化器。下面的make_account版本与第 3.1.1 节中给出的原始版本相同,只是提供了一个序列化器来保护余额变量,并且通过消息传递导出了序列化器:

function make_account_and_serializer(balance) {
    function withdraw(amount) {
        if (balance > amount) {
            balance = balance - amount;
            return balance;
        } else {
            return "Insufficient funds";
        }
    }
    function deposit(amount) {
        balance = balance + amount;
        return balance;
    }
    const balance_serializer = make_serializer();
    return m => m === "withdraw"
                ? withdraw
                : m === "deposit"
                ? deposit
                : m === "balance"
                ? balance
                : m === "serializer"
                ? balance_serializer
                : error(m, "unknown request – make_account");
}

我们可以使用这个来进行序列化的存款和取款。然而,与我们之前的序列化账户不同,现在每个银行账户对象的用户都有责任显式地管理序列化,例如:

function deposit(account, amount) {
    const s = account("serializer");
    const d = account("deposit");
    s(d(amount));
}

以这种方式导出序列化器为我们提供了足够的灵活性来实现一个序列化的交换程序。我们只需使用两个账户的序列化器对原始的exchange函数进行序列化:

function serialized_exchange(account1, account2) {
    const serializer1 = account1("serializer");
    const serializer2 = account2("serializer");
    serializer1(serializer2(exchange))(account1, account2);
}
练习 3.43

假设三个账户的余额初始为 $10、$20 和 $30,并且多个线程运行,交换账户中的余额。论证如果线程按顺序运行,在任意数量的并发交换之后,账户余额应该以某种顺序为 $10、$20 和 $30。绘制一个类似于图 3.29 中的时间图,以展示如果使用本节中账户交换程序的第一个版本,这个条件如何被违反。另外,论证即使使用这个exchange程序,账户余额的总和也会被保留。绘制一个时间图,以展示如果我们没有对各个账户上的交易进行序列化,即使这个条件也会被违反。

练习 3.44

考虑从一个账户转账到另一个账户的问题。本·比特迪德尔声称,即使有多个人同时在多个账户之间转账,使用任何序列化存款和取款交易的账户机制,例如上文中的make_account版本,也可以通过以下函数实现。

function transfer(from_account, to_account, amount) {
    from_account("withdraw")(amount);
    to_account("deposit")(amount);
}

路易斯·里森纳声称这里存在问题,我们需要使用更复杂的方法,比如处理交换问题所需的方法。路易斯是对的吗?如果不是,转账问题和交换问题之间的本质区别是什么?(假设from_account中的余额至少为amount。)

练习 3.45

路易斯·里森纳认为我们的银行账户系统现在过于复杂和容易出错,因为存款和取款不再自动序列化。他建议make_account_and_serializer应该导出序列化器(供serialized_exchange等函数使用),而不是像make_account一样使用它来序列化账户和存款。他建议重新定义账户如下:

function make_account_and_serializer(balance) {
    function withdraw(amount) {
        if (balance > amount) {
            balance = balance - amount;
            return balance;
        } else {
            return "Insufficient funds";
        }
    }
    function deposit(amount) {
        balance = balance + amount;
        return balance;
    }
    const balance_serializer = make_serializer();
    return m => m === "withdraw"
                ? balance_serializer(withdraw)
                : m === "deposit"
                ? balance_serializer(deposit)
                : m === "balance"
                ? balance
                : m === "serializer"
                ? balance_serializer
                : error(m, "unknown request – make_account");
}

然后存款的处理方式与原始的make_account相同:

function deposit(account, amount) {
    account("deposit")(amount);
}

解释路易斯的推理有什么问题。特别是考虑serialized_exchange被调用时会发生什么。

实现序列化器

我们根据一种称为互斥体的更原始的同步机制来实现序列化器。互斥体是支持两种操作的对象——可以获取互斥体,也可以释放互斥体。一旦互斥体被获取,那么在互斥体被释放之前,该互斥体上的其他获取操作都无法进行。在我们的实现中,每个序列化器都有一个关联的互斥体。给定一个函数f,序列化器返回一个函数,该函数获取互斥体,运行f,然后释放互斥体。这确保了由序列化器产生的函数中只有一个可以同时运行,这正是我们需要保证的序列化属性。为了将序列化器应用于接受任意数量参数的函数,我们使用 JavaScript 的剩余参数和展开语法。参数args前面的…收集函数的任何调用中的所有参数(这里是全部参数)到一个向量数据结构中。在应用f(…args)中args前面的…将args的元素展开,使它们成为f的单独参数。

function make_serializer() {
    const mutex = make_mutex();
    return f => {
               function serialized_f(…args) {
                   mutex("acquire");
                   const val = f(…args);
                   mutex("release");
                   return val;
               }
               return serialized_f;
           };
}

互斥体是一个可变对象(这里我们将使用一个一元列表,称为单元格),它可以保存true或false的值。当值为false时,互斥体可用于获取。当值为true时,互斥体不可用,任何试图获取互斥体的线程都必须等待。

我们的互斥锁构造函数make_mutex首先将单元内容初始化为假。要获取互斥锁,我们测试单元。如果互斥锁可用,我们将单元内容设置为真并继续。否则,我们在一个循环中等待,一遍又一遍地尝试获取,直到我们发现互斥锁可用。⁵⁰ 要释放互斥锁,我们将单元内容设置为假。

function make_mutex() {
    const cell = list(false);
    function the_mutex(m) {
    return m === "acquire"
           ? test_and_set(cell)
             ? the_mutex("acquire") // retry
             : true
           : m === "release"
           ? clear(cell)
           : error(m, "unknown request – mutex");
    }
    return the_mutex;
}
function clear(cell) {
    set_head(cell, false);
}

函数test_and_set测试单元并返回测试结果。此外,如果测试结果为假,test_and_set在返回假之前将单元内容设置为真。我们可以将这种行为表达为以下函数:

function test_and_set(cell) {
    if (head(cell)) {
        return true;
    } else {
        set_head(cell, true);
        return false;
    }
}

然而,这种test_and_set的实现并不足以满足要求。这里有一个关键的微妙之处,这是并发控制进入系统的基本地方:test_and_set操作必须被原子化执行。也就是说,我们必须保证,一旦一个线程测试了单元并发现它为假,单元内容实际上会在任何其他线程测试单元之前被设置为真。如果我们不能做到这一点,那么互斥锁可能会以类似于图 3.29 中的银行账户失败的方式失败。(参见练习 3.46。)

test_and_set的实际实现取决于我们的系统如何运行并发线程的细节。例如,我们可能正在使用时间片轮转机制在顺序处理器上执行并发线程,该机制循环遍历线程,允许每个线程在中断之前运行一小段时间。在这种情况下,test_and_set可以通过在测试和设置期间禁用时间片轮转来工作。另外,多处理计算机提供了直接在硬件中支持原子操作的指令。⁵¹

练习 3.46

假设我们使用文本中所示的普通函数来实现test_and_set,而不尝试使操作原子化。绘制一个类似于图 3.29 中的时序图,以演示互斥锁实现如何通过允许两个线程同时获取互斥锁而失败。

练习 3.47

信号量(大小为n)是互斥锁的一种泛化。像互斥锁一样,信号量支持获取和释放操作,但它更一般,最多n个线程可以同时获取它。尝试获取信号量的其他线程必须等待释放操作。给出信号量的实现

  1. a. 以互斥锁为条件

  2. b. 以原子test_and_set操作为条件。

死锁

现在我们已经看到了如何实现串行化器,我们可以看到即使在上面的serialized_exchange函数中,账户交换仍然存在问题。假设 Peter 试图将a[1]与a[2]交换,同时 Paul 尝试将a[2]与a[1]交换。假设 Peter 的线程到达了进入保护a[1]的串行化函数的点,就在那之后,Paul 的线程进入了保护a[2]的串行化函数。现在 Peter 无法继续(进入保护a[2]的串行化函数)直到 Paul 退出保护a[2]的串行化函数。同样,Paul 在 Peter 退出保护a[1]的串行化函数之前也无法继续。每个线程都永远被阻塞,等待另一个线程。这种情况被称为死锁。在提供对多个共享资源的并发访问的系统中,死锁总是一个危险。

在这种情况下避免死锁的一种方法是给每个账户分配一个唯一的标识号,并重写serialized_exchange,使得一个线程总是尝试首先进入保护最低编号账户的函数。虽然这种方法对于交换问题效果很好,但还有其他需要更复杂的死锁避免技术的情况,或者根本无法避免死锁。(参见练习 3.48 和 3.49。)⁵²

练习 3.48

详细解释上述避免死锁的方法(即,账户编号,并且每个线程尝试先获取编号较小的账户)在交换问题中避免死锁的原因。重写serialized_exchange以纳入这个想法。(您还需要修改make_account,以便每个账户都带有一个可以通过发送适当消息访问的编号。)

练习 3.49

给出一个情景,说明上述避免死锁的机制不起作用的情况。(提示:在交换问题中,每个线程事先知道它将需要访问的账户。考虑一个情况,一个线程必须在知道它将需要访问哪些额外的共享资源之前获得对一些共享资源的访问。)

并发、时间和通信

我们已经看到,编写并发系统需要控制不同线程访问共享状态时事件的顺序,并且我们已经看到如何通过合理使用串行器来实现这种控制。但并发的问题并不仅仅如此,因为从根本上来看,“共享状态”并不总是清楚是什么意思。

诸如test_and_set之类的机制要求线程在任意时间检查全局共享标志。这在现代高速处理器中实现起来是有问题且低效的,因为由于流水线和缓存内存等优化技术,内存的内容可能不会在每一时刻处于一致状态。因此,在一些多处理系统中,串行器范式正在被其他并发控制方法所取代。

共享状态的问题也出现在大型分布式系统中。例如,想象一个分布式银行系统,其中各个分行维护银行余额的本地值,并定期将其与其他分行维护的值进行比较。在这样的系统中,“账户余额”的价值在同步之后才会确定。如果 Peter 向他与 Paul 共同持有的账户存钱,我们应该在何时说账户余额已经改变——当本地分行的余额改变时,还是直到同步之后?如果 Paul 从不同的分行访问账户,对于银行系统来说应该放置什么合理的约束条件,以使行为“正确”?对于正确性来说,唯一重要的可能是 Peter 和 Paul 个别观察到的行为以及同步后账户的“状态”。关于“真实”账户余额或同步之间事件顺序的问题可能是无关或无意义的。

这里的基本现象是,同步不同的线程,建立共享状态,或对事件进行排序都需要线程之间的通信。实质上,并发控制中的任何时间概念都必须与通信紧密联系在一起。引人入胜的是,在相对论中也存在时间和通信之间的类似联系,光速(可以用来同步事件的最快信号)是一个将时间和空间联系起来的基本常数。我们在处理计算模型中的时间和状态时遇到的复杂性,实际上可能反映了物理宇宙的基本复杂性。

3.5 流

我们已经对作为建模工具的赋值有了很好的理解,也对赋值引发的复杂问题有了认识。现在是时候问问我们是否可以以不同的方式进行事情,以避免其中一些问题。在本节中,我们将探讨一种基于称为“流”的数据结构的状态建模的替代方法。正如我们将看到的,流可以减轻一些状态建模的复杂性。

让我们退一步,回顾一下这种复杂性的根源。为了模拟现实世界的现象,我们做出了一些看似合理的决定:我们用具有局部变量的计算对象来模拟具有局部状态的现实世界对象。我们将现实世界中的时间变化与计算机中的时间变化相对应。我们用模型对象的局部变量的赋值来实现计算机中模型对象状态的时间变化。

还有其他方法吗?我们能否避免将计算机中的时间与模拟世界中的时间相对应?我们必须使模型随时间变化以模拟变化中的世界现象吗?从数学函数的角度来思考这个问题。我们可以将数量x的随时间变化描述为时间的函数x(t)。如果我们一瞬间地专注于x,我们会认为它是一个变化的数量。然而,如果我们专注于值的整个时间历史,我们并不强调变化——函数本身并不改变。⁵⁶

如果时间以离散步骤来衡量,那么我们可以将时间函数建模为(可能是无限的)序列。在本节中,我们将看到如何以代表被建模系统的时间历史的序列来建模变化。为了实现这一点,我们引入了称为流的新数据结构。从抽象的角度来看,流只是一个序列。然而,我们会发现,将流的直接实现作为列表(如 2.2.1 节中所示)并不能充分展现流处理的威力。作为替代,我们引入了延迟求值技术,这使我们能够将非常大(甚至是无限的)序列表示为流。

流处理让我们能够建模具有状态的系统,而无需使用赋值或可变数据。这对于理论和实践都有重要的影响,因为我们可以构建避免引入赋值固有缺点的模型。另一方面,流框架本身也带来了困难,以及哪种建模技术能够导致更模块化和更易维护的系统的问题仍然是开放的。

3.5.1 流是延迟列表

正如我们在 2.2.3 节中看到的,序列可以作为组合程序模块的标准接口。我们为操作序列制定了强大的抽象,如map、filter和accumulate,以简洁而优雅的方式捕捉了各种操作。

不幸的是,如果我们将序列表示为列表,这种优雅是以计算效率严重不足的代价换来的,无论是在时间还是空间上。当我们将对序列的操作表示为列表的转换时,我们的程序必须在过程的每一步构造和复制数据结构(可能非常庞大)。

为了理解这一点,让我们比较两个计算区间内所有质数之和的程序。第一个程序是用标准的迭代风格编写的:⁵⁷

function sum_primes(a, b) {
    function iter(count, accum) {
        return count > b
               ? accum
               : is_prime(count)
               ? iter(count + 1, count + accum)
               : iter(count + 1, accum);
    }
    return iter(a, 0);
}

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

function sum_primes(a, b) {
    return accumulate((x, y) => x + y,
                      0,
                      filter(is_prime,
                             enumerate_interval(a, b)));
}

在进行计算时,第一个程序只需要存储正在累积的总和。相比之下,第二个程序中的过滤器在enumerate_interval构建完整的区间数字列表之前无法进行任何测试。过滤器生成另一个列表,然后传递给accumulate,然后被折叠以形成总和。第一个程序不需要这样大的中间存储,我们可以将其视为逐步枚举区间,将每个质数添加到生成的总和中。

如果我们使用序列范例来计算从 10,000 到 1,000,000 的区间中第二个质数,那么使用列表的低效性就会变得非常明显,通过求值表达式

head(tail(filter(is_prime,
                 enumerate_interval(10000, 1000000))));

这个表达式确实找到了第二个素数,但计算开销是过分的。我们构造了一个接近一百万的整数列表,通过测试每个元素的素性来过滤这个列表,然后忽略了几乎所有的结果。在更传统的编程风格中,我们会交错枚举和过滤,并在达到第二个素数时停止。

流是一个巧妙的想法,它允许我们使用序列操作而不会产生列表操作的成本。有了流,我们可以实现两全其美:我们可以优雅地将程序构建为序列操作,同时获得增量计算的效率。基本思想是只部分构造流,并将部分构造传递给消费流的程序。如果消费者尝试访问尚未构造的流的一部分,流将自动构造足够的自身来产生所需的部分,从而保持整个流存在的幻觉。换句话说,尽管我们将编写程序,就好像我们正在处理完整的序列,但我们的流实现被设计为自动透明地交错流的构造和使用。

为了实现这一点,我们将使用对构造流,流的第一项在对的头部。然而,我们不是将流的其余值放入对的尾部,而是在那里放置一个“承诺”,以计算其余部分(如果有的话)。如果我们有一个数据项h和一个流t,我们通过求值pair(h, () => t)来构造一个流,其头部是h,尾部是t—流的尾部t被包装在一个没有参数的函数中,因此其求值将被延迟。空流是null,与空列表相同。

要访问非空流的第一个数据项,我们只需选择一对的head,就像列表一样。但是要访问流的尾部,我们需要求值延迟的表达式。为了方便起见,我们定义

function stream_tail(stream) {
    return tail(stream)();
}

这选择了一对的尾部,并应用在那里找到的函数来获得流的下一对(或者如果流的尾部为空则为null)—实际上,强制了对尾部的函数来实现其承诺。

我们可以制作和使用流,就像我们可以制作和使用列表一样,来表示按顺序排列的聚合数据。特别是,我们可以构建章节 2 中的列表操作的流模拟,例如list_ref、map和for_each:⁵⁸

function stream_ref(s, n) {
    return n === 0
           ? head(s)
           : stream_ref(stream_tail(s), n - 1);
}
function stream_map(f, s) {
    return is_null(s)
           ? null
           : pair(f(head(s)),
                  () => stream_map(f, stream_tail(s)));
}
function stream_for_each(fun, s) {
    if (is_null(s)) {
        return true;
    } else {
        fun(head(s));
        return stream_for_each(fun, stream_tail(s));
    }
}

stream_for_each函数对于查看流是有用的。

function display_stream(s) {
    return stream_for_each(display, s);
}

为了使流的实现自动透明地交错流的构造和使用,我们安排了流的尾部在被stream_tail函数访问时进行求值,而不是在pair构造流时进行求值。这种实现选择让人想起了我们在 2.1.2 节中讨论有理数时所看到的情况,那里我们看到我们可以选择实现有理数,使得分子和分母的约分在构造时或选择时进行。这两种有理数实现产生相同的数据抽象,但选择对效率有影响。流和普通列表之间存在类似的关系。作为数据抽象,流和列表是相同的。不同之处在于元素的求值时间。对于普通列表,head和tail都在构造时进行求值。对于流,tail在选择时进行求值。

流的实际应用

为了看到这种数据结构的行为,让我们分析上面看到的“过分”的素数计算,以流的术语重新表述:

head(stream_tail(stream_filter(
                     is_prime,
                     stream_enumerate_interval(10000, 1000000))));

我们将看到它确实有效地工作。

我们首先使用参数 10,000 和 1,000,000 调用stream_enumerate_interval函数。函数stream_enumerate_interval是enumerate_interval(2.2.3 节)的流模拟:

function stream_enumerate_interval(low, high) {
    return low > high
           ? null
           : pair(low,
                  () => stream_enumerate_interval(low + 1, high));
}

因此,由stream_enumerate_interval返回的结果,由pair组成,是⁵⁹

pair(10000, () => stream_enumerate_interval(10001, 1000000));

也就是说,stream_enumerate_interval返回一个表示为pair的流,其head为 10,000,tail是一个承诺,如果需要的话会枚举更多的间隔。现在,使用filter函数的流模拟对素数进行过滤。

function stream_filter(pred, stream) {
    return is_null(stream)
           ? null
           : pred(head(stream))
           ? pair(head(stream),
                  () => stream_filter(pred, stream_tail(stream)))
           : stream_filter(pred, stream_tail(stream));
}

函数stream_filter测试流的head(即 10,000)。由于这不是素数,stream_filter检查其输入流的尾部。调用stream_tail迫使延迟的stream_enumerate_interval,现在返回

pair(10001, () => stream_enumerate_interval(10002, 1000000));

函数stream_filter现在查看这个流的head,10,001,看到这也不是素数,强制另一个stream_tail,依此类推,直到stream_enumerate_interval产生素数 10,007,然后根据其定义,stream_filter返回

pair(head(stream),
     stream_filter(pred, stream_tail(stream)));

在这种情况下是

pair(10007,
     () => stream_filter(
              is_prime,
              pair(10008,
                   () => stream_enumerate_interval(10009, 1000000))));

这个结果现在传递给了我们原始表达式中的stream_tail。这迫使延迟的stream_filter,进而不断迫使延迟的stream_enumerate_interval,直到找到下一个素数,即 10,009。最后,结果传递给了我们原始表达式中的head。

pair(10009,
     () => stream_filter(
              is_prime,
              pair(10010,
                   () => stream_enumerate_interval(10011, 1000000))));

函数head返回 10,009,计算完成。只有测试了必要数量的整数以确定素数,只有在必要时才枚举了间隔以提供素数过滤器。

一般来说,我们可以将延迟求值看作是“需求驱动”的编程,即流处理中的每个阶段只激活足够满足下一个阶段的部分。我们所做的是将计算中的实际事件顺序与函数的表面结构分离。我们编写函数,就好像流“一次性”存在一样,而实际上,计算是逐步进行的,就像传统的编程风格一样。

一个优化

当我们构造流对时,我们通过将这些表达式包装在函数中来延迟求值它们的尾部表达式。我们在需要时通过应用函数来强制执行它们的求值。

这个实现足以使流按照广告宣传的方式工作,但是在需要时我们将考虑一个重要的优化。在许多应用中,我们最终会多次强制执行相同的延迟对象。这可能导致涉及流的递归程序严重低效。(见练习 3.57。)解决方案是构建延迟对象,使得第一次强制执行时,它们存储计算的值。后续的强制执行将简单地返回存储的值,而不重复计算。换句话说,我们实现了流对的构造,作为类似于练习 3.27 中描述的记忆化函数的一种方式。实现这一点的一种方法是使用以下函数,它以一个函数(无参数)作为参数,并返回函数的记忆化版本。第一次运行记忆化函数时,它保存计算结果。在后续的求值中,它只是返回结果。⁶⁰

function memo(fun) {
    let already_run = false;
    let result = undefined;
    return () => {
               if (!already_run) {
                   result = fun();
                   already_run = true;
                   return result;
               } else {
                   return result;
               }
           };
}

我们可以在构造流对时使用memo。例如,而不是

function stream_map(f, s) {
    return is_null(s)
           ? null
           : pair(f(head(s)),
                  () => stream_map(f, stream_tail(s)));
}

我们可以定义一个优化的函数stream_map如下:

function stream_map_optimized(f, s) {
    return is_null(s)
           ? null
           : pair(f(head(s)),
                  memo(() =>
                         stream_map_optimized(f, stream_tail(s))));
}
练习 3.50

声明一个函数stream_map_2,它接受一个二元函数和两个流作为参数,并返回一个流,其元素是将函数成对应用于参数流的相应元素的结果。

function stream_map_2(f, s1, s2) {
    …
}

类似于stream_map_optimized,通过修改stream_map_2声明一个函数stream_map_2_optimized,使结果流使用记忆化。

练习 3.51

请注意,我们的原始函数display在显示后返回其参数。解释器在求值以下序列中的每个语句时打印什么?

let x = stream_map(display, stream_enumerate_interval(0, 10));
stream_ref(x, 5);
stream_ref(x, 7);

如果使用stream_map_optimized而不是stream_map,解释器会打印什么?

let x = stream_map_optimized(display, stream_enumerate_interval(0, 10));
stream_ref(x, 5);
stream_ref(x, 7);
练习 3.52

考虑以下语句序列

let sum = 0;
function accum(x) {
    sum = x + sum;
    return sum;
}
const seq = stream_map(accum, stream_enumerate_interval(1, 20));
const y = stream_filter(is_even, seq);
const z = stream_filter(x => x % 5 === 0, seq);
stream_ref(y, 7);
display_stream(z);

在上述每个语句被求值后,sum的值是多少?求值stream_ref和display_stream表达式的打印响应是什么?如果我们在每个构造的流对的每个尾部应用了函数memo,如上面的优化建议,这些响应会有所不同吗?请解释。

3.5.2 无限流

我们已经看到如何支持操作流的幻觉,即使在实际上,我们只计算我们需要访问的流的部分。我们可以利用这种技术来有效地表示序列作为流,即使序列非常长。更重要的是,我们可以使用流来表示无限长的序列。例如,考虑以下正整数流的定义:

function integers_starting_from(n) {
    return pair(n, () => integers_starting_from(n + 1));
}
const integers = integers_starting_from(1);

这是有道理的,因为integers将是一个对,其head是 1,tail是一个承诺去产生从 2 开始的整数。这是一个无限长的流,但在任何给定的时间,我们只能检查其中的有限部分。因此,我们的程序永远不会知道整个无限流不存在。

使用integers,我们可以定义其他无限流,例如不能被 7 整除的整数流:

function is_divisible(x, y) { return x % y === 0; }
const no_sevens = stream_filter(x => ! is_divisible(x, 7),
                                integers);

然后我们可以通过访问该流的元素来找到不能被 7 整除的整数:

stream_ref(no_sevens, 100);
117

类似于integers,我们可以定义斐波那契数的无限流:

function fibgen(a, b) {
    return pair(a, () => fibgen(b, a + b));
}
const fibs = fibgen(0, 1);

常量fibs是一个对的head是 0,tail是一个承诺去求值fibgen(1, 1)。当我们求值这个延迟的fibgen(1, 1)时,它将产生一个对,其head是 1,tail是一个承诺去求值fibgen(1, 2),依此类推。

要查看更激动人心的无限流,我们可以将no_sevens示例推广到使用称为厄拉托斯特尼筛法的方法构造质数的无限流。我们从从 2 开始的整数开始,这是第一个质数。为了得到其余的质数,我们首先从其余的整数中过滤出 2 的倍数。这留下了一个以 3 开始的流,这是下一个质数。现在我们从这个流的其余部分中过滤出 3 的倍数。这留下了一个以 5 开始的流,这是下一个质数,依此类推。换句话说,我们通过筛选过程构造质数,描述如下:对流 S 进行筛选,形成一个流,其第一个元素是 S 的第一个元素,其余部分是通过从 S 的其余部分中过滤出 S 的第一个元素的所有倍数并进行筛选得到的。这个过程可以很容易地用流操作来描述:

function sieve(stream) {
    return pair(head(stream),
                () => sieve(stream_filter(
                                 x => ! is_divisible(x, head(stream)),
                                 stream_tail(stream))));
}
const primes = sieve(integers_starting_from(2));

现在,要找到特定的质数,我们只需要询问:

stream_ref(primes, 50);
233

思考一下由sieve建立的信号处理系统,如图 3.31 中的“亨德森图”所示。输入流馈入一个unpairer,将流的第一个元素与其余部分分开。第一个元素用于构造一个可被整除的过滤器,通过该过滤器传递其余部分,并将过滤器的输出馈送到另一个筛子箱中。然后将原始的第一个元素与内部筛子的输出相连,形成输出流。因此,流不仅是无限的,信号处理器也是无限的,因为筛子中包含一个筛子。

c3-fig-0031.jpg

图 3.31 将素数筛视为信号处理系统。每条实线代表正在传输的值流。从head到pair和filter的虚线表示这是一个单个值,而不是一个流。

隐式定义流

上述integers和fibs流是通过指定显式计算流元素的“生成”函数来定义的。指定流的另一种方法是利用延迟求值来隐式定义流。例如,以下语句定义了流ones为无限流的 1:

const ones = pair(1, () => ones);

这与递归函数的声明非常相似:ones是一个head为 1 且tail是一个承诺来求值ones的对,求值tail再次给我们一个 1 和一个承诺来求值ones,依此类推。

我们可以通过使用add_streams等操作来操作流,从而做更有趣的事情,该操作产生两个给定流的逐元素和。⁶⁴

function add_streams(s1, s2) {
    return stream_map_2((x1, x2) => x1 + x2, s1, s2);
}
posted @ 2026-10-17 13:17  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报