NUS CS1101S:SICP JavaScript 描述:二、使用数据构建抽象(上)

195 阅读1小时+

原文:2 Building Abstractions with Data

译者:飞龙

协议:CC BY-NC-SA 4.0

我们现在来到数学抽象的决定性步骤:我们忘记符号代表什么。...[数学家]不需要闲着;他可以用这些符号进行许多操作,而无需看它们所代表的东西。

——赫尔曼·维尔,《数学思维方式》

在第 1 章,我们集中讨论了计算过程和函数在程序设计中的作用。我们看到了如何使用原始数据(数字)和原始操作(算术操作),如何通过组合、条件语句和参数的使用来组合函数以形成复合函数,以及如何通过使用函数声明来抽象过程。我们看到函数可以被看作是一个过程的局部演变的模式,并且我们对一些常见的过程模式进行了分类、推理和简单的算法分析,这些过程模式体现在函数中。我们还看到,高阶函数通过使我们能够操纵,从而能够根据一般的计算方法进行推理,增强了我们语言的能力。这正是编程的本质的很大一部分。

在本章中,我们将研究更复杂的数据。第 1 章中的所有函数都是针对简单的数值数据进行操作,而简单的数据对于我们希望使用计算解决的许多问题是不够的。程序通常被设计来模拟复杂的现象,往往必须构建具有多个部分的计算对象,以模拟具有多个方面的现实世界现象。因此,虽然我们在第 1 章的重点是通过组合函数来构建抽象函数,但在本章中,我们转向编程语言的另一个关键方面:它提供了一种通过组合数据对象来构建复合数据的手段。

为什么我们希望在编程语言中使用复合数据?出于与希望使用复合函数相同的原因:提高我们设计程序的概念水平,增加设计的模块化,并增强我们语言的表达能力。正如声明函数的能力使我们能够处理比语言的原始操作更高概念水平的过程一样,构建复合数据对象的能力使我们能够处理比语言的原始数据对象更高概念水平的数据。

考虑设计一个系统来执行有理数的算术运算的任务。我们可以想象一个操作add_rat,它接受两个有理数并产生它们的和。就简单数据而言,有理数可以被看作是两个整数:一个分子和一个分母。因此,我们可以设计一个程序,其中每个有理数将由两个整数(一个分子和一个分母)表示,并且add_rat将由两个函数实现(一个产生和的分子,一个产生分母)。但这将是笨拙的,因为我们将需要明确地跟踪哪个分子对应哪个分母。在一个旨在对许多有理数执行许多操作的系统中,这些簿记细节将大大地使程序混乱,更不用说它们对我们的思维会产生什么影响了。如果我们能够“粘合”分子和分母以形成一对——一个复合数据对象,那将会好得多,这样我们的程序就可以以一种一致的方式来操作它,这种方式将有助于将有理数视为一个单一的概念单位。

复合数据的使用还使我们能够增加程序的模块化。如果我们可以直接将有理数作为对象进行操作,那么我们就可以将处理有理数本身的程序部分与有理数如何表示为整数对的细节分开。隔离处理数据对象如何表示的程序部分与处理数据对象如何使用的程序部分是一种称为数据抽象的强大设计方法。我们将看到数据抽象如何使程序更容易设计、维护和修改。

复合数据的使用实际上增加了我们编程语言的表达能力。考虑形成“线性组合”ax + by的想法。我们可能希望编写一个函数,接受abxy作为参数,并返回ax + by的值。如果参数是数字,这没有困难,因为我们可以轻松地声明函数。

function linear_combination(a, b, x, y) {
    return a * x + b * y;
}

但是,假设我们不仅关心数字。假设我们希望描述一个过程,只要定义了加法和乘法,就可以形成线性组合——对于有理数、复数、多项式或其他任何东西。我们可以将这表达为以下形式的函数。

function linear_combination(a, b, x, y) {
    return add(mul(a, x), mul(b, y));
}

addmul不是原始函数+*,而是更复杂的东西,它们将根据我们传递的参数abxy执行适当的操作。关键是linear_combination唯一需要知道的是函数addmul将执行适当的操作。从linear_combination函数的角度来看,abxy是什么并不重要,甚至更不重要的是它们可能如何以更原始的数据形式表示。这个例子也说明了为什么我们的编程语言提供直接操作复合对象的能力是重要的:如果没有这一点,linear_combination这样的函数就无法将其参数传递给addmul,而不必知道它们的详细结构。

我们通过实现上面提到的有理数算术系统来开始本章。这将为我们讨论复合数据和数据抽象提供背景。与复合函数一样,需要解决的主要问题是抽象作为一种处理复杂性的技术,我们将看到数据抽象如何使我们能够在程序的不同部分之间建立适当的抽象屏障

我们将看到形成复合数据的关键在于编程语言应该提供某种“粘合剂”,使得数据对象可以组合成更复杂的数据对象。有许多可能的粘合剂。事实上,我们将发现如何使用没有特殊“数据”操作的函数来形成复合数据。这将进一步模糊“函数”和“数据”的区别,这在第 1 章末尾已经变得模糊。我们还将探讨一些表示序列和树的常规技术。处理复合数据的一个关键思想是闭包的概念——我们用于组合数据对象的粘合剂应该允许我们组合不仅是原始数据对象,还有复合数据对象。另一个关键思想是复合数据对象可以作为常规接口,以混合和匹配的方式组合程序模块。我们通过介绍一个利用闭包的简单图形语言来说明这些想法。

然后,我们将通过引入符号表达式来增强我们语言的表现力——数据的基本部分可以是任意符号,而不仅仅是数字。我们探索表示对象集的各种替代方案。我们将发现,就像给定的数值函数可以通过许多不同的计算过程来计算一样,给定数据结构可以用更简单的对象来表示的方式有很多种,表示的选择对操纵数据的过程的时间和空间要求有重要影响。我们将在符号微分、集合表示和信息编码的背景下研究这些想法。接下来,我们将解决处理可能由程序的不同部分以不同方式表示的数据的问题。这导致需要实现通用操作,这些操作必须处理许多不同类型的数据。在存在通用操作的情况下保持模块化需要比仅使用简单数据抽象建立更强大的抽象屏障。特别是,我们引入数据导向编程作为一种技术,允许单独设计数据表示,然后累加(即不修改)组合这些表示。为了说明这种系统设计方法的强大之处,我们通过将所学应用于在多项式上执行符号算术的包的实现来结束本章,其中多项式的系数可以是整数、有理数、复数,甚至其他多项式。

2.1 数据抽象简介

在 1.1.8 节中,我们注意到一个作为创建更复杂函数的元素使用的函数不仅可以被视为一组特定操作,还可以被视为一个函数抽象。也就是说,可以抑制函数的实现细节,并且可以用具有相同整体行为的任何其他函数来替换特定的函数本身。换句话说,我们可以进行一个抽象,将函数的使用方式与如何使用更基本的函数来实现函数的细节分离。复合数据的类似概念称为数据抽象。数据抽象是一种方法论,使我们能够将复合数据对象的使用方式与它是如何由更基本的数据对象构造出来的细节隔离开来。

数据抽象的基本思想是构造使用复合数据对象的程序,使其操作“抽象数据”。也就是说,我们的程序应该以一种不假设关于数据的任何信息的方式来使用数据,除了执行手头的任务所严格需要的信息。与此同时,“具体”数据表示是独立于使用数据的程序定义的。我们系统的这两部分之间的接口将是一组函数,称为选择器构造器,它们以具体表示为基础实现抽象数据。为了说明这种技术,我们将考虑如何设计一组用于操作有理数的函数。

2.1.1 示例:有理数的算术运算

假设我们想要对有理数进行算术运算。我们希望能够对它们进行加法、减法、乘法和除法,并测试两个有理数是否相等。

让我们首先假设我们已经有一种方法,可以从分子和分母构造一个有理数。我们还假设,给定一个有理数,我们有一种方法来提取(或选择)它的分子和分母。让我们进一步假设构造器和选择器作为函数是可用的:

  • make_rat(n, d)返回其分子为整数n,分母为整数d的有理数。

  • numer(x)返回有理数x的分子。

  • denom(x)返回有理数x的分母。

我们在这里使用了一种强大的综合策略:wishful thinking。我们还没有说有理数是如何表示的,或者函数numerdenommake_rat应该如何实现。即使如此,如果我们有了这三个函数,我们就可以通过以下关系来进行加法、减法、乘法、除法和相等性测试:

c2-fig-5001.jpg

我们可以将这些规则表示为函数:

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));
}
function equal_rat(x, y) {
    return numer(x) * denom(y) === numer(y) * denom(x);
}

现在我们已经定义了有理数的操作,这些操作是基于选择器定义的

和构造函数numerdenommake_rat。但我们还没有定义这些。我们需要一种方法来将分子和分母粘合在一起形成一个有理数。

为了使我们能够实现数据抽象的具体层,我们的 JavaScript 环境提供了一种称为pair的复合结构,它可以用原始函数pair构造。此函数接受两个参数并返回一个包含两个参数作为部分的复合数据对象。给定一个对,我们可以使用原始函数headtail提取部分。因此,我们可以如下使用pairheadtail

const x = pair(1, 2);
head(x);
`1`
tail(x);
`2`

注意,对是一个可以被赋予名称并且可以被操作的数据对象,就像原始数据对象一样。此外,pair可以用来形成其元素为对的对,依此类推:

const x = pair(1, 2);

const y = pair(3, 4);

const z = pair(x, y);

head(head(z));
`1`

head(tail(z));
`3`

在第 2.2 节中,我们将看到这种组合对的能力意味着对可以用作通用的构建块来创建各种复杂的数据结构。由对构造的数据对象称为列表结构数据。

表示有理数

对提供了一种自然的方式来完成有理数系统。简单地将有理数表示为两个整数的对:一个分子和一个分母。然后make_ratnumerdenom可以如下实现:²

function make_rat(n, d) { return pair(n, d); }
function numer(x) { return head(x); }
function denom(x) { return tail(x); }

此外,为了显示我们计算的结果,我们可以通过打印分子、斜杠和分母来打印有理数。我们使用原始函数stringify将任何值(这里是一个数字)转换为字符串。JavaScript 中的运算符+重载的;它可以应用于两个数字或两个字符串,在后一种情况下,它返回连接两个字符串的结果。³

function print_rat(x) {
    return display(stringify(numer(x)) + " / " + stringify(denom(x)));
}

现在我们可以尝试我们的有理数函数:⁴

const one_half = make_rat(1, 2);

print_rat(one_half);
"1 / 2"

const one_third = make_rat(1, 3);

print_rat(add_rat(one_half, one_third));
"5 / 6"

print_rat(mul_rat(one_half, one_third));
"1 / 6"

print_rat(add_rat(one_third, one_third));
"6 / 9"

