nus-cs1101s-sicp-js-merge-2

NUS CS1101S:SICP JavaScript 描述(三)

原文:SICP JavaScript

译者:飞龙

协议:CC BY-NC-SA 4.0

  1. a. 这两个函数对每棵树都产生相同的结果吗?如果不是,结果有何不同?这两个函数为图 2.16 中的树产生了什么列表?

  2. b. 这两个函数在将具有n个元素的平衡树转换为列表所需的步骤数量的增长顺序相同吗?如果不是,哪一个增长更慢?

练习 2.64

以下函数list_to_tree将有序列表转换为平衡二叉树。辅助函数partial_tree以整数n和至少n个元素的列表作为参数,并构造包含列表的前n个元素的平衡树。partial_tree返回的结果是一个由pair组成的对,其head是构造的树,tail是未包含在树中的元素列表。

function list_to_tree(elements) {
    return head(partial_tree(elements, length(elements)));
}
function partial_tree(elts, n) {
    if (n === 0) {
        return pair(null, elts);
    } else {
        const left_size = math_floor((n - 1) / 2);
        const left_result = partial_tree(elts, left_size);
        const left_tree = head(left_result);
        const non_left_elts = tail(left_result);
        const right_size = n - (left_size + 1);
        const this_entry = head(non_left_elts);
        const right_result = partial_tree(tail(non_left_elts), right_size);
        const right_tree = head(right_result);
        const remaining_elts = tail(right_result);
        return pair(make_tree(this_entry, left_tree, right_tree),
                    remaining_elts);
    }
}
  1. a. 用尽可能清晰的方式写一段简短的段落,解释partial_tree是如何工作的。为列表list(1, 3, 5, 7, 9, 11)绘制list_to_tree生成的树。

  2. b. list_to_tree将n个元素的列表转换为所需的步骤数量的增长顺序是多少?

练习 2.65

使用练习 2.63 和 2.64 的结果,为作为(平衡)二叉树实现的集合提供union_set和intersection_set的Θ(n)实现。³⁷

集合和信息检索

我们已经研究了使用列表表示集合的选项,并且已经看到数据对象的表示选择对使用数据的程序的性能有很大影响。专注于集合的另一个原因是,这里讨论的技术在涉及信息检索的应用中一再出现。

考虑一个包含大量个体记录的数据库,例如公司的人事档案或会计系统中的交易。典型的数据管理系统花费大量时间访问或修改记录中的数据,因此需要一种有效的访问记录的方法。这是通过识别每个记录的一部分作为标识键来完成的。键可以是任何唯一标识记录的东西。对于人事档案,它可能是员工的 ID 号。对于会计系统,它可能是交易号。无论键是什么,当我们将记录定义为数据结构时,我们应该包括一个key选择器函数,用于检索与给定记录关联的键。

现在我们将数据库表示为一组记录。要定位具有给定键的记录,我们使用一个名为lookup的函数,它以键和数据库作为参数,并返回具有该键的记录,如果没有这样的记录则返回false。函数lookup的实现方式几乎与is_element_of_set相同。例如,如果记录集被实现为无序列表,我们可以使用

function lookup(given_key, set_of_records) {
    return is_null(set_of_records)
           ? false
           : equal(given_key, key(head(set_of_records)))
           ? head(set_of_records)
           : lookup(given_key, tail(set_of_records));
}

当然,有更好的方法来表示大型集合,而不是作为无序列表。记录必须“随机访问”的信息检索系统通常通过基于树的方法实现,例如之前讨论过的二叉树表示。在设计这样的系统时,数据抽象的方法论可以提供很大的帮助。设计者可以使用简单直接的表示方法(例如无序列表)创建一个初始实现。这对于最终的系统来说是不合适的,但它可以用来提供一个“快速脏数据”数据库,用于测试系统的其余部分。稍后,数据表示可以修改为更复杂的形式。如果数据库是根据抽象选择器和构造器访问的,那么这种表示的更改不需要对系统的其余部分进行任何更改。

练习 2.66

为记录集结构化为按键的数值顺序排列的二叉树的情况实现lookup函数。

2.3.4 示例:霍夫曼编码树

本节提供了使用列表结构和数据抽象来操作集合和树的实践。应用于将数据表示为一系列一和零(位)的方法。例如,用于在计算机中表示文本的 ASCII 标准代码将每个字符编码为七位序列。使用七位可以区分2⁷,或 128 个可能的不同字符。一般来说,如果我们想区分n个不同的符号,我们将需要使用log₂n位每个符号。如果我们的所有消息由八个符号 A、B、C、D、E、F、G 和 H 组成,我们可以选择每个字符三位的代码,例如

| A | 000 | C | 010 | E | 100 | G | 110 |

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

| B | 001 | D | 011 | F | 101 | H | 111 |

使用这个代码,消息

BACADAEAFABBAAAGAH

被编码为 54 位的字符串

001000010000011000100000101000001001000000000110000111

诸如 ASCII 和上面的 A 到 H 代码之类的代码被称为固定长度代码,因为它们用相同数量的位表示消息中的每个符号。有时使用可变长度代码是有利的,其中不同的符号可以由不同数量的位表示。例如,莫尔斯电码不使用相同数量的点和破折号来表示字母表中的每个字母。特别是,最常见的字母 E 由一个单点表示。一般来说,如果我们的消息中有一些符号出现非常频繁,而另一些符号出现非常少,如果我们为频繁的符号分配较短的代码,我们可以更有效地编码数据(即每个消息使用更少的位)。考虑以下字母 A 到 H 的替代代码:

| A | 0 | C | 1010 | E | 1100 | G | 1110 |

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

| B | 100 | D | 1011 | F | 1101 | H | 1111 |

使用这个编码,与上面相同的消息被编码为

100010100101101100011010100100000111001111

这个字符串包含 42 位,因此与上面显示的固定长度编码相比,节省了超过 20%的空间。

使用可变长度编码的一个困难是在读取一系列零和一时,知道何时已经到达符号的结尾。摩尔斯电码通过在每个字母的点和划线序列之后使用特殊的分隔符编码(在这种情况下是暂停)来解决这个问题。另一个解决方案是设计编码,使得任何符号的完整编码都不是另一个符号的编码的开头(或前缀)。这样的编码称为前缀编码。在上面的例子中,A 由 0 编码,B 由 100 编码,因此没有其他符号可以以 0 或 100 开头的编码。

一般来说,如果我们使用可变长度的前缀编码,可以实现显著的节省,这些编码利用了消息中符号的相对频率。其中一种特定的方案称为哈夫曼编码方法,以其发现者大卫·哈夫曼命名。哈夫曼编码可以表示为一个二叉树,其叶子节点是被编码的符号。在树的每个非叶节点上,有一个包含所有位于节点下方的叶子中的符号的集合。此外,每个叶子上的符号被分配一个权重(即其相对频率),每个非叶节点包含一个权重,该权重是其下方所有叶子的权重之和。权重不用于编码或解码过程。我们将在下面看到它们如何用于帮助构建树。

图 2.18 显示了上面给出的 A 到 H 编码的哈夫曼树。叶子上的权重表明,该树是为了相对频率为 8 的 A、相对频率为 3 的 B 和其他每个字母的相对频率为 1 的消息而设计的。

c2-fig-0018.jpg

图 2.18 哈夫曼编码树。

给定一个哈夫曼树,我们可以通过从根开始并向下移动,直到到达包含符号的叶子为止,找到任何符号的编码。每次向下移动左分支时,我们将 0 添加到编码中,每次向下移动右分支时,我们将 1 添加到编码中。(我们决定要跟随哪个分支,通过测试看哪个分支是符号的叶节点或包含符号在其集合中。)例如,从图 2.18 的树的根开始,我们通过向右、向左、向右、向右移动到达 D 的叶子;因此,D 的编码是 1011。

使用哈夫曼树解码比特序列时,我们从根开始,使用比特序列的连续零和一来确定是向左还是向右移动。每次到达叶子时,我们在消息中生成一个新的符号,然后我们从树的根重新开始,找到下一个符号。例如,假设我们有上面的树和序列 10001010。从根开始,我们向右移动(因为字符串的第一个位是 1),然后向左移动(因为第二位是 0),然后向左移动(因为第三位也是 0)。这将带我们到 B 的叶子,因此解码消息的第一个符号是 B。现在我们再次从根开始,因为字符串的下一个位是 0,所以我们向左移动。这将带我们到 A 的叶子。然后我们从树的根重新开始,使用剩下的字符串 1010,所以我们向右、左、右、左移动,到达 C。因此,整个消息是 BAC。

生成哈夫曼树

给定一组符号和它们的相对频率的“字母表”,我们如何构建“最佳”编码?(换句话说,哪棵树会用最少的位来编码消息?)赫夫曼提出了一个算法来做到这一点,并证明了所得到的编码确实是相对频率与编码构造时频率匹配的消息的最佳可变长度编码。我们将不在这里证明赫夫曼编码的最优性,但我们将展示赫夫曼树是如何构建的。

生成赫夫曼树的算法非常简单。其思想是安排树,使得频率最低的符号出现在距离根最远的地方。从包含符号和它们的频率的叶子节点集开始,这些频率是根据构造编码的初始数据确定的。现在找到两个权重最低的叶子并合并它们,以产生一个节点,该节点具有这两个节点作为其左右分支。新节点的权重是两个权重的总和。从原始集合中删除这两个叶子,并用这个新节点替换它们。现在继续这个过程。在每一步中,合并两个权重最小的节点,将它们从集合中删除,并用一个节点替换它们,该节点具有这两个节点作为其左右分支。当只剩下一个节点时,即整个树的根节点时,该过程停止。以下是图 2.18 的赫夫曼树是如何生成的:

| 初始叶子 | {(A 8) (B 3) (C 1) (D 1) (E 1) (F 1) (G 1) (H 1)} |

| --- | --- |

| 合并 | {(A 8) (B 3) ({C D} 2) (E 1) (F 1) (G 1) (H 1)} |

| 合并 | {(A 8) (B 3) ({C D} 2) ({E F} 2) (G 1) (H 1)} |

| 合并 | {(A 8) (B 3) ({C D} 2) ({E F} 2) ({G H} 2)} |

| 合并 | {(A 8) (B 3) ({C D} 2) ({E F G H} 4)} |

| 合并 | {(A 8) ({B C D} 5) ({E F G H} 4)} |

| 合并 | {(A 8) ({B C D E F G H} 9)} |

| 最终合并 | {({A B C D E F G H} 17)} |

该算法并不总是指定一个唯一的树,因为在每一步可能没有唯一的最小权重节点。此外,合并两个节点的顺序(即哪个将成为右分支,哪个将成为左分支)是任意的。

表示赫夫曼树

在下面的练习中,我们将使用一个使用赫夫曼树来编码和解码消息并根据上述算法生成赫夫曼树的系统。我们将首先讨论树是如何表示的。

树的叶子由包含字符串leaf,叶子上的符号和权重的列表表示:

function make_leaf(symbol, weight) {
    return list("leaf", symbol, weight);
}
function is_leaf(object) {
    return head(object) === "leaf";
}
function symbol_leaf(x) { return head(tail(x)); }
function weight_leaf(x) { return head(tail(tail(x))); }

一棵一般的树将是一个字符串code_tree的列表,一个左分支,一个右分支,一组符号和一个权重。符号集将只是符号的列表,而不是一些更复杂的集合表示。当我们通过合并两个节点来构造一棵树时,我们得到树的权重是节点权重的总和,符号集是节点的符号集的并集。由于我们的符号集被表示为列表,我们可以使用我们在 2.2.1 节中定义的append函数来形成并集:

function make_code_tree(left, right) {
    return list("code_tree", left, right,
                append(symbols(left), symbols(right)),
                weight(left) + weight(right));
}

如果我们以这种方式制作一棵树,我们将有以下选择器:

function left_branch(tree) { return head(tail(tree)); }
function right_branch(tree) { return head(tail(tail(tree))); }
function symbols(tree) {
    return is_leaf(tree)
           ? list(symbol_leaf(tree))
           : head(tail(tail(tail(tree))));
}
function weight(tree) {
    return is_leaf(tree)
           ? weight_leaf(tree)
           : head(tail(tail(tail(tail(tree)))));
}

函数symbols和weight必须根据它们是与叶子还是一般树一起调用而稍有不同。这些是通用函数(可以处理多种数据类型的函数)的简单示例,我们将在 2.4 和 2.5 节中有更多关于它们的话要说。

解码函数

以下函数实现了解码算法。它以零和一的列表以及赫夫曼树作为参数。

function decode(bits, tree) {
    function decode_1(bits, current_branch) {
        if (is_null(bits)) {
            return null;
        } else {
            const next_branch = choose_branch(head(bits),
                                              current_branch);
            return is_leaf(next_branch)
                   ? pair(symbol_leaf(next_branch),
                          decode_1(tail(bits), tree))
                   : decode_1(tail(bits), next_branch);
        }
    }
    return decode_1(bits, tree);
}
function choose_branch(bit, branch) {
    return bit === 0
           ? left_branch(branch)
           : bit === 1
           ? right_branch(branch)
           : error(bit, "bad bit – choose_branch");
}

函数decode_1接受两个参数:剩余位的列表和树中的当前位置。它不断地“向下”移动树,根据列表中的下一个位是零还是一选择左侧或右侧分支。(这是通过函数choose_branch完成的。)当它到达叶子时,它返回该叶子上的符号作为消息中的下一个符号,将其与解码消息的其余部分连接起来,从树的根开始。请注意choose_branch的最后一个子句中的错误检查,如果函数在输入数据中找到除零或一之外的其他内容,则会发出投诉。

加权元素的集合

在我们对树的表示中,每个非叶节点包含一组符号,我们已经将其表示为一个简单的列表。然而,上面讨论的生成树算法要求我们还要处理叶子和树的集合,依次合并两个最小的项目。由于我们需要重复地在集合中找到最小的项目,因此方便起见,我们使用有序表示来表示这种类型的集合。

我们将一个叶子和树的集合表示为一个元素列表,按权重递增排列。用于构建集合的adjoin_set函数与练习 2.61 中描述的类似;然而,项目是按其权重进行比较的,并且要添加到集合中的元素从未在其中。

function adjoin_set(x, set) {
    return is_null(set)
           ? list(x)
           : weight(x) < weight(head(set))
           ? pair(x, set)
           : pair(head(set), adjoin_set(x, tail(set)));
}

以下函数接受一个符号-频率对的列表,例如

list(list("A", 4), list("B", 2), list("C", 1), list("D", 1))

并构建一个初始的有序叶子集,准备根据 Huffman 算法合并:

function make_leaf_set(pairs) {
    if (is_null(pairs)) {
        return null;
    } else {
        const first_pair = head(pairs);
        return adjoin_set(
                   make_leaf(head(first_pair),        // symbol
                             head(tail(first_pair))), // frequency
                   make_leaf_set(tail(pairs)));
    }
}
练习 2.67

声明一个编码树和一个样本消息:

const sample_tree = make_code_tree(make_leaf("A", 4),
                                   make_code_tree(make_leaf("B", 2),
                                                  make_code_tree(
                                                      make_leaf("D", 1),
                                                      make_leaf("C", 1))));
const sample_message = list(0, 1, 1, 0, 0, 1, 0, 1, 0, 1, 1, 1, 0);

使用decode函数解码消息,并给出结果。

练习 2.68

encode函数以消息和树作为参数,并生成给定编码消息的位列表。

function encode(message, tree) {
    return is_null(message)
           ? null
           : append(encode_symbol(head(message), tree),
                    encode(tail(message), tree));
}

函数encode_symbol由你编写,返回根据给定树对给定符号进行编码的位列表。你应该设计encode_symbol,以便如果符号根本不在树中,则发出错误信号。通过使用样本树对你在练习 2.67 中获得的结果进行编码,并查看它是否与原始样本消息相同来测试你的函数。

练习 2.69

以下函数以其参数为一个符号-频率对的列表(其中没有一个符号出现在多个对中),并根据 Huffman 算法生成 Huffman 编码树。

function generate_huffman_tree(pairs) {
    return successive_merge(make_leaf_set(pairs));
}

将转换成一个有序叶子集的make_leaf_set函数如上所示。使用make_code_tree编写successive_merge函数,以便连续合并集合中最小权重的元素,直到只剩下一个元素,即所需的 Huffman 树。(这个函数有点棘手,但并不是真的复杂。如果你发现自己设计了一个复杂的函数,那么你几乎肯定做错了什么。你可以充分利用我们使用有序集合表示的事实。)

练习 2.70

以下带有相关相对频率的八个符号字母表被设计用来高效地编码 1950 年代摇滚歌曲的歌词。(注意,“字母表”的“符号”不一定是单个字母。)

| A | 2 | NA | 16 |

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

| BOOM | 1 | SHA | 3 |

| GET | 2 | YIP | 9 |

| JOB | 2 | WAH | 1 |

使用generate_huffman_tree(练习 2.69)生成相应的 Huffman 树,并使用encode(练习 2.68)对以下消息进行编码:

找一份工作

Sha na na na na na na na na

找一份工作

Sha na na na na na na na na

Wah yip yip yip yip yip yip yip yip yip

Sha boom

需要多少位来进行编码?如果我们对这个八个符号字母表使用固定长度编码,那么需要的最小位数是多少?

练习 2.71

假设我们有一个用于n个符号的赫夫曼树,并且符号的相对频率为1, 2, 4, ..., 2^(n–1)。为n=5绘制树;为n=10绘制树。在这样的树中(对于一般的n),编码最频繁的符号需要多少位?最不频繁的符号呢?

练习 2.72

考虑您在练习 2.68 中设计的编码函数。编码一个符号所需步骤的增长顺序是多少?确保包括在每个遇到的节点上搜索符号列表所需的步骤数。一般来说,回答这个问题是困难的。考虑相对频率如练习 2.71 中描述的n个符号的特殊情况,并给出编码字母表中最频繁和最不频繁的符号所需步骤数的增长顺序(作为n的函数)。

2.4 抽象数据的多重表示

我们引入了数据抽象,这是一种构建系统的方法,使得程序的大部分内容可以独立于实现程序操作的数据对象的选择而指定。例如,我们在 2.1.1 节中看到了如何将设计使用有理数的程序的任务与使用计算机语言的原始机制构造复合数据的任务分开。关键思想是建立一个抽象屏障——在这种情况下,有理数的选择器和构造器(make_rat、numer、denom)——它将有理数的使用方式与它们在列表结构方面的底层表示隔离开来。类似的抽象屏障将执行有理数算术的函数的细节(add_rat、sub_rat、mul_rat和div_rat)与使用有理数的“高级”函数隔离开来。生成的程序的结构如图 2.1 所示。

这些数据抽象屏障是控制复杂性的强大工具。通过隔离数据对象的底层表示,我们可以将设计大型程序的任务分解为可以分开执行的较小任务。但这种数据抽象还不够强大,因为对于数据对象来说,“底层表示”并不总是有意义的。

首先,一个数据对象可能有多个有用的表示方式,我们可能希望设计可以处理多个表示的系统。举个简单的例子,复数可以用两种几乎等效的方式表示:直角坐标形式(实部和虚部)和极坐标形式(幅度和角度)。有时直角坐标形式更合适,有时极坐标形式更合适。事实上,可以想象一个系统,其中复数以两种方式表示,并且用于操作复数的函数可以使用任一种表示。

更重要的是,编程系统通常是由许多人在较长时间内共同设计的,受到随时间变化的要求的影响。在这样的环境中,不可能让每个人事先就数据表示的选择达成一致。因此,除了将表示与使用隔离的数据抽象屏障之外,我们还需要将不同的设计选择与其他选择隔离开,并允许不同的选择在单个程序中共存。此外,由于大型程序通常是通过组合先前独立设计的模块创建的,我们需要一些约定,允许程序员将模块“增量”地合并到更大的系统中,即无需重新设计或重新实现这些模块。

在本节中,我们将学习如何处理可能由程序的不同部分以不同方式表示的数据。这需要构建通用函数——可以操作以多种方式表示的数据的函数。我们构建通用函数的主要技术是使用类型标签的数据对象,即包含有关如何处理它们的显式信息的数据对象。我们还将讨论数据导向编程,这是一种强大而方便的实现策略,用于通过加法组装具有通用操作的系统。

我们从简单的复数示例开始。我们将看到类型标签和数据导向风格如何使我们能够为复数设计独立的矩形和极坐标表示,同时保持抽象“复数”数据对象的概念。我们将通过定义复数的算术函数(add_complex、sub_complex、mul_complex和div_complex)来实现这一点,这些函数是基于通用选择器定义的,这些选择器可以访问复数的部分,而不考虑数字的表示方式。如图 2.19 所示,得到的复数系统包含两种不同的抽象屏障。 “水平”抽象屏障起到与图 2.1 中相同的作用。它们将“高级”操作与“低级”表示隔离开来。此外,还有一个“垂直”屏障,使我们能够分别设计和安装替代表示。

c2-fig-0019.jpg

图 2.19 复数系统中的数据抽象屏障。

在第 2.5 节中,我们将展示如何使用类型标签和数据导向风格来开发通用算术包。这提供了函数(add、mul等),可以用于操作各种“数字”,并且在需要新类型的数字时可以轻松扩展。在第 2.5.3 节中,我们将展示如何在执行符号代数的系统中使用通用算术。

2.4.1 复数的表示

我们将开发一个系统,它可以对复数执行算术运算,这是一个简单但不切实际的示例,用于使用通用操作的程序。我们首先讨论复数的两种合理表示形式:有序对的矩形形式(实部和虚部)和极坐标形式(大小和角度)。第 2.4.2 节将展示如何通过使用类型标签和通用操作,使这两种表示可以在单个系统中共存。

与有理数一样,复数自然地表示为有序对。复数集可以被看作是一个二维空间,有两个正交轴,“实”轴和“虚”轴。(见图 2.20。)从这个角度来看,复数z = x + iy(其中i² = –1)可以被看作是平面上的点,其实部是x,虚部是y。复数的加法在这种表示中减少到坐标的加法:

Real-part(z[1] + z[2])  =  Real-part(z[1]) + Real-part(z[2]) 
Imaginary-part(z[1] + z[2])  =  Imaginary-part(z[1]) + Imaginary-part(z[2]) 

c2-fig-0020.jpg

图 2.20 复数作为平面上的点。

当乘法复数时,更自然地考虑以极坐标形式表示复数,即大小和角度(图 2.20 中的r和A)。两个复数的乘积是通过拉伸一个复数的长度并通过另一个复数的角度旋转得到的向量:

Magnitude(z[1] · z[2])  =  Magnitude(z[1]) · Magnitude(z[2]) 

Angle(z[1] · z[2])  =  Angle(z[1]) + Angle(z[2]) 

因此,复数有两种不同的表示形式,适用于不同的操作。然而,从使用复数的程序编写者的角度来看,数据抽象原则表明,无论计算机使用哪种表示形式,都应该提供操纵复数的所有操作。例如,通常有用的是能够找到由矩形坐标指定的复数的幅值。同样,通常有用的是能够确定由极坐标指定的复数的实部。

为了设计这样一个系统,我们可以遵循与在 2.1.1 节中设计有理数包时相同的数据抽象策略。假设复数的操作是根据四个选择器实现的:real_part、imag_part、magnitude和angle。还假设我们有两个用于构造复数的函数:make_from_real_imag返回具有指定实部和虚部的复数,make_from_mag_ang返回具有指定幅值和角度的复数。这些函数具有这样的属性,对于任何复数z,都有

make_from_real_imag(real_part(z), imag_part(z));

和

make_from_mag_ang(magnitude(z), angle(z));

产生等于z的复数。

使用这些构造函数和选择器,我们可以使用构造函数和选择器指定的“抽象数据”来实现复数的算术运算,就像我们在 2.1.1 节中对有理数所做的那样。如上面的公式所示,我们可以根据实部和虚部相加和相减复数,而根据幅值和角度相乘和相除复数:

function add_complex(z1, z2) {
    return make_from_real_imag(real_part(z1) + real_part(z2),
                               imag_part(z1) + imag_part(z2));
}
function sub_complex(z1, z2) {
    return make_from_real_imag(real_part(z1) - real_part(z2),
                               imag_part(z1) - imag_part(z2));
}
function mul_complex(z1, z2) {
    return make_from_mag_ang(magnitude(z1) * magnitude(z2),
                             angle(z1) + angle(z2));
}
function div_complex(z1, z2) {
    return make_from_mag_ang(magnitude(z1) / magnitude(z2),
                             angle(z1) - angle(z2));
}

为了完成复数包,我们必须选择一种表示,并且必须根据原始数字和原始列表结构实现构造函数和选择器。有两种明显的方法可以做到这一点:我们可以将复数表示为“矩形形式”,即一对(实部,虚部),或者将其表示为“极坐标形式”,即一对(幅值,角度)。我们应该选择哪一种呢?

为了使不同的选择具体化,想象有两个程序员,Ben Bitdiddle 和 Alyssa P. Hacker,他们独立设计复数系统的表示。Ben 选择用矩形形式表示复数。通过这种选择,选择复数的实部和虚部是直接的,就像构造具有给定实部和虚部的复数一样。为了找到幅值和角度,或者构造具有给定幅值和角度的复数,他使用三角关系

 x = r cos A  
 r = √(x² + y²)

 y = r sin A  
 A = arctan(y, x) 

这些关系将实部和虚部(x, y)与幅值和角度(r, A)联系起来。因此,Ben 的表示由以下选择器和构造函数给出:

function real_part(z) { return head(z); }
function imag_part(z) { return tail(z); }
function magnitude(z) {
    return math_sqrt(square(real_part(z)) + square(imag_part(z)));
}
function angle(z) {
    return math_atan2(imag_part(z), real_part(z));
}
function make_from_real_imag(x, y) { return pair(x, y); }

function make_from_mag_ang(r, a) {
    return pair(r * math_cos(a), r * math_sin(a));
}

相比之下,Alyssa 选择用极坐标形式表示复数。对她来说,选择幅值和角度是直接的,但她必须使用三角关系来获得实部和虚部。Alyssa 的表示是:

function real_part(z) {
    return magnitude(z) * math_cos(angle(z));
}
function imag_part(z) {
    return magnitude(z) * math_sin(angle(z));
}
function magnitude(z) { return head(z); }
function angle(z) { return tail(z); }
function make_from_real_imag(x, y) {
    return pair(math_sqrt(square(x) + square(y)),
                math_atan2(y, x));
}
function make_from_mag_ang(r, a) { return pair(r, a); }

数据抽象的学科确保add_complex、sub_complex、mul_complex和div_complex的相同实现将适用于 Ben 的表示或 Alyssa 的表示。

2.4.2 标记数据

将数据抽象视为“最小承诺原则”的应用之一。在 2.4.1 节中实现复数系统时,我们可以使用 Ben 的矩形表示或 Alyssa 的极坐标表示。由选择器和构造函数形成的抽象屏障使我们能够在最后可能的时刻推迟对数据对象的具体表示的选择,从而在系统设计中保留最大的灵活性。

最小承诺原则可以被推到更极端的程度。如果我们愿意,甚至可以在设计选择器和构造器之后保持表示的模糊性,并选择同时使用本的表示和 Alyssa 的表示。然而,如果两种表示都包含在一个系统中,我们将需要一些方法来区分极坐标形式的数据和矩形形式的数据。否则,例如,如果我们被要求找到一对(3,4)的“大小”,我们将不知道是回答 5(解释为矩形形式的数字)还是 3(解释为极坐标形式的数字)。实现这种区分的一种简单方法是在每个复数中包含一个类型标签——字符串“矩形”或“极坐标”。然后,当我们需要操作一个复数时,我们可以使用标签来决定应用哪个选择器。

为了操作带标签的数据,我们将假设我们有函数type_tag和contents,它们从数据对象中提取标签和实际内容(在复数的情况下是极坐标或矩形坐标)。我们还假设有一个函数attach_tag,它接受标签和内容,并产生一个带标签的数据对象。实现这一点的一种简单方法是使用普通的列表结构:

function attach_tag(type_tag, contents) {
    return pair(type_tag, contents);
}
function type_tag(datum) {
    return is_pair(datum)
           ? head(datum)
           : error(datum, "bad tagged datum – type_tag");
}
function contents(datum) {
    return is_pair(datum)
           ? tail(datum)
           : error(datum, "bad tagged datum – contents");
}

使用type_tag,我们可以定义谓词is_rectangular和is_polar,分别识别矩形和极坐标数:

function is_rectangular(z) {
    return type_tag(z) === "rectangular";
}
function is_polar(z) {
    return type_tag(z) === "polar";
}

有了类型标签,Ben 和 Alyssa 现在可以修改他们的代码,使得他们的两种不同表示可以在同一个系统中共存。每当 Ben 构造一个复数时,他将其标记为矩形。每当 Alyssa 构造一个复数时,她将其标记为极坐标。此外,Ben 和 Alyssa 必须确保他们的函数名称不冲突。一种方法是让 Ben 在他的每个表示函数的名称后附加后缀rectangular,让 Alyssa 在她的函数名称后附加polar。这是 Ben 修改后的矩形表示,来自第 2.4.1 节:

function real_part_rectangular(z) { return head(z); }
function imag_part_rectangular(z) { return tail(z); }
function magnitude_rectangular(z) {
    return math_sqrt(square(real_part_rectangular(z)) +
                     square(imag_part_rectangular(z)));
}
function angle_rectangular(z) {
    return math_atan(imag_part_rectangular(z),
                     real_part_rectangular(z));
}
function make_from_real_imag_rectangular(x, y) {
    return attach_tag("rectangular", pair(x, y));
}
function make_from_mag_ang_rectangular(r, a) {
    return attach_tag("rectangular",
                      pair(r * math_cos(a), r * math_sin(a)));
}

这是 Alyssa 修改后的极坐标表示:

function real_part_polar(z) {
    return magnitude_polar(z) * math_cos(angle_polar(z));
}
function imag_part_polar(z) {
    return magnitude_polar(z) * math_sin(angle_polar(z));
}
function magnitude_polar(z) { return head(z); }
function angle_polar(z) { return tail(z); }
function make_from_real_imag_polar(x, y) {
    return attach_tag("polar",
                      pair(math_sqrt(square(x) + square(y)),
                           math_atan(y, x)));
}
function make_from_mag_ang_polar(r, a) {
    return attach_tag("polar", pair(r, a));
}

每个通用选择器都被实现为一个函数,该函数检查其参数的标签,并调用处理该类型数据的适当函数。例如,要获得复数的实部,real_part检查标签以确定是使用 Ben 的real_part_rectangular还是 Alyssa 的real_part_polar。在任何一种情况下,我们使用contents来提取裸的、未标记的数据,并根据需要将其发送到矩形或极坐标函数:

function real_part(z) {
    return is_rectangular(z)
           ? real_part_rectangular(contents(z))
           : is_polar(z)
           ? real_part_polar(contents(z))
           : error(z, "unknown type – real_part");
}
function imag_part(z) {
    return is_rectangular(z)
           ? imag_part_rectangular(contents(z))
           : is_polar(z)
           ? imag_part_polar(contents(z))
           : error(z, "unknown type – imag_part");
}
function magnitude(z) {
    return is_rectangular(z)
           ? magnitude_rectangular(contents(z))
           : is_polar(z)
           ? magnitude_polar(contents(z))
           : error(z, "unknown type – magnitude");
}
function angle(z) {
    return is_rectangular(z)
           ? angle_rectangular(contents(z))
           : is_polar(z)
           ? angle_polar(contents(z))
           : error(z, "unknown type – angle");
}

为了实现复数算术运算,我们可以使用第 2.4.1 节中的相同函数add_complex、sub_complex、mul_complex和div_complex,因为它们调用的选择器是通用的,所以可以与任何表示一起使用。例如,函数add_complex仍然是

function add_complex(z1, z2) {
    return make_from_real_imag(real_part(z1) + real_part(z2),
                               imag_part(z1) + imag_part(z2));
}

最后,我们必须选择是使用本的表示法还是 Alyssa 的表示法来构造复数。一个合理的选择是,每当我们有实部和虚部时构造矩形数,每当我们有大小和角度时构造极坐标数:

function make_from_real_imag(x, y) {
    return make_from_real_imag_rectangular(x, y);
}
function make_from_mag_ang(r, a) {
    return make_from_mag_ang_polar(r, a);
}

生成的复数系统的结构如图 2.21 所示。该系统已被分解为三个相对独立的部分:复数算术运算、Alyssa 的极坐标实现和 Ben 的矩形实现。极坐标和矩形实现可以由本和 Alyssa 分别编写,并且这两者都可以作为第三个程序员在抽象构造器/选择器接口的基础表示来实现复数算术函数。

c2-fig-0021.jpg

图 2.21 通用复数算术系统的结构。

由于每个数据对象都带有其类型标记,因此选择器以通用方式对数据进行操作。也就是说,每个选择器被定义为具有取决于其应用的特定数据类型的行为。注意将各个表示接口化的一般机制:在给定的表示实现中(比如,艾莉莎的极坐标包),复数是一个无类型的对(幅度,角度)。当通用选择器对polar类型的数字进行操作时,它会去掉标记并将内容传递给艾莉莎的代码。相反,当艾莉莎构造一个用于一般用途的数字时,她会给它打上一个类型标记,以便它可以被高级函数适当地识别。随着数据对象从一个级别传递到另一个级别时,去掉和附加标记的这种纪律可以成为一种重要的组织策略,我们将在第 2.5 节中看到。

2.4.3 数据导向编程和可加性

检查数据类型并调用适当的函数的一般策略称为类型分派。这是在系统设计中获得模块化的强大策略。另一方面,像在第 2.4.2 节中实现分派的方法有两个显著的弱点。一个弱点是通用接口函数(real_part、imag_part、magnitude和angle)必须了解所有不同的表示。例如,假设我们想将新的复数表示合并到我们的复数系统中。我们需要用类型标识这个新表示,然后在每个通用接口函数中添加一个子句,以检查新类型并应用该表示的适当选择器。

该技术的另一个弱点是,即使可以单独设计各个表示,我们必须保证整个系统中没有两个函数具有相同的名称。这就是为什么本和艾莉莎不得不更改他们在第 2.4.1 节中的原始函数的名称。

这两个弱点的根本问题是实现通用接口的技术不是可加的。实现通用选择器函数的人必须每次安装新表示时修改这些函数,而接口各个表示的人必须修改他们的代码以避免名称冲突。在这些情况下,必须对代码进行的更改是直截了当的,但仍然必须进行这些更改,这是一种不便和错误的来源。对于复数系统来说,这并不是什么大问题,但是假设复数有数百种不同的表示,而不是两种。假设抽象数据接口中有许多通用选择器需要维护。事实上,假设没有一个程序员知道所有接口函数或所有表示。这个问题是真实存在的,必须在大规模数据库管理系统等程序中加以解决。