正如最后一个例子所示,我们的有理数实现没有将有理数化简为最低项。我们可以通过更改make_rat来解决这个问题。如果我们有一个像第 1.2.5 节中那样产生两个整数的最大公约数的gcd函数,我们可以使用gcd在构造对之前将分子和分母化简为最低项:

function make_rat(n, d) {
    const g = gcd(n, d);
    return pair(n / g, d / g);
}

现在我们有

print_rat(add_rat(one_third, one_third));
"2 / 3"

如所需。通过更改构造函数make_ rat而不更改实现实际操作的任何函数(如add_ratmul_rat),已完成此修改。

练习 2.1

定义一个更好的make_rat版本,处理正数和负数参数。函数make_rat应该规范化符号,以便如果有理数是正数,则分子和分母都是正数,如果有理数是负数,则只有分子是负数。

2.1.2 抽象屏障

在继续介绍复合数据和数据抽象的更多示例之前,让我们考虑一下有理数示例引发的一些问题。我们用构造函数make_rat和选择器numerdenom来定义有理数运算。一般来说,数据抽象的基本思想是为每种数据对象类型确定一组基本操作,通过这些操作来表达对该类型数据对象的所有操作,然后在操作数据时只使用这些操作。

我们可以将有理数系统的结构设想为图 2.1 所示。水平线代表抽象屏障,隔离系统的不同“层级”。在每个层级,该屏障将使用数据抽象的程序(上方)与实现数据抽象的程序(下方)分开。使用有理数的程序仅通过有理数包提供的“供公共使用”的函数来操作它们:add_ratsub_ratmul_ratdiv_ratequal_rat。这些函数又仅仅是通过构造函数和选择器make_ratnumerdenom来实现的,它们本身是通过对偶实现的。对偶的具体实现细节对于有理数包的其余部分来说是无关紧要的,只要对偶可以通过pairheadtail来操作。实际上,每个层级的函数都是定义抽象屏障并连接不同层级的接口。这个简单的想法有很多优点。其中一个优点是它使程序更容易维护和修改。任何复杂的数据结构都可以用编程语言提供的原始数据结构的多种方式来表示。当然,表示的选择会影响操作它的程序;因此,如果表示在以后的某个时间被更改,所有这样的程序可能都必须相应地进行修改。对于大型程序来说,这个任务可能会耗费大量时间和金钱,除非通过设计将对表示的依赖限制在非常少的程序模块中。

c2-fig-0001.jpg

图 2.1 有理数包中的数据抽象屏障。

例如,解决将有理数化简为最低项的问题的另一种方法是在访问有理数的部分时执行化简,而不是在构造有理数时执行。这导致了不同的构造函数和选择器函数:

function make_rat(n, d) {
    return pair(n, d);
}
function numer(x) {
    const g = gcd(head(x), tail(x));
    return head(x) / g;
}
function denom(x) {
    const g = gcd(head(x), tail(x));
    return tail(x) / g;
}

这种实现与之前的实现的不同之处在于我们何时计算gcd。如果在我们典型的有理数使用中,我们多次访问相同有理数的分子和分母,那么在构造有理数时计算gcd会更好。如果不是,我们可能最好等到访问时计算gcd。无论如何,当我们从一种表示形式改变为另一种表示形式时,函数add_ratsub_rat等都不需要进行任何修改。

将对表示的依赖限制在少数接口函数中有助于我们设计程序以及修改程序,因为它允许我们保持灵活性来考虑替代实现。继续我们的简单例子,假设我们正在设计一个有理数包,最初无法确定是在构造时还是在选择时执行gcd。数据抽象方法为我们提供了一种推迟决定而不失去在系统的其余部分上取得进展的方法。

练习 2.2

考虑在平面上表示线段的问题。每个线段都表示为一对点:起点和终点。声明一个构造器make_segment和选择器start_segmentend_segment,以点的形式定义线段的表示。此外,一个点可以表示为一对数字:x坐标和y坐标。因此,指定一个构造器make_point和选择器x_pointy_point来定义这种表示。最后,使用您的选择器和构造器,声明一个函数midpoint_segment,它以线段作为参数并返回其中点(坐标是端点坐标的平均值)。要尝试您的函数,您需要一种打印点的方法:

function print_point(p) {
    return display("(" + stringify(x_point(p)) + ", "
                       + stringify(y_point(p)) + ")");
}
练习 2.3

在平面上实现矩形的表示。 (提示:您可能需要使用练习 2.2。)根据您的构造器和选择器,创建计算给定矩形的周长和面积的函数。现在实现矩形的不同表示。您能否设计您的系统,使得具有合适的抽象屏障,以便相同的周长和面积函数将使用任一表示?

2.1.3 数据的含义是什么?

我们在 2.1.1 节中开始了有理数的实现,通过实现有理数操作add_ratsub_rat等,这些操作是根据三个未指定的函数make_ratnumerdenom来定义的。在那时,我们可以认为这些操作是根据数据对象——分子、分母和有理数——来定义的,后三个函数规定了它们的行为。

但是,数据究竟是什么意思?仅仅说“由给定的选择器和构造器实现的任何东西”是不够的。显然,并非每一组任意的三个函数都可以作为有理数实现的适当基础。我们需要保证,如果我们从一对整数nd构造一个有理数x,那么提取xnumerdenom并将它们相除应该得到与n除以d相同的结果。换句话说,make_ratnumerdenom必须满足这样的条件,对于任何整数n和任何非零整数d,如果xmake_rat(n, d),那么

c2-fig-5002.jpg

事实上,这是make_ratnumerdenom必须满足的唯一条件,以形成有理数表示的合适基础。一般来说,我们可以认为数据是由一些选择器和构造器的集合定义的,以及这些函数必须满足的指定条件,以便成为有效的表示。[5]

这种观点不仅可以用来定义“高级”数据对象,比如有理数,还可以用来定义更低级的对象。考虑一对的概念,我们用它来定义我们的有理数。我们从来没有说过一对是什么,只是语言提供了用于操作对的函数pairheadtail。但我们只需要知道关于这三个操作的唯一事情是,如果我们使用pair将两个对象粘合在一起,我们可以使用headtail来检索对象。也就是说,这些操作满足这样的条件,对于任何对象xy,如果zpair(x, y),那么head(z)xtail(z)y。事实上,我们提到这三个函数是作为原语包含在我们的语言中的。然而,任何满足上述条件的三个函数的三元组都可以用作实现对的基础。这一点通过这样一个事实引人注目,即我们可以实现pairheadtail而不使用任何数据结构,只使用函数。以下是定义:[6]

function pair(x, y) {
    function dispatch(m) {
        return m === 0
               ? x
               : m === 1
               ? y
               : error(m, "argument not 0 or 1 – pair");
    }
    return dispatch;
}
function head(z) { return z(0); }
function tail(z) { return z(1); }

这种使用函数的方法与我们对数据的直观概念完全不同。然而,要证明这是表示对偶的有效方式,我们只需要验证这些函数是否满足上面给出的条件。

要注意的微妙之处是pair(x, y)返回的值是一个函数——即内部定义的函数dispatch,它接受一个参数,并根据参数是 0 还是 1 返回xy。相应地,head(z)被定义为将 0 应用于z。因此,如果z是由pair(x, y)形成的函数,那么将 0 应用于z将产生x。因此,我们已经证明了head(pair(x, y))产生x,就像我们希望的那样。类似地,tail(pair(x, y))将由pair(x, y)返回的函数应用于 1,返回y。因此,这种对偶的函数实现是有效的实现,如果我们只使用pairheadtail来访问对偶,我们无法将这种实现与使用“真实”数据结构的实现区分开。

展示对偶的函数表示的重点不在于我们的语言是否以这种方式工作(对偶的高效实现可能会使用 JavaScript 的原始向量数据结构),而在于它可以以这种方式工作。函数表示,虽然晦涩,但是是表示对偶的完全足够的方式,因为它满足对偶需要满足的唯一条件。这个例子还表明,能够操作函数作为对象自动提供了表示复合数据的能力。现在这可能看起来像是一种奇特现象,但是数据的函数表示将在我们的编程技能中扮演一个核心角色。这种编程风格通常被称为消息传递,当我们在第 3 章讨论建模和模拟的问题时,我们将把它作为一个基本工具来使用。

练习 2.4

这里是对对偶的另一种函数表示。对于这种表示,验证head(pair(x, y))对于任何对象xy都产生x

function pair(x, y) {
    return m => m(x, y);
}
function head(z) {
    return z((p, q) => p);
}

tail的对应定义是什么?(提示:要验证这个定义是否有效,可以利用第 1.1.5 节的替换模型。)

练习 2.5

证明我们可以只使用数字和算术运算来表示非负整数对,如果我们将对偶ab表示为乘积2^a3^b的整数。给出函数pairheadtail的相应定义。

练习 2.6

如果将对偶表示为函数(练习 2.4)还不够令人费解,那么可以考虑,在一个可以操作函数的语言中,我们可以通过实现 0 和加 1 的操作来不使用数字(至少就非负整数而言):

const zero = f => x => x;

function add_1(n) {
    return f => x => f(n(f)(x));
}

这种表示被称为Church 数,以其发明者阿隆佐·邱奇命名,他是发明λ演算的逻辑学家。

直接定义onetwo(不要用zeroadd_1)。(提示:使用替换来计算add_1(zero))。直接定义加法函数plus(不要用重复应用add_1)。

2.1.4 扩展练习:区间算术

Alyssa P. Hacker 正在设计一个帮助人们解决工程问题的系统。她希望在她的系统中提供一个功能,可以处理不精确的数量(例如物理设备的测量参数),并且知道精度,这样当使用这种近似数量进行计算时,结果将是已知精度的数字。

电气工程师将使用 Alyssa 的系统来计算电气量。有时,他们需要使用以下公式计算两个电阻R[1]R[2]的并联等效电阻R[p]的值

c2-fig-5003.jpg

电阻值通常只能知道制造商保证的一定公差。例如,如果你购买一个标有“6.8 欧姆,公差 10%”的电阻器,你只能确定电阻器的电阻在 6.8 - 0.68 = 6.12 和 6.8 + 0.68 = 7.48 欧姆之间。因此,如果你有一个 6.8 欧姆 10%的电阻器与一个 4.7 欧姆 5%的电阻器并联,组合的电阻可以在大约 2.58 欧姆(如果两个电阻器在下限)到大约 2.97 欧姆(如果两个电阻器在上限)之间变化。

Alyssa 的想法是将“区间算术”实现为一组用于组合“区间”的算术操作(表示不精确数量的可能值范围的对象)。将两个区间相加、相减、相乘或相除的结果本身是一个区间,表示结果的范围。

Alyssa 假设存在一个称为“区间”的抽象对象,它有两个端点:一个下限和一个上限。她还假设,给定区间的端点,她可以使用数据构造函数make_interval构造区间。Alyssa 首先编写了一个函数来添加两个区间。她推断出和的最小值是两个下限的和,最大值是两个上限的和:

function add_interval(x, y) {
    return make_interval(lower_bound(x) + lower_bound(y),
                         upper_bound(x) + upper_bound(y));
}

Alyssa 还通过找到边界的最小值和最大值来计算两个区间的乘积,并将它们用作结果区间的边界。(函数math_minmath_max是原始函数,用于找到任意数量参数的最小值或最大值。)

function mul_interval(x, y) {
    const p1 = lower_bound(x) * lower_bound(y); 
    const p2 = lower_bound(x) * upper_bound(y);
    const p3 = upper_bound(x) * lower_bound(y);
    const p4 = upper_bound(x) * upper_bound(y);
    return make_interval(math_min(p1, p2, p3, p4),
                         math_max(p1, p2, p3, p4));
}

要划分两个区间,Alyssa 将第一个乘以第二个的倒数。注意倒数区间的边界是上限的倒数和下限的倒数,按顺序排列。

function div_interval(x, y) {
    return mul_interval(x, make_interval(1 / upper_bound(y),
                                         1 / lower_bound(y)));
}
练习 2.7

Alyssa 的程序是不完整的,因为她没有指定区间抽象的实现。这里是区间构造函数的定义:

function make_interval(x, y) { return pair(x, y); }

定义选择器upper_boundlower_bound来完成实现。

练习 2.8

使用类似 Alyssa 的推理,描述如何计算两个区间的差。定义一个相应的减法函数,称为sub_interval

练习 2.9

区间的宽度是其上限和下限之间的差的一半。宽度是区间指定的数字的不确定性的度量。对于一些算术操作,组合两个区间的结果的宽度仅取决于参数区间的宽度,而对于其他一些算术操作,组合的宽度并不是参数区间的宽度的函数。证明两个区间的和(或差)的宽度仅取决于要添加(或减去)的区间的宽度。举例说明,这对于乘法或除法来说并不成立。

练习 2.10

专家系统程序员 Ben Bitdiddle 看着 Alyssa 的肩膀,评论说不清楚通过跨越零的区间进行除法意味着什么。修改 Alyssa 的程序以检查这种情况,并在发生时发出错误信号。

练习 2.11

顺便说一句,Ben 也神秘地评论说:“通过测试区间端点的符号,可以将mul_interval分解为九种情况,其中只有一种需要超过两次乘法。”使用 Ben 的建议重写这个函数。

调试完她的程序后,Alyssa 将其展示给一个潜在的用户,后者抱怨说她的程序解决了错误的问题。他想要一个能够处理以中心值和加法公差表示的数字的程序;例如,他想要处理像 3.5 ± 0.15 这样的区间,而不是[3.35, 3.65]。Alyssa 回到她的桌子上,通过提供一个替代构造函数和替代选择器来解决这个问题:

function make_center_width(c, w) {
    return make_interval(c - w, c + w);
}
function center(i) {
    return (lower_bound(i) + upper_bound(i)) / 2;
}
function width(i) {
    return (upper_bound(i) - lower_bound(i)) / 2;
}

不幸的是,Alyssa 的大多数用户都是工程师。真正的工程情况通常涉及只有小不确定性的测量,测量值是区间宽度与区间中点的比率。工程师通常会在设备参数上指定百分比的容差,就像前面给出的电阻器规格一样。

练习 2.12

定义一个构造函数make_center_percent,它接受一个中心和一个百分比容差,并产生所需的区间。你还必须定义一个选择器percent,它为给定的区间产生百分比容差。center选择器与上面显示的相同。

练习 2.13

证明在小百分比容差的假设下,有一个简单的公式可以用因子的容差来近似计算两个区间的乘积的百分比容差。你可以通过假设所有数字都是正数来简化这个问题。

经过相当多的工作,Alyssa P. Hacker 交付了她的成品系统。几年后,当她已经忘记了这一切时,她接到了一个愤怒的用户 Lem E. Tweakit 的电话。看来 Lem 已经注意到并联电阻的公式可以用两种代数上等价的方式来写:

c2-fig-5004.jpg

并且

c2-fig-5005.jpg

他写了以下两个程序,每个程序都以不同的方式计算并联电阻的公式:

function par1(r1, r2) {
    return div_interval(mul_interval(r1, r2),
                        add_interval(r1, r2));
}
function par2(r1, r2) {
    const one = make_interval(1, 1);
    return div_interval(one,
                        add_interval(div_interval(one, r1),
                                     div_interval(one, r2)));
}

Lem 抱怨 Alyssa 的程序对于两种计算方式给出了不同的答案。这是一个严重的投诉。

练习 2.14

证明 Lem 是对的。研究系统对各种算术表达式的行为。创建一些区间AB,并在计算表达式A / AA / B时使用它们。通过使用宽度是中心值的小百分比的区间,你将获得最多的见解。以中心百分比形式检查计算结果(参见练习 2.12)。

练习 2.15

另一位用户 Eva Lu Ator 也注意到了不同的区间是由不同但代数上等价的表达式计算出来的。她说,使用 Alyssa 的系统计算区间的公式,如果可以以不重复代表不确定数字的名称的形式编写,将产生更紧的误差界限。因此,她说,par2par1是一个“更好”的并联电阻程序。她是对的吗?为什么?

练习 2.16

一般来说,解释等价的代数表达式可能导致不同的答案。你能设计一个没有这个缺点的区间算术包吗,还是这个任务是不可能的?(警告:这个问题非常困难。)

2.2 分层数据和闭包性质

正如我们所看到的,一对提供了一个原始的“粘合剂”,我们可以用它来构造复合数据对象。图 2.2 显示了一种标准的可视化一对的方法——在这种情况下,是由pair(1, 2)形成的一对。在这种表示中,称为盒式和指针表示法,每个复合对象都显示为指向一个盒子的指针。一对的盒子有两部分,左部分包含一对的头部,右部分包含尾部。

c2-fig-0002.jpg

图 2.2 pair(1, 2)的盒式图表示。

我们已经看到pair不仅可以用来组合数字,还可以用来组合一对。 (你在做练习 2.2 和 2.3 时已经利用了这一事实,或者应该利用了。)因此,一对提供了一个通用的构建块,我们可以用它来构造各种数据结构。图 2.3 显示了使用一对组合数字 1、2、3 和 4 的两种方法。

c2-fig-0003.jpg

图 2.3 使用一对的两种组合 1、2、3 和 4 的方法。

创建元素为对的对的能力是列表结构作为表示工具的重要性的本质。我们将这种能力称为pair闭包属性。一般来说,如果组合数据对象的操作满足闭包属性,那么使用该操作组合的结果本身可以使用相同的操作进行组合。⁷ 闭包是任何组合手段中权力的关键,因为它允许我们创建分层结构——由部分组成的结构,这些部分本身又由部分组成,依此类推。

从第 1 章开始,我们在处理函数时已经基本使用了闭包,因为除了非常简单的程序之外,所有程序都依赖于组合的元素本身可以是组合的事实。在本节中,我们将讨论闭包对于复合数据的影响。我们描述了一些使用对来表示序列和树的传统技术,并展示了一种图形语言,以生动的方式说明了闭包。

2.2.1 表示序列

我们可以使用对构建一种序列,即有序的数据对象集合。当然,有许多方法可以用对来表示序列。其中一种特别直接的表示方法如图 2.4 所示,其中序列 1, 2, 3, 4 被表示为一系列对。每对的head是链中对应的项目,而对的tail是链中的下一个对。最后一对的tail表示序列的结尾,在盒子和指针图中表示为对角线,而在程序中表示为 JavaScript 的原始值null。整个序列是通过嵌套的pair操作构建的:

c2-fig-0004.jpg

图 2.4 序列 1, 2, 3, 4 表示为一系列对。

pair(1,
     pair(2,
          pair(3,
               pair(4, null))));

由嵌套的pair应用形成的这样一系列对称为列表,我们的 JavaScript 环境提供了一个名为list的原语来帮助构建列表。⁸ 上述序列可以通过list(1, 2, 3, 4)生成。一般来说,