我们需要的是进一步模块化系统设计的手段。这是由编程技术数据导向编程提供的。要理解数据导向编程的工作原理,首先观察一下,每当我们处理一组通用操作,这些操作对一组不同类型都是通用的时候,实际上,我们正在处理一个二维表,该表包含一个轴上可能的操作和另一个轴上可能的类型。表中的条目是为每种类型的参数实现每个操作的函数。在前一节中开发的复数系统中,操作名称、数据类型和实际函数之间的对应关系分散在通用接口函数的各种条件子句中。但是相同的信息可以组织在一个表中,如图 2.22 所示。

c2-fig-0022.jpg

图 2.22 复数系统的操作表。

数据导向编程是一种直接设计程序与这样一个表一起工作的技术。以前,我们实现了将复数算术代码与两个表示包接口化的机制,作为一组函数,每个函数都对类型进行显式分派。在这里,我们将实现接口作为一个单一函数,该函数查找表中的操作名称和参数类型的组合,以找到正确的应用函数,然后将其应用于参数的内容。如果我们这样做,那么要向系统添加新的表示包,我们不需要更改任何现有的函数;我们只需要向表中添加新条目。

为了实现这个计划,假设我们有两个函数put和get,用于操作操作和类型表:

  • put(op, type, item)

    将item安装在表中,由op和type索引。

  • get(op, type)

    查找表中的op、type条目,并返回找到的项目。如果找不到项目,get返回一个唯一的原始值,该值由名称undefined引用,并由原始谓词is_undefined识别。

现在,我们可以假设put和get包含在我们的语言中。在第 3 章(第 3.3.3 节)中,我们将看到如何实现这些和其他操作来操作表。

以下是数据导向编程如何在复数系统中使用。开发了矩形表示的 Ben,实现他的代码就像他最初做的那样。他定义了一组函数或包,并通过向表中添加条目来将其接口化,告诉系统如何在矩形数上操作。这是通过调用以下函数完成的:

function install_rectangular_package() {
    // internal functions
    function real_part(z) { return head(z); }
    function imag_part(z) { return tail(z); }
    function make_from_real_imag(x, y) { return pair(x, y); }
    function magnitude(z) {
        return math_sqrt(square(real_part(z)) + square(imag_part(z)));
    }
    function angle(z) {
        return math_atan(imag_part(z), real_part(z));
    }
    function make_from_mag_ang(r, a) {
        return pair(r * math_cos(a), r * math_sin(a));
    }

    // interface to the rest of the system
    function tag(x) { return attach_tag("rectangular", x); }
    put("real_part", list("rectangular"), real_part);
    put("imag_part", list("rectangular"), imag_part);
    put("magnitude", list("rectangular"), magnitude);
    put("angle", list("rectangular"), angle);
    put("make_from_real_imag", "rectangular",
        (x, y) => tag(make_from_real_imag(x, y)));
    put("make_from_mag_ang", "rectangular",
        (r, a) => tag(make_from_mag_ang(r, a)));
    return "done";
}

请注意,这里的内部函数与第 2.4.1 节中 Ben 在孤立工作时编写的相同函数。为了将它们接口化到系统的其余部分,不需要进行任何更改。此外,由于这些函数声明是内部的安装函数,Ben 不必担心与矩形包外的其他函数名称冲突。为了将这些接口化到系统的其余部分,Ben 将他的real_part函数安装在操作名称real_part和类型list("rectangular")下,其他选择器也是如此。接口还定义了外部系统要使用的构造函数。这些与 Ben 内部定义的构造函数相同,只是它们附加了标签。

Alyssa 的极坐标包类似:

function install_polar_package() {
    // internal functions
    function magnitude(z) { return head(z); }
    function angle(z) { return tail(z); }
    function make_from_mag_ang(r, a) { return pair(r, a); }
    function real_part(z) {
        return magnitude(z) * math_cos(angle(z));
    }
    function imag_part(z) {
        return magnitude(z) * math_sin(angle(z));
    }
    function make_from_real_imag(x, y) {
        return pair(math_sqrt(square(x) + square(y)),
                    math_atan(y, x));
    }    

    // interface to the rest of the system
    function tag(x) { return attach_tag("polar", x); }
    put("real_part", list("polar"), real_part);
    put("imag_part", list("polar"), imag_part);
    put("magnitude", list("polar"), magnitude);
    put("angle", list("polar"), angle);
    put("make_from_real_imag", "polar",
        (x, y) => tag(make_from_real_imag(x, y)));
    put("make_from_mag_ang", "polar",
        (r, a) => tag(make_from_mag_ang(r, a)));
    return "done";
}

尽管 Ben 和 Alyssa 仍然使用彼此相同名称的原始函数(例如real_part),但这些声明现在是内部不同函数的(参见第 1.1.8 节),因此没有名称冲突。

复数算术选择器通过一个名为apply_generic的通用“操作”函数访问表,该函数将通用操作应用于一些参数。函数apply_generic在表中查找操作的名称和参数的类型,并在存在的情况下应用结果函数。

function apply_generic(op, args) {
    const type_tags = map(type_tag, args);
    const fun = get(op, type_tags);
    return ! is_undefined(fun)
           ? apply_in_underlying_javascript(fun, map(contents, args))
           : error(list(op, type_tags),
                   "no method for these types – apply_generic");
}

使用apply_generic,我们可以定义我们的通用选择器如下:

function real_part(z) { return apply_generic("real_part", list(z)); }
function imag_part(z) { return apply_generic("imag_part", list(z)); }
function magnitude(z) { return apply_generic("magnitude", list(z)); }
function angle(z)     { return apply_generic("angle", list(z));     }

注意,如果系统中添加了新的表示,这些都不会改变。

我们还可以从表中提取构造函数,供程序包外的程序使用,从实部和虚部以及从幅度和角度构造复数。与第 2.4.2 节一样,我们在有实部和虚部时构造矩形数,在有幅度和角度时构造极坐标数:

function make_from_real_imag(x, y) {
    return get("make_from_real_imag", "rectangular")(x, y);
}
function make_from_mag_ang(r, a) {
    return get("make_from_mag_ang", "polar")(r, a);
}
练习 2.73

第 2.3.2 节描述了一个执行符号微分的程序:

function deriv(exp, variable) {
    return is_number(exp)
           ? 0
           : is_variable(exp)
           ? is_same_variable(exp, variable) ? 1 : 0
           : is_sum(exp)
           ? make_sum(deriv(addend(exp), variable),
                      deriv(augend(exp), variable))
           : is_product(exp)
           ? make_sum(make_product(multiplier(exp),
                                   deriv(multiplicand(exp), variable)),
                      make_product(deriv(multiplier(exp), variable),
                                   multiplicand(exp)))
           // more rules can be added here
           : error(exp, "unknown expression type – deriv");
}
deriv(list("*", list("*", "x", "y"), list("+", "x", 4)), "x");
list("+", list("*", list("*", x, y), list("+", 1, 0)),
          list("*", list("+", list("*", x, 0), list("*", 1, y)),
                    list("+", x, 4)))

我们可以将这个程序视为对要进行区分的表达式类型进行调度。在这种情况下,数据的“类型标签”是代数运算符号(如+),正在执行的操作是deriv。我们可以通过将基本导数函数重写为数据导向风格来将这个程序转换为数据导向风格

function deriv(exp, variable) {
    return is_number(exp)
           ? 0
           : is_variable(exp)
           ? is_same_variable(exp, variable) ? 1 : 0
           : get("deriv", operator(exp))(operands(exp), variable);
}
function operator(exp) { return head(exp); }
function operands(exp) { return tail(exp); }
  1. a. 解释上面做了什么。为什么我们不能将谓词is_number和is_variable合并到数据导向调度中?

  2. b. 编写求和和乘积的导数函数,以及安装它们在上面程序使用的表中所需的辅助代码。

  3. c. 选择任何你喜欢的额外区分规则,比如指数的规则(练习 2.56),并将其安装在这个数据导向系统中。

  4. d. 在这个简单的代数操作程序中,表达式的类型是将其绑定在一起的代数运算符。然而,假设我们以相反的方式索引函数,使得deriv中的调度行看起来像

    get(operator(exp), "deriv")(operands(exp), variable);
    

    对导数系统的相应更改是什么?

练习 2.74

Insatiable Enterprises, Inc.是一个高度分散的企业集团公司,由遍布全球的大量独立部门组成。该公司的计算机设施刚刚通过一种巧妙的网络接口方案相互连接,使得整个网络对任何用户来说都像是一台单一的计算机。Insatiable 的总裁在首次尝试利用网络从部门档案中提取行政信息时,惊讶地发现,尽管所有部门的档案都以 JavaScript 中的数据结构实现,但使用的特定数据结构因部门而异。部门经理们匆忙召开会议,寻求一种整合档案的策略,既能满足总部的需求,又能保留部门的现有自治权。

展示如何使用数据导向编程实现这样的策略。例如,假设每个部门的人事档案都包含一个单一文件,其中包含以员工姓名为键的一组记录。集合的结构因部门而异。此外,每个员工的记录本身是一个集合(在不同部门结构不同),其中包含以address和salary为标识的信息。特别是:

  1. a. 为总部实现一个get_record函数,该函数从指定的人事档案中检索指定员工的记录。该函数应适用于任何部门的档案。解释个别部门的档案应该如何结构化。特别是,必须提供什么类型的信息?

  2. b. 为总部实现一个get_salary函数,该函数从任何部门的人事档案中返回给定员工的薪水信息。为了使此操作起作用,记录应该如何结构化?

  3. c. 为总部实现一个find_employee_record函数。这个函数应该搜索所有部门的档案,找到给定员工的记录并返回记录。假设这个函数的参数是员工的姓名和所有部门档案的列表。

  4. d. 当贪婪公司接管新公司时,必须进行哪些更改才能将新员工信息整合到中央系统中?

消息传递

数据导向编程的关键思想是通过显式处理操作和类型表来处理程序中的通用操作,比如图 2.22 中的表。我们在 2.4.2 节中使用的编程风格通过让每个操作负责自己的调度来组织所需的类型调度。实际上,这将操作和类型表分解为行,每个通用操作函数代表表的一行。

另一种实现策略是将表分解为列,而不是使用在数据类型上分派的“智能操作”,而是使用在操作名称上分派的“智能数据对象”。我们可以通过安排事物,使得数据对象,如矩形数,表示为一个函数,该函数以所需的操作名称作为输入,并执行指示的操作。在这样的学科中,make_from_real_imag可以被写成

function make_from_real_imag(x, y) {
    function dispatch(op) {
        return op === "real_part"
               ? x
               : op === "imag_part"
               ? y
               : op === "magnitude"
               ? math_sqrt(square(x) + square(y))
               : op === "angle"
               ? math_atan(y, x)
               : error(op, "unknown op – make_from_real_imag");
    }
    return dispatch;
}

相应的apply_generic函数,将通用操作应用于参数,现在只需将操作名称传递给数据对象,让对象完成工作:⁴⁵

function apply_generic(op, arg) { return head(arg)(op); }

请注意,make_from_real_imag返回的值是一个函数——内部的dispatch函数。这是在apply_generic请求执行操作时调用的函数。

这种编程风格称为消息传递。这个名字来自这样一个形象,即数据对象是一个接收所请求的操作名称作为“消息”的实体。我们已经在第 2.1.3 节中看到了消息传递的一个例子,在那里我们看到了如何定义pair,head和tail,而不是数据对象,而只是函数。在这里,我们看到消息传递不是一个数学技巧,而是一种组织具有通用操作的系统的有用技术。在本章的其余部分,我们将继续使用数据导向编程,而不是消息传递,来讨论通用算术操作。在第 3 章中,我们将回到消息传递,并且我们将看到它可以是一种构建仿真程序的强大工具。

练习 2.75

以消息传递方式实现构造函数make_from_mag_ang。这个函数应该类似于上面给出的make_from_real_imag函数。

练习 2.76

随着具有通用操作的大型系统的发展,可能需要新类型的数据对象或新操作。对于三种策略——具有显式分派的通用操作、数据导向风格和消息传递风格——描述必须对系统进行的更改,以添加新类型或新操作。对于经常需要添加新类型的系统,哪种组织方式最合适?对于经常需要添加新操作的系统,哪种组织方式最合适?

2.5 具有通用操作的系统

在前一节中,我们看到了如何设计系统,其中数据对象可以以多种方式表示。关键思想是通过通用接口函数将指定数据操作的代码与多种表示链接起来。现在我们将看到如何使用相同的思想,不仅定义可以适用于不同表示的操作,还可以定义可以适用于不同类型参数的操作。我们已经看到了几种不同的算术操作包:语言内置的原始算术(+,-,*,/),第 2.1.1 节中的有理数算术(add_rat,sub_rat,mul_rat,div_rat),以及我们在第 2.4.3 节中实现的复数算术。现在我们将使用数据导向技术来构建一个包含我们已经构建的所有算术包的算术操作包。

图 2.23 显示了我们将构建的系统的结构。注意抽象屏障。从使用“数字”的人的角度来看,有一个单一的函数add,它对提供的任何数字进行操作。函数add是通用接口的一部分,它允许使用数字的程序以统一的方式访问单独的普通算术、有理算术和复数算术包。任何单独的算术包(如复数包)本身可以通过通用函数(如add_complex)访问,这些函数结合了为不同表示设计的包(如矩形和极坐标)。此外,系统的结构是可加性的,因此可以单独设计各个算术包,并将它们组合以产生通用算术系统。

c2-fig-0023.jpg

图 2.23 通用算术系统。

2.5.1 通用算术操作

设计通用算术操作的任务类似于设计通用复数操作。例如,我们希望有一个通用加法函数add,它在普通数字上的行为类似于普通原始加法+,在有理数上类似于add_rat,在复数上类似于add_complex。我们可以通过遵循与我们在 2.4.3 节中用于实现复数的通用选择器相同的策略来实现add和其他通用算术操作。我们将为每种数字类型附加一个类型标签,并使通用函数根据其参数的数据类型分派到适当的包。

通用算术函数定义如下:

function add(x, y) { return apply_generic("add", list(x, y)); }
function sub(x, y) { return apply_generic("sub", list(x, y)); }
function mul(x, y) { return apply_generic("mul", list(x, y)); }
function div(x, y) { return apply_generic("div", list(x, y)); }

我们首先安装一个用于处理普通数字的包,即我们语言的原始数字。我们用字符串javascript_number标记这些数字。此包中的算术操作是原始算术函数(因此无需定义额外的函数来处理未标记的数字)。由于这些操作每个都需要两个参数,它们被安装在由列表list("javascript_number", "javascript_number")键入的表中。

function install_javascript_number_package() {
    function tag(x) {
        return attach_tag("javascript_number", x);
    }
    put("add", list("javascript_number", "javascript_number"),
        (x, y) => tag(x + y));
    put("sub", list("javascript_number", "javascript_number"),
        (x, y) => tag(x - y));
    put("mul", list("javascript_number", "javascript_number"),
        (x, y) => tag(x * y));
    put("div", list("javascript_number", "javascript_number"),
        (x, y) => tag(x / y));
    put("make", "javascript_number",
        x => tag(x));
    return "done";
}

JavaScript-number 包的用户将通过函数创建(标记)普通数字:

function make_javascript_number(n) {
    return get("make", "javascript_number")(n);
}

现在通用算术系统的框架已经建立,我们可以轻松地包含新类型的数字。这是一个执行有理数算术的包。注意,由于可加性的好处,我们可以在包中使用 2.1.1 节中的有理数代码作为内部函数,而无需修改:

function install_rational_package() {
    // internal functions
    function numer(x) { return head(x); }
    function denom(x) { return tail(x); }
    function make_rat(n, d) {
        const g = gcd(n, d);
        return pair(n / g, d / g);
    }
    function add_rat(x, y) {
        return make_rat(numer(x) * denom(y) + numer(y) * denom(x),
                        denom(x) * denom(y));
    }
    function sub_rat(x, y) {
        return make_rat(numer(x) * denom(y) - numer(y) * denom(x),
                        denom(x) * denom(y));
    }
    function mul_rat(x, y) {
        return make_rat(numer(x) * numer(y),
                        denom(x) * denom(y));
    }
    function div_rat(x, y) {
        return make_rat(numer(x) * denom(y),
                        denom(x) * numer(y));
    }
    // interface to rest of the system
    function tag(x) {
        return attach_tag("rational", x);
    }
    put("add", list("rational", "rational"),
        (x, y) => tag(add_rat(x, y)));
    put("sub", list("rational", "rational"),
        (x, y) => tag(sub_rat(x, y)));
    put("mul", list("rational", "rational"),
        (x, y) => tag(mul_rat(x, y)));
    put("div", list("rational", "rational"),
        (x, y) => tag(div_rat(x, y)));
    put("make", "rational",
        (n, d) => tag(make_rat(n, d)));
    return "done";
}

function make_rational(n, d) {
    return get("make", "rational")(n, d);
}

我们可以安装一个类似的包来处理复数,使用标签complex。在创建包时,我们从表中提取了矩形和极坐标包定义的操作make_from_real_imag和make_from_mag_ang。可加性允许我们使用相同的add_complex、sub_complex、mul_complex和div_complex函数作为内部操作,这些函数来自 2.4.1 节。

function install_complex_package() {
    // imported functions from rectangular and polar packages
    function make_from_real_imag(x, y) {
        return get("make_from_real_imag", "rectangular")(x, y);
    }
    function make_from_mag_ang(r, a) {
        return get("make_from_mag_ang", "polar")(r, a);
    }
    // internal functions
    function add_complex(z1, z2) {
        return make_from_real_imag(real_part(z1) + real_part(z2),
                                   imag_part(z1) + imag_part(z2));
    }
    function sub_complex(z1, z2) {
        return make_from_real_imag(real_part(z1) - real_part(z2),
                                   imag_part(z1) - imag_part(z2));
    }
    function mul_complex(z1, z2) {
        return make_from_mag_ang(magnitude(z1) * magnitude(z2),
                                 angle(z1) + angle(z2));
    }
function div_complex(z1, z2) {
    return make_from_mag_ang(magnitude(z1) / magnitude(z2),
                                 angle(z1) - angle(z2));
    }
    // interface to rest of the system
    function tag(z) { return attach_tag("complex", z); }
    put("add", list("complex", "complex"),
        (z1, z2) => tag(add_complex(z1, z2)));
    put("sub", list("complex", "complex"),
        (z1, z2) => tag(sub_complex(z1, z2)));
    put("mul", list("complex", "complex"),
        (z1, z2) => tag(mul_complex(z1, z2)));
    put("div", list("complex", "complex"),
        (z1, z2) => tag(div_complex(z1, z2)));
    put("make_from_real_imag", "complex",
        (x, y) => tag(make_from_real_imag(x, y)));
    put("make_from_mag_ang", "complex",
        (r, a) => tag(make_from_mag_ang(r, a)));
    return "done";
}

复数包之外的程序可以通过实部和虚部或幅度和角度构造复数。注意,最初在矩形和极坐标包中定义的基础函数如何被导出到复数包,并从那里导出到外部世界。

function make_complex_from_real_imag(x, y){
    return get("make_from_real_imag", "complex")(x, y);
}
function make_complex_from_mag_ang(r, a){
    return get("make_from_mag_ang", "complex")(r, a);
}

我们这里有一个两级标签系统。一个典型的复数,比如矩形形式中的3 + 4i,如图 2.24 所示。外部标签("complex")用于将数字定向到复数包。一旦进入复数包,下一个标签("rectangular")用于将数字定向到矩形包。在一个庞大而复杂的系统中,可能会有许多级别,每个级别通过通用操作与下一个级别接口连接。当数据对象被“传递”时,用于将其定向到适当包的外部标签被剥离(通过应用contents),并且下一个级别的标签(如果有)变得可见,以便用于进一步的分派。

c2-fig-0024.jpg

图 2.24 矩形形式中 3 + 4i 的表示。

在上述包中,我们使用了add_rat、add_complex和其他算术函数,其实就是原始写法。然而,一旦这些声明内部化到不同的安装函数中,它们就不再需要彼此不同的名称:我们可以在两个包中简单地将它们命名为add、sub、mul和div。

练习 2.77

路易斯·里森纳试图求值表达式magnitude(z),其中z是图 2.24 中显示的对象。令他惊讶的是,他没有得到答案 5,而是从apply_generic得到了一个错误消息,说没有针对类型list("complex")的magnitude操作的方法。他把这个交互展示给了 Alyssa P. Hacker,后者说:“问题在于复数选择器从未为"complex"数字定义过,只为"polar"和"rectangular"数字定义过。要使其工作,你只需将以下内容添加到complex包中:”

put("real_part", list("complex"), real_part);
put("imag_part", list("complex"), imag_part);
put("magnitude", list("complex"), magnitude);
put("angle",      list("complex"), angle);

详细描述为什么这样可以工作。例如,跟踪在求值表达式magnitude(z)时调用的所有函数。特别是apply_generic被调用了多少次?在每种情况下分派给了哪个函数?

练习 2.78

javascript_number包中的内部函数本质上只是对原始函数+、-等的调用。由于我们的类型标签系统要求每个数据对象都附有类型,因此无法直接使用语言的原始函数。实际上,所有 JavaScript 实现都有一个类型系统,它们在内部使用。诸如is_string和is_number之类的原始谓词确定数据对象是否具有特定类型。修改第 2.4.2 节中的type_tag、contents和attach_tag的定义,以便我们的通用系统利用 JavaScript 的内部类型系统。也就是说,该系统应该像以前一样工作,只是普通数字应该简单地表示为 JavaScript 数字,而不是作为head为字符串"javascript_number"的对。

练习 2.79

定义一个通用相等谓词is_equal,用于测试两个数字的相等性,并将其安装在通用算术包中。此操作应适用于普通数字、有理数和复数。

练习 2.80

定义一个通用谓词is_equal_to_zero,用于测试其参数是否为零,并将其安装在通用算术包中。此操作应适用于普通数字、有理数和复数。

2.5.2 组合不同类型的数据

我们已经看到如何定义一个统一的算术系统,包括普通数、复数、有理数和我们可能决定发明的任何其他类型的数字,但我们忽略了一个重要问题。到目前为止,我们定义的操作将不同的数据类型视为完全独立。因此,有单独的包用于添加两个普通数或两个复数。我们还没有考虑的是,定义跨类型边界的操作是有意义的,比如将复数加到普通数上。我们已经非常费力地引入了程序的各个部分之间的障碍,以便它们可以分开开发和理解。我们希望以一种精心控制的方式引入跨类型操作,以便我们可以支持它们,而不严重违反我们的模块边界。

处理跨类型操作的一种方法是为每种可能的类型组合设计不同的函数,这些函数对于这些操作是有效的。例如,我们可以扩展复数包,以便它提供一个将复数加到普通数的函数,并使用标签list("complex", "javascript_number")将其安装在表中:⁴⁶

// to be included in the complex package
function add_complex_to_javascript_num(z, x) {
    return make_complex_from_real_imag(real_part(z) + x, imag_part(z));
}
put("add", list("complex", "javascript_number"),
    (z, x) => tag(add_complex_to_javascript_num(z, x)));

这种技术是行得通的,但它很繁琐。在这样的系统中,引入新类型的成本不仅仅是为该类型构建函数包,还包括实现跨类型操作的函数的构建和安装。这很容易比定义类型本身的操作所需的代码多得多。这种方法还破坏了我们将单独的包累加在一起的能力,或者至少限制了个别包的实现者需要考虑其他包的程度。例如,在上面的例子中,处理复数和普通数的混合操作应该是复数包的责任。然而,组合有理数和复数可能由复数包、有理数包或者使用这两个包中提取的操作的第三个包来完成。在设计具有许多包和许多跨类型操作的系统时,制定关于包之间责任分配的一致政策可能是一个艰巨的任务。

强制转换

在完全不相关的操作作用于完全不相关的类型的一般情况下,实现显式的跨类型操作,尽管繁琐,是人们所能期望的最好的。幸运的是,我们通常可以通过利用可能潜在于我们类型系统中的附加结构来做得更好。通常不同的数据类型并不是完全独立的,而且可能有一些方法,可以将一个类型的对象视为另一种类型的对象。这个过程称为强制转换。例如,如果我们被要求将一个普通数与一个复数进行算术运算,我们可以将普通数视为其虚部为零的复数。这将问题转化为两个复数的组合问题,可以通过复数算术包以普通方式处理。

一般来说,我们可以通过设计强制转换函数来实现这个想法,这些函数将一个类型的对象转换为另一个类型的等效对象。这是一个典型的强制转换函数,它将给定的普通数转换为具有该实部和零虚部的复数:

function javascript_number_to_complex(n) {
    return make_complex_from_real_imag(contents(n), 0);
}

我们将这些强制转换函数安装在一个特殊的强制转换表中,索引为两种类型的名称:

put_coercion("javascript_number", "complex",
             javascript_number_to_complex);

(我们假设有put_coercion和get_coercion函数可用于操作此表。)通常表中的某些槽位将是空的,因为通常无法将每种类型的任意数据对象强制转换为所有其他类型。例如,没有办法将任意复数强制转换为普通数字,因此在表中将不包括通用的complex_to_javascript_number函数。

一旦建立了强制转换表,我们可以通过修改第 2.4.3 节中的apply_generic函数以统一处理强制转换。当要求应用操作时,我们首先检查操作是否适用于参数的类型,就像以前一样。如果是,我们将分派到在操作和类型表中找到的函数。否则,我们尝试强制转换。为简单起见,我们只考虑有两个参数的情况。我们检查强制转换表,看看第一种类型的对象是否可以被强制转换为第二种类型。如果可以,我们强制转换第一个参数,然后再次尝试操作。如果第一种类型的对象通常无法被强制转换为第二种类型,我们尝试以另一种方式进行强制转换,看看是否有一种方法可以将第二个参数强制转换为第一个参数的类型。最后,如果没有已知的方法可以强制转换任一类型为另一类型,我们放弃。以下是该函数:

function apply_generic(op, args) {
    const type_tags = map(type_tag, args);
    const fun = get(op, type_tags);
    if (! is_undefined(fun)) {
        return apply(fun, map(contents, args));
    } else {
        if (length(args) === 2) {
            const type1 = head(type_tags);
            const type2 = head(tail(type_tags));
            const a1 = head(args);
            const a2 = head(tail(args));
            const t1_to_t2 = get_coercion(type1, type2);
            const t2_to_t1 = get_coercion(type2, type1);
            return ! is_undefined(t1_to_t2)
                   ? apply_generic(op, list(t1_to_t2(a1), a2))
                   : ! is_undefined(t2_to_t1)
                   ? apply_generic(op, list(a1, t2_to_t1(a2)))
                   : error(list(op, type_tags),
                           "no method for these types");
        } else {
            return error(list(op, type_tags),
                         "no method for these types");
        }
    }
}

与上述定义显式跨类型操作的方法相比,这种强制转换方案具有许多优点。虽然我们仍然需要编写强制转换函数来关联类型(对于具有n种类型的系统,可能需要n²个函数),但我们只需要为每对类型编写一个函数,而不是为每组类型和每个通用操作编写不同的函数。我们在这里所依赖的是这样一个事实,即类型之间的适当转换只取决于类型本身,而不取决于要应用的操作。

另一方面,可能有一些应用程序,我们的强制方案并不够通用。即使要合并的两个对象都无法转换为对方的类型,仍然可以通过将两个对象转换为第三种类型来执行操作。为了处理这种复杂性并仍然保持程序的模块化,通常需要构建利用类型之间关系中更进一步结构的系统,接下来我们将讨论这一点。

类型的层次结构

上述强制转换方案依赖于类型对之间存在自然关系的存在。通常,不同类型之间的关系具有更多的“全局”结构。例如,假设我们正在构建一个通用算术系统来处理整数、有理数、实数和复数。在这样的系统中,将整数视为有理数的一种特殊类型,而有理数又是实数的一种特殊类型,实数又是复数的一种特殊类型,这是非常自然的。实际上,我们拥有所谓的类型层次结构,例如,整数是有理数的子类型(即,可以应用于有理数的任何操作也可以自动应用于整数)。相反,我们说有理数是整数的超类型。我们这里拥有的特定层次结构是一种非常简单的结构,其中每种类型最多只有一个超类型和一个子类型。这样的结构,称为塔,在图 2.25 中有所说明。

c2-fig-0025.jpg

图 2.25 类型的层次结构。

如果我们有一个塔结构,那么我们可以极大地简化将新类型添加到层次结构中的问题,因为我们只需要指定新类型如何嵌入到其上一级的超类型中,以及它如何成为其下一级类型的超类型。例如,如果我们想要将整数添加到复数中,我们不需要明确地定义一个特殊的强制转换函数integer_to_complex。相反,我们定义整数如何转换为有理数,有理数如何转换为实数,以及实数如何转换为复数。然后,我们允许系统通过这些步骤将整数转换为复数,然后将两个复数相加。

我们可以按照以下方式重新设计我们的apply_generic函数:对于每种类型,我们需要提供一个raise函数,它可以将该类型的对象在塔中提升一级。然后,当系统需要对不同类型的对象进行操作时,它可以依次提升较低的类型,直到所有对象在塔中处于相同的级别。(练习 2.83 和 2.84 涉及实现这种策略的细节。)

塔的另一个优点是,我们可以很容易地实现这样一个概念,即每种类型“继承”了在超类型上定义的所有操作。例如,如果我们没有为查找整数的实部提供一个特殊的函数,我们仍然应该期望整数的real_part是由于整数是复数的子类型而被定义的。在一个塔中,我们可以通过修改apply_generic以一种统一的方式安排这种情况发生。如果给定对象的类型没有直接定义所需的操作,我们将把对象提升到其超类型并重试。因此,我们沿着塔向上爬行,随着我们的参数的转换,直到我们找到可以执行所需操作的级别,或者达到顶部(在这种情况下我们放弃)。

塔相对于更一般的层次结构的另一个优点是,它为我们提供了一种简单的方法来将数据对象“降低”到最简单的表示。例如,如果我们将 2 + 3i加到4 - 3i,我们希望得到的答案是整数 6,而不是复数6 + 0i。练习 2.85 讨论了实现这种降低操作的方法。(诀窍在于我们需要一种通用的方法来区分那些可以被降低的对象,比如6 + 0i,和那些不能被降低的对象,比如6 + 2i。)

层次结构的不足之处

如果我们的系统中的数据类型可以自然地排列成一个塔,这将极大地简化处理不同类型的通用操作的问题,正如我们所见。不幸的是,这种情况通常并非如此。图 2.26 展示了一种更复杂的混合类型排列,其中显示了不同类型的几何图形之间的关系。我们看到,一般来说,一个类型可能有多个子类型。例如,三角形和四边形都是多边形的子类型。此外,一个类型可能有多个超类型。例如,等腰直角三角形可以被视为等腰三角形或直角三角形。这个多超类型的问题特别棘手,因为这意味着在层次结构中没有唯一的“提升”类型的方法。在对象上应用操作时,找到“正确”的超类型可能需要在整个类型网络中进行大量搜索,这是apply_generic等函数的一个问题。由于一般情况下一个类型有多个子类型,因此在将值“降低”到类型层次结构时也存在类似的问题。在设计大型系统的同时处理大量相互关联的类型是非常困难的,也是当前研究的一个重要领域。

c2-fig-0026.jpg

图 2.26 几何图形类型之间的关系。

练习 2.81

路易斯·里森纳(Louis Reasoner)注意到apply_generic可能会尝试将参数强制转换为彼此的类型,即使它们已经具有相同的类型。因此,他推断,我们需要在强制转换表中放置函数以将每种类型的参数“强制”为其自己的类型。例如,除了上面显示的javascript_number_to_complex强制转换外,他还会这样做:

function javascript_number_to_javascript_number(n) { return n; }
function complex_to_complex(n) { return n; }
put_coercion("javascript_number", "javascript_number",
             javascript_number_to_javascript_number);
put_coercion("complex", "complex", complex_to_complex);
  1. a. 如果安装了路易斯的强制转换函数,当apply_generic使用两个类型为complex的参数或两个类型为javascript_number的参数调用一个在这些类型的表中找不到的操作时会发生什么?例如,假设我们定义了一个通用的幂运算操作:

    function exp(x, y) {
        return apply_generic("exp", list(x, y));
    }
    

    并且在 JavaScript-number 包中放置了一个幂运算函数,但在任何其他包中都没有:

    // following added to JavaScript-number package
    put("exp", list("javascript_number", "javascript_number"),
        (x, y) => tag(math_exp(x, y))); // using primitive math_exp
    

    如果我们用两个复数作为参数调用exp会发生什么?

  2. b. 路易斯是否正确,需要对相同类型的参数进行强制转换,或者apply_generic按原样工作?

  3. c. 修改apply_generic,使其在两个参数具有相同类型时不尝试强制转换。

练习 2.82

展示如何将apply_generic泛化以处理多个参数的一般情况。一种策略是尝试将所有参数强制转换为第一个参数的类型,然后转换为第二个参数的类型,依此类推。给出一个情况的例子,该策略(以及上面给出的两参数版本)不够通用。(提示:考虑存在一些适当的混合类型操作的情况,这些操作将不会被尝试。)

练习 2.83

假设您正在设计一个用于处理类型塔的通用算术系统,如图 2.25 所示:整数,有理数,实数,复数。对于每种类型(复数除外),设计一个将该类型的对象提升一级的函数。展示如何安装一个通用的raise操作,该操作将适用于每种类型(复数除外)。

练习 2.84

使用练习 2.83 的raise操作,修改apply_generic函数,使其通过本节讨论的连续提升的方法强制其参数具有相同的类型。您需要设计一种方法来测试两种类型中哪种更高。以一种与系统的其余部分“兼容”的方式进行此操作,并且不会在向类型塔添加新级别时出现问题。

练习 2.85