list(`a[1]`, `a[2], ..., `a[n]`)

等同于

pair(`a[1]`, pair(`a[2]`, pair(..., pair(a[n], null)...)))

我们的解释器使用盒子和指针图的文本表示来打印对。pair(1, 2)的结果打印为1, 2],[图 2.4 中的数据对象打印为[1, [2, [3, [4, null]]]]

const one_through_four = list(1, 2, 3, 4);

one_through_four;
[1, [2, [3, [4, null]]]]

我们可以将head视为选择列表中的第一项,将tail视为选择除第一项外的所有子列表。可以使用嵌套的headtail应用来提取列表中的第二、第三和后续项。构造函数pair使得像原始列表一样的列表,但在开头增加了一个额外的项目。

head(one_through_four);
1

tail(one_through_four);
[2, [3, [4, null]]]

head(tail(one_through_four));
2

pair(10, one_through_four);
[10, [1, [2, [3, [4, null]]]]]

pair(5, one_through_four);
[5, [1, [2, [3, [4, null]]]]]

用于终止对链的值null可以被视为没有元素的序列,即空列表。⁹

盒子表示法有时很难阅读。在本书中,当我们想要指示数据结构的列表性质时,我们将使用另一种列表表示法:在可能的情况下,列表表示法使用list的应用,其求值将导致所需的结构。例如,代替盒子表示法

*[1, [[2, 3], [[4, [5, null]], [6, null]]]]*

我们写

list(1, [2, 3], list(4, 5), 6)

在列表表示法中。¹⁰

列表操作

使用对来表示列表中元素的序列的方法伴随着传统的编程技术,通过连续使用tail来遍历列表。例如,函数list_ref以列表和数字n作为参数,并返回列表的第n项。习惯上从 0 开始对列表的元素进行编号。计算list_ref的方法如下:

  • 对于n = 0list_ref应返回列表的head

  • 否则,list_ref应返回列表的tail(n – 1)项。

function list_ref(items, n) {
    return n === 0
           ? head(items)
           : list_ref(tail(items), n - 1);
}

const squares = list(1, 4, 9, 16, 25);

list_ref(squares, 3);
16

通常我们会遍历整个列表。为了帮助实现这一点,我们的 JavaScript 环境包括一个原始谓词is_null,用于测试其参数是否为空列表。返回列表中项目的数量的函数length说明了这种典型的使用模式:

function length(items) {
    return is_null(items)
           ? 0
           : 1 + length(tail(items));
}

const odds = list(1, 3, 5, 7);

length(odds);
`4`

length函数实现了一个简单的递归计划。减少步骤是:

  • 任何列表的length都是taillength加 1。

这将一直应用,直到达到基本情况:

  • 空列表的length为 0。

我们也可以以迭代的方式计算length

function length(items) {
    function length_iter(a, count) {
        return is_null(a)
               ? count
               : length_iter(tail(a), count + 1);
    }
    return length_iter(items, 0);
}

另一种常规的编程技术是通过使用pair将元素附加到列表的前面来构造一个答案列表,同时使用tail在列表中行走,就像函数append中那样,该函数接受两个列表作为参数并组合它们的元素以生成一个新列表:

append(squares, odds);
list(1, 4, 9, 16, 25, 1, 3, 5, 7)

append(odds, squares);
list(1, 3, 5, 7, 1, 4, 9, 16, 25)

函数append也是使用递归计划实现的。要append列表list1list2,请执行以下操作:

  • 如果list1是空列表,则结果就是list2

  • 否则,append list1taillist2,并将list1head添加到结果中:

function append(list1, list2) {
    return is_null(list1)
           ? list2
           : pair(head(list1), append(tail(list1), list2));
}
练习 2.17

定义一个函数last_pair,返回一个只包含给定(非空)列表的最后一个元素的列表:

last_pair(list(23, 72, 149, 34));
list(34)
练习 2.18

定义一个函数reverse,它以列表作为参数并返回相同元素的逆序列表:

reverse(list(1, 4, 9, 16, 25));
list(25, 16, 9, 4, 1)
练习 2.19

考虑第 1.2.2 节的找零程序。很高兴能够轻松更改程序使用的货币,这样我们就可以计算例如英镑的找零方式。按照程序的编写方式,货币的知识部分分布在函数first_denomination和函数count_change中(它知道有五种美国硬币)。最好能够提供要用于找零的硬币列表。

我们想要重写函数cc,使得它的第二个参数是要使用的硬币的值的列表,而不是指定要使用哪些硬币的整数。然后我们可以有定义每种货币的列表:

const us_coins = list(50, 25, 10, 5, 1);
const uk_coins = list(100, 50, 20, 10, 5, 2, 1);

然后我们可以这样调用cc

cc(100, us_coins);
292

这将需要在一定程度上更改程序cc。它仍然具有相同的形式,但将以不同的方式访问其第二个参数,如下所示:

function cc(amount, coin_values) {
    return amount === 0
           ? 1
           : amount < 0 || no_more(coin_values)
           ? 0
           : cc(amount, except_first_denomination(coin_values)) +
             cc(amount - first_denomination(coin_values), coin_values);
}

根据列表结构的原始操作定义函数first_denominationexcept_first_denominationno_more。列表coin_values的顺序是否会影响cc产生的答案?为什么?

练习 2.20

在高阶函数的存在下,函数不一定需要有多个参数;一个就足够了。如果我们有一个像plus这样自然需要两个参数的函数,我们可以编写一个函数的变体,逐个传递参数。将变体应用于第一个参数可能会返回一个函数,然后我们可以将其应用于第二个参数,依此类推。这种做法——称为柯里化,以美国数学家和逻辑学家 Haskell Brooks Curry 命名——在 Haskell 和 OCaml 等编程语言中非常常见。在 JavaScript 中,plus的柯里化版本如下。

function plus_curried(x) {
    return y => x + y;
}

编写一个函数brooks,它以柯里化函数作为第一个参数,并以柯里化函数应用的给定顺序逐个应用作为第二个参数的参数列表。例如,brooks的以下应用应该与plus_curried(3)(4)具有相同的效果:

brooks(plus_curried, list(3, 4));
`7`

趁热打铁,我们也可以对函数brooks进行柯里化!编写一个函数brooks_curried,可以按以下方式应用:

brooks_curried(list(plus_curried, 3, 4));
`7`

使用这个函数brooks_curried,求值以下两个语句的结果是什么?

brooks_curried(list(brooks_curried,
                    list(plus_curried, 3, 4)));

brooks_curried(list(brooks_curried,
                    list(brooks_curried,
                         list(plus_curried, 3, 4))));
对列表进行映射

有一个非常有用的操作是对列表中的每个元素应用一些转换,并生成结果列表。例如,以下函数通过给定的因子来缩放列表中的每个数字:

function scale_list(items, factor) {
    return is_null(items)
           ? null
           : pair(head(items) * factor,
                  scale_list(tail(items), factor));
}

scale_list(list(1, 2, 3, 4, 5), 10);
*[10, [20, [30, [40, [50, null]]]]]*

我们可以将这个一般的想法抽象出来,并将其作为一个通用模式表达为一个高阶函数,就像在 1.3 节中一样。这里的高阶函数称为map。函数map接受一个参数和一个列表,并返回通过将函数应用于列表中的每个元素产生的结果列表:

function map(fun, items) {
    return is_null(items)
           ? null
           : pair(fun(head(items)),
                  map(fun, tail(items)));
}

map(abs, list(-10, 2.5, -11.6, 17));
*[10, [2.5, [11.6, [17, null]]]]*

map(x => x * x, list(1, 2, 3, 4));
*[1, [4, [9, [16, null]]]]*

现在我们可以通过map给出scale_list的新定义:

function scale_list(items, factor) {
    return map(x => x * factor, items);
}

函数map是一个重要的构造,不仅因为它捕捉了一个常见的模式,而且因为它在处理列表时建立了一个更高的抽象级别。在scale_list的原始定义中,程序的递归结构引起了对列表的逐个处理的注意。通过map定义scale_list抑制了那个细节级别,并强调了缩放将元素列表转换为结果列表。这两个定义之间的区别不是计算机执行了不同的过程(它没有),而是我们对过程的思考方式不同。实际上,map有助于建立一个抽象屏障,将转换列表的函数的实现与提取和组合列表元素的细节隔离开来。就像图 2.1 中显示的屏障一样,这种抽象给了我们改变序列如何实现的低级细节的灵活性,同时保留了将序列转换为序列的操作的概念框架。2.2.3 节扩展了这种将序列作为组织程序的框架的用法。

练习 2.21

函数square_list接受一个数字列表作为参数,并返回这些数字的平方列表。

square_list(list(1, 2, 3, 4));
*[1, [4, [9, [16, null]]]]*

这里有两种不同的square_list的定义。通过填写缺失的表达式来完成它们:

function square_list(items) {
    return is_null(items)
           ? null
           : pair(〈??〉, 〈??〉);
}

function square_list(items) {
    return map(〈??〉, 〈??〉);
}
练习 2.22

Louis Reasoner 试图重写练习 2.21 的第一个square_list函数,以便它演变成一个迭代过程:

function square_list(items) {
    function iter(things, answer) {
        return is_null(things)
               ? answer
               : iter(tail(things),
                      pair(square(head(things)),
                           answer));
    }
    return iter(items, null);
}

不幸的是,用这种方式定义square_list会产生与期望的相反顺序的答案列表。为什么?

然后 Louis 尝试通过交换pair的参数来修复他的错误:

function square_list(items) {
    function iter(things, answer) {
        return is_null(things)
               ? answer
               : iter(tail(things),
                      pair(answer,
                           square(head(things))));
    }
    return iter(items, null);
}

这也不起作用。解释一下。

练习 2.23

函数for_each类似于map。它接受一个函数和一个元素列表作为参数。但是,for_each不会形成结果列表,而是依次对每个元素应用函数,从左到右。应用函数到元素后返回的值根本不会被使用——for_each用于执行动作的函数,比如打印。例如,

for_each(x => display(x), list(57, 321, 88));
57
321
88

调用for_each(上面未显示)的返回值可以是任意的,比如true。给出for_each的实现。

2.2.2 分层结构

以列表的形式表示序列的表示自然地推广到表示元素本身可以是序列的序列。例如,我们可以将由[[1, [2, null]], [3, [4, null]]]构成的对象视为

pair(list(1, 2), list(3, 4));

作为一个包含三个项目的列表,第一个项目本身是一个列表,1, [2, null]]。[图 2.5 显示了这个结构的表示形式。

c2-fig-0005.jpg

图 2.5 pair(list(1, 2), list(3, 4))形成的结构。

将元素为序列的序列视为的另一种方式。序列的元素是树的分支,而元素本身是序列的元素是子树。图 2.6 显示了图 2.5 中的结构被视为树。

c2-fig-0006.jpg

图 2.6 图 2.5 中的列表结构被视为树。

递归是处理树结构的自然工具,因为我们通常可以将树上的操作减少到对其分支的操作,这些操作反过来又减少到对分支的分支的操作,依此类推,直到达到树的叶子。例如,比较第 2.2.1 节的length函数和count_leaves函数,后者返回树的总叶子数:

const x = pair(list(1, 2), list(3, 4));

length(x);
`3`

count_leaves(x);
`4`

list(x, x);
list(list(list(1, 2), 3, 4), list(list(1, 2), 3, 4))

length(list(x, x));
`2`

count_leaves(list(x, x));
`8`

要实现count_leaves,请回想一下计算length的递归计划:

  • 列表xlength是 1 加上xtaillength

  • 空列表的length为 0。

函数count_leaves类似。空列表的值是相同的:

  • 空列表的count_leaves为 0。

但在减少步骤中,我们剥离列表的head时,我们必须考虑到head本身可能是一个我们需要计算叶子的树。因此,适当的减少步骤是

  • xcount_leavesxheadcount_leaves加上xtailcount_leaves

最后,通过取head,我们到达实际的叶子,因此我们需要另一个基本情况:

  • 叶子的count_leaves为 1。

为了帮助编写树的递归函数,我们的 JavaScript 环境提供了原始谓词is_pair,用于测试其参数是否为对。以下是完整的函数:¹¹

function count_leaves(x) {
    return is_null(x)
           ? 0
           : ! is_pair(x)
           ? 1
           : count_leaves(head(x)) + count_leaves(tail(x));
}
练习 2.24

假设我们求值表达式list(1, list(2, list(3, 4)))。给出解释器打印的结果,相应的框和指针结构,以及将其解释为树的解释(如图 2.6 中所示)。

练习 2.25

给出headtail的组合,将从以下每个列表中挑选出 7 个,以列表表示:

list(1, 3, list(5, 7), 9)

list(list(7))

list(1, list(2, list(3, list(4, list(5, list(6, 7))))))
练习 2.26

假设我们定义xy为两个列表:

const x = list(1, 2, 3);
const y = list(4, 5, 6);

求值以下每个表达式的结果是什么,以框表示法和列表表示法?

append(x, y)

pair(x, y)

list(x, y)
练习 2.27

修改练习 2.18 的reverse函数,以生成一个deep_reverse函数,该函数以列表作为参数,并将其值作为其元素反转,并且所有子列表也进行深度反转。例如,

const x = list(list(1, 2), list(3, 4));

x;
list(list(1, 2), list(3, 4))

reverse(x);
list(list(3, 4), list(1, 2))

deep_reverse(x);
list(list(4, 3), list(2, 1))
练习 2.28

编写一个名为fringe的函数,该函数以树(表示为列表)作为参数,并返回一个列表,其中的元素都是树的叶子,按从左到右的顺序排列。例如,

const x = list(list(1, 2), list(3, 4));

fringe(x);
list(1, 2, 3, 4)

fringe(list(x, x));
list(1, 2, 3, 4, 1, 2, 3, 4)
练习 2.29

二进制移动由两个分支组成,左分支和右分支。每个分支都是一根特定长度的杆,从中悬挂着一个重量或另一个二进制移动。我们可以使用复合数据来表示二进制移动,通过从两个分支构造它(例如,使用list):

function make_mobile(left, right) {
    return list(left, right);
}

分支由length(必须是数字)和structure(可以是数字(表示简单重量)或另一个移动)组成:

function make_branch(length, structure) {
    return list(length, structure);
}
  1. a.编写相应的选择器left_branchright_branch,它们返回移动的分支,以及branch_lengthbranch_structure,它们返回分支的组件。

  2. b.使用您的选择器,定义一个名为total_weight的函数,返回移动的总重量。

  3. c.如果移动的顶部左分支施加的力矩等于顶部右分支施加的力矩(也就是说,如果左杆的长度乘以悬挂在该杆上的重量等于右侧对应的乘积),并且挂在其分支上的每个子移动都是平衡的,则称移动为平衡。设计一个谓词,测试二进制移动是否平衡。

  4. d.假设我们更改移动的表示形式,使构造函数为

    function make_mobile(left, right) {
        return pair(left, right);
    }
    function make_branch(length, structure) {
        return pair(length, structure);
    }
    

    您需要更改程序以转换为新表示形式吗?

对树进行映射

就像map是处理序列的强大抽象一样,map和递归一起是处理树的强大抽象。例如,scale_tree函数类似于 2.2.1 节的scale_list,它的参数是一个数字因子和一个叶子为数字的树。它返回一个相同形状的树,其中每个数字都乘以因子。scale_tree的递归计划类似于count_leaves的计划:

function scale_tree(tree, factor) {
    return is_null(tree)
           ? null
           : ! is_pair(tree)
           ? tree * factor
           : pair(scale_tree(head(tree), factor),
                  scale_tree(tail(tree), factor));
}

scale_tree(list(1, list(2, list(3, 4), 5), list(6, 7)),
           10);
list(10, list(20, list(30, 40), 50), list(60, 70))

另一种实现scale_tree的方法是将树视为子树序列,并使用map。我们在序列上进行映射,依次缩放每个子树,并返回结果列表。在基本情况下,树是叶子时,我们只需乘以因子:

function scale_tree(tree, factor) {
    return map(sub_tree => is_pair(sub_tree)
                           ? scale_tree(sub_tree, factor)
                           : sub_tree * factor,
               tree);
}

许多树操作可以通过类似的序列操作和递归的组合来实现。

练习 2.30

声明一个类似于练习 2.21 的square_list函数的函数square_tree。也就是说,square_tree应该表现如下:

square_tree(list(1,
                 list(2, list(3, 4), 5),
                 list(6, 7)));
list(1, list(4, list(9, 16), 25), list(36, 49)))

声明square_tree,既直接(即,不使用任何高阶函数),也使用map和递归。

练习 2.31

将您对练习 2.30 的答案抽象化,以生成一个具有square_tree属性的函数tree_map,可以声明为

function square_tree(tree) { return tree_map(square, tree); }
练习 2.32

我们可以将集合表示为不同元素的列表,并且可以将集合的所有子集表示为列表的列表。例如,如果集合是list(1, 2, 3),那么所有子集的集合是

list(null, list(3), list(2), list(2, 3),
     list(1), list(1, 3), list(1, 2),
     list(1, 2, 3))

完成以下函数声明,生成一个集合的子集,并清楚解释为什么它有效:

function subsets(s) {
    if (is_null(s)) {
        return list(null);
    } else {
        const rest = subsets(tail(s));
        return append(rest, map( ?? , rest));
    }
}

2.2.3 序列作为常规接口

在处理复合数据时,我们强调了数据抽象如何使我们能够设计程序,而不会陷入数据表示的细节,并且抽象保留了对我们来说灵活性,可以尝试替代表示。在本节中,我们介绍了另一个处理数据结构的强大设计原则——使用常规接口

在 1.3 节中,我们看到了程序抽象如何作为高阶函数实现,可以捕捉处理数字数据的程序中的常见模式。我们能够为处理复合数据制定类似操作的能力,关键取决于我们操作数据结构的风格。例如,考虑以下函数,类似于 2.2.2 节中的count_leaves函数,它以树作为参数,并计算奇数叶子的平方和:

function sum_odd_squares(tree) {
    return is_null(tree)
           ? 0
           : ! is_pair(tree)
           ? is_odd(tree) ? square(tree) : 0
           : sum_odd_squares(head(tree)) +
             sum_odd_squares(tail(tree));
}

表面上,这个函数与以下函数非常不同,后者构造了一个所有偶数斐波那契数Fib(k)的列表,其中k小于或等于给定的整数n

function even_fibs(n) {
    function next(k) {
        if (k > n) {
            return null;
        } else {
            const f = fib(k);
            return is_even(f)
                   ? pair(f, next(k + 1))
                   : next(k + 1);
        }
    }
    return next(0);
}

尽管这两个函数在结构上非常不同,但对这两个计算的更抽象描述揭示了很多相似之处。第一个程序

  • 枚举树的叶子;

  • 过滤它们,选择奇数;

  • 平方选定的每一个;和

  • 使用+累积结果,从 0 开始。

第二个程序

  • 枚举从 0 到n的整数;

  • 计算每个整数的斐波那契数;

  • 过滤它们,选择偶数;和

  • 使用pair累积结果,从空列表开始。

信号处理工程师会自然地将这些过程概念化为信号流经过一系列阶段,每个阶段实现程序计划的一部分,如图 2.7 所示。在sum_odd_squares中,我们从一个枚举开始,它生成一个由给定树的叶子组成的“信号”。这个信号通过一个过滤器,它消除除奇数元素以外的所有元素。结果信号依次通过一个映射,它是一个应用square函数到每个元素的“转换器”。映射的输出然后被传递给一个累加器,它使用+组合元素,从初始 0 开始。even_fibs的计划是类似的。

c2-fig-0007.jpg

图 2.7 函数sum_odd_squares(顶部)和even_fibs(底部)的信号流计划揭示了这两个程序之间的共同点。

不幸的是,上述两个函数声明未能展现出这种信号流结构。例如,如果我们检查sum_odd_squares函数,我们会发现枚举部分部分地由is_nullis_pair测试实现,部分地由函数的树递归结构实现。同样,累积部分地在测试中找到,部分地在递归中使用的加法中找到。一般来说,两个函数没有明显的部分与信号流描述中的元素相对应。我们的两个函数以不同的方式分解计算,将枚举分散到程序中,并将其与映射、过滤和累积混合在一起。如果我们能够组织我们的程序,使得信号流结构在我们编写的函数中显现出来,这将增加结果程序的概念清晰度。

序列操作

组织程序以更清晰地反映信号流结构的关键是集中于从一个过程阶段到下一个阶段流动的“信号”。如果我们将这些信号表示为列表,那么我们可以使用列表操作来实现每个阶段的处理。例如,我们可以使用第 2.2.1 节中的map函数来实现信号流图的映射阶段:

map(square, list(1, 2, 3, 4, 5));
list(1, 4, 9, 16, 25)

通过过滤序列以选择仅满足给定谓词的元素来实现

function filter(predicate, sequence) {
    return is_null(sequence)
           ? null
           : predicate(head(sequence))
           ? pair(head(sequence),
                  filter(predicate, tail(sequence)))
           : filter(predicate, tail(sequence));
}

例如,

filter(is_odd, list(1, 2, 3, 4, 5));
list(1, 3, 5)

累积可以通过实现

function accumulate(op, initial, sequence) {
    return is_null(sequence)
           ? initial
           : op(head(sequence),
                accumulate(op, initial, tail(sequence)));
}

accumulate(plus, 0, list(1, 2, 3, 4, 5));
15

accumulate(times, 1, list(1, 2, 3, 4, 5));
120

accumulate(pair, null, list(1, 2, 3, 4, 5));
list(1, 2, 3, 4, 5)

实现信号流图的所有剩下的部分就是枚举要处理的元素序列。对于even_fibs,我们需要生成给定范围内的整数序列,可以按如下方式实现:

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

enumerate_interval(2, 7);
list(2, 3, 4, 5, 6, 7)

要枚举树的叶子,我们可以使用¹²

function enumerate_tree(tree) {
    return is_null(tree)
           ? null
           : ! is_pair(tree)
           ? list(tree)
           : append(enumerate_tree(head(tree)),
                    enumerate_tree(tail(tree)));
}

enumerate_tree(list(1, list(2, list(3, 4)), 5));
list(1, 2, 3, 4, 5)

现在我们可以像信号流图一样重新制定sum_odd_squareseven_fibs。对于sum_odd_squares,我们枚举树的叶子序列,过滤以保留序列中的奇数,对每个元素求平方,并求和结果:

function sum_odd_squares(tree) {
    return accumulate(plus,
                      0,
                      map(square,
                          filter(is_odd,
                                 enumerate_tree(tree))));
}

对于even_fibs,我们枚举从 0 到n的整数,为每个整数生成斐波那契数,过滤结果序列以保留偶数元素,并将结果累积到列表中:

function even_fibs(n) {
    return accumulate(pair,
                      null,
                      filter(is_even,
                             map(fib,
                                 enumerate_interval(0, n))));
}

将程序表达为序列操作的价值在于,这有助于我们制定模块化的程序设计,即由相对独立的部分组合而成的设计。我们可以通过提供一组标准组件的库以及用灵活方式连接组件的传统接口来鼓励模块化设计。

在工程设计中,模块化构建是控制复杂性的强大策略。例如,在实际的信号处理应用中,设计师经常通过级联从标准化的滤波器和传感器系列中选择的元素来构建系统。同样,序列操作提供了一系列标准程序元素的库,我们可以随意组合。例如,我们可以在一个程序中重用sum_odd_squareseven_fibs函数的部分,以构建前n + 1个斐波那契数的平方的列表:

function list_fib_squares(n) {
    return accumulate(pair,
                      null,
                      map(square,
                          map(fib,
                              enumerate_interval(0, n))));
}

list_fib_squares(10);
list(0, 1, 1, 4, 9, 25, 64, 169, 441, 1156, 3025)

我们可以重新排列这些部分,并在计算序列中奇数的平方的乘积时使用它们:

function product_of_squares_of_odd_elements(sequence) {
    return accumulate(times,
                      1,
                      map(square,
                          filter(is_odd, sequence)));
}

product_of_squares_of_odd_elements(list(1, 2, 3, 4, 5));
225

我们还可以用序列操作来制定常规的数据处理应用。假设我们有一个人员记录序列,我们想要找到薪水最高的程序员的薪水。假设我们有一个选择器salary,返回记录的薪水,和一个谓词is_programmer,测试记录是否是程序员。然后我们可以写

function salary_of_highest_paid_programmer(records) {
    return accumulate(math_max,
                      0,
                      map(salary,
                          filter(is_programmer, records)));
}

这些例子只是给出了可以表达为序列操作的广泛操作范围的一点提示。¹³

这里实现的序列作为列表,作为一个传统接口,允许我们组合处理模块。此外,当我们将结构统一表示为序列时,我们已经将程序中的数据结构依赖局限在了少量序列操作中。通过更改这些操作,我们可以尝试使用序列的替代表示,同时保持程序的整体设计不变。在第 3.5 节中,我们将利用这种能力,将序列处理范式推广到允许无限序列。

练习 2.33

填写缺失的表达式,以完成一些基本的列表操作的累积定义:

function map(f, sequence) {
    return accumulate((x, y) => 〈??〉,
                      null, sequence);
}
function append(seq1, seq2) {
    return accumulate(pair, 〈??〉, 〈??〉);
}
function length(sequence) {
    return accumulate( 〈??〉, 0, sequence);
}
练习 2.34

在给定x的值的情况下,用x求值多项式可以被制定为一个累积。我们求值多项式

a[n]xⁿ + a[n][–1]xⁿ^(–1) + ... + a[1]x + a[0]

使用一种称为Horner's rule的著名算法,将计算结构化为

(... (a[n]x + a[n–1])x + ... + a[1]) x + a[0]

换句话说,我们从a[n]开始,乘以x,加上a[n]–1,乘以x,依此类推,直到达到a[0].¹⁴ 填写以下模板,以生成使用 Horner's rule 计算多项式的函数。假设多项式的系数按顺序排列,从a[0]a[n]

function horner_eval(x, coefficient_sequence) {
    return accumulate((this_coeff, higher_terms) => ?? ,
                      0,
                      coefficient_sequence);
}

例如,要计算1 + 3x + 5x³ + x⁵x = 2时,您需要计算

horner_eval(2, list(1, 3, 0, 5, 0, 1));
练习 2.35

将 2.2.2 节中的count_leaves重新定义为累积:

function count_leaves(t) {
    return accumulate( ?? , ?? , map( ?? , ?? ));
}
练习 2.36

函数accumulate_n类似于accumulate,只是它的第三个参数是一个序列的序列,假定它们都有相同数量的元素。它将指定的累积函数应用于组合所有序列的第一个元素,所有序列的第二个元素,依此类推,并返回结果的序列。例如,如果s是一个包含四个序列的序列

list(list(1, 2, 3), list(4, 5, 6), list(7, 8, 9), list(10, 11, 12))

然后accumulate_n(plus, 0, s)的值应该是序列list(22, 26, 30)。填写以下accumulate_n的定义中缺失的表达式:

function accumulate_n(op, init, seqs) {
    return is_null(head(seqs))
           ? null
           : pair(accumulate(op, init, 〈??〉),
                  accumulate_n(op, init, 〈??〉));
}
练习 2.37

假设我们将向量v = (v[i])表示为数字序列,并将矩阵m = (m[ij])表示为向量序列(矩阵的行)。例如,矩阵

c2-fig-5006.jpg

表示为以下序列:

list(list(1, 2, 3, 4),
     list(4, 5, 6, 6),
     list(6, 7, 8, 9))

有了这种表示,我们可以使用序列操作简洁地表示基本的矩阵和向量操作。这些操作(在任何一本关于矩阵代数的书中都有描述)如下:

c2-fig-5007.jpg

我们可以将点积定义为¹⁵

function dot_product(v, w) {
    return accumulate(plus, 0, accumulate_n(times, 1, list(v, w)));
}

填写以下函数中的缺失表达式,用于计算其他矩阵操作。(函数accumulate_n在练习 2.36 中声明。)

function matrix_times_vector(m, v) {
    return map( ?? , m);
}
function transpose(mat) {
    return accumulate_n( ?? , ?? , mat);
}
function matrix_times_matrix(n, m) {
    const cols = transpose(n);
    return map( ?? , m);
}
练习 2.38

accumulate函数也被称为fold_right,因为它将序列的第一个元素与组合所有元素的结果结合。还有一个fold_left,它类似于fold_right,只是它是从相反方向组合元素的:

function fold_left(op, initial, sequence) {
    function iter(result, rest) {
        return is_null(rest)
               ? result
               : iter(op(result, head(rest)),
                      tail(rest));
    }
    return iter(initial, sequence);
}

以下是

fold_right(divide, 1, list(1, 2, 3));

fold_left(divide, 1, list(1, 2, 3));

fold_right(list, null, list(1, 2, 3));

fold_left(list, null, list(1, 2, 3));

给出一个op应满足的属性,以确保fold_rightfold_left对于任何序列都会产生相同的值。

练习 2.39

根据练习 2.38 中的fold_rightfold_left,完成reverse(练习 2.18)的以下定义:

function reverse(sequence) {
    return fold_right((x, y) => ?? , null, sequence);
}

function reverse(sequence) {
    return fold_left((x, y) => ?? , null, sequence);
}
嵌套映射

我们可以扩展序列范式,包括许多通常使用嵌套循环表达的计算。¹⁶考虑这个问题:给定一个正整数n,找到所有有序对的不同正整数ij,其中1 j < i n,使得i + j是素数。例如,如果n为 6,则这些对是以下的:

i2344566
j1213215
i + j35577711

组织这个计算的一种自然方式是生成所有小于或等于n的正整数的有序对序列,过滤以选择其和为素数的那些对,然后对于通过过滤的每个对(i, j),产生三元组(i, j, i + j)

以下是生成对序列的方法:对于每个整数i <= n,枚举小于i的整数j,对于这样的ij生成对(i, j)。在序列操作方面,我们沿着序列enumerate_interval(1, n)进行映射。对于这个序列中的每个i,我们沿着序列enumerate_interval(1, i - 1)进行映射。对于后一个序列中的每个j,我们生成对list(i, j)。这给我们每个i的一系列对。将所有i的序列组合起来(通过累积使用append)产生所需的对序列:¹⁷

accumulate(append,
           null,
           map(i => map(j => list(i, j),
                        enumerate_interval(1, i - 1)),
               enumerate_interval(1, n)));

在这种程序中,映射和使用append进行累积的组合是如此常见,以至于我们将其作为一个单独的函数进行隔离:

function flatmap(f, seq) {
    return accumulate(append, null, map(f, seq));
}

现在过滤这些对的序列,找到其和为素数的对。过滤谓词对序列的每个元素进行调用;它的参数是一个对,并且它必须从对中提取整数。因此,应用于序列中的每个元素的谓词是

function is_prime_sum(pair) {
    return is_prime(head(pair) + head(tail(pair)));
}

最后,通过使用以下函数对过滤后的对进行映射,生成结果的序列,该函数构造一个由对的两个元素及其和组成的三元组:

function make_pair_sum(pair) {
    return list(head(pair), head(tail(pair)),
                head(pair) + head(tail(pair)));
}

将所有这些步骤组合起来得到完整的函数:

function prime_sum_pairs(n) {
    return map(make_pair_sum,
               filter(is_prime_sum,
                      flatmap(i => map(j => list(i, j),
                                       enumerate_interval(1, i - 1)),
                              enumerate_interval(1, n))));
}

嵌套映射对于除了枚举间隔的序列之外的序列也是有用的。假设我们希望生成集合S的所有排列;也就是说,集合中项目的所有排序方式。例如,{1, 2, 3}的排列是{1, 2, 3},{1, 3, 2},{2, 1, 3},{2, 3, 1},{3, 1, 2}和{3, 2, 1}。以下是生成S的排列的计划:对于S中的每个项目x,递归生成Sx的排列序列,然后将x添加到每个排列的前面。这为S中的每个x产生了以x开头的排列序列。将所有x的这些序列组合起来得到S的所有排列:¹⁹

function permutations(s) {
    return is_null(s) // empty set?
           ? list(null) // sequence containing empty set
           : flatmap(x => map(p => pair(x, p),
                              permutations(remove(x, s))),
                     s);
}

注意这种策略如何将生成S的排列的问题简化为生成比S元素更少的集合的排列的问题。在终端情况下,我们一直向下工作,直到空列表,它表示没有元素的集合。对于这个,我们生成list(null),它是一个具有一个项目的序列,即没有元素的集合。permutations中使用的remove函数返回给定序列中除了给定项目之外的所有项目。这可以表示为一个简单的过滤器:

function remove(item, sequence) {
    return filter(x => ! (x === item),
                  sequence);
}
练习 2.40

编写一个名为unique_pairs的函数,给定一个整数n,生成一对(i, j)的序列,其中1, j < i, n。使用unique_pairs来简化上面给出的prime_sum_pairs的定义。

练习 2.41

编写一个函数,找到所有小于或等于给定整数n的不同正整数ijk的有序三元组,它们的和为给定整数s

练习 2.42

“八皇后问题”是问如何在国际象棋棋盘上放置八个皇后,以便没有一个皇后受到其他任何一个皇后的攻击(即,没有两个皇后在同一行,列或对角线上)。一个可能的解决方案如图 2.8 所示。解决这个难题的一种方法是逐列工作,将一个皇后放在每一列。一旦我们放置了k - 1个皇后,我们必须将第k个皇后放在一个位置,使得它不会攻击棋盘上已经存在的任何一个皇后。我们可以递归地制定这种方法:假设我们已经生成了在棋盘的前k - 1列中放置k - 1个皇后的所有可能方式的序列。对于这些方式中的每一种,通过在第k列的每一行放置一个皇后来生成一个扩展的位置集。现在过滤这些位置,只保留对其他皇后来说第k列中的皇后是安全的位置。这样就产生了在前k列中放置k个皇后的所有方式的序列。通过继续这个过程,我们将产生不止一个解决方案,而是所有解决方案。

c2-fig-0008.jpg

图 2.8 八皇后问题的一个解决方案。

我们将这个解决方案实现为一个名为queens的函数,它返回在n n国际象棋棋盘上放置n个皇后的所有解决方案的序列。函数queens有一个内部函数queens_cols,它返回在棋盘的前k列中放置皇后的所有方式的序列。

function queens(board_size) {
    function queen_cols(k) {
        return k === 0
               ? list(empty_board)
               : filter(positions => is_safe(k, positions),
                        flatmap(rest_of_queens =>
                                  map(new_row =>
                                        adjoin_position(new_row, k,
                                                        rest_of_queens),
                                      enumerate_interval(1, board_size)),
                                queen_cols(k - 1)));
    }
    return queen_cols(board_size);
}

在这个函数中,rest_of_queens是在前k - 1列中放置k - 1个皇后的一种方法,new_row是一个建议的行,用于放置第k列的皇后。通过实现代表棋盘位置集的函数adjoin_position,包括将新的行列位置添加到位置集的函数adjoin_position,以及代表空位置集的函数empty_board,来完成程序。您还必须编写函数is_safe,它确定一组位置中的第k列的皇后是否与其他皇后安全。(请注意,我们只需要检查新皇后是否安全——其他皇后已经保证彼此之间是安全的。)

练习 2.43

Louis Reasoner 在做练习 2.42 时遇到了很大的困难。他的queens函数似乎可以工作,但运行速度非常慢。(Louis 甚至没有等到它解决 6 6 的情况。)当 Louis 向 Eva Lu Ator 寻求帮助时,她指出他已经交换了flatmap中嵌套映射的顺序,将其写成

flatmap(new_row =>
          map(rest_of_queens =>
                adjoin_position(new_row, k, rest_of_queens),
              queen_cols(k - 1)),
        enumerate_interval(1, board_size));

解释为什么这种交换会使程序运行缓慢。估计 Louis 的程序解决八皇后问题需要多长时间,假设练习 2.42 中的程序在时间T内解决了这个问题。

2.2.4 例子:一个图片语言

本节介绍了一种简单的绘图语言,它展示了数据抽象和闭包的强大力量,并且以一种基本的方式利用了高阶函数。该语言旨在使实验变得容易,例如图 2.9 中的图案,这些图案由重复的元素组成,这些元素被移动和缩放。在这种语言中,被组合的数据对象被表示为函数,而不是列表结构。正如pair满足闭包属性使我们能够轻松构建任意复杂的列表结构一样,这种语言中的操作也满足闭包属性,使我们能够轻松构建任意复杂的图案。

c2-fig-0009.jpg

图 2.9 使用图片语言生成的设计。

图片语言

当我们在 1.1 节开始学习编程时,我们强调了通过关注语言的基本元素、组合方式和抽象方式来描述一种语言的重要性。我们将在这里遵循这个框架。

这种图片语言的优雅之处在于只有一种元素,称为画家。画家绘制的图像被移动和缩放以适应指定的平行四边形框架。例如,有一个我们称为wave的原始画家,它绘制了一个粗线条的图像,如图 2.10 所示。图像的实际形状取决于框架——图 2.10 中的所有四幅图像都是由相同的wave画家生成的,但是与四个不同的框架相关。画家可以比这更复杂:名为rogers的原始画家绘制了麻省理工学院的创始人威廉·巴顿·罗杰斯的画像,如图 2.11 所示。图 2.11 中的四幅图像是与图 2.10 中的wave图像相对应的四个框架绘制的。

c2-fig-0010.jpg

图 2.10 由wave画家生成的图像,与四个不同的框架相关。虚线框不是图像的一部分。

c2-fig-0011.jpg

图 2.11 以与图 2.10 相同的四个框架为基础绘制的麻省理工学院创始人和第一任校长威廉·巴顿·罗杰斯的形象(原始图片由麻省理工学院博物馆提供)。

为了组合图像,我们使用各种操作从给定的画家构造新的画家。例如,beside操作接受两个画家,并产生一个新的复合画家,它在帧的左半部分绘制第一个画家的图像,在右半部分绘制第二个画家的图像。类似地,below接受两个画家,并产生一个复合画家,它在第一个画家的图像下方绘制第二个画家的图像。一些操作可以转换单个画家以产生新的画家。例如,flip_vert接受一个画家,并产生一个绘制其图像上下颠倒的画家,flip_horiz产生一个绘制原始画家图像从左到右翻转的画家。

图 2.12 显示了一个名为wave4的画家的绘制,它是从wave开始分两个阶段构建的:

const wave2 = beside(wave, flip_vert(wave));
const wave4 = below(wave2, wave2);

通过这种方式构建复杂的图像,我们利用了画家在语言的组合方式下是闭合的这一事实。两个画家的besidebelow本身就是一个画家;因此,我们可以将其用作制作更复杂画家的元素。与使用pair构建列表结构一样,数据在组合方式下的闭合对于能够仅使用少量操作创建复杂结构至关重要。

c2-fig-0012.jpg

图 2.12 从图 2.10 的wave画家开始创建一个复杂的图形。

一旦我们能够组合画家,我们希望能够抽象出典型的组合画家模式。我们将画家操作实现为 JavaScript 函数。这意味着在图片语言中我们不需要特殊的抽象机制:由于组合的方式是普通的 JavaScript 函数,我们自动具有对画家操作进行任何操作的能力。例如,我们可以将wave4中的模式抽象为

function flipped_pairs(painter) {
    const painter2 = beside(painter, flip_vert(painter));
    return below(painter2, painter2);
}

并将wave4声明为此模式的一个实例:

const wave4 = flipped_pairs(wave);

我们还可以定义递归操作。以下是一个使画家向右分割和分支的操作,如图 2.13 和 2.14 所示:

function right_split(painter, n) {
    if (n === 0) {
        return painter;
    } else {
        const smaller = right_split(painter, n - 1);
        return beside(painter, below(smaller, smaller));
    }
}

c2-fig-0013.jpg

图 2.13 right_splitcorner_split的递归计划。

c2-fig-0014.jpg

图 2.14 递归操作right_split应用于画家waverogers。将四个corner_split图形组合成对称的square_limit,如图 2.9 所示。

我们可以通过向上和向右分支来产生平衡的图案(参见练习 2.44 和图 2.13 和 2.14):

function corner_split(painter, n) {
    if (n === 0) {
        return painter;
    } else {
        const up = up_split(painter, n - 1);
        const right = right_split(painter, n - 1);
        const top_left = beside(up, up);
        const bottom_right = below(right, right);
        const corner = corner_split(painter, n - 1);
        return beside(below(painter, top_left),
                      below(bottom_right, corner));
    }
}

通过适当放置四个corner_split的副本,我们可以获得一个名为square_limit的图案,其应用于waverogers如图 2.9 所示:

function square_limit(painter, n) {
    const quarter = corner_split(painter, n);
    const half = beside(flip_horiz(quarter), quarter);
    return below(flip_vert(half), half);
}
练习 2.44

声明由corner_split使用的函数up_split。它类似于right_split,只是它交换了belowbeside的角色。

高阶操作

除了抽象出组合画家的模式之外,我们还可以在更高的层次上工作,抽象出组合画家操作的模式。也就是说,我们可以将画家操作视为要操作的元素,并且可以编写这些元素的组合方式——接受画家操作作为参数并创建新的画家操作的函数。

例如,flipped_pairssquare_limit都将画家的图像排列成方形图案的四个副本;它们之间的区别只在于它们如何定位这些副本。抽象这种画家组合的一种方法是使用以下函数,该函数接受四个一元画家操作并生成一个画家操作,该操作使用这四个操作对给定的画家进行变换并将结果排列成一个方形。²² 函数tltrblbr分别是要应用于左上角副本、右上角副本、左下角副本和右下角副本的变换。

function square_of_four(tl, tr, bl, br) {
    return painter => {
        const top = beside(tl(painter), tr(painter));
        const bottom = beside(bl(painter), br(painter));
        return below(bottom, top);
    };
}

然后可以根据square_of_four定义flipped_pairs如下:²³

function flipped_pairs(painter) {
    const combine4 = square_of_four(identity, flip_vert,
                                    identity, flip_vert);
    return combine4(painter);
}

square_limit可以表示为²⁴

function square_limit(painter, n) {
    const combine4 = square_of_four(flip_horiz, identity,
                                    rotate180, flip_vert);
    return combine4(corner_split(painter, n));
}
练习 2.45

函数right_splitup_split可以表示为一般分割操作的实例。声明一个具有属性的函数split,使其求值

const right_split = split(beside, below);
const up_split = split(below, beside);

生成具有与已声明的相同行为的函数right_splitup_split

在我们展示如何实现画家及其组合方式之前,我们必须首先考虑帧。一个帧可以由三个向量描述——一个原点向量和两个边缘向量。原点向量指定了帧的原点与平面上某个绝对原点的偏移量,而边缘向量指定了帧的角落与其原点的偏移量。如果边缘是垂直的,那么帧将是矩形的。否则,帧将是一个更一般的平行四边形。

图 2.15 显示了一个帧及其相关的向量。根据数据抽象,我们不需要具体说明帧是如何表示的,除了说有一个构造函数make_frame,它接受三个向量并生成一个帧,以及三个相应的选择器origin_frameedge1_frameedge2_frame(参见练习 2.47)。

c2-fig-0015.jpg

图 2.15 一个框架由三个向量描述——一个原点和两个边。

我们将使用单位正方形中的坐标(0 ≤ x, y ≤ 1)来指定图像。对于每个框架,我们关联一个 框架坐标映射,它将用于移动和缩放图像以适应框架。该映射通过将单位正方形映射到框架来将向量v = (x, y)映射到向量和

原点(框架) + x · 边[1] (框架) + y · 边[2] (框架)

例如,(0, 0)被映射到框架的原点,(1, 1)被映射到对角于原点的顶点,(0.5, 0.5)被映射到框架的中心。我们可以使用以下函数创建框架的坐标映射:²⁵

function frame_coord_map(frame) {
    return v => add_vect(origin_frame(frame),
                         add_vect(scale_vect(xcor_vect(v),
                                             edge1_frame(frame)),
                                  scale_vect(ycor_vect(v),
                                             edge2_frame(frame))));
}

观察将frame_coord_map应用于框架会返回一个函数,给定一个向量,返回一个向量。如果参数向量在单位正方形内,则结果向量将在框架内。例如,

frame_coord_map(a_frame)(make_vect(0, 0)); 

返回与

origin_frame(a_frame);
练习 2.46

从原点到一个点的二维向量v可以表示为一个对,包括一个x坐标和一个y坐标。通过给出一个构造函数make_vect和相应的选择器xcor_vectycor_vect来为向量实现数据抽象。根据你的选择器和构造函数,实现函数add_vectsub_vectscale_vect,执行向量加法、向量减法和将向量乘以标量的操作:

 (x[1], y[1]) + (x[2], y[2])  =  (x[1] + x[2], y[1] + y[2]) 

 (x[1], y[1]) – (x[2], y[2])  =  (x[1] – x[2], y[1] – y[2]) 

 s · (x, y)  =  (sx, sy) 
练习 2.47

以下是框架的两个可能的构造函数:

function make_frame(origin, edge1, edge2) {
    return list(origin, edge1, edge2);
}

function make_frame(origin, edge1, edge2) {
   return pair(origin, pair(edge1, edge2));
}

对于每个构造函数,提供适当的选择器以生成框架的实现。

画家

画家表示为一个函数,给定一个框架作为参数,绘制一个特定的图像,移位和缩放以适应框架。也就是说,如果p是一个画家,f是一个框架,那么我们通过调用p传入f作为参数来在f中产生p的图像。

原始画家的实现细节取决于图形系统的特定特性和要绘制的图像类型。例如,假设我们有一个函数draw_line,它在屏幕上在两个指定点之间画一条线。然后我们可以根据线段列表创建线条绘制的画家,例如 图 2.10 中的wave画家,如下所示:²⁶

function segments_to_painter(segment_list) {
    return frame =>
             for_each(segment =>
                        draw_line(
                            frame_coord_map(frame)
                                (start_segment(segment)),
                            frame_coord_map(frame)
                                (end_segment(segment))),
                      segment_list);
}

使用单位正方形的坐标给出线段。对于列表中的每个线段,画家使用框架坐标映射转换线段端点,并在转换后的点之间画一条线。

将画家表示为函数在图片语言中建立了强大的抽象屏障。我们可以创建和混合各种基于各种图形能力的原始画家。它们的实现细节并不重要。任何函数都可以作为画家,只要它以框架作为参数并绘制适合框架的内容。²⁷

练习 2.48

平面上的有向线段可以表示为一对向量——从原点到线段起点的向量,以及从原点到线段终点的向量。使用练习 2.46 中的向量表示来定义具有构造函数make_segment和选择器start_segmentend_segment的线段表示。

练习 2.49

使用segments_to_painter来定义以下原始画家:

  1. a. 绘制指定框架的轮廓的画家。

  2. b. 通过连接框架的对角线绘制X的画家。

  3. c. 连接框架边中点绘制菱形形状的画家。

  4. d. wave画家。

转换和组合画家

对画家的操作(如flip_vertbeside)通过创建一个画家来实现,该画家根据参数框架派生的框架调用原始画家。因此,例如,flip_vert不需要知道画家的工作方式就可以翻转它——它只需要知道如何将框架颠倒:翻转后的画家只是使用原始画家,但在倒置的框架中。

画家操作基于transform_painter函数,它接受一个画家和如何转换框架的信息作为参数,并产生一个新的画家。转换后的画家在给定一个框架时,会转换框架并在转换后的框架上调用原始画家。transform_painter的参数是指定新框架角落的点(表示为向量):当映射到框架中时,第一个点指定新框架的原点,另外两个点指定其边缘向量的端点。因此,在单位正方形内的参数指定了包含在原始框架内的框架。

function transform_painter(painter, origin, corner1, corner2) {
    return frame => {
             const m = frame_coord_map(frame);
             const new_origin = m(origin);
             return painter(make_frame(
                                new_origin,
                                sub_vect(m(corner1), new_origin),
                                sub_vect(m(corner2), new_origin)));
           };
}

以下是如何垂直翻转画家图像:

function flip_vert(painter) {
    return transform_painter(painter,
                             make_vect(0, 1),  // new origin
                             make_vect(1, 1),  // new end of edge1
                             make_vect(0, 0)); // new end of edge2
}

使用transform_painter,我们可以轻松定义新的转换。例如,我们可以声明一个画家,将其图像缩小到给定框架的右上角。

function shrink_to_upper_right(painter) {
    return transform_painter(painter,
                             make_vect(0.5, 0.5),
                             make_vect(1, 0.5),
                             make_vect(0.5, 1));
}

其他转换将图像逆时针旋转 90 度²⁸

function rotate90(painter) {
    return transform_painter(painter,
                             make_vect(1, 0),
                             make_vect(1, 1),
                             make_vect(0, 0));
}

或者将图像压缩到框架的中心:²⁹

function squash_inwards(painter) {
    return transform_painter(painter,
                             make_vect(0, 0),
                             make_vect(0.65, 0.35),
                             make_vect(0.35, 0.65));
}

框架转换也是定义两个或更多画家组合方式的关键。例如,beside函数接受两个画家,将它们转换为分别在参数框架的左半部分和右半部分绘制,并产生一个新的复合画家。当给复合画家一个框架时,它调用第一个转换后的画家在框架的左半部分绘制,并调用第二个转换后的画家在框架的右半部分绘制:

function beside(painter1, painter2) {
    const split_point = make_vect(0.5, 0);
    const paint_left = transform_painter(painter1,
                                         make_vect(0, 0),
                                         split_point,
                                         make_vect(0, 1));
    const paint_right = transform_painter(painter2,
                                         split_point,
                                         make_vect(1, 0),
                                         make_vect(0.5, 1));
    return frame => {
               paint_left(frame);
               paint_right(frame);
           };
}

观察画家数据抽象,特别是将画家表示为函数,使得beside易于实现。beside函数不需要了解组件画家的任何细节,只需要知道每个画家将在其指定的框架中绘制一些东西。

练习 2.50

声明转换flip_horiz,它可以水平翻转画家,并且可以逆时针旋转 180 度和 270 度。

练习 2.51

声明画家的below操作。below函数接受两个画家作为参数。给定一个框架,结果画家用第一个画家在框架底部绘制,并用第二个画家在顶部绘制。以两种不同的方式定义below——首先编写一个类似于上面给出的beside函数的函数,然后再根据beside和适当的旋转操作(来自练习 2.50)定义below

语言水平的稳健设计

图片语言利用了我们介绍的关于函数和数据抽象的一些关键思想。基本数据抽象,画家,是使用函数表示实现的,这使得语言可以以统一的方式处理不同的基本绘图能力。组合的方式满足封闭性质,这使我们可以轻松地构建复杂的设计。最后,所有用于抽象函数的工具都可以用于抽象画家的组合方式。

我们还对语言和程序设计的另一个关键思想有了一瞥。这就是分层设计的方法,即复杂系统应该被构造为一系列使用一系列语言描述的级别。每个级别都是通过组合在该级别被视为原始的部分构建的,而在下一个级别,每个级别构建的部分都被用作原语。分层设计的每个级别使用适合该级别细节的原语、组合手段和抽象手段。

分层设计渗透到复杂系统的工程中。例如,在计算机工程中,电阻器和晶体管被组合(并使用模拟电路语言描述)以产生诸如与门和或门之类的部件,这些部件构成了数字电路设计语言的原语。这些部件被组合以构建处理器、总线结构和存储系统,然后使用适合计算机体系结构的语言将它们组合成计算机。计算机被组合成分布式系统,使用适合描述网络互连的语言,依此类推。

作为分层的一个微小示例,我们的图片语言使用原始元素(原始画家)来指定点和线,以提供像rogers这样的画家的形状。我们对图片语言的描述主要集中在组合这些原始元素上,使用几何组合器如besidebelow。我们还在更高的级别上工作,将besidebelow视为在一个语言中被操作的原语,这个语言的操作,比如square_of_four,捕捉了组合几何组合器的常见模式。

分层设计有助于使程序健壮,也就是说,这样做可以使规范的微小变化很可能只需要相应地对程序进行微小的修改。例如,假设我们想要根据图 2.9 中显示的wave来改变图像。我们可以在最低级别上改变wave元素的详细外观;我们可以在中间级别上改变corner_split复制wave的方式;我们可以在最高级别上改变square_limit如何排列四个角的方式。通常情况下,分层设计的每个级别都提供了一个不同的词汇表来表达系统的特征,并且提供了不同类型的改变能力。

练习 2.52

通过在上述每个级别上工作,对wavesquare_limit进行更改,如图 2.9 所示。特别是:

  1. a. 向练习 2.49 中的原始wave画家添加一些段(例如添加一个微笑)。

  2. b. 改变corner_split构造的模式(例如,只使用一个up_splitright_split图像的副本,而不是两个)。

  3. c. 修改使用square_of_four来组装角落的square_limit版本,以便以不同的模式组装角落。(例如,你可以让大的 Mr. Rogers 从正方形的每个角向外看。)

2.3 符号数据

到目前为止,我们使用的所有复合数据对象最终都是由数字构建的。在本节中,我们通过引入使用字符字符串的能力来扩展我们的语言的表示能力。

2.3.1 字符串

到目前为止,我们已经使用字符串来显示消息,使用displayerror函数(例如在练习 1.22 中)。我们可以使用字符串形成复合数据,并且有列表,比如

list("a", "b", "c", "d")
list(23, 45, 17)
list(list("Jakob", 27), list("Lova", 9), list("Luisa", 24))

为了区分字符串和名称,我们用双引号将它们括起来。例如,JavaScript 表达式z表示名称z的值,而 JavaScript 表达式"z"表示由单个字符组成的字符串,即英语字母表中的最后一个字母的小写形式。

通过引号,我们可以区分字符串和名称:

const a = 1;
const b = 2;

list(a, b);
[1, [2, null]]

list("a", "b");
["a", ["b", null]]

list("a", b);
["a", [2, null]]

在第 1.1.6 节,我们将===!==作为数字的原始谓词引入。从现在开始,我们将允许===!==的操作数为两个字符串。谓词===返回true,当且仅当两个字符串相同时,!==返回true,当且仅当两个字符串不同时。使用===,我们可以实现一个有用的函数称为member。它有两个参数:一个字符串和一个字符串列表或一个数字和一个数字列表。如果第一个参数不包含在列表中(即不与列表中的任何项===),则member返回null。否则,它返回列表中从第一次出现的字符串或数字开始的子列表:

function member(item, x) {
    return is_null(x)
           ? null
           : item === head(x)
           ? x
           : member(item, tail(x));
}

例如,值为

member("apple", list("pear", "banana", "prune"))

null,而

member("apple", list("x", "y", "apple", "pear"))

list("apple", "pear")

练习 2.53

求出以下每个表达式的求值结果,使用框表示法和列表表示法?

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

list(list("george"))

tail(list(list("x1", "x2"), list("y1", "y2")))

tail(head(list(list("x1", "x2"), list("y1", "y2"))))

member("red", list("blue", "shoes", "yellow", "socks"))

member("red", list("red", "shoes", "blue", "socks"))
练习 2.54

如果两个列表包含相同顺序排列的相等元素,则称它们为equal。例如,

equal(list("this", "is", "a", "list"), list("this", "is", "a", "list"))

true,但

equal(list("this", "is", "a", "list"), list("this", list("is", "a"), "list"))

false。更准确地说,我们可以通过基本的===相等性递归地定义equal,即如果ab都是字符串或数字并且它们===,或者如果它们都是对,使得head(a)等于head(b)并且tail(a)等于tail(b)。使用这个想法,实现equal作为一个函数。

练习 2.55

JavaScript 解释器在双引号"后读取字符,直到找到另一个双引号。两者之间的所有字符都是字符串的一部分,不包括双引号本身。但是如果我们想要一个字符串包含双引号呢?为此,JavaScript 还允许单引号来界定字符串,例如在'say your name aloud'中。在单引号字符串中,我们可以使用双引号,反之亦然,因此'say "your name" aloud'"say 'your name' aloud"是有效的字符串,它们在位置 4 和 14 有不同的字符,如果我们从 0 开始计数。根据使用的字体,两个单引号可能不容易与双引号区分开。你能分辨出哪个是哪个,并计算出以下表达式的值吗?

' " ' === " "