本节提到了一种通过将数据对象尽可能降低到类型塔中的方法来“简化”数据对象。设计一个函数drop,以实现练习 2.83 中描述的类型塔的降低。关键是以一种一般的方式决定对象是否可以降低。例如,复数1.5 + 0i可以降低到real,复数1 + 0i可以降低到integer,而复数2 + 3i根本无法降低。以下是确定对象是否可以降低的计划:首先定义一个“推”对象下降到塔中的通用操作project。例如,投影复数将涉及丢弃虚部。然后,如果我们project它并将结果raise回到我们开始的类型,我们得到与我们开始的相等的东西,那么数字就可以被丢弃。展示如何通过编写一个drop函数来详细实现这个想法,该函数将对象尽可能地降低。您需要设计各种投影操作⁵⁰并在系统中安装project作为通用操作。您还需要使用通用相等谓词,例如练习 2.79 中描述的谓词。最后,使用drop重写练习 2.84 中的apply_generic,以便“简化”其答案。

练习 2.86

假设我们想处理复数,其实部、虚部、幅度和角度可以是普通数、有理数,或者我们可能希望添加到系统中的其他数。描述并实现系统需要的更改,以适应这一点。您将不得不定义诸如sine和cosine这样的操作,这些操作对普通数和有理数是通用的。

2.5.3 示例:符号代数

符号代数表达式的操作是一个复杂的过程,它展示了大规模系统设计中出现的许多最困难的问题。一般来说,代数表达式可以被看作是一个分层结构,即操作符应用于操作数的树。我们可以通过从一组原始对象(如常数和变量)开始,并通过代数运算符(如加法和乘法)组合这些对象来构造代数表达式。与其他语言一样,我们形成抽象,使我们能够用简单的术语引用复合对象。符号代数中的典型抽象是诸如线性组合、多项式、有理函数或三角函数等概念。我们可以将这些看作是复合的“类型”,它们经常有助于指导表达式的处理。例如,我们可以描述表达式

x² sin(y² + 1) + x cos 2y + cos(y³ – 2y²)

作为x的多项式,其系数是y的多项式的三角函数。

我们不打算在这里开发一个完整的代数运算系统。这样的系统是非常复杂的程序,包含深奥的代数知识和优雅的算法。我们要做的是看一看代数运算的一个简单但重要的部分:多项式的算术。我们将说明这样一个系统的设计者面临的决策类型,并且如何应用抽象数据和通用操作的思想来帮助组织这一努力。

多项式的算术

我们在设计一个用于多项式算术的系统时的第一个任务是决定多项式到底是什么。多项式通常是相对于某些变量(多项式的不定元)定义的。为简单起见,我们将限制自己只处理具有一个不定元的多项式(一元多项式)。我们将定义多项式为项的和,每个项可以是系数、不定元的幂,或者系数和不定元的乘积。系数被定义为不依赖于多项式的不定元的代数表达式。例如,

5x² + 3x + 7

是一个关于x的简单多项式,以及

(y² + 1)x³ + (2y)x + 1

是一个关于x的多项式,其系数是y的多项式。

我们已经在绕过一些棘手的问题。第一个多项式是否与多项式5y² + 3y + 7相同,还是不同?一个合理的答案可能是“如果我们把多项式纯粹看作是一个数学函数,那么是的,但如果我们把多项式看作是一种语法形式,那么不是”。第二个多项式在代数上等价于关于y的多项式,其系数是x的多项式。我们的系统应该认识到这一点吗?还是不应该?此外,还有其他表示多项式的方式,例如作为因子的乘积,或者(对于一元多项式)作为根的集合,或者作为在指定点集上的多项式值的列表。我们可以通过决定在我们的代数运算系统中,“多项式”将是一种特定的语法形式,而不是其潜在的数学含义来回避这些问题。

现在我们必须考虑如何进行多项式的算术。在这个简单的系统中,我们只考虑加法和乘法。此外,我们要求要组合的两个多项式必须具有相同的不定元。

我们将通过遵循数据抽象的熟悉原则来设计我们的系统。我们将使用一个称为poly的数据结构来表示多项式,它由一个变量和一组项组成。我们假设我们有选择器variable和term_list,它们从多项式中提取这些部分,以及一个构造函数make_poly,它从给定的变量和项列表组装一个多项式。一个变量将只是一个字符串,因此我们可以使用第 2.3.2 节中的is_same_variable函数来比较变量。以下函数定义了多项式的加法和乘法:

function add_poly(p1, p2) {
    return is_same_variable(variable(p1), variable(p2))
           ? make_poly(variable(p1),
                       add_terms(term_list(p1), term_list(p2)))
           : error(list(p1, p2), "polys not in same var – add_poly");
}
function mul_poly(p1, p2) {
    return is_same_variable(variable(p1), variable(p2))
           ? make_poly(variable(p1),
                       mul_terms(term_list(p1), term_list(p2)))
           : error(list(p1, p2), "polys not in same var – mul_poly");
}

为了将多项式纳入我们的通用算术系统,我们需要为它们提供类型标签。我们将使用标签"polynomial",并在操作表中为带标签的多项式安装适当的操作。我们将把所有代码嵌入到多项式包的安装函数中,类似于第 2.5.1 节中的安装函数:

function install_polynomial_package() {
    // internal functions
    // representation of poly
    function make_poly(variable, term_list) {
        return pair(variable, term_list);
    }
    function variable(p) { return head(p); }
    function term_list(p) { return tail(p); }
    〈functions is_same_variable and is_variable from section 2.3.2〉    

    // representation of terms and term lists
    〈functions adjoin_term…coeff from text below〉

    function add_poly(p1, p2) { … }
    〈functions used by add_poly〉
    function mul_poly(p1, p2) { … }
    〈functions used by mul_poly〉    

    // interface to rest of the system
    function tag(p) { return attach_tag("polynomial", p); }
    put("add", list("polynomial", "polynomial"),
        (p1, p2) => tag(add_poly(p1, p2)));
    put("mul", list("polynomial", "polynomial"),
        (p1, p2) => tag(mul_poly(p1, p2)));
    put("make", "polynomial",
        (variable, terms) => tag(make_poly(variable, terms)));
    return "done";
}

多项式的加法是逐项进行的。必须合并同一次幂的项(即,具有相同未知数幂的项)。这是通过形成一个新的同一次幂的项来完成的,其系数是被加数的系数的和。一个加数中的项,如果在另一个加数中没有相同次幂的项,那么它们就简单地累积到正在构造的和多项式中。

为了操作项列表,我们将假定我们有一个构造函数the_empty_termlist,它返回一个空的项列表,以及一个构造函数adjoin_ term,它将一个新的项添加到项列表中。我们还将假定我们有一个谓词is_empty_termlist,它告诉我们给定的项列表是否为空,一个选择器first_term,它从项列表中提取最高次项,以及一个选择器rest_terms,它返回除了最高次项之外的所有项。为了操作项,我们将假设我们有一个构造函数make_term,它构造一个具有给定次序和系数的项,以及选择器order和coeff,它们分别返回项的次序和系数。这些操作使我们能够将项和项列表都视为数据抽象,我们可以分别担心它们的具体表示。

以下是构造两个多项式之和的项列表的函数;⁵³请注意,我们通过允许在**else**后面的块中添加另一个条件语句,略微扩展了第 1.3.2 节中描述的条件语句的语法:

function add_terms(L1, L2) {
    if (is_empty_termlist(L1)) {
        return L2;
    } else if (is_empty_termlist(L2)) {
        return L1;
    } else {
        const t1 = first_term(L1);
        const t2 = first_term(L2);
        return order(t1) > order(t2)
               ? adjoin_term(t1, add_terms(rest_terms(L1), L2))
               : order(t1) < order(t2)
               ? adjoin_term(t2, add_terms(L1, rest_terms(L2)))
               : adjoin_term(make_term(order(t1),
                                       add(coeff(t1), coeff(t2))),
                             add_terms(rest_terms(L1),
                                       rest_terms(L2)));
    }
}

这里最重要的一点是,我们使用了通用的加法函数add来将合并的项的系数相加。这具有强大的后果,我们将在下面看到。

为了将两个项列表相乘,我们将第一个列表的每个项与另一个列表的所有项相乘,重复使用mul_term_by_all_terms,它将给定的项与给定的项列表中的所有项相乘。结果项列表(对于第一个列表的每个项)被累积到一个和中。将两个项相乘形成一个次序是因子次序之和,系数是因子系数之积的项:

function mul_terms(L1, L2) {
    return is_empty_termlist(L1)
          ? the_empty_termlist
          : add_terms(mul_term_by_all_terms(
                                first_term(L1), L2),
                      mul_terms(rest_terms(L1), L2));
}
function mul_term_by_all_terms(t1, L) {
    if (is_empty_termlist(L)) {
        return the_empty_termlist;
    } else {
        const t2 = first_term(L);
        return adjoin_term(
                   make_term(order(t1) + order(t2),
                             mul(coeff(t1), coeff(t2))),
                   mul_term_by_all_terms(t1, rest_terms(L)));
    }
}

这确实就是多项式的加法和乘法。请注意,由于我们使用通用函数add和mul对项进行操作,我们的多项式包自动能够处理通用算术包已知的任何类型的系数。如果我们包括一个强制转换机制,例如在第 2.5.2 节中讨论的机制之一,那么我们也能够自动处理不同系数类型的多项式的操作,例如

c2-fig-5012.jpg

因为我们在通用算术系统中安装了多项式加法和乘法函数add_ poly和mul_poly作为类型polynomial的add和mul操作,我们的系统也能够自动处理多项式操作,例如

(y + 1)x² + (y² + 1)x + (y – 1) · (y – 2)x + (y³ + 7)

原因是当系统尝试组合系数时,它将通过add和mul进行分派。由于系数本身是多项式(在y中),这些将使用add_poly和mul_poly进行组合。结果是一种“数据导向递归”,例如,对mul_poly的调用将导致递归调用mul_poly以便乘以系数。如果系数的系数本身是多项式(可能用于表示三个变量的多项式),数据方向将确保系统将遵循另一级递归调用,以及数据结构所决定的任意级别。

表示项列表

最后,我们必须面对实现一个良好的项列表表示的工作。项列表实际上是一组由项的顺序作为键的系数集合。因此,可以将第 2.3.3 节中讨论的任何表示集合的方法应用于此任务。另一方面,我们的函数add_terms和mul_terms总是按顺序从最高到最低的顺序访问项列表。因此,我们将使用某种有序列表表示。

我们应该如何构造表示项列表的列表?一个考虑因素是我们打算操作的多项式的“密度”。如果多项式在大多数阶的系数中都是非零的,则称为密集多项式。如果它有许多零项,则称为稀疏。例如,

A:x⁵ + 2x⁴ + 3x² – 2x – 5

是一个密集多项式,而

B:x¹⁰⁰ + 2x² + 1

是稀疏的。

密集多项式的项列表最有效地表示为系数的列表。例如,上面的多项式A可以很好地表示为list(1, 2, 0, 3, -2, -5)。在这种表示中,项的顺序是以该项系数开头的子列表的长度,减 1。这对于稀疏多项式B来说是一个糟糕的表示:会有一个巨大的零项列表,中间夹杂着一些孤独的非零项。项列表的更合理的表示是作为非零项的列表,其中每个项都是包含该项顺序和该顺序系数的列表。在这样的方案中,多项式B可以有效地表示为list(list(100, 1), list(2, 2), list(0, 1))。由于大多数多项式操作是在稀疏多项式上执行的,我们将使用这种方法。我们将假设项列表被表示为项的列表,从最高阶到最低阶的项。一旦我们做出了这个决定,实现项和项列表的选择器和构造器就很简单了。

function adjoin_term(term, term_list) {
    return is_equal_to_zero(coeff(term))
           ? term_list
           : pair(term, term_list);
}

const the_empty_termlist = null;
function first_term(term_list) { return head(term_list); }
function rest_terms(term_list) { return tail(term_list); }
function is_empty_termlist(term_list) { return is_null(term_list); }

function make_term(order, coeff) { return list(order, coeff); }
function order(term) { return head(term); }
function coeff(term) { return head(tail(term)); }

其中is_equal_to_zero如练习 2.80 中所定义。(另见下面的练习 2.87。)

多项式包的用户将通过以下函数创建(标记的)多项式:

function make_polynomial(variable, terms) {
    return get("make", "polynomial")(variable, terms);
}
练习 2.87

为通用算术包中的多项式安装is_equal_to_zero。这将允许adjoin_term用于系数本身是多项式的多项式。

练习 2.88

扩展多项式系统以包括多项式的减法。(提示:您可能会发现定义一个通用的否定操作很有帮助。)

练习 2.89

声明实现上述项列表表示的函数,适用于密集多项式。

练习 2.90

假设我们希望拥有一个对稀疏和密集多项式都有效的多项式系统。实现这一点的一种方法是在我们的系统中允许两种类型的项列表表示。这种情况类似于第 2.4 节中的复数示例,我们在那里允许了矩形和极坐标表示。为了做到这一点,我们必须区分不同类型的项列表,并使项列表上的操作成为通用的。重新设计多项式系统以实现这种泛化。这是一项重大工作,而不是一个局部变化。

练习 2.91

一个一元多项式可以被另一个一元多项式除以,得到一个多项式商和一个多项式余数。例如,

(x⁵ - 1) / (x² - 1) = x³ + x, 余数x – 1

可以通过长除法进行除法。也就是说,将被除数的最高次项除以除数的最高次项。结果是商的第一项。接下来,将结果乘以除数,从被除数中减去,通过递归地将差除以除数来得出答案的其余部分。当除数的次序超过被除数的次序时停止,并声明被除数为余数。此外,如果被除数变为零,返回零作为商和余数。

我们可以设计一个div_poly函数,模仿add_poly和mul_poly的模式。该函数检查两个多项式是否具有相同的变量。如果是这样,div_poly会剥离变量并将问题传递给div_terms,后者在项列表上执行除法操作。div_poly函数最终会将div_terms提供的结果重新附加到变量上。设计div_terms计算除法的商和余数是方便的。div_terms函数可以接受两个项列表作为参数,并返回商项列表和余数项列表的列表。

通过填写缺失的部分来完成div_terms的以下定义。使用这个来实现div_poly,它接受两个多项式作为参数,并返回商和余数多项式的列表。

function div_terms(L1, L2) {
    if (is_empty_termlist(L1)) {
        return list(the_empty_termlist, the_empty_termlist);
    } else {
        const t1 = first_term(L1);
        const t2 = first_term(L2);
        if (order(t2) > order(t1)) {
            return list(the_empty_termlist, L1);
        } else {
            const new_c = div(coeff(t1), coeff(t2));
            const new_o = order(t1) - order(t2);
            const rest_of_result = 〈compute rest of result recursively〉;
            〈form and return complete result〉
        }
    }
}
符号代数中的类型层次结构

我们的多项式系统说明了一个类型的对象(多项式)实际上可能是复杂对象,其中包含许多不同类型的对象作为部分。这在定义通用操作时并不困难。我们只需要为执行复合类型的部分必要操作安装适当的通用操作。事实上,我们看到多项式形成一种“递归数据抽象”,因为多项式的部分本身可能是多项式。我们的通用操作和数据导向的编程风格可以轻松处理这种复杂性。

另一方面,多项式代数是一个数据类型不能自然排列成塔形的系统。例如,可能存在多项式x,其系数是y的多项式。也可能存在多项式y,其系数是x的多项式。这两种类型都没有自然的“上下”关系,然而通常需要将每个集合中的元素相加。有几种方法可以做到这一点。一种可能性是通过扩展和重新排列项,将一个多项式转换为另一个多项式的类型,使得两个多项式具有相同的主要变量。通过对变量进行排序,可以在这个上建立一个类似塔的结构,从而总是将任何多项式转换为一个“规范形式”,其中优先级最高的变量占主导地位,较低优先级的变量被埋在系数中。这种策略效果相当不错,只是转换可能会不必要地扩展多项式,使其难以阅读,也许效率更低。这种塔形策略对于这个领域或者用户可以使用各种组合形式动态地发明新类型的任何领域都不是自然的。例如,三角函数、幂级数和积分。

控制强制转换在大规模代数操作系统的设计中是一个严重的问题并不奇怪。这样的系统的复杂性很大程度上涉及到各种类型之间的关系。事实上,可以说我们还没有完全理解强制转换。事实上,我们甚至还没有完全理解数据类型的概念。然而,我们所知道的为我们提供了强大的结构化和模块化原则,以支持大型系统的设计。

练习 2.92

通过对变量进行排序,扩展多项式包,使得多项式在不同变量中的加法和乘法可以工作。(这并不容易!)

扩展练习:有理函数

我们可以将我们的通用算术系统扩展到包括有理函数。这些是“分数”,其分子和分母都是多项式,例如

c2-fig-5014.jpg

该系统应该能够对有理函数进行加法、减法、乘法和除法,并执行诸如

c2-fig-5015.jpg

(这里的和已经通过去除公因子来简化。普通的“交叉乘法”会产生一个五次多项式的四次多项式。)

如果我们修改我们的有理算术包,使其使用通用操作,那么它将做我们想要的事情,除了将分数化简为最低项的问题。

练习 2.93

修改有理算术包以使用通用操作,但更改make_rat,使其不尝试将分数化简为最低项。通过调用make_rational在两个多项式上测试您的系统,以生成一个有理函数

const p1 = make_polynomial("x", list(make_term(2, 1), make_term(0, 1)));
const p2 = make_polynomial("x", list(make_term(3, 1), make_term(0, 1)));
const rf = make_rational(p2, p1);

现在使用add将rf加到自身。您会观察到这个加法函数不会将分数化简为最低项。

我们可以使用与整数相同的思想将多项式分数化简为最低项:修改make_rat,将分子和分母都除以它们的最大公约数。对于多项式来说,“最大公约数”的概念是有意义的。事实上,我们可以使用基本上与整数相同的欧几里得算法来计算两个多项式的最大公约数。 ⁵⁷整数版本是

function gcd(a, b) {
    return b === 0
           ? a
           : gcd(b, a % b);
}

使用这个,我们可以做一个明显的修改来定义一个在项列表上工作的 GCD 操作:

function gcd_terms(a, b) {
    return is_empty_termlist(b)
           ? a
           : gcd_terms(b, remainder_terms(a, b));
}

其中remainder_terms选出了由练习 2.91 中实现的项列表除法操作div_terms返回的列表的余项部分。

练习 2.94

使用div_terms,实现函数remainder_terms并使用它来定义gcd_terms。现在编写一个函数gcd_poly来计算两个多项式的多项式 GCD。(如果两个多项式不是在相同的变量上,则该函数应发出错误信号。)在系统中安装一个通用操作greatest_common_divisor,对于多项式,它将简化为gcd_poly,对于普通数字,它将简化为普通的gcd。作为测试,尝试

const p1 = make_polynomial("x", list(make_term(4, 1), make_term(3, -1),
                                     make_term(2, -2), make_term(1, 2)));
const p2 = make_polynomial("x", list(make_term(3, 1), make_term(1, -1)));
greatest_common_divisor(p1, p2);

并通过手动检查您的结果。

练习 2.95

定义P[1],P[2]和P[3]为多项式

P[1]: x² – 2x + 1

P[2]: 11x² + 7

P[3]: 13x + 5

现在定义Q[1]为P[1]和P[2]的乘积,Q[2]为P[1]和P[3]的乘积,并使用greatest_common_divisor(练习 2.94)来计算Q[1]和Q[2]的 GCD。请注意,答案与P[1]不同。这个例子引入了非整数运算到计算中,导致 GCD 算法出现困难。要理解发生了什么,请尝试在计算 GCD 时跟踪gcd_terms,或者尝试手动进行除法。

如果我们使用以下修改的 GCD 算法(实际上只适用于具有整数系数的多项式),我们可以解决练习 2.95 中展示的问题。在 GCD 计算中进行任何多项式除法之前,我们将被除数乘以一个整数常数因子,选择保证在除法过程中不会出现任何分数。因此,我们的答案将与实际 GCD 相差一个整数常数因子,但在将有理函数化简为最低项时并不重要;GCD 将用于同时除以分子和分母,因此整数常数因子将被抵消。

更准确地说,如果P和Q是多项式,让O[1]是P的阶(即P的最高项的阶),O[2]是Q的阶。让c是Q的首项系数。然后可以证明,如果我们将P乘以整数化因子c^(1+)^O^(1–)^O^(2,),则所得多项式可以使用div_terms算法除以Q,而不会引入任何分数。将被除数乘以这个常数然后再除以它,有时被称为P除以Q的伪除法。除法的余数称为伪余数。

练习 2.96
  1. a. 实现函数pseudoremainder_terms,它与remainder_terms类似,只是在调用div_terms之前,它会将被除数乘以上面描述的整数化因子。修改gcd_terms以使用pseudoremainder_terms,并验证greatest_common_divisor现在在练习 2.95 中产生具有整数系数的答案。

  2. b. GCD 现在具有整数系数,但它们比P[1]的系数大。修改gcd_terms,以便通过将所有系数除以它们的(整数)最大公约数来删除答案的系数的公共因子。

因此,这是将有理函数化简为最低项的方法:

  • 使用练习 2.96 中的gcd_terms版本计算分子和分母的 GCD。

  • 当获得 GCD 时,在通过 GCD 除以分子和分母之前,将分子和分母都乘以相同的整数化因子,以便通过 GCD 除法不会引入任何非整数系数。作为因子,您可以使用 GCD 的首项系数提高到 1 + O[1] – O[2]的幂,其中O[2]是 GCD 的阶,O[1]是分子和分母的阶的最大值。这将确保通过 GCD 除以分子和分母不会引入任何分数。

  • 这个操作的结果将是一个分子和一个分母,它们都有整数系数。系数通常会非常大,因为所有的整数化因素,所以最后一步是通过计算分子和分母的所有系数的(整数)最大公约数,并通过这个因子除以它来去除冗余因子。

练习 2.97
  1. a. 实现这个算法作为一个函数reduce_terms,它接受两个项列表n和d作为参数,并返回一个列表nn,dd,它们是通过上面给出的算法将n和d化简为最低项。还要编写一个函数reduce_poly,类似于add_poly,它检查两个多项式是否具有相同的变量。如果是这样,reduce_poly会剥离变量并将问题传递给reduce_terms,然后重新将变量附加到reduce_terms提供的两个项列表上。

  2. b. 定义一个类似于reduce_terms的函数,它可以为整数做原始的make_rat所做的事情:

    function reduce_integers(n, d) {
        const g = gcd(n, d);
        return list(n / g, d / g);
    }
    

    并定义reduce作为一个通用操作,它调用apply_generic来分派到reduce_poly(对于polynomial参数)或reduce_integers(对于javascript_ number参数)。现在,通过在组合给定的分子和分母形成有理数之前调用reduce,可以轻松地使有理算术包将分数化简为最低项。系统现在可以处理整数或多项式的有理表达式。要测试你的程序,请尝试在这个扩展练习的开头的例子:

    const p1 = make_polynomial("x", list(make_term(1, 1), make_term(0, 1)));
    const p2 = make_polynomial("x", list(make_term(3, 1), make_term(0, -1)));
    const p3 = make_polynomial("x", list(make_term(1, 1)));
    const p4 = make_polynomial("x", list(make_term(2, 1), make_term(0, -1)));
    
    const rf1 = make_rational(p1, p2);
    const rf2 = make_rational(p3, p4);
    
    add(rf1, rf2);
    

    看看你是否得到了正确的答案,正确地化简为最低项。

GCD 计算是任何对有理函数进行操作的系统的核心。上面使用的算法,虽然在数学上很直接,但非常慢。这种慢是部分由于大量的除法运算,部分是由于伪除法生成的中间系数的巨大大小。代数操作系统开发中的一个活跃领域是设计更好的算法来计算多项式的最大公约数。

三、模块化、对象和状态

原文:3 Modularity, Objects, and State

译者:飞龙

协议:CC BY-NC-SA 4.0

变化中安宁

(即使它在变化,它仍然保持不变。)

——赫拉克利特

变化越大,越是相同。

——阿方斯·卡尔

前面的章节介绍了构成程序的基本元素。我们看到了原始函数和原始数据是如何组合成复合实体的,我们也了解到抽象对于帮助我们应对大型系统的复杂性是至关重要的。但是这些工具并不足以用于设计程序。有效的程序合成还需要组织原则,可以指导我们制定程序的整体设计。特别是,我们需要策略来帮助我们结构大型系统,使它们成为模块化,也就是说,它们可以被“自然地”划分为可以单独开发和维护的连贯部分。

一种强大的设计策略,特别适用于构建用于建模物理系统的程序,是基于被建模系统的结构来构建程序的结构。对于系统中的每个对象,我们构建一个相应的计算对象。对于每个系统动作,我们在计算模型中定义一个符号操作。我们使用这种策略的希望是,扩展模型以适应新对象或新动作将不需要对程序进行战略性的更改,只需要添加这些对象或动作的新符号模拟。如果我们在系统组织上取得了成功,那么要添加新功能或调试旧功能,我们只需要在系统的局部部分工作。

在很大程度上,我们组织大型程序的方式是由我们对待建模系统的看法所决定的。在本章中,我们将研究两种突出的组织策略,这些策略源自对系统结构的两种相当不同的“世界观”。第一种组织策略集中在对象上,将一个大型系统视为一组随时间可能发生变化的不同对象。另一种组织策略集中在系统中流动的信息流上,就像电气工程师看待信号处理系统一样。

对象为基础的方法和流处理方法都在编程中引发了重大的语言问题。对于对象,我们必须关注计算对象如何改变,但又保持其身份不变。这将迫使我们放弃我们旧的替换计算模型(第 1.1.5 节),转而采用更机械但理论上不太可解的环境模型计算。处理对象、改变和身份的困难是需要在计算模型中处理时间的一个基本结果。当我们允许程序并发执行的可能性时,这些困难变得更加严重。当我们在模型中将模拟时间与计算机在求值过程中发生的事件顺序分离时,流方法可以得到最充分的利用。我们将使用一种称为延迟求值的技术来实现这一点。

3.1 分配和本地状态

我们通常将世界看作由独立的对象组成,每个对象都有随时间变化的状态。如果一个对象的行为受其历史影响,那么就说这个对象“有状态”。例如,银行账户有状态,因为对于问题“我可以取 100 美元吗?”的答案取决于存款和取款交易的历史。我们可以通过一个或多个状态变量来描述对象的状态,这些变量中包含了足够的关于历史的信息,以确定对象的当前行为。在一个简单的银行系统中,我们可以通过当前余额来描述账户的状态,而不是通过记住整个账户交易历史。

在由许多对象组成的系统中,这些对象很少是完全独立的。每个对象可能通过相互作用影响其他对象的状态,这些相互作用将一个对象的状态变量与其他对象的状态变量耦合在一起。事实上,当系统的状态变量可以被分成紧密耦合的子系统,并且这些子系统只与其他子系统松散耦合时,系统由独立对象组成的观点是最有用的。

这种对系统的观点可以是组织系统的计算模型的强大框架。为了使这样的模型具有模块化,它应该被分解成模拟系统中实际对象的计算对象。每个计算对象必须有其自己的本地状态变量来描述实际对象的状态。由于被建模系统中的对象的状态随时间变化,相应计算对象的状态变量也必须改变。如果我们选择通过编程语言中的普通符号名称来模拟系统中的时间流逝,那么语言必须提供赋值操作来使我们能够改变与名称关联的值。

3.1.1 本地状态变量

为了说明我们所说的具有时间变化状态的计算对象,让我们模拟从银行账户中取钱的情况。我们将使用一个名为withdraw的函数来实现这一点,该函数以要提取的amount作为参数。如果账户中有足够的钱来容纳提款,那么withdraw应该返回提款后剩余的余额。否则,withdraw应该返回消息资金不足。例如,如果我们开始

在账户中有 100 美元的情况下,我们应该使用withdraw获得以下响应序列:

withdraw(25);
75

withdraw(25);
50

withdraw(60);
"Insufficient funds"

withdraw(15);
35

注意,表达式withdraw(25)被求值两次,产生不同的值。这是函数的一种新行为。到目前为止,我们所有的 JavaScript 函数都可以被视为计算数学函数的规范。对函数的调用计算了应用于给定参数的函数的值,并且对具有相同参数的同一函数的两次调用总是产生相同的结果。¹

到目前为止,我们所有的名称都是不可变的。当应用函数时,其参数引用的值从不改变,一旦声明被求值,声明的名称就不会改变其值。为了实现像withdraw这样的函数,我们引入了变量声明,它使用关键字let,除了使用关键字const的常量声明。我们可以声明一个变量balance来表示账户中的余额,并将withdraw定义为一个访问balance的函数。withdraw函数检查balance是否至少与请求的amount一样大。如果是,withdraw将balance减去amount并返回balance的新值。否则,withdraw返回资金不足的消息。这是balance和withdraw的声明:

let balance = 100;

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

通过表达式语句来减少balance

balance = balance - amount;

赋值表达式的语法是

name = new-value

这里的name已经用let声明或作为函数参数,并且new-value是任何表达式。赋值改变了name,使得其值是通过求值new-value得到的结果。在这种情况下,我们正在改变balance,使其新值是从先前的balance值中减去amount得到的结果。²

withdraw函数还使用语句序列来导致两个语句在if测试为真的情况下被求值:首先减少balance,然后返回balance的值。一般来说,执行一个序列

stmt[1] stmt[2] ...stmt[n]

导致语句stmt[1]到stmt[n]按顺序进行求值。

尽管withdraw的功能符合预期,但变量balance存在问题。如上所述,balance是程序环境中定义的一个名称,并且可以自由访问和修改。如果我们可以将balance作为withdraw的内部变量,那将会更好,这样withdraw将是唯一可以直接访问balance的函数,任何其他函数只能间接访问balance(通过调用withdraw)。这将更准确地模拟balance是withdraw用来跟踪账户状态的本地状态变量的概念。

我们可以通过以下方式将balance作为withdraw的内部变量来重写定义:

function make_withdraw_balance_100() {
    let balance = 100;
    return amount => {
               if (balance >= amount) {
                   balance = balance - amount;
                   return balance;
               } else {
                   return "Insufficient funds";
               }
           };
}
const new_withdraw = make_withdraw_balance_100();

我们在这里所做的是使用let建立一个具有本地变量balance的环境,绑定到初始值 100。在这个本地环境中,我们使用 lambda 表达式创建一个函数,该函数以amount作为参数,并且像我们之前的withdraw函数一样行为。这个函数——作为make_withdraw_balance_100函数的主体求值的结果返回——行为与withdraw完全相同,但它的变量balance不可被任何其他函数访问。

将赋值与变量声明结合起来是我们用于构建具有本地状态的计算对象的一般编程技术。不幸的是,使用这种技术会引发一个严重的问题:当我们首次引入函数时,我们还引入了求值的替换模型(第 1.1.5 节)来解释函数应用的含义。我们说,应用一个函数,其主体是一个返回语句,应该被解释为用参数的值替换后求值函数的返回表达式。对于主体更复杂的函数,我们需要用参数的值替换来求值整个主体。问题在于,一旦我们在语言中引入赋值,替换就不再是函数应用的充分模型。(我们将在第 3.1.3 节看到为什么会这样。)因此,从技术上讲,我们目前无法理解new_withdraw函数的行为方式。为了真正理解new_withdraw这样的函数,我们需要开发一个新的函数应用模型。在第 3.2 节中,我们将介绍这样一个模型,以及对赋值和变量声明的解释。然而,首先,我们将检查new_withdraw所建立的主题的一些变化。

函数的参数以及使用let声明的名称都是变量。以下函数make_withdraw创建“取款处理器”。make_withdraw中的参数balance指定了账户中的初始金额。

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

函数make_withdraw可以如下使用来创建两个对象W1和W2:

const W1 = make_withdraw(100);
const W2 = make_withdraw(100);

W1(50);
50

W2(70);
30

W2(40);
"Insufficient funds"

W1(40);
10

观察到W1和W2是完全独立的对象,每个对象都有自己的本地状态变量balance。从一个对象中提取不会影响另一个对象。

我们还可以创建处理存款和取款的对象,因此我们可以表示简单的银行账户。以下是一个返回具有指定初始余额的“银行账户对象”的函数:

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;
    }
    function dispatch(m) {
        return m === "withdraw"
               ? withdraw
               : m === "deposit"
               ? deposit
               : error(m, "unknown request – make_account");
    }
    return dispatch;
}

每次调用make_account都会设置一个具有本地状态变量balance的环境。在此环境中,make_account定义了访问balance的函数deposit和withdraw,以及一个接受“消息”作为输入并返回两个本地函数之一的附加函数dispatch。dispatch函数本身作为代表银行账户对象的值返回。这正是我们在 2.4.3 节中看到的消息传递编程风格,尽管在这里我们将其与修改本地变量的能力结合使用。

函数make_account可以如下使用:

const acc = make_account(100);

acc("withdraw")(50);
50

acc("withdraw")(60);
"Insufficient funds"

acc("deposit")(40);
90

acc("withdraw")(60);
30

每次调用acc都会返回本地定义的deposit或withdraw函数,然后将其应用于指定的amount。与make_withdraw一样,对make_account的另一个调用

const acc2 = make_account(100);

将产生一个完全独立的账户对象,该对象维护其自己的本地balance。

练习 3.1

累加器是一个反复调用的函数,每次只接受一个数字参数并将其累积到总和中。每次调用它时,它都会返回当前累积的总和。编写一个函数make_accumulator,它生成累加器,每个累加器都维护一个独立的总和。make_accumulator的输入应该指定总和的初始值;例如

const a = make_accumulator(5); 

a(10);
15

a(10);
25
练习 3.2

在软件测试应用程序中,能够计算在计算过程中调用给定函数的次数是很有用的。编写一个函数make_monitored,该函数以一个函数f作为输入,该函数本身接受一个输入。make_monitored返回的结果是第三个函数,称为mf,它通过维护内部计数器来跟踪其被调用的次数。如果mf的输入是字符串"how many calls",那么mf将返回计数器的值。如果输入是字符串"reset count",那么mf将计数器重置为零。对于任何其他输入,mf返回调用f对该输入的结果并增加计数器。例如,我们可以制作sqrt函数的监视版本:

const s = make_monitored(math_sqrt);

s(100);
10

s("how many calls");
`1`
练习 3.3

修改make_account函数,使其创建受密码保护的账户。也就是说,make_account应该接受一个字符串作为额外的参数,如下所示

const acc = make_account(100, "secret password");

生成的账户对象应该只处理在创建账户时附带的密码,并且否则应该返回投诉:

acc("secret password", "withdraw")(40);
60

acc("some other password", "deposit")(40);
"Incorrect password"
练习 3.4

通过添加另一个本地状态变量修改练习 3.3 的make_account函数,以便如果一个账户连续访问超过七次并且密码不正确,它会调用函数call_the_cops。

3.1.2 引入赋值的好处

正如我们将看到的,将赋值引入我们的编程语言会导致一系列困难的概念问题。然而,将系统视为具有本地状态的对象集合是一种维护模块化设计的强大技术。举一个简单的例子,考虑设计一个函数rand,每次调用该函数时,它都会返回一个随机选择的整数。

“随机选择”是什么意思并不清楚。我们想要的是连续调用rand产生具有均匀分布统计特性的数字序列。我们不会在这里讨论生成适当序列的方法。相反,让我们假设我们有一个函数rand_update,如果我们从给定的数字x[1]开始并形成

`x[2]` = rand_update(`x[1]`);
`x[3]` = rand_update(`x[2]`);

然后值序列x[1], x[2], x[3], ...,将具有所需的统计特性。⁷

我们可以将rand实现为一个带有本地状态变量x的函数,该变量初始化为某个固定值random_init。每次调用rand都会计算x的当前值的rand_update,将其作为随机数返回,并将其存储为x的新值。

function make_rand() {
    let x = random_init;
    return () => {
              x = rand_update(x);
              return x;
           };
}
const rand = make_rand();

当然,我们可以通过直接调用rand_update来生成相同的随机数序列,而不使用赋值。然而,这意味着我们程序的任何部分使用随机数都必须明确记住x的当前值,以便作为rand_update的参数传递。要意识到这将是多么烦人,考虑使用随机数来实现一种称为蒙特卡罗模拟的技术。

蒙特卡罗方法包括从一个大集合中随机选择样本实验,然后根据从这些实验结果制表估计的概率进行推断。例如,我们可以利用6/π²是两个随机选择的整数没有公共因子的概率来近似π;也就是说,它们的最大公约数为 1 的概率。为了获得对π的近似值,我们进行大量实验。在每次实验中,我们随机选择两个整数并进行测试,以查看它们的最大公约数是否为 1。测试通过的次数所占的比例给出了我们对6/π²的估计,从中我们获得了对π的近似值。

我们程序的核心是一个名为monte_carlo的函数,它以尝试实验的次数和实验作为参数,实验表示为每次运行时返回真或假的无参数函数。函数monte_carlo对指定次数的试验运行实验,并返回一个数字,告诉我们实验被发现为真的试验的比例。

function estimate_pi(trials) {
    return math_sqrt(6 / monte_carlo(trials, dirichlet_test));
}
function dirichlet_test() {
    return gcd(rand(), rand()) === 1;
}
function monte_carlo(trials, experiment) {
    function iter(trials_remaining, trials_passed) {
        return trials_remaining === 0
               ? trials_passed / trials
               : experiment()
               ? iter(trials_remaining - 1, trials_passed + 1)
               : iter(trials_remaining - 1, trials_passed);
    }
    return iter(trials, 0);
}

现在让我们尝试使用rand_update直接进行相同的计算,而不是使用rand,这是我们不得不采取的方式,如果我们不使用赋值来模拟局部状态:

function estimate_pi(trials) {
    return math_sqrt(6 / random_gcd_test(trials, random_init));
}
function random_gcd_test(trials, initial_x) {
    function iter(trials_remaining, trials_passed, x) {
        const x1 = rand_update(x);
        const x2 = rand_update(x1);
        return trials_remaining === 0
               ? trials_passed / trials
               : gcd(x1, x2) === 1
               ? iter(trials_remaining - 1, trials_passed + 1, x2)
               : iter(trials_remaining - 1, trials_passed, x2);
    }
    return iter(trials, 0, initial_x);
}

虽然程序仍然很简单,但它暴露了一些痛苦的模块性漏洞。在我们程序的第一个版本中,使用rand,我们可以直接将蒙特卡罗方法表达为一个通用的monte_carlo函数,该函数以任意的experiment函数作为参数。在我们程序的第二个版本中,随机数生成器没有局部状态,random_gcd_test必须明确操作随机数x1和x2,并通过迭代循环将x2重新输入到rand_update中。随机数的显式处理将累积测试结果的结构与我们特定实验使用两个随机数的事实交织在一起,而其他蒙特卡罗实验可能使用一个随机数或三个随机数。甚至顶层函数estimate_pi也必须关注提供初始随机数。随机数生成器的内部泄漏到程序的其他部分,使我们难以将蒙特卡罗思想隔离出来,以便将其应用于其他任务。在程序的第一个版本中,赋值封装了随机数生成器的状态在rand函数内部,使得随机数生成的细节保持独立于程序的其他部分。

蒙特卡洛示例所展示的一般现象是:从复杂过程的某一部分的角度来看,其他部分似乎随时间变化。它们具有隐藏的随时间变化的局部状态。如果我们希望编写的计算机程序的结构反映了这种分解,我们将创建计算对象(例如银行账户和随机数生成器),其行为随时间变化。我们用局部状态变量模拟状态,并用对这些变量的赋值来模拟状态的变化。

通过引入赋值和将状态隐藏在局部变量中的技术,我们可以以比必须通过传递额外参数显式操作所有状态更模块化的方式来构建系统。然而,不幸的是,正如我们将看到的那样,事情并不那么简单。

练习 3.5

蒙特卡洛积分是一种通过蒙特卡洛模拟来估计定积分的方法。考虑计算由谓词P(x, y)描述的空间区域的面积,该谓词对于区域中的点(x, y)为真,对于不在区域中的点为假。例如,以(5, 7)为中心的半径为 3 的圆内的区域由测试(x – 5)² + (y – 7)² = 3²的谓词描述。为了估计由这样一个谓词描述的区域的面积,首先选择一个包含该区域的矩形。例如,对角线在(2, 4)和(8, 10)的矩形包含上述圆。所需的积分是矩形中位于该区域内的部分的面积。我们可以通过随机选择位于矩形中的点(x, y),并对每个点测试P(x, y)来估计积分。如果我们尝试这样做很多次,那么落在该区域内的点的比例应该给出矩形中位于该区域内的比例的估计。因此,将这个比例乘以整个矩形的面积应该产生积分的估计。

实现蒙特卡洛积分作为一个名为estimate_integral的函数,该函数以谓词P、矩形的上下界x1、x2、y1和y2以及进行估计所需的试验次数作为参数。您的函数应该使用与上面用于估计π的相同的monte_carlo函数。使用您的estimate_integral通过测量单位圆的面积来估计π。

您会发现有一个从给定范围中随机选择一个数字的函数是很有用的。以下的random_in_range函数实现了这一点,它是基于 1.2.6 节中使用的math_random函数实现的,该函数返回小于 1 的非负数。

function random_in_range(low, high) {
    const range = high - low;
    return low + math_random() * range;
}
练习 3.6

能够重置随机数生成器以产生从给定值开始的序列是很有用的。设计一个新的rand函数,它被调用时带有一个参数,该参数是字符串"generate"或字符串"reset",并且行为如下:rand("generate")产生一个新的随机数;rand("reset")(new-value)将内部状态变量重置为指定的new-value。因此,通过重置状态,可以生成可重复的序列。在测试和调试使用随机数的程序时,这些是非常方便的。

3.1.3 引入赋值的成本

正如我们所看到的,赋值使我们能够模拟具有局部状态的对象。然而,这种优势是有代价的。我们的编程语言不再能够根据我们在 1.1.5 节中介绍的函数应用替换模型来解释。此外,在处理对象和编程语言中的赋值时,没有简单的具有“良好”数学属性的模型可以成为一个足够的框架。

只要我们不使用赋值,对相同参数的同一函数的两次求值将产生相同的结果,因此函数可以被视为计算数学函数。因此,没有使用任何赋值的编程,就像我们在本书的前两章中所做的那样,因此被称为函数式编程。

要理解赋值如何使事情复杂化,考虑 3.1.1 节中make_withdraw函数的简化版本,它不需要检查金额是否不足:

function make_simplified_withdraw(balance) {
    return amount => {
               balance = balance - amount;
               return balance;
           };
}

const W = make_simplified_withdraw(25);

W(20);
5

W(10);
-5

将此函数与不使用赋值的以下make_decrementer函数进行比较:

function make_decrementer(balance) {
   return amount => balance - amount;
}

函数make_decrementer返回一个从指定金额balance中减去其输入的函数,但是在连续调用中没有累积效果,就像make_simplified_withdraw一样:

const D = make_decrementer(25);

D(20);
`5`

D(10);
15

我们可以使用替换模型来解释make_decrementer的工作原理。例如,让我们分析表达式的求值

make_decrementer(25)(20)

我们首先通过在make_decrementer的主体中用 25 替换balance来简化应用的函数表达式。这将表达式简化为

(amount => 25 - amount)(20)

现在我们通过在 lambda 表达式的主体中用 20 替换amount来应用函数:

25 - 20

最终答案是 5。

然而,观察一下,如果我们尝试用make_simplified_withdraw进行类似的替换分析会发生什么:

make_simplified_withdraw(25)(20)

我们首先通过在make_simplified_withdraw的主体中用 25 替换balance来简化函数表达式。这将表达式简化为⁹

(amount => {
    balance = 25 - amount;
    return 25;
})(20)

现在我们通过在 lambda 表达式的主体中用 20 替换amount来应用函数:

balance = 25 - 20;
return 25;

如果我们坚持替换模型,我们将不得不说函数应用的含义是首先将balance设置为 5,然后返回 25 作为表达式的值。这得到了错误的答案。为了得到正确的答案,我们必须以某种方式区分balance的第一次出现(在赋值的效果之前)和balance的第二次出现(在赋值的效果之后),而替换模型无法做到这一点。

这里的问题是,替换基本上是基于这样一个概念,即我们语言中的名称本质上是值的符号。这对常量效果很好。但是,一个变量的值可以随着赋值而改变,不能简单地成为一个值的名称。变量在某种程度上指的是一个值可以被存储的地方,而存储在这个地方的值可以改变。在 3.2 节中,我们将看到环境如何在我们的计算模型中扮演“位置”的角色。

相同和变化

这里出现的问题比计算模型的简单崩溃更加深刻。一旦我们在计算模型中引入变化,许多以前简单明了的概念就变得棘手。考虑两个事物“相同”的概念。

假设我们用相同的参数两次调用make_decrementer来创建两个函数:

const D1 = make_decrementer(25);
const D2 = make_decrementer(25);

D1和D2是相同的吗?一个可以接受的答案是是,因为D1和D2具有相同的计算行为——每个都是从 25 中减去其输入的函数。实际上,D1可以在任何计算中替换为D2而不改变结果。

与此形成对比的是两次调用make_simplified_withdraw:

const W1 = make_simplified_withdraw(25);
const W2 = make_simplified_withdraw(25);

W1和W2是相同的吗?当然不是,因为对W1和W2的调用具有不同的效果,如下交互序列所示:

W1(20);
5

W1(20);
-15

W2(20);
5

即使W1和W2在某种意义上是“相等”的,因为它们都是通过求值相同的表达式make_simplified_withdraw(25)创建的,但并不是说W1可以在任何表达式中替换为W2而不改变表达式的结果。

一个支持“等号可以替换为等号”概念的语言在不改变表达式的值的情况下被称为引用透明。当我们在计算机语言中包含赋值时,引用透明性就会被违反。这使得确定何时可以通过替换等价表达式来简化表达式变得非常棘手。因此,对使用赋值的程序进行推理变得极其困难。

一旦我们放弃了引用透明性,计算对象“相同”的概念就变得难以以正式的方式捕捉。事实上,我们的程序模拟的现实世界中“相同”的含义本身就不太清晰。通常情况下,我们只能通过修改一个对象,然后观察另一个对象是否以相同的方式发生了变化,来确定两个看似相同的对象是否确实是“同一个”。但是,我们如何判断一个对象是否“改变”,除了观察“相同”的对象两次并查看对象的某些属性是否从一次观察到下一次观察发生了变化?因此,我们无法在没有某种先验的“相同”概念的情况下确定“改变”,也无法在没有观察到改变的效果的情况下确定相同。

举个编程中出现这个问题的例子,考虑一下彼得和保罗各自有 100 美元的银行账户的情况。将这种情况建模为

const peter_acc = make_account(100);
const paul_acc = make_account(100);

和将其建模为

const peter_acc = make_account(100);
const paul_acc = peter_acc;

在第一种情况下,两个银行账户是不同的。彼得的交易不会影响保罗的账户,反之亦然。然而,在第二种情况下,我们已经定义paul_acc与peter_acc是同一件事。实际上,彼得和保罗现在有一个联合银行账户,如果彼得从peter_acc中取款,保罗会发现paul_acc中的钱变少。这两种相似但不同的情况可能会在构建计算模型时造成混淆。特别是对于共享账户,令人困惑的是有一个对象(银行账户)有两个不同的名称(peter_acc和paul_acc);如果我们正在寻找程序中所有可能改变paul_acc的地方,我们必须记得也要查看那些改变peter_acc的地方。¹⁰

关于“相同”和“改变”的上述评论,可以观察到,如果彼得和保罗只能查看他们的银行余额,并且不能执行改变余额的操作,那么两个账户是否不同的问题就没有意义了。一般来说,只要我们不修改数据对象,我们就可以认为复合数据对象恰好是其各部分的总和。例如,有理数是通过给出其分子和分母来确定的。但是,在存在改变的情况下,复合数据对象具有一个与其组成部分不同的“身份”。即使我们通过取款改变了银行账户的余额,银行账户仍然是“相同的”银行账户;反之,我们可以有两个具有相同状态信息的不同银行账户。这种复杂性是我们对银行账户作为一个对象的感知的结果,而不是我们的编程语言的结果。例如,我们通常不将有理数视为具有身份的可变对象,这样我们就可以改变分子但仍然拥有“相同”的有理数。

命令式编程的陷阱

与函数式编程相比,大量使用赋值的编程被称为命令式编程。除了引发关于计算模型的复杂性之外,以命令式风格编写的程序容易出现在函数式程序中不会出现的错误。例如,回想一下 1.2.1 节中的迭代阶乘程序(这里使用条件语句而不是条件表达式):

function factorial(n) {
    function iter(product, counter) {
        if (counter > n) {
            return product;
        } else {
            return iter(counter * product,
                        counter + 1);
        }
    }
    return iter(1, 1);
}

与在内部迭代循环中传递参数不同,我们可以采用更加命令式的风格,通过显式赋值来更新变量product和counter的值:

function factorial(n) { 
    let product = 1;
    let counter = 1;
    function iter() {
        if (counter > n) {
            return product;
        } else {
            product = counter * product;
            counter = counter + 1;
            return iter();
        }
    }
    return iter();
}

这并不会改变程序产生的结果,但它确实引入了一个微妙的陷阱。我们如何决定赋值的顺序?事实上,程序按照原样编写是正确的。但是,如果将赋值的顺序写反

counter = counter + 1;
product = counter * product;

一般来说,使用赋值进行编程会迫使我们仔细考虑赋值的相对顺序,以确保每个语句都使用了已更改的变量的正确版本。这个问题在函数式程序中根本不会出现。

如果考虑到多个进程同时执行的应用程序,那么命令式程序的复杂性将变得更加糟糕。我们将在第 3.4 节中回到这一点。然而,首先,我们将解决涉及赋值的表达式的计算模型,并探讨在设计模拟中使用具有局部状态的对象的用途。

练习 3.7

考虑make_account创建的银行账户对象,其中包括练习 3.3 中描述的密码修改。假设我们的银行系统需要能够创建联合账户。定义一个名为make_joint的函数来实现这一点。函数make_joint应该有三个参数。第一个是受密码保护的账户。第二个参数必须与账户定义时的密码匹配,才能进行make_joint操作。第三个参数是一个新密码。函数make_joint将使用新密码创建对原始账户的额外访问。例如,如果peter_acc是一个密码为"open sesame"的银行账户,则

const paul_acc = make_joint(peter_acc, "open sesame", "rosebud");

将允许使用名称paul_acc和密码"rosebud"在peter_acc上进行交易。您可能希望修改您对练习 3.3 的解决方案,以适应这一新功能。

练习 3.8

当我们在第 1.1.3 节中定义了求值模型时,我们说求值表达式的第一步是求值其子表达式。但我们从未指定子表达式应该以何种顺序进行求值(例如,从左到右还是从右到左)。当我们引入赋值时,操作符组合的操作数的求值顺序可能会影响结果。定义一个简单的函数f,使得求值f(0) + f(1)将返回 0,如果+的操作数从左到右进行求值,但如果操作数从右到左进行求值,则返回 1。

3.2 求值的环境模型

当我们在第 1 章介绍了复合函数时,我们使用了求值的替换模型(第 1.1.5 节)来定义应用函数到参数的含义:

  • 要将复合函数应用到参数上,用相应的参数替换每个参数后,求值函数的返回表达式(更一般地说,是主体)。

一旦我们允许在我们的编程语言中进行赋值,这样的定义就不再合适。特别是,第 3.1.3 节认为,在存在赋值的情况下,一个名称不能仅仅被认为是代表一个值。相反,一个名称必须以某种方式指定一个“位置”,在这个位置中值可以被存储。在我们的新的求值模型中,这些位置将被维护在称为环境的结构中。

环境是一个帧的序列。每个帧都是一个绑定(可能为空)的表,它将名称与相应的值关联起来。(单个帧最多可以包含一个名称的绑定。)每个帧还有一个指向其封闭环境的指针,除非出于讨论的目的,该帧被认为是全局的。与环境相关的名称的值是由环境中包含该名称的第一个帧中的绑定给出的值。如果序列中的任何帧都没有为名称指定绑定,则称该名称在环境中是未绑定的。

图 3.1 显示了一个简单的环境结构,由三个标记为 I、II 和 III 的框架组成。在图中,A、B、C 和 D 是指向环境的指针。C 和 D 指向相同的环境。名称z和x在框架 II 中绑定,而y和x在框架 I 中绑定。环境 D 中x的值为 3。相对于环境 B,x的值也是 3。这是这样确定的:我们检查序列中的第一个框架(框架 III),并没有找到x的绑定,所以我们继续到封闭的环境 D,并在框架 I 中找到了绑定。另一方面,相对于环境 A,x的值为 7,因为序列中的第一个框架(框架 II)包含了x绑定到 7。相对于环境 A,框架 II 中x绑定到 7 被称为屏蔽了框架 I 中x绑定到 3。

c3-fig-0001.jpg

图 3.1 一个简单的环境结构。

环境对于求值过程至关重要,因为它决定了表达式应该在其中环境中进行求值的上下文。事实上,可以说编程语言中的表达式本身并没有任何意义。相反,表达式只有在某个环境中进行求值时才会获得意义。甚至对于像display(1)这样直接的表达式的解释也取决于理解在其中名称display指的是显示值的原始函数的上下文。因此,在我们的求值模型中,我们将始终讨论相对于某个环境求值表达式。为了描述与解释器的交互,我们假设存在一个全局环境,由一个单一框架(没有封闭环境)组成,其中包括与原始函数相关联的名称的值。例如,display是原始显示函数的名称的想法被捕捉为名称display在全局环境中绑定到原始显示函数。

在求值程序之前,我们在全局环境中添加一个新框架,即程序框架,得到程序环境。我们将程序顶层声明的名称添加到这个框架中,这些名称在任何块之外声明。然后,给定的程序将相对于程序环境进行求值。

3.2.1 求值规则

解释器求值函数应用的整体规范与我们在第 1.1.4 节首次介绍时保持一致:

  • 要求值一个应用:

    1. 1. 求值应用的子表达式。¹²

    2. 2. 将函数子表达式的值应用于参数子表达式的值。

求值环境模型取代了替换模型,以指定将复合函数应用于参数的含义。

在求值环境模型中,函数始终是一个由一些代码和指向环境的指针组成的对。函数只能通过求值 lambda 表达式来创建。这会产生一个函数,其代码是从 lambda 表达式的文本中获取的,其环境是求值 lambda 表达式以产生函数的环境。例如,考虑函数声明

function square(x) {
    return x * x;
}

在程序环境中求值。函数声明语法等同于底层的隐式 lambda 表达式。使用¹³也是等效的

const square = x => x * x;

这将求值x => x * x并将square绑定到结果值,都在程序环境中。

图 3.2 显示了求值此声明语句的结果。全局环境包含程序环境。为了减少混乱,在此图之后,我们将不显示全局环境(因为它总是相同的),但是通过从程序环境向上的指针来提醒我们它的存在。函数对象是一个对,其代码指定函数有一个参数,即x,和一个函数体return x * x;。函数的环境部分是指向程序环境的指针,因为这是求值 lambda 表达式以生成函数的环境。一个新的绑定,将函数对象与名称square关联起来,已添加到程序帧中。

c3-fig-0002.jpg

图 3.2 在程序环境中求值function square(x) { return x * x; }所产生的环境结构。

一般来说,const,function和let会向帧中添加绑定。常量不允许赋值,因此我们的环境模型需要区分指向常量的名称和指向变量的名称。我们通过在名称后面的冒号后写一个等号来表示名称是常量。我们认为函数声明等同于常量声明;请参见图 3.2 中冒号后的等号。

现在我们已经看到了函数是如何创建的,我们可以描述函数是如何应用的。环境模型指定:要将函数应用于参数,创建一个新的环境,其中包含一个将参数绑定到参数值的帧。此帧的封闭环境是函数指定的环境。现在,在这个新环境中,求值函数体。

为了展示这条规则是如何遵循的,图 3.3 说明了在程序环境中求值表达式square(5)所创建的环境结构,其中square是在图 3.2 中生成的函数。应用函数会导致创建一个新的环境,图中标记为 E1,它以一个帧开始,其中函数的参数x绑定到参数 5。请注意,环境 E1 中的名称x后面跟着一个冒号,没有等号,这表明参数x被视为变量。从这个帧向上指的指针显示了帧的封闭环境是程序环境。这里选择程序环境,因为这是square函数对象的一部分所指示的环境。在 E1 中,我们求值函数体,return x * x;。由于 E1 中x的值是 5,结果是5 * 5,即 25。

c3-fig-0003.jpg

图 3.3 在程序环境中求值square(5)所创建的环境。

函数应用的环境模型可以总结为两条规则:

  • 通过构建一个帧,将函数的参数绑定到调用的参数,然后在构建的新环境的上下文中求值函数体,将函数对象应用于一组参数。新帧的封闭环境是被应用的函数对象的环境部分。应用的结果是在求值函数体时遇到的第一个return语句的返回表达式的结果。

  • 通过在给定环境中求值 lambda 表达式来创建函数。生成的函数对象是一个对,包括 lambda 表达式的文本和指向创建函数的环境的指针。

最后,我们指定了赋值的行为,这个操作迫使我们首先引入环境模型。在某个环境中求值表达式name = value会找到环境中名称的绑定。也就是说,找到环境中包含名称绑定的第一个框架。如果绑定是变量绑定——在框架中名称后面只有:表示——那么该绑定将被更改以反映变量的新值。否则,如果框架中的绑定是常量绑定——在名称后面由:=表示——赋值会发出“对常量赋值”的错误。如果环境中的名称未绑定,则赋值会发出“变量未声明”的错误。

这些求值规则虽然比替换模型复杂得多,但仍然相当简单。此外,求值模型虽然抽象,但提供了解释器如何求值表达式的正确描述。在第 4 章中,我们将看到这个模型如何作为实现工作解释器的蓝图。以下各节通过分析一些说明性程序详细阐述了该模型的细节。

3.2.2 应用简单函数

当我们在 1.1.5 节介绍了替换模型时,我们展示了应用f(5)的求值结果为 136,给定以下函数声明:

function square(x) {
    return x * x;
}
function sum_of_squares(x, y) {
    return square(x) + square(y);
}
function f(a) {
    return sum_of_squares(a + 1, a * 2);
}

我们可以使用环境模型分析相同的例子。图 3.4 显示了通过在程序环境中求值f,square和sum_of_squares的定义而创建的三个函数对象。每个函数对象由一些代码组成,以及指向程序环境的指针。

c3-fig-0004.jpg

图 3.4 程序框架中的函数对象。

在图 3.5 中,我们看到通过求值表达式f(5)创建的环境结构。对f的调用创建了一个新的环境 E1,从一个框架开始,其中f的参数a绑定到参数 5。在 E1 中,我们求值f的主体:

return sum_of_squares(a + 1, a * 2);

c3-fig-0005.jpg

图 3.5 通过使用图 3.4 中的函数求值f(5)而创建的环境。

为了求值返回语句,我们首先求值返回表达式的子表达式。第一个子表达式sum_of_squares的值是一个函数对象。(注意如何找到这个值:我们首先查找 E1 的第一个框架,其中不包含sum_of_squares的绑定。然后我们继续到封闭环境,即程序环境,并找到图 3.4 中显示的绑定。)其他两个子表达式通过应用原始操作+和*来求值两个组合a + 1和a * 2,分别获得 6 和 10。

现在我们将函数对象sum_of_squares应用于参数 6 和 10。这将导致一个新的环境 E2,其中参数x和y绑定到参数。在 E2 中,我们求值语句

return square(x) + square(y);

这导致我们求值square(x),其中square在程序框架中找到,x为 6。再次,我们建立一个新的环境 E3,在其中x绑定到 6,并在其中求值square的主体,即return x * x;。同样作为应用sum_of_squares的一部分,我们必须求值子表达式square(y),其中y为 10。对square的第二次调用创建了另一个环境 E4,在其中square的参数x绑定到 10。在 E4 中,我们必须求值return x * x;。

需要注意的重要一点是,每次调用square都会创建一个包含x绑定的新环境。我们可以在这里看到不同的帧是如何保持分开的不同的名为x的本地变量的。请注意,square创建的每个帧都指向程序环境,因为这是square函数对象指定的环境。

在子表达式被求值之后,结果被返回。square的两次调用生成的值被sum_of_squares相加,这个结果被f返回。由于我们这里的重点是环境结构,我们不会详细讨论这些返回值是如何从调用传递到调用的;然而,这也是求值过程的一个重要方面,我们将在第 5 章中详细讨论它。

练习 3.9

在 1.2.1 节中,我们使用替换模型来分析两个计算阶乘的函数,一个是递归版本

function factorial(n) {
    return n === 1
           ? 1
           : n * factorial(n - 1);
}

和迭代版本

function factorial(n) {
    return fact_iter(1, 1, n);
}
function fact_iter(product, counter, max_count) {
    return counter > max_count
           ? product
           : fact_iter(counter * product,
                       counter + 1,
                       max_count);
}

展示了使用factorial函数的每个版本来求值factorial(6)的环境结构。¹⁶

3.2.3 帧作为本地状态的存储库

我们可以转向环境模型,看看如何使用函数和赋值来表示具有本地状态的对象。例如,考虑通过调用函数创建的“取款处理器”(来自第 3.1.1 节)

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

让我们描述一下

const W1 = make_withdraw(100); 

接着

W1(50);
50

图 3.6 显示了在程序环境中声明make_withdraw函数的结果。这产生了一个包含指向程序环境的指针的函数对象。到目前为止,这与我们已经看到的例子没有什么不同,只是函数主体中的返回表达式本身是一个 lambda 表达式。

c3-fig-0006.jpg

图 3.6 在程序环境中定义make_withdraw的结果。

当我们将函数make_withdraw应用到一个参数时,计算的有趣部分发生了:

const W1 = make_withdraw(100);

我们通常是通过设置环境 E1 来开始的,在这个环境中,参数balance绑定到参数 100。在这个环境中,我们求值make_withdraw的主体,即返回语句,其返回表达式是一个 lambda 表达式。对这个 lambda 表达式的求值构造了一个新的函数对象,其代码由 lambda 表达式指定,其环境是 E1,lambda 表达式被求值以产生函数的环境。由对make_withdraw的调用返回的结果是这个函数对象。由于常量声明本身是在程序环境中被求值的,所以它在程序环境中绑定到W1。图 3.7 显示了生成的环境结构。

c3-fig-0007.jpg

图 3.7 求值const W1 = make_withdraw(100);的结果。

现在我们可以分析当W1应用到一个参数时会发生什么:

W1(50);
50

我们首先构建一个帧,在这个帧中,W1的参数amount绑定到参数 50。需要注意的关键点是,这个帧的封闭环境不是程序环境,而是环境 E1,因为这是由W1函数对象指定的环境。在这个新环境中,我们求值函数的主体:

if (balance >= amount) {
    balance = balance - amount;
    return balance;
} else {
    return "insufficient funds";
}

生成的环境结构如图 3.8 所示。正在求值的表达式引用了amount和balance。变量amount将在环境中的第一个帧中找到,而balance将通过跟随封闭环境指针到 E1 中找到。

c3-fig-0008.jpg

图 3.8 应用函数对象W1创建的环境。

当执行赋值时,E1 中balance的绑定被更改。在调用W1完成时,balance为 50,并且仍然由函数对象W1指向包含balance的帧。绑定amount的帧(我们执行了更改balance的代码)不再相关,因为构造它的函数调用已经终止,并且没有来自环境其他部分的指针指向该帧。下次调用W1时,这将构建一个绑定amount的新帧,其封闭环境为 E1。我们看到 E1 充当了为函数对象W1保存局部状态变量的“位置”。图 3.9 显示了调用W1后的情况。

c3-fig-0009.jpg

图 3.9 调用W1后的环境。

观察当我们通过再次调用make_withdraw创建第二个withdraw对象时会发生什么:

const W2 = make_withdraw(100);

这产生了图 3.10 中的环境结构,显示W2是一个函数对象,即一个带有一些代码和一个环境的对。W2的环境 E2 是通过调用make_withdraw创建的。它包含一个带有自己的局部绑定balance的帧。另一方面,W1和W2具有相同的代码:make_withdraw主体中 lambda 表达式指定的代码。¹⁷我们在这里看到了为什么W1和W2表现为独立对象。对W1的调用引用存储在 E1 中的状态变量balance,而对W2的调用引用 E2 中存储的balance。因此,对一个对象的局部状态的更改不会影响另一个对象。

c3-fig-0010.jpg

图 3.10 使用const W2 = make_withdraw(100);创建第二个对象。

练习 3.10

在make_withdraw函数中,局部变量balance作为make_withdraw的参数创建。我们还可以使用我们可以称之为立即调用的 lambda 表达式单独创建局部状态变量,如下所示:

function make_withdraw(initial_amount) {
    return (balance =>
              amount => {
                  if (balance >= amount) {
                      balance = balance - amount;
                      return balance;
                   } else {
                      return "insufficient funds";
                   }
              })(initial_amount);
}

外部 lambda 表达式在求值后立即被调用。它的唯一目的是创建一个名为balance的局部变量,并将其初始化为initial_amount。使用环境模型分析make_withdraw的这个替代版本,绘制类似上面的图形以说明交互。

const W1 = make_withdraw(100);

W1(50);

const W2 = make_withdraw(100);

展示make_withdraw的两个版本创建具有相同行为的对象。这两个版本的环境结构有何不同?

3.2.4 内部声明

在本节中,我们处理包含声明的函数体或其他块(例如条件语句的分支)。每个块为在块中声明的名称打开一个新的作用域。为了在给定环境中求值一个块,我们通过一个包含在块的主体中直接声明的所有名称的新帧来扩展该环境,然后在新构建的环境中求值主体。

1.1.8 节介绍了函数可以具有内部声明的概念,从而导致块结构,如下面的函数计算平方根:

function sqrt(x) {
   function is_good_enough(guess) {
      return abs(square(guess) - x) < 0.001;
   }
   function improve(guess) {
      return average(guess, x / guess);
   }
   function sqrt_iter(guess){
      return is_good_enough(guess)
             ? guess
             : sqrt_iter(improve(guess));
   }
   return sqrt_iter(1);
}

现在我们可以使用环境模型来看为什么这些内部声明的行为符合预期。图 3.11 显示了在求值表达式sqrt(2)时,内部函数is_good_enough首次被调用,其中guess等于 1。

c3-fig-0011.jpg

图 3.11 带有内部声明的sqrt函数。

观察环境的结构。名称sqrt在程序环境中绑定到一个函数对象,其关联的环境是程序环境。当调用sqrt时,形成了一个新的环境 E1,它是程序环境的下属,在其中参数x绑定到 2。然后在 E1 中求值了sqrt的主体。该主体是一个带有本地函数声明的块,因此 E1 被扩展为这些声明的新框架,导致新的环境 E2。然后在 E2 中求值了该块的主体。由于主体中的第一条语句是...

function is_good_enough(guess) {
    return abs(square(guess) - x) < 0.001;
}

求值此声明在环境 E2 中创建了函数is_good_enough。更准确地说,E2 中的第一个框架中的名称is_good_enough绑定到一个函数对象,其关联的环境是 E2。类似地,improve和sqrt_iter在 E2 中被定义为函数。为简洁起见,图 3.11 仅显示了is_good_enough的函数对象。

在定义了本地函数之后,仍然在环境 E2 中求值了表达式sqrt_iter(1)。因此,在环境 E2 中绑定到sqrt_iter的函数对象被调用,并以 1 作为参数。这创建了一个环境 E3,在其中sqrt_iter的参数guess绑定到 1。然后sqrt_iter调用is_good_enough,并以guess的值(来自 E3)作为is_good_enough的参数。这建立了另一个环境 E4,在其中is_good_enough的参数guess绑定到 1。尽管sqrt_iter和is_good_enough都有一个名为guess的参数,但这些是位于不同框架中的两个不同的本地变量。此外,E3 和 E4 都将 E2 作为其封闭环境,因为sqrt_iter和is_good_enough函数都将 E2 作为其环境部分。这的一个结果是is_good_enough主体中出现的名称x将引用 E1 中出现的x的绑定,即调用原始sqrt函数时的x的值。

因此,环境模型解释了使本地函数声明成为模块化程序的两个关键属性。

  • 本地函数的名称不会干扰封闭函数之外的名称,因为当块在求值时,本地函数名称将绑定在创建时的框架中,而不是绑定在程序环境中。

  • 本地函数可以通过使用参数名称作为自由名称来访问封闭函数的参数。这是因为本地函数的主体在比封闭函数的求值环境低的环境中进行求值。

练习 3.11

在 3.2.3 节中,我们看到环境模型如何描述具有本地状态的函数的行为。现在我们已经看到了内部声明的工作原理。典型的消息传递函数包含了这两个方面。考虑 3.1.1 节中的银行账户函数:

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;
    }
    function dispatch(m) {
        return m === "withdraw"
               ? withdraw
               : m === "deposit"
               ? deposit
               : "Unknown request: make_account";
    }
    return dispatch;
}

展示由交互序列生成的环境结构

const acc = make_account(50);

acc("deposit")(40);
90

acc("withdraw")(60);
30

acc的本地状态在哪里保存?假设我们定义另一个帐户。

const acc2 = make_account(100);
posted @ 2026-10-17 13:17  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报