原文:1 Building Abstractions with Functions
译者:飞龙
协议:CC BY-NC-SA 4.0
心灵的行为,其中它对简单的想法施加其力量,主要有以下三种:1.将几个简单的想法组合成一个复合的想法,从而形成所有复杂的想法。2.第二个是将两个想法,无论是简单的还是复杂的,放在一起,并将它们放在一起,以便一次看到它们,而不将它们合并成一个,从而获得它们所有的关系想法。3.第三个是将它们与实际存在的所有其他想法分开:这被称为抽象,从而形成所有的一般想法。------约翰·洛克,《人类理解论》(1690)
我们将要研究计算过程 的概念。计算过程是存在于计算机中的抽象实体。随着它们的发展,过程操纵其他抽象的东西,称为数据 。过程的演变由一组称为程序的规则指导。人们创建程序来指导过程。实际上,我们用我们的咒语召唤计算机的精神。
计算过程确实很像巫师对精神的想法。它看不见,摸不着。它根本不是由物质组成的。然而,它是非常真实的。它可以进行智力工作。它可以回答问题。它可以通过在银行分发钱或控制工厂中的机器人手臂来影响世界。我们用来施法的程序就像巫师的咒语。它们是从奥秘和神秘的编程语言中的符号表达式中精心组成的,这些语言规定了我们希望我们的过程执行的任务。
计算过程在一个正确工作的计算机中精确和准确地执行程序。因此,像巫师的学徒一样,新手程序员必须学会理解和预料他们施法的后果。即使是程序中的小错误(通常称为bug)也可能产生复杂和意想不到的后果。
幸运的是,学习编程远比学习巫术危险得多,因为我们处理的精神被方便地以安全的方式包含在其中。然而,现实世界的编程需要谨慎、专业知识和智慧。例如,计算机辅助设计程序中的一个小错误可能导致飞机或大坝的灾难性崩溃,或者工业机器人的自我毁灭。
优秀的软件工程师有能力组织程序,以便他们可以相当确信所得到的过程将执行预期的任务。他们可以预先可视化系统的行为。他们知道如何构造程序,以便意想不到的问题不会导致灾难性后果,而当问题出现时,他们可以调试他们的程序。设计良好的计算系统,就像设计良好的汽车或核反应堆一样,都是以模块化的方式设计的,以便部件可以分别构建、更换和调试。
JavaScript 编程
我们需要一个适当的语言来描述过程,为此我们将使用编程语言 JavaScript。正如我们的日常思维通常用我们的母语(如英语、瑞典语或中文)表达一样,对数量现象的描述用数学符号表示,我们的过程思维将用 JavaScript 表示。JavaScript 于 1995 年开发,用作控制万维网浏览器行为的编程语言,通过嵌入在网页中的脚本。该语言最初由 Brendan Eich 构思,最初名为Mocha ,后来更名为LiveScript,最终改名为 JavaScript。名称"JavaScript"是 Oracle Corporation 的商标。
尽管 JavaScript 最初是作为网页脚本语言而诞生的,但它是一种通用编程语言。JavaScript 解释器是一台执行 JavaScript 语言描述的过程的机器。第一个 JavaScript 解释器是由 Eich 在网景通信公司为网景导航器网页浏览器实现的。JavaScript 从 Scheme 和 Self 编程语言继承了其核心特性。Scheme 是 Lisp 的一个方言,并且曾被用作本书原始版本的编程语言。JavaScript 从 Scheme 继承了其最基本的设计原则,如词法作用域的一流函数和动态类型。
JavaScript 与其(最终)命名的语言 Java 只有表面上的相似之处;Java 和 JavaScript 都使用语言 C 的块结构。与通常使用编译到低级语言的 Java 和 C 相反,JavaScript 程序最初是由网页浏览器解释 的。在网景导航器之后,其他网页浏览器提供了对该语言的解释器,包括微软的 Internet Explorer,其 JavaScript 版本称为JScript 。JavaScript 在控制网页浏览器方面的流行性促使了一项标准化工作,最终导致了ECMAScript的产生。ECMAScript 标准的第一版由盖伊·刘易斯·斯蒂尔(Guy Lewis Steele Jr.)领导,并于 1997 年 6 月完成(ECMA 1997)。第六版,即 ECMAScript 2015,由艾伦·韦尔夫斯-布洛克(Allen Wirfs-Brock)领导,并于 2015 年 6 月被 ECMA 大会采纳(ECMA 2015)。
将 JavaScript 程序嵌入网页的做法鼓励了网页浏览器的开发人员实现 JavaScript 解释器。随着这些程序变得更加复杂,解释器在执行它们时变得更加高效,最终采用了诸如即时(JIT)编译等复杂的实现技术。截至本文撰写时(2021 年),大多数 JavaScript 程序都嵌入在网页中,并由浏览器解释,但 JavaScript 越来越多地被用作通用编程语言,使用诸如 Node.js 等系统。
ECMAScript 2015 拥有一系列功能,使其成为研究重要的编程构造和数据结构以及将它们与支持它们的语言特性相关联的绝佳媒介。它的词法作用域的一流函数及其通过 lambda 表达式的语法支持直接而简洁地访问函数抽象,动态类型允许适应保持接近 Scheme 原始状态。除了这些考虑之外,在 JavaScript 中编程非常有趣。
1.1 编程的元素
强大的编程语言不仅仅是指示计算机执行任务的手段。语言还作为一个框架,我们在其中组织关于过程的想法。因此,当我们描述一种语言时,我们应该特别关注语言提供的手段,用于将简单的想法组合成更复杂的想法。每种强大的语言都有三种机制来实现这一点:
-
原始表达式,代表语言关注的最简单的实体,
-
组合手段,通过这种手段,可以从更简单的元素构建复合元素,以及
-
抽象手段,通过这种手段,可以将复合元素命名并作为单元进行操作。
在编程中,我们处理两种元素:函数和数据。(后来我们会发现它们实际上并不那么不同。)非正式地说,数据是我们想要操作的"东西",而函数是描述操作数据规则的描述。因此,任何强大的编程语言都应该能够描述原始数据和原始函数,并且应该有方法来组合和抽象函数和数据。
在本章中,我们只处理简单的数值数据,以便我们可以专注于构建函数的规则。¹在后面的章节中,我们将看到这些相同的规则允许我们构建函数来操作复合数据。
1.1.1 表达式
开始编程的一种简单方法是检查与 JavaScript 语言解释器的一些典型交互。您键入一个语句 ,解释器会通过显示其求值结果来做出响应。
您可能键入的一种语句是表达式语句,它由一个表达式后跟一个分号组成。一种原始表达式是数字。(更准确地说,您键入的表达式由表示十进制数字的数字组成。)如果您向 JavaScript 提供程序
js
486;
解释器将通过打印²来做出响应
js
486
表示数字的表达式可以与运算符(如+
或*
)结合,形成一个复合表达式,表示对这些数字应用相应原始函数的应用。例如,
js
137 + 349;
486
1000 - 334;
666
5 * 99;
495
10 / 4;
2.5
2.7 + 10;
12.7
这样的表达式,其中包含其他表达式作为组成部分,被称为组合 。由运算符 符号在中间形成的组合,左右两侧是操作数 表达式,被称为运算符组合。运算符组合的值是通过将运算符指定的函数应用于操作数的值来获得的。
将运算符放在操作数之间的约定称为中缀表示法 。它遵循您在学校和日常生活中最熟悉的数学表示法。与数学一样,运算符组合可以是嵌套的,也就是说,它们可以有自身是运算符组合的操作数:
js
(3 * 5) + (10 - 6);
19
通常情况下,括号用于分组运算符组合,以避免歧义。当省略括号时,JavaScript 也遵循通常的约定:乘法和除法比加法和减法更紧密地绑定。例如,
js
3 * 5 + 10 / 2;
代表
js
(3 * 5) + (10 / 2);
我们说*
和/
比+
和-
有更高的优先级。加法和减法的序列从左到右读取,乘法和除法的序列也是如此。因此,
js
1 - 5 / 2 * 4 + 3;
代表
js
(1 - ((5 / 2) * 4)) + 3;
我们说+
、-
、*
和/
是左结合的。
在原则上,这种嵌套的深度和 JavaScript 解释器可以求值的表达式的整体复杂性没有限制。我们人类可能会被仍然相对简单的表达式所困惑,比如
js
3 * 2 * (3 - 5 + 4) + 27 / 6 * 10;
解释器会立即求值为 57。我们可以通过以以下形式编写这样的表达式来帮助自己
js
3 * 2 * (3 - 5 + 4)
+
27 / 6 * 10;
以视觉上分隔表达式的主要组件。
即使是复杂的表达式,解释器始终以相同的基本循环运行:它读取用户键入的语句,求值语句,并打印结果。这种操作模式通常被称为解释器运行在读取-求值-打印循环中。特别要注意的是,不需要明确指示解释器打印语句的值。³
1.1.2 命名和环境
编程语言的一个关键方面是它提供了使用名称来引用计算对象的手段,我们的第一种手段是常量。我们说名称标识了一个值为对象的常量。
在 JavaScript 中,我们使用常量声明为常量命名。
js
const size = 2;
使解释器将值 2 与名称size
关联起来。⁴一旦名称size
与数字 2 关联起来,我们可以通过名称引用值 2:
js
size;
2
5 * size;
10
以下是对const
的进一步使用示例:
js
const pi = 3.14159;
const radius = 10;
pi * radius * radius;
314.159
const circumference = 2 * pi * radius;
circumference;
62.8318
常量声明是我们语言中最简单的抽象手段,因为它允许我们使用简单的名称来引用复合操作的结果,例如上面计算的circumference
。一般来说,计算对象可能具有非常复杂的结构,要记住并重复它们的细节每次想要使用它们将会非常不方便。事实上,复杂的程序是通过逐步构建越来越复杂的计算对象而构建的。解释器使得这种逐步程序构建特别方便,因为名称-对象关联可以在连续的交互中逐步创建。这个特性鼓励程序的逐步开发和测试,并且在很大程度上负责 JavaScript 程序通常由大量相对简单的函数组成。
应该清楚的是,将值与名称关联并稍后检索这些值的可能性意味着解释器必须维护一种记忆,以跟踪名称-对象对。这种记忆称为环境 (更确切地说是程序环境,因为我们将在后面看到,计算可能涉及多种不同的环境)。
1.1.3 求值运算符组合
在本章中,我们的一个目标是分离有关过程式思维的问题。例如,让我们考虑一下,在求值运算符组合时,解释器本身正在遵循一个过程。
-
要求值运算符组合,请执行以下操作:
-
1. 求值组合的操作数表达式。
-
2. 应用由运算符表示的函数到操作数的值。
-
即使这个简单的规则也说明了一般过程中的一些重要点。首先,观察到第一步规定,为了完成组合的求值过程,我们必须首先对组合的每个操作数执行求值过程。因此,求值规则在本质上是递归的;也就是说,它包括作为其步骤之一的需要调用规则本身。
注意递归的想法如何简洁地表达了在深度嵌套组合的情况下,否则将被视为一个相当复杂的过程。例如,求值
js
(2 + 4 * 6) * (3 + 12);
需要将求值规则应用于四种不同的组合。我们可以通过将组合表示为树的形式来获得这个过程的图像,如下图所示。每个组合都由一个节点表示,其分支对应于从中衍生出的运算符和组合的操作数。终端节点(即没有从它们衍生出的分支的节点)表示运算符或数字。从树的角度来看求值,我们可以想象操作数的值从终端节点向上渗透,然后在更高的层次上组合。一般来说,我们将看到递归是一种处理分层、树状对象的非常强大的技术。事实上,"向上渗透值"形式的求值规则是一种称为树累积的一般过程的例子。
图 1.1 树表示,显示每个子表达式的值。
接下来,观察到第一步的重复应用使我们到达需要求值的点,不是组合,而是诸如数字或名称的原始表达式。我们通过规定以下来处理原始情况:
-
数字的值是它们所代表的数字,而
-
名称的值是环境中与这些名称关联的对象。
要注意的关键点是环境在确定表达式中名称的含义方面的作用。在 JavaScript 这样的交互式语言中,谈论表达式的值,比如x + 1
,而不指定任何关于提供名称x
含义的环境信息是没有意义的。正如我们将在第 3 章中看到的,环境的一般概念作为提供求值所发生的上下文将在我们理解程序执行方面发挥重要作用。
请注意,上面给出的求值规则不处理声明。例如,求值const x = 3;
不会将等号=
应用于两个参数,其中一个是名称x
的值,另一个是 3,因为声明的目的恰好是将x
与一个值关联起来。(也就是说,const x = 3;
不是一个组合。)
const
中的字母以粗体呈现,以表明它是 JavaScript 中的关键字 。关键字具有特定的含义,因此不能用作名称。语句中的关键字或关键字组合指示 JavaScript 解释器以特殊方式处理语句。每种这样的语法形式都有其自己的求值规则。各种类型的语句和表达式(每种都有其关联的求值规则)构成了编程语言的语法。
1.1.4 复合函数
我们已经确定了 JavaScript 中必须出现的一些元素:
-
数字和算术运算是原始数据和函数。
-
组合的嵌套提供了一种组合操作的方法。
-
将名称与值关联的常量声明提供了有限的抽象手段。
现在我们将学习函数声明,这是一种更强大的抽象技术,通过它可以给复合操作命名,然后作为一个单元引用。
我们首先来看如何表达"平方"的概念。我们可以说,"对某物求平方,就是将其乘以自身。"在我们的语言中,这可以表达为
js
function square(x) {
return x * x;
}
我们可以这样理解:
我们这里有一个复合函数 ,它被命名为square
。该函数表示将某物乘以自身的操作。要被乘的东西被赋予一个本地名称x
,它扮演的角色与自然语言中的代词相同。求值声明会创建这个复合函数,并将其与名称square
关联起来。⁶
函数声明的最简单形式是
js
function name(parameters) { return expression; }
名称 是一个符号,用于将函数定义与环境关联起来。⁷ 参数 是函数体内用于引用函数对应参数的名称。参数 被括在括号内,并用逗号分隔,就像在应用被声明的函数时一样。在最简单的形式中,函数声明的函数体 是一个单一的返回语句 ,⁸它由关键字return
后跟将产生函数应用值的返回表达式组成,当参数被实际参数替换时,函数将被应用。与常量声明和表达式语句一样,返回语句以分号结束。
声明了square
之后,我们现在可以在函数应用表达式中使用它,并使用分号将其转换为语句。
js
square(21);
441
函数应用是------在操作符组合之后------我们遇到的将表达式组合成更大表达式的第二种组合。函数应用的一般形式是
js
function-expression(argument-expressions)
应用的function-expression
指定要应用于逗号分隔的argument-expressions
的函数。为了求值函数应用,解释器遵循了与 1.1.3 节中描述的操作符组合的过程非常相似的过程。
-
要求值函数应用,执行以下操作:
-
1.求值应用的子表达式,即函数表达式和参数表达式。
-
2.将函数表达式的值应用于参数表达式的值。
-
js
square(2 + 5);
49
在这里,参数表达式本身是一个复合表达式,操作符组合2 + 5
。
js
square(square(3));
81
当然,函数应用表达式也可以作为参数表达式。
我们还可以使用square
作为定义其他函数的构建块。例如,x² + y²
可以表示为
js
square(x) + square(y)
我们可以轻松地声明一个名为sum_of_squares
⁹的函数,它给定任意两个数字作为参数,产生它们的平方和:
js
function sum_of_squares(x, y) {
return square(x) + square(y);
}
sum_of_squares(3, 4);
25
现在我们可以使用sum_of_squares
作为构建更多函数的构建块:
js
function f(a) {
return sum_of_squares(a + 1, a * 2);
}
f(5);
136
除了复合函数,任何 JavaScript 环境都提供了内置于解释器或从库加载的原始函数。除了由操作符提供的原始函数之外,本书中使用的 JavaScript 环境还包括其他原始函数,例如计算其参数的自然对数的函数math_log
¹⁰。这些额外的原始函数与复合函数完全相同;求值应用math_log(1)
的结果是数字 0。事实上,通过查看上面给出的sum_of_squares
的定义,无法确定square
是内置于解释器中,从库加载,还是定义为复合函数。
1.1.5 函数应用的替换模型
为了求值函数应用,解释器遵循 1.1.4 节中描述的过程。也就是说,解释器求值应用的元素,并将函数(即应用的函数表达式的值)应用于参数(即应用的参数表达式的值)。
我们可以假设原始函数的应用由解释器或库处理。对于复合函数,应用过程如下:
- 要将复合函数应用于参数,求值函数的返回表达式,其中每个参数都替换为相应的参数。¹¹
为了说明这个过程,让我们求值应用
js
f(5)
其中f
是 1.1.4 节中声明的函数。我们首先检索f
的返回表达式:
js
sum_of_squares(a + 1, a * 2)
然后我们用参数 5 替换参数a
:
js
sum_of_squares(5 + 1, 5 * 2)
因此,问题简化为求值具有两个参数和函数表达式sum_of_squares
的应用。求值此应用涉及三个子问题。我们必须求值函数表达式以获得要应用的函数,并且我们必须求值参数表达式以获得参数。现在5 + 1
产生 6,5 * 2
产生 10,因此我们必须将sum_of_squares
函数应用于 6 和 10。这些值替换为sum_of_squares
的主体中的参数x
和y
,将表达式简化为
js
square(6) + square(10)
如果我们使用square
的声明,这将简化为
js
(6 * 6) + (10 * 10)
这通过乘法减少到
js
36 + 100
最后
js
136
我们刚刚描述的过程称为函数应用的替换模型。就本章中的函数而言,它可以被视为确定函数应用"含义"的模型。但是,有两点应该强调:
-
替换的目的是帮助我们思考函数应用,而不是提供解释器实际工作方式的描述。典型的解释器不会通过操纵函数的文本来替换参数的值来求值函数应用。实际上,"替换"是通过使用参数的本地环境来完成的。当我们在第 3 和第 4 章中详细研究解释器的实现时,我们将更全面地讨论这一点。
-
在本书的过程中,我们将提出一系列越来越复杂的解释器工作模型,最终在第 5 章中实现一个解释器和编译器的完整实现。替换模型只是这些模型中的第一个,是一种开始正式思考求值过程的方式。一般来说,在科学和工程中建模现象时,我们从简化的、不完整的模型开始。随着我们更详细地研究事物,这些简单的模型变得不足以满足需求,必须被更精细的模型所取代。替换模型也不例外。特别是,当我们在第 3 章中讨论使用带有"可变数据"的函数时,我们将看到替换模型崩溃了,必须被更复杂的函数应用模型所取代。
应用顺序与正常顺序
根据 1.1.4 节中给出的求值描述,解释器首先求值函数和参数表达式,然后将得到的函数应用于得到的参数。这不是执行求值的唯一方法。另一种求值模型将不会求值参数,直到它们的值被需要为止。相反,它首先用参数表达式替换参数,直到获得只涉及运算符和原始函数的表达式,然后执行求值。如果我们使用这种方法,那么
js
f(5)
将按照扩展的顺序进行。
js
sum_of_squares(5 + 1, 5 * 2)
square(5 + 1) + square(5 * 2)
(5 + 1) * (5 + 1) + (5 * 2) * (5 * 2)
随后是减少
js
6 * 6 + 10 * 10
36 + 100
136
这与我们先前的求值模型得出相同的答案,但过程是不同的。特别是,5 + 1
和5 * 2
的求值在这里分别执行两次,对应于表达式的减少
js
x * x
分别用5 + 1
和5 * 2
替换x
。
这种替代的"完全展开然后减少"求值方法被称为正常顺序求值 ,与解释器实际使用的"求值参数然后应用"方法相对应,后者被称为应用顺序求值。可以证明,对于可以使用替换进行建模的函数应用(包括本书前两章中的所有函数)并产生合法值,正常顺序和应用顺序求值会产生相同的值。(参见练习 1.5,其中有一个"非法"值的例子,正常顺序和应用顺序求值得出的结果不同。)
JavaScript 使用应用顺序求值,部分原因是为了避免像上面所示的5 + 1
和5 * 2
这样的表达式的多次求值所获得的额外效率,更重要的是,当我们离开可以通过替换建模的函数领域时,正常顺序求值变得更加复杂。另一方面,正常顺序求值可以是一个非常有价值的工具,我们将在第 3 和第 4 章中探讨它的一些影响。
1.1.6 条件表达式和谓词
到目前为止,我们可以定义的函数类的表达能力非常有限,因为我们无法进行测试并根据测试结果执行不同的操作。例如,我们无法声明一个通过测试数字是否为非负来计算绝对值的函数,并根据规则分别采取不同的操作
这个结构是一个案例分析,并且可以用 JavaScript 的条件表达式来写成
js
function abs(x) {
return x >= 0 ? x : - x;
}
这可以用英语表达为"如果x
大于或等于零,则返回x
;否则返回--x
。"条件表达式的一般形式是
js
predicate ? consequent-expression : alternative-expression
条件表达式以predicate
开头,即其值为 JavaScript 中的两个特殊布尔值true
或false
的表达式。原始布尔表达式true
和false
分别以布尔值true
和false
进行简单求值。predicate
后面跟着一个问号,consequent-expression
,一个冒号,最后是alternative-expression
。
为了求值条件表达式,解释器首先求值表达式的谓词。如果谓词求值为true
,解释器求值结果表达式并返回其值作为条件表达式的值。如果谓词求值为false
,它求值替代表达式并返回其值作为条件表达式的值。
单词"谓词"用于返回true
或false
的运算符和函数,以及求值为true
或false
的表达式。绝对值函数abs
使用原始谓词>=
,这是一个接受两个数字作为参数并测试第一个数字是否大于或等于第二个数字的运算符,根据情况返回true
或false
。
如果我们更喜欢单独处理零的情况,我们可以通过写一个函数来指定计算一个数字的绝对值
在 JavaScript 中,我们通过在其他条件表达式中将替代表达式作为条件表达式的替代表达式来嵌套条件表达式来表示具有多个情况的案例分析:
js
function abs(x) {
return x > 0
? x
: x === 0
? 0
: - x;
}
括号不需要在替代表达式x === 0 ? 0 : - x
周围,因为条件表达式的语法形式是右结合的。解释器忽略空格和换行符,这里插入是为了可读性,以使?
和:
在案例分析的第一个谓词下对齐。案例分析的一般形式是
js
p[1]
? e[1]
: p[2]
? e[2]
⁝
: p[n]
? e[n]
: final-alternative-expression
我们将谓词p[i]
和其结果表达式e[i]
一起称为子句 。案例分析可以看作是一系列子句,后跟最终的替代表达式。根据条件表达式的求值,案例分析首先求值谓词p[1]
。如果它的值为false
,则求值p[2]
。如果p[2]
的值也为false
,则求值p[3]
。这个过程一直持续,直到找到一个值为true
的谓词,此时解释器将返回子句的相应结果表达式e
的值作为案例分析的值。如果没有找到任何p
为true
,则案例分析的值是最终替代表达式的值。
除了应用于数字的原始谓词,如>=
、>
、<
、<=
、===
和!==
之外,还有逻辑组合操作,它们使我们能够构造复合谓词。最常用的三个是这些:
-
表达式[1] && 表达式[2]
这个操作表示逻辑连接,大致意思与英语单词"and"相同。这种语法形式是语法糖,用于
表达式[1] ? 表达式[2] : false
。 -
表达式[1] 表达式[2]
这个操作表示逻辑析取,大致意思与英语单词"or"相同。这种语法形式是语法糖。
表达式[1] ? true : 表达式[2]
。
-
! 表达式
这个操作表示逻辑否定,大致意思与英语单词"not"相同。当表达式求值为
false
时,表达式的值为true
,当表达式求值为true
时,表达式的值为false
。
注意&&
和||
是语法形式,而不是运算符;它们的右侧表达式并不总是被求值。另一方面,运算符!
遵循第 1.1.3 节的求值规则。它是一个 一元 运算符,这意味着它只接受一个参数,而迄今为止讨论的算术运算符和原始谓词都是 二元 运算符,接受两个参数。运算符!
在其参数之前;我们称它为 前缀运算符 。另一个前缀运算符是数值否定运算符,其示例是上面的abs
函数中的表达式- x
。
作为这些谓词如何使用的一个例子,表达一个数x
在范围5 < x < 10
中的条件可以表示为
js
x > 5 && x < 10
&&
的语法形式的优先级低于比较运算符>
和<
,条件表达式的语法形式... ?... :...
的优先级低于迄今为止我们遇到的任何其他运算符,这是我们在上面的abs
函数中使用的一个特性。
作为另一个例子,我们可以声明一个谓词,测试一个数字是否大于或等于另一个数字
js
function greater_or_equal(x, y) {
return x > y || x === y;
}
或者作为
js
function greater_or_equal(x, y) {
return ! (x < y);
}
函数greater_or_equal
应用于两个数字时,与运算符>=
的行为相同。一元运算符的优先级高于二元运算符,这使得这个例子中的括号是必需的。
练习 1.1
下面是一系列的陈述。解释器对每个陈述的响应中打印的结果是什么?假设要按照呈现的顺序进行求值。
js
10;
5 + 3 + 4;
9 - 1;
6 / 2;
2 * 4 + (4 - 6);
const a = 3;
const b = a + 1;
a + b + a * b;
a === b;
b > a && b < a * b ? b : a;
a === 4
? 6
: b === 4
? 6 + 7 + a
: 25;
2 + (b > a ? b : a);
(a > b
? a
: a < b
? b
: -1)
*
(a + 1);
最后两个陈述中条件表达式周围的括号是必需的,因为条件表达式的语法形式的优先级低于算术运算符+
和*
。
练习 1.2
将以下表达式翻译成 JavaScript
练习 1.3
声明一个以三个数字作为参数并返回两个较大数字的平方和的函数。
练习 1.4
注意我们的求值模型允许应用其函数表达式为复合表达式的应用。使用这一观察来描述a_plus_abs_b
的行为:
js
function plus(a, b) { return a + b; }
function minus(a, b) { return a - b; }
function a_plus_abs_b(a, b) {
return (b >= 0 ? plus : minus)(a, b);
}
练习 1.5
Ben Bitdiddle 发明了一个测试,以确定他所面对的解释器是使用应用序求值还是正则序求值。他声明了以下两个函数:
js
function p() { return p(); }
function test(x, y) {
return x === 0 ? 0 : y;
}
然后他求值了这个陈述
js
test(0, p());
Ben 会观察到使用应用序求值的解释器会有什么行为?他会观察到使用正则序求值的解释器会有什么行为?解释你的答案。(假设条件表达式的求值规则在解释器使用正常或应用序时是相同的:谓词表达式首先被求值,结果决定是求值结果还是替代表达式。)
1.1.7 例子:牛顿法求平方根
如上所述,函数很像普通的数学函数。它们指定由一个或多个参数确定的值。但是数学函数和计算机函数之间有一个重要的区别。计算机函数必须是有效的。
作为一个例子,考虑计算平方根的问题。我们可以定义平方根函数为
√x = y
,满足y ≥ 0
和y² = x
这描述了一个完全合法的数学函数。我们可以用它来识别一个数字是否是另一个数字的平方根,或者推导关于平方根的一般事实。另一方面,这个定义并不描述一个计算机函数。事实上,它几乎没有告诉我们如何实际找到给定数字的平方根。重新用伪 JavaScript 重新表述这个定义也不会有所帮助:
js
function sqrt(x) {
return the y with y >= 0 && square(y) === x;
}
这只是在回避问题。
数学函数和计算机函数之间的对比反映了描述事物属性和描述如何做事物的一般区别,有时也被称为声明性知识和命令性知识之间的区别。在数学中,我们通常关注声明性(是什么)描述,而在计算机科学中,我们通常关注命令性(如何)描述。¹⁷
如何计算平方根?最常见的方法是使用牛顿的迭代逼近法,该方法指出,每当我们对数字x
的平方根的值有一个猜测y
时,我们可以通过对y
和x / y
进行平均来进行简单的操作,得到一个更好的猜测(更接近实际平方根)。¹⁸ 例如,我们可以计算 2 的平方根如下。假设我们的初始猜测是 1:
继续这个过程,我们得到越来越好的近似值。
现在让我们用函数的术语来形式化这个过程。我们从被开方数(我们试图计算其平方根的数字)的值和猜测的值开始。如果猜测对我们的目的来说足够好,我们就完成了;如果不是,我们必须用一个改进的猜测重复这个过程。我们将这个基本策略写成一个函数:
js
function sqrt_iter(guess, x) {
return is_good_enough(guess, x)
? guess
: sqrt_iter(improve(guess, x), x);
}
通过将猜测与被开方数和旧猜测的商进行平均来改进猜测:
js
function improve(guess, x) {
return average(guess, x / guess);
}
其中
js
function average(x, y) {
return (x + y) / 2;
}
我们还必须说明"足够好"的含义。以下内容可以用于说明,但实际上并不是一个非常好的测试。(见练习 1.7。)这个想法是改进答案,直到它足够接近,以至于它的平方与被开方数之间的差异小于预定的容差(这里是 0.001):¹⁹
js
function is_good_enough(guess, x) {
return abs(square(guess) - x) < 0.001;
}
最后,我们需要一种开始的方法。例如,我们总是可以猜测任何数字的平方根是 1:
js
function sqrt(x) {
return sqrt_iter(1, x);
}
如果我们将这些声明输入解释器,我们可以像使用任何函数一样使用sqrt
:
js
sqrt(9);
3.00009155413138
sqrt(100 + 37);
11.704699917758145
sqrt(sqrt(2) + sqrt(3));
1.7739279023207892
square(sqrt(1000));
1000.000369924366
sqrt
程序还说明了我们迄今为止介绍的简单函数语言足以编写任何纯数值程序,这些程序可以在 C 或 Pascal 中编写。这可能看起来令人惊讶,因为我们的语言中没有包括任何迭代(循环)结构,指导计算机一遍又一遍地做某事。另一方面,函数sqrt_iter
演示了如何使用除了普通调用函数的能力之外,还可以实现迭代。²⁰
练习 1.6
Alyssa P. Hacker 不喜欢条件表达式的语法,涉及到字符?
和:
。"为什么我不能只声明一个普通的条件函数,其应用方式就像条件表达式一样呢?"她问道。²¹ Alyssa 的朋友 Eva Lu Ator 声称这确实可以做到,并声明了一个conditional
函数如下:
js
function conditional(predicate, then_clause, else_clause) {
return predicate ? then_clause : else_clause;
}
Eva 为 Alyssa 演示程序:
js
conditional(2 === 3, 0, 5);
`5`
conditional(1 === 1, 0, 5);
`0`
高兴的是,Alyssa 使用conditional
来重写平方根程序:
js
function sqrt_iter(guess, x) {
return conditional(is_good_enough(guess, x),
guess,
sqrt_iter(improve(guess, x),
x));
}
当 Alyssa 尝试使用这个方法来计算平方根时会发生什么?解释。
练习 1.7
用于计算平方根的is_good_enough
测试对于找到非常小的数字的平方根不会非常有效。此外,在实际计算机中,算术运算几乎总是以有限的精度进行。这使得我们的测试对于非常大的数字是不够的。解释这些陈述,并举例说明测试对于小数字和大数字的失败。实现is_good_enough
的另一种策略是观察guess
从一次迭代到下一次迭代的变化,并在变化是猜测的一个非常小的分数时停止。设计一个使用这种结束测试的平方根函数。这对于小数字和大数字效果更好吗?
练习 1.8
牛顿的立方根方法是基于这样一个事实:如果y
是x
的立方根的近似值,那么更好的近似值由这个值给出
使用这个公式来实现一个类似于平方根函数的立方根函数。(在第 1.3.4 节中,我们将看到如何将牛顿方法作为这些平方根和立方根函数的抽象来实现。)
1.1.8 函数作为黑盒抽象
函数sqrt
是我们第一个例子,它是由一组相互定义的函数定义的过程。请注意,sqrt_iter
的声明是递归 的;也就是说,函数是根据自身定义的。能够根据自身定义函数的想法可能会让人感到不安;可能不清楚这样的"循环"定义到底如何有意义,更不用说指定计算机执行的明确定义的过程了。这将在第 1.2 节中更加仔细地讨论。但首先让我们考虑一下sqrt
示例所说明的一些其他重要点。
注意,计算平方根的问题自然地分解为许多子问题:如何判断猜测是否足够好,如何改进猜测,等等。每个任务都由一个单独的函数完成。整个sqrt
程序可以被看作是一组函数(在图 1.2 中显示),它反映了将问题分解为子问题的过程。
图 1.2 sqrt
程序的功能分解。
这种分解策略的重要性不仅仅在于将程序分成部分。毕竟,我们可以将任何大型程序分成部分------前十行,接下来的十行,再接下来的十行,依此类推。相反,关键在于每个函数都能够完成一个可识别的任务,可以用作定义其他函数的模块。例如,当我们根据square
定义is_good_enough
函数时,我们可以将square
函数视为"黑盒"。我们当时并不关心函数如何计算其结果,只关心它计算平方的事实。计算平方的细节可以被抑制,以便在以后考虑。实际上,就is_good_enough
函数而言,square
不完全是一个函数,而是一个函数的抽象,所谓的函数抽象。在这个抽象级别上,任何计算平方的函数都是一样好的。
因此,仅考虑它们返回的值,下面两个平方一个数字的函数应该是无法区分的。每个函数都接受一个数字参数,并将该数字的平方作为值返回。
js
function square(x) {
return x * x;
}
function square(x) {
return math_exp(double(math_log(x)));
}
function double(x) {
return x + x;
}
因此,函数应该能够抑制细节。函数的用户可能并没有自己编写函数,而是从另一个程序员那里获得的一个黑盒。用户不需要知道函数的实现方式就能使用它。
本地名称
函数实现的一个细节不应该影响函数的用户,那就是实现者对函数参数的名称的选择。因此,以下函数不应该有区别:
js
function square(x) {
return x * x;
}
function square(y) {
return y * y;
}
这个原则------函数的含义应该与其作者使用的参数名称无关------表面上似乎是不言自明的,但其后果是深远的。最简单的结果是函数的参数名称必须局限于函数体内。例如,在我们的平方根函数的is_good_enough
声明中,我们使用了square
:
js
function is_good_enough(guess, x) {
return abs(square(guess) - x) < 0.001;
}
is_good_enough
的作者的意图是确定第一个参数的平方是否在给定的公差范围内与第二个参数相匹配。我们看到is_good_enough
的作者使用名称guess
来指代第一个参数,x
指代第二个参数。square
的参数是guess
。如果square
的作者使用x
(如上所述)来指代该参数,我们可以看到is_good_enough
中的x
必须是与square
中的不同的x
。运行函数square
不应该影响is_good_enough
使用的x
的值,因为is_good_enough
在square
计算完成后可能需要该值。
如果参数不是局限于各自函数体的局部变量,那么square
中的参数x
可能会与is_good_enough
中的参数x
混淆,而is_good_enough
的行为将取决于我们使用的square
的版本。因此,square
将不会是我们所期望的黑匣子。
函数的参数在函数声明中有一个非常特殊的角色,即参数的名称是什么并不重要。这样的名称称为绑定 ,我们说函数声明绑定 了它的参数。如果一个名称没有被绑定,我们说它是自由 的。一个绑定声明名称的语句集被称为该名称的作用域。在函数声明中,作为函数参数声明的绑定名称具有函数体作为它们的作用域。
在上面的is_good_enough
声明中,guess
和x
是绑定的名称,但abs
和square
是自由的。is_good_enough
的含义应该与我们为guess
和x
选择的名称无关,只要它们与abs
和square
不同即可。(如果我们将guess
重命名为abs
,我们将通过捕获 名称abs
引入一个错误。它将从自由变为绑定。)然而,is_good_enough
的含义并不独立于其自由名称的选择。然而,它肯定取决于(不包括在此声明中的)名称abs
是指计算数字的绝对值的函数。如果我们在其声明中用math_cos
(原始余弦函数)替换abs
,is_good_enough
将计算一个不同的函数。
内部声明和块结构
到目前为止,我们已经有一种名称隔离的方式:函数的参数是局限于函数体的。求平方根的程序展示了我们希望控制名称使用的另一种方式。现有的程序由独立的函数组成:
js
function sqrt(x) {
return sqrt_iter(1, x);
}
function sqrt_iter(guess, x) {
return is_good_enough(guess, x)
? guess
: sqrt_iter(improve(guess, x), x);
}
function is_good_enough(guess, x) {
return abs(square(guess) - x) < 0.001;
}
function improve(guess, x) {
return average(guess, x / guess);
}
这个程序的问题在于,对于sqrt
的用户来说,唯一重要的函数是sqrt
。其他函数(sqrt_iter
,is_good_enough
和improve
)只会混淆他们的思维。他们可能不会在另一个程序中声明任何名为is_good_enough
的其他函数,以便与求平方根程序一起工作,因为sqrt
需要它。这个问题在由许多独立程序员构建大型系统时尤为严重。例如,在构建大型数值函数库时,许多数值函数是作为连续逼近计算的,因此可能有名为is_good_enough
和improve
的辅助函数。我们希望将子函数局部化,将它们隐藏在sqrt
内部,以便sqrt
可以与其他连续逼近并存,每个都有自己的私有is_good_enough
函数。
为了实现这一点,我们允许函数具有局部于该函数的内部声明。例如,在求平方根的问题中,我们可以写
js
function sqrt(x) {
function is_good_enough(guess, x) {
return abs(square(guess) - x) < 0.001;
}
function improve(guess, x) {
return average(guess, x / guess);
}
function sqrt_iter(guess, x) {
return is_good_enough(guess, x)
? guess
: sqrt_iter(improve(guess, x), x);
}
return sqrt_iter(1, x);
}
任何匹配的大括号对都指定了一个块 ,并且块内的声明对该块是局部的。这种声明的嵌套,称为块结构 ,基本上是最简单的名称封装问题的正确解决方案。但这里潜在的想法更好。除了内部化辅助函数的声明外,我们还可以简化它们。由于x
在sqrt
的声明中被绑定,因此在sqrt
内部声明的is_good_enough
、improve
和sqrt_iter
函数在x
的作用域内。因此,不需要显式地将x
传递给这些函数中的每一个。相反,我们允许x
在内部声明中成为一个自由名称,如下所示。然后,x
从调用封闭函数sqrt
的参数中获取其值。这种规则称为词法作用域。²⁴
js
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);
}
我们将广泛使用块结构来帮助我们将大型程序分解为可处理的部分。²⁵ 块结构的概念起源于编程语言 Algol 60。它出现在大多数高级编程语言中,是帮助组织大型程序构建的重要工具。
1.2 函数及其生成的过程
我们现在已经考虑了编程的元素:我们使用了原始算术运算,我们组合了这些运算,并通过将它们声明为复合函数来抽象化这些复合运算。但这还不足以使我们能够说我们知道如何编程。我们的情况类似于学会了国际象棋中棋子如何移动的规则,但对典型的开局、战术或策略一无所知的人。就像初学国际象棋的人一样,我们还不知道领域中的常见使用模式。我们缺乏哪些着法是值得做的(哪些函数值得声明)的知识。我们缺乏预测做出着法(执行函数)后的后果的经验。
能够可视化所考虑行动的后果对于成为专业程序员至关重要,就像在任何综合的创造性活动中一样。例如,要成为专业摄影师,必须学会如何观察一个场景,并知道每个区域在曝光和处理选项的每种可能选择下在打印品上会显得多暗。只有这样,才能向后推理,规划构图、光线、曝光和处理,以获得期望的效果。编程也是如此,我们在规划进程采取的行动,并通过程序控制进程。要成为专家,我们必须学会可视化各种类型函数生成的过程。只有在我们培养了这样的技能之后,才能学会可靠地构建表现出所需行为的程序。
函数是计算过程的局部演变 的模式。它指定了过程的每个阶段是如何建立在前一个阶段之上的。我们希望能够对由函数指定局部演变的过程的整体或全局行为做出陈述。这在一般情况下非常难以做到,但我们至少可以尝试描述一些典型的过程演变模式。
在本节中,我们将研究一些简单函数生成的常见"形状"。我们还将调查这些过程消耗时间和空间等重要计算资源的速率。我们将考虑的函数非常简单。它们的作用类似于摄影中的测试图案:作为过度简化的原型模式,而不是实际示例。
1.2.1 线性递归和迭代
我们首先考虑阶乘函数的定义。
js
n != n · (n -- 1) *·* (n -- 2) ... 3 · 2 · 1
计算阶乘有很多方法。一种方法是利用这样的观察:对于任何正整数n
,n!
等于n
乘以(n -- 1)!
:
js
n! = n · [(n -- 1) · (n -- 2) ... 3 · 2 · 1] = n · (n -- 1)!
因此,我们可以通过计算(n -- 1)!
并将结果乘以n
来计算n!
。如果我们添加规定 1! 等于 1,这一观察直接转化为计算机函数:
js
function factorial(n) {
return n === 1
? 1
: n * factorial(n - 1);
}
我们可以使用第 1.1.5 节的替换模型来观察这个函数计算6!
的过程,如图 1.3 所示。
图 1.3 计算 6! 的线性递归过程。
现在让我们以不同的角度来计算阶乘。我们可以通过指定首先将 1 乘以 2,然后将结果乘以 3,然后乘以 4,依此类推,直到达到n
,来描述计算n!
的规则。更正式地说,我们保持一个运行乘积,以及一个从 1 计数到n
的计数器。我们可以通过以下规则描述计算过程:
js
product ← counter · product
counter ← counter + 1
并规定当计数器超过n
时,n!
是乘积的值。
再次,我们可以将我们的描述重新构造为计算阶乘的函数:²⁶
js
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);
}
与之前一样,我们可以使用替换模型来可视化计算 6! 的过程,如图 1.4 所示。
图 1.4 计算 6! 的线性迭代过程。
比较这两个过程。从某种角度来看,它们似乎几乎没有什么不同。两者都在相同的域上计算相同的数学函数,并且每个都需要与n
成比例的步骤来计算n!
。事实上,这两个过程甚至执行相同的乘法序列,得到相同的部分积序列。另一方面,当我们考虑这两个过程的"形状"时,我们发现它们的演变方式完全不同。
考虑第一个过程。替换模型显示了一个扩展后跟着收缩的形状,如图 1.3 中的箭头所示。扩展发生在过程构建一系列延迟操作 (在本例中是一系列乘法)时。收缩发生在实际执行操作时。这种过程,以一系列延迟操作为特征,被称为递归过程 。执行这个过程需要解释器跟踪稍后要执行的操作。在计算n!
时,延迟乘法的链的长度,因此需要跟踪它的信息量,与步骤数量一样,随n
线性增长(与n
成比例)。这样的过程被称为线性递归过程。
相比之下,第二个过程不会增长和缩小。在每一步中,无论n
是多少,我们都只需要跟踪名称product
、counter
和max_count
的当前值。我们称这个过程为迭代过程 。一般来说,迭代过程是指其状态可以由固定数量的状态变量 以及描述状态变量如何在过程从一个状态转移到另一个状态时更新的固定规则以及(可选的)结束测试来总结。在计算n!
时,所需的步骤数量随n
增长而线性增长。这样的过程被称为线性迭代过程。
两个过程之间的对比可以从另一个角度看出。在迭代的情况下,状态变量提供了在任何时候过程状态的完整描述。如果我们在步骤之间停止计算,只需向解释器提供三个状态变量的值,就可以恢复计算。而在递归过程中则不然。在这种情况下,还有一些额外的"隐藏"信息,由解释器维护,不包含在状态变量中,它指示了在处理延迟操作链时的"位置"。链越长,就必须维护的信息就越多。
在对比迭代和递归时,我们必须小心,不要混淆递归过程 的概念和递归函数 的概念。当我们将一个函数描述为递归时,我们指的是函数声明引用(直接或间接)函数本身的语法事实。但是当我们描述一个遵循某种模式的过程时,比如说线性递归,我们说的是过程如何演变,而不是函数的语法如何编写。将递归函数fact_iter
称为生成迭代过程可能看起来令人不安。然而,这个过程确实是迭代的:它的状态完全由它的三个状态变量捕获,解释器只需要跟踪三个名称就能执行这个过程。
区分过程和函数可能令人困惑的一个原因是,大多数常见语言的实现(包括 C、Java 和 Python)都是设计成这样的方式,即任何递归函数的解释都会消耗随着函数调用次数增加而增长的内存,即使所描述的过程原则上是迭代的。因此,这些语言只能通过专门的"循环结构"(如do
、repeat
、until
、for
和while
)来描述迭代过程。我们将在第 5 章中考虑的 JavaScript 实现不具有这个缺陷。即使迭代过程是由递归函数描述的,它也将在恒定空间中执行迭代过程。具有这种属性的实现被称为尾递归。使用尾递归实现,迭代可以使用普通的函数调用机制来表达,因此特殊的迭代结构只有作为语法糖才有用。
练习 1.9
以下两个函数中的每一个都定义了一个方法,用函数inc
来增加其参数 1,和函数dec
来减少其参数 1,来实现两个正整数的加法。
js
function plus(a, b) {
return a === 0 ? b : inc(plus(dec(a), b));
}
function plus(a, b) {
return a === 0 ? b : plus(dec(a), inc(b));
}
使用替换模型,说明每个函数生成的过程在求值plus(4, 5);
时。这些过程是迭代的还是递归的?
练习 1.10
以下函数计算了一个称为 Ackermann 函数的数学函数。
js
function A(x, y) {
return y === 0
? 0
: x === 0
? 2 * y
: y === 1
? 2
: A(x - 1, A(x, y - 1));
}
以下语句的值是多少?
js
A(1, 10);
A(2, 4);
A(3, 3);
考虑以下函数,其中A
是上面声明的函数:
js
function f(n) {
return A(0, n);
}
function g(n) {
return A(1, n);
}
function h(n) {
return A(2, n);
}
function k(n) {
return 5 * n * n;
}
给出函数f
,g
和h
的简洁数学定义,用于正整数值n
。例如,k(n)
计算5n²
。
1.2.2 树递归
另一种常见的计算模式称为树递归。例如,考虑计算斐波那契数列,其中每个数字是前两个数字的和:
js
0, 1, 1, 2, 3, 5, 8, 13, 21, ...
一般来说,斐波那契数可以通过以下规则定义
我们可以立即将这个定义转化为一个递归函数,用于计算斐波那契数:
js
function fib(n) {
return n === 0
? 0
: n === 1
? 1
: fib(n - 1) + fib(n - 2);
}
考虑这个计算的模式。要计算fib(5)
,我们计算fib(4)
和fib(3)
。要计算fib(4)
,我们计算fib(3)
和fib(2)
。一般来说,演变的过程看起来像一棵树,如图 1.5 所示。注意到在每个级别(除了底部)分支分成两个,这反映了fib
函数每次被调用时调用自身两次的事实。
图 1.5 计算fib(5)
时生成的树递归过程。
这个函数作为一个典型的树递归很有启发性,但它是计算斐波那契数的一种可怕的方式,因为它做了很多冗余的计算。注意在图 1.5 中,fib(3)
的整个计算------几乎一半的工作------是重复的。事实上,不难证明函数将计算fib(1)
或fib(0)
(通常情况下上述树中的叶子数)的次数恰好是Fib(n + 1)
。为了了解这有多糟糕,可以证明Fib(n)
的值随着n
的增长呈指数增长。更准确地说(见练习 1.13),Fib(n)
是最接近ϕⁿ/√5
的整数,其中
js
ϕ = (1 + √5)/2 ≈ 1.6180
是黄金比例,满足方程
js
ϕ² = ϕ + 1
因此,这个过程所需的步骤数随着输入的增长呈指数增长。另一方面,所需的空间只是线性增长,因为我们只需要在计算的任何时候跟踪树中在我们上面的哪些节点。一般来说,树递归过程所需的步骤数将与树中的节点数成正比,而所需的空间将与树的最大深度成正比。
我们还可以制定一个迭代的过程来计算斐波那契数。这个想法是使用一对整数a
和b
,初始化为Fib(1) = 1
和Fib(0) = 0
,并反复应用同时的转换
js
a ← a + b
b ← a
不难证明,经过这种转换n
次后,a
和b
分别等于Fib(n + 1)
和Fib(n)
。因此,我们可以使用以下函数迭代地计算斐波那契数
js
function fib(n) {
return fib_iter(1, 0, n);
}
function fib_iter(a, b, count) {
return count === 0
? b
: fib_iter(a + b, a, count - 1);
}
这种计算Fib(n)
的第二种方法是线性迭代。这两种方法所需的步骤数的差异------一个与n
成正比,一个与Fib(n)
本身一样快地增长------是巨大的,即使对于小的输入也是如此。
我们不应该因此得出树递归过程是无用的结论。当我们考虑操作层次结构化数据而不是数字的过程时,我们会发现树递归是一种自然而强大的工具。³⁰但即使在数值运算中,树递归过程也可以帮助我们理解和设计程序。例如,尽管第一个fib
函数比第二个函数效率低得多,但它更直接,几乎只是斐波那契数列定义的 JavaScript 翻译。要制定迭代算法,需要注意到计算可以重塑为具有三个状态变量的迭代。
例子:找零
只需要一点巧妙就能想出迭代的斐波那契算法。相比之下,考虑以下问题:我们有多少种不同的方法可以找零 1.00 美元(100 美分),给定半美元、25 美分、10 美分、5 美分和 1 美分的硬币(分别为 50 美分、25 美分、10 美分、5 美分和 1 美分)?更一般地,我们能否编写一个函数来计算任意给定金额的找零方式?
这个问题有一个简单的解决方案,作为一个递归函数。假设我们把可用的硬币类型按照某种顺序排列。那么以下关系成立:
使用n
种硬币来改变金额a
的方式数等于
-
使用除第一种硬币之外的所有硬币来改变金额
a
的方式,再加上 -
使用所有
n
种硬币来改变金额a - d
的方式,其中d
是第一种硬币的面额。
要看到这是为什么,观察一下找零的方式可以分为两组:那些不使用第一种硬币的和那些使用的。因此,某个金额的找零方式的总数等于不使用第一种硬币的金额的找零方式的数量,加上假设我们使用第一种硬币的找零方式的数量。但后者的数量等于使用第一种硬币后剩下的金额的找零方式的数量。
因此,我们可以递归地将改变给定金额的问题减少到改变较小金额或使用更少种类的硬币的问题。仔细考虑这个减少规则,并让自己相信,我们可以用它来描述一个算法,如果我们指定以下退化情况:³¹
-
如果
a
恰好为 0,我们应该将其视为 1 种找零的方式。 -
如果
a
小于 0,我们应该将其视为 0 种找零的方式。 -
如果
n
为 0,我们应该将其视为 0 种找零的方式。
我们可以很容易地将这个描述转化为一个递归函数:
js
function count_change(amount) {
return cc(amount, 5);
}
function cc(amount, kinds_of_coins) {
return amount === 0
? 1
: amount < 0 || kinds_of_coins === 0
? 0
: cc(amount, kinds_of_coins - 1)
+
cc(amount - first_denomination(kinds_of_coins),
kinds_of_coins);
}
function first_denomination(kinds_of_coins) {
return kinds_of_coins === 1 ? 1
: kinds_of_coins === 2 ? 5
: kinds_of_coins === 3 ? 10
: kinds_of_coins === 4 ? 25
: kinds_of_coins === 5 ? 50
: 0;
}
(first_denomination
函数以可用硬币种类的数量作为输入,并返回第一种硬币的面额。在这里,我们将硬币按从大到小的顺序排列,但任何顺序都可以。)现在我们可以回答关于找零一美元的最初问题:
js
count_change(100);
292
函数count_change
生成了一个树形递归过程,其中包含与我们对fib
的第一个实现类似的冗余。另一方面,设计一个更好的算法来计算结果并不明显,我们将这个问题留作挑战。树形递归过程可能非常低效,但通常易于指定和理解的观察,这导致人们提出,通过设计一个"智能编译器",可以将树形递归函数转换为计算相同结果的更有效的函数,从而获得两全其美的最佳结果。³²
练习 1.11
一个函数f
由以下规则定义:如果n < 3
,f(n) = n
,如果n > 3
,f(n) = f(n - 1) + 2f(n - 2) + 3f(n - 3)
。编写一个通过递归过程计算f
的 JavaScript 函数。编写一个通过迭代过程计算f
的函数。
练习 1.12
以下数字模式称为帕斯卡三角形。
三角形边缘的数字都是 1,三角形内部的每个数字都是它上面两个数字的和。³³编写一个通过递归过程计算帕斯卡三角形元素的函数。
练习 1.13
证明Fib(n)
是最接近ϕⁿ/√5
的整数,其中ϕ=(1+√5)/2
。提示:使用归纳法和斐波那契数的定义来证明Fib(n)=(ϕⁿ-ψⁿ)/√5
,其中ψ=(1-√5)/2
。
1.2.3 增长的顺序
前面的例子说明了过程在消耗计算资源的速率上可能有很大的不同。描述这种差异的一种方便的方法是使用增长的顺序的概念,以获得一个粗略的度量,即随着输入变大,过程所需的资源。
让n
成为衡量问题规模的参数,R(n)
是过程对规模为n
的问题所需的资源量。在我们之前的例子中,我们取n
为要计算给定函数的数字,但还有其他可能性。例如,如果我们的目标是计算一个数字的平方根的近似值,我们可能会取n
为所需的精度位数。对于矩阵乘法,我们可能会取n
为矩阵中的行数。一般来说,关于问题的一些属性是值得分析给定过程的。同样,R(n)
可能衡量使用的内部存储寄存器的数量,执行的基本机器操作的数量等等。在一次只执行固定数量的操作的计算机中,所需的时间将与执行的基本机器操作的数量成正比。
我们说R(n)
的增长顺序是Θ(f(n))
,写作R(n) = Θ(f(n))
(读作"theta f(n)
"),如果存在正常数k[1]
和k[2]
,独立于n
,使得
js
k[1] f(n) ≤ R(n) ≤ k[2] f(n)
对于任何足够大的n
值。(换句话说,对于大的n
,值R(n)
夹在k[1]f(n)
和k[2]f(n)
之间。)
例如,在第 1.2.1 节中描述的用于计算阶乘的线性递归过程,步数与输入n
成比例增长。因此,此过程所需的步数增长为Θ(n)
。我们还看到所需的空间增长为Θ(n)
。对于迭代阶乘,步数仍然是Θ(n)
,但空间是Θ(1)
------即常数。[^34] 在第 1.2.2 节中描述的黄金比例ϕ
,树递归的斐波那契计算需要Θ(ϕⁿ)
步和Θ(n)
空间。
增长顺序只提供了对过程行为的粗略描述。例如,需要n²
步的过程和需要1000n²
步的过程以及需要3n² + 10n + 17
步的过程都具有Θ(n²)
的增长顺序。另一方面,增长顺序提供了一个有用的指示,告诉我们当问题的规模改变时,我们可以期望过程的行为如何改变。对于Θ(n)
(线性)过程,将问题规模加倍将大致使资源使用量加倍。对于指数过程,问题规模的每个增量将通过一个常数因子来增加资源利用率。在第 1.2 节的其余部分,我们将研究两种增长顺序为对数的算法,因此问题规模加倍将使资源需求增加一个常数量。
练习 1.14
绘制树,说明第 1.2.2 节中的count_change
函数生成的过程,用于找零 11 美分。随着要找零的金额增加,该过程使用的空间和步数的增长顺序是多少?
练习 1.15
如果x
足够小,则可以利用近似值sin x ≈ x
来计算角的正弦(以弧度表示),以及三角恒等式
减小sin
的参数大小。(对于本练习而言,如果角的大小不大于 0.1 弧度,则被认为是"足够小"。)这些想法被纳入以下函数:
js
function cube(x) {
return x * x * x;
}
function p(x) {
return 3 * x - 4 * cube(x);
}
function sine(angle) {
return ! (abs(angle) > 0.1)
? angle
: p(sine(angle / 3));
}
-
a. 当计算
sine(12.15)
时,函数p
被应用了多少次? -
b. 当计算
sine(a)
时,由sine
函数生成的过程的空间和步数的增长顺序是多少(作为a
的函数)?
1.2.4 指数
考虑计算给定数字的指数的问题。我们希望有一个函数,它以基数b
和正整数指数n
作为参数,并计算bⁿ
。一种方法是通过递归定义来实现这一点
js
bⁿ = b bⁿ^(--1)
b⁰ = 1
这很容易转化为函数
js
function expt(b, n) {
return n === 0
? 1
: b * expt(b, n - 1);
}
这是一个线性递归过程,需要Θ(n)
步和Θ(n)
空间。就像阶乘一样,我们可以很容易地制定一个等效的线性迭代:
js
function expt(b, n) {
return expt_iter(b, n, 1);
}
function expt_iter(b, counter, product) {
return counter === 0
? product
: expt_iter(b, counter - 1, b * product);
}
这个版本需要Θ(n)
步和Θ(1)
空间。
通过使用连续平方,我们可以用更少的步骤计算指数。例如,不是计算b
⁸为
js
b · (b · (b · (b · (b · (b · (b · b))))))
我们可以使用三次乘法来计算它:
js
b² = b · b
b⁴ = b² · b²
b⁸ = b⁴ · b⁴
这种方法适用于 2 的幂次方。如果我们使用规则
bⁿ = (b^(n/2))²
如果n
是偶数
bⁿ = b · b^(n--1)
如果n
是奇数
我们可以将这种方法表达为一个函数:
js
function fast_expt(b, n) {
return n === 0
? 1
: is_even(n)
? square(fast_expt(b, n / 2))
: b * fast_expt(b, n - 1);
}
其中用于测试整数是否为偶数的谓词是根据运算符%
定义的,该运算符在整数除法后计算余数,通过
js
function is_even(n) {
return n % 2 === 0;
}
fast_expt
产生的过程在空间和步数上都以对数方式增长。观察到使用fast_expt
计算b²ⁿ
只需要比计算bⁿ
多一次乘法。因此,我们可以大约每次允许新的乘法时,我们可以计算的指数大小加倍。因此,对于n
的指数所需的乘法数量大约与以 2 为底的n
的对数一样快。该过程具有Θ(log n)
增长。³⁵
当n
变大时,Θ(log n)
增长和Θ(n)
增长之间的差异变得明显。例如,n=1000
时,fast_expt
只需要 14 次乘法。³⁶也可以使用连续平方的想法设计一个迭代算法,该算法使用对数数量的步骤计算指数(参见练习 1.16),尽管通常情况下,这与递归算法一样,不那么直接。³⁷
练习 1.16
设计一个函数,它演变出一个使用连续平方并使用对数数量步骤的迭代指数过程,就像fast_expt
一样。(提示:使用观察到的事实(b^(n/2))² = (b²)^(n/2)
,除了指数n
和基数b
之外,还保留一个额外的状态变量a
,并定义状态转换,使得乘积abⁿ
从一个状态到另一个状态保持不变。在过程开始时,a
被认为是 1,并且答案由过程结束时的a
的值给出。通常,定义一个从一个状态到另一个状态保持不变的不变量数量的技术是思考设计迭代算法的强大方法。)
练习 1.17
本节中的幂运算算法是基于通过重复乘法进行幂运算。类似地,可以通过重复加法执行整数乘法。下面的乘法函数(假设我们的语言只能加法,不能乘法)类似于expt
函数:
js
function times(a, b) {
return b === 0
? 0
: a + times(a, b - 1);
}
该算法的步骤数量与b
成正比。现在假设我们还包括double
函数,它可以将整数加倍,以及halve
函数,它可以将(偶数)整数除以 2。使用这些函数,设计一个类似于fast_expt
的乘法函数,它使用对数数量的步骤。
练习 1.18
使用练习 1.16 和 1.17 的结果,设计一个函数,该函数生成一个迭代过程,用于以加法,加倍和减半的术语乘以两个整数,并且使用对数数量的步骤。³⁸
练习 1.19
有一个巧妙的算法可以在对数步骤内计算斐波那契数。回顾第 1.2.2 节中fib_iter
过程中状态变量a
和b
的转换:a, a + b
和b, a
。将这个转换称为T
,观察到反复应用Tⁿ
次,从 1 和 0 开始,会产生一对Fib(n + 1)
和Fib(n)
。换句话说,斐波那契数是通过从一对(1, 0)
开始应用Tⁿ
,即T
的n
次幂,产生的。现在考虑T
是变换T[pq]
中p = 0
和q = 1
的特殊情况,其中T[pq]
根据a ← bq + aq + ap
和b ← bp + aq
转换对(a, b)
。证明如果我们两次应用这样的变换T[pq]
,其效果与使用相同形式的单个变换T[p′q′]
相同,并计算p′
和q′
。这给了我们一个明确的方法来平方这些变换,因此我们可以使用连续平方来计算Tⁿ
,就像fast_expt
函数中一样。将所有这些放在一起,完成以下函数,可以在对数步骤内运行:³⁹
js
function fib(n) {
return fib_iter(1, 0, 0, 1, n);
}
function fib_iter(a, b, p, q, count) {
return count === 0
? b
: is_even(count)
? fib_iter(a,
b,
(??), // compute p′
(??), // compute q′
count / 2)
: fib_iter(b * q + a * q + a * p,
b * p + a * q,
p,
q,
count - 1);
}
1.2.5 最大公约数
两个整数a
和b
的最大公约数(GCD)被定义为能够整除a
和b
且没有余数的最大整数。例如,16 和 28 的 GCD 是 4。在第 2 章中,当我们研究如何实现有理数算术时,我们需要能够计算 GCD 以将有理数化简为最低项。 (要将有理数化简为最低项,我们必须将分子和分母都除以它们的 GCD。例如,16/28 化简为 4/7。)找到两个整数的 GCD 的一种方法是对它们进行因式分解并搜索公因数,但有一个著名的算法效率更高。
该算法的思想基于这样的观察:如果a
除以b
的余数为r
,那么a
和b
的公约数恰好与b
和r
的公约数相同。因此,我们可以使用方程式
js
GCD(a, b) = GCD(b, r)
将计算 GCD 的问题逐步减少为计算更小的整数对的 GCD 的问题。例如,
js
GCD(206, 40) = GCD(40, 6)
= GCD(6, 4)
= GCD(4, 2)
= GCD(2, 0)
= 2
将 GCD(206, 40)减少到 GCD(2, 0),即为 2。可以证明,从任意两个正整数开始,进行重复的减少操作最终总会产生一个第二个数字为 0 的对。然后 GCD 就是对中的另一个数字。这种计算 GCD 的方法被称为欧几里得算法。⁴⁰
很容易将欧几里得算法表达为一个函数:
js
function gcd(a, b) {
return b === 0 ? a : gcd(b, a % b);
}
这产生了一个迭代过程,其步数随涉及的数字的对数增长。
欧几里得算法所需的步骤数量具有对数增长与斐波那契数有有趣的关系:
**拉梅定理:**如果欧几里得算法需要k
步来计算一对的 GCD,那么一对中较小的数字必须大于或等于第k
个斐波那契数。⁴¹
我们可以使用这个定理来估计欧几里得算法的增长顺序。让n
为函数的两个输入中较小的那个。如果过程需要k
步,那么我们必须有n ≥ Fib(k) ≈ ϕ^k/√5
。因此,步数k
随着n
的对数(以ϕ
为底)增长。因此,增长顺序为Θ(log n)
。
练习 1.20
函数生成的过程当然取决于解释器使用的规则。例如,考虑上面给出的迭代gcd
函数。假设我们使用正则序求值来解释这个函数,就像在 1.1.5 节中讨论的那样。(条件表达式的正则序求值规则在练习 1.5 中描述。)使用替换方法(用于正则序),说明在求值gcd(206, 40)
时生成的过程,并指出实际执行的remainder
操作。在正则序求值gcd(206, 40)
时实际执行了多少次remainder
操作?在应用序求值中呢?
1.2.6 示例:素性测试
本节描述了检查整数n
的素性的两种方法,一种具有Θ()的增长阶数,另一种是具有Θ(log n)
的"概率"算法。本节末尾的练习建议基于这些算法进行编程项目。
寻找除数
自古以来,数学家们一直对素数问题着迷,许多人一直致力于确定测试数字是否为质数的方法。测试一个数是否为质数的一种方法是找到这个数的除数。以下程序找到给定数字n
的最小整数除数(大于 1)。它通过以 2 开始的连续整数测试n
是否可被整除的方式来直接进行。
js
function smallest_divisor(n) {
return find_divisor(n, 2);
}
function find_divisor(n, test_divisor) {
return square(test_divisor) > n
? n
: divides(test_divisor, n)
? test_divisor
: find_divisor(n, test_divisor + 1);
}
function divides(a, b) {
return b % a === 0;
}
我们可以通过以下方式测试一个数是否为质数:n
是质数当且仅当n
是其自身的最小除数。
js
function is_prime(n) {
return n === smallest_divisor(n);
}
find_divisor
的结束测试基于这样一个事实,即如果n
不是质数,它必须有一个小于或等于√n
的除数。⁴²这意味着算法只需要测试 1 和√n
之间的除数。因此,识别n
为质数所需的步骤数量将具有Θ(√n)
的增长阶数。
费马测试
Θ(log n)
素性测试基于数论中称为费马小定理的结果。⁴³
费马小定理: 如果n
是一个质数,a
是小于n
的任意正整数,则a
的n
次幂与n
模同余。
(如果两个数除以n
的余数相同,则它们被称为n
模同余。当一个数a
除以n
的余数也被称为a
模n
的余数,或者简称为a
模n
。)
如果n
不是质数,那么一般来说,大多数小于n
的数a
都不满足上述关系。这导致了用于测试素性的以下算法:给定一个数n
,选择一个小于n
的随机数a
,并计算aⁿ 模n
的余数。如果结果不等于a
,那么n
肯定不是质数。如果它等于a
,那么n
很可能是质数。现在选择另一个随机数a
,并用相同的方法进行测试。如果它也满足方程,则我们可以更加确信n
是质数。通过尝试更多的a
值,我们可以增加对结果的信心。这个算法被称为费马测试。
为了实现费马测试,我们需要一个计算一个数的指数模另一个数的函数:
js
function expmod(base, exp, m) {
return exp === 0
? 1
: is_even(exp)
? square(expmod(base, exp / 2, m)) % m
: (base * expmod(base, exp - 1, m)) % m;
}
这与 1.2.4 节中的fast_expt
函数非常相似。它使用连续平方,因此步骤数量随指数对数增长。⁴⁴
费马测试是通过随机选择一个介于 1 和n-1
之间的数字a
来执行的,并检查a
的n
次幂的模n
的余数是否等于a
。随机数a
是使用原始函数math_random
选择的,该函数返回小于 1 的非负数。因此,要获得 1 和n-1
之间的随机数,我们将math_random
的返回值乘以n-1
,用原始函数math_floor
向下舍入结果,然后加 1。
js
function fermat_test(n) {
function try_it(a) {
return expmod(a, n, n) === a;
}
return try_it(1 + math_floor(math_random() * (n - 1)));
}
以下函数根据参数指定的次数运行测试。如果测试每次都成功,则其值为true
,否则为false
。
js
function fast_is_prime(n, times) {
return times === 0
? true
: fermat_test(n)
? fast_is_prime(n, times - 1)
: false;
}
概率方法
费马测试在性质上与大多数熟悉的算法不同,大多数算法计算的答案是保证正确的。在这里,获得的答案只是可能正确。更准确地说,如果n
曾经未通过费马测试,我们可以肯定n
不是质数。但是,n
通过测试的事实,虽然是一个极为强有力的指示,但仍不能保证n
是质数。我们想说的是,对于任何数字n
,如果我们进行足够多次测试并发现n
总是通过测试,那么我们的素性测试中的错误概率可以尽可能小。
不幸的是,这个断言并不完全正确。确实存在一些可以欺骗费马测试的数字:不是质数但具有性质aⁿ
对n
取模等于a
对n
取模的数字n
,其中所有整数a < n
。这样的数字非常罕见,因此费马测试在实践中是相当可靠的。费马测试有一些无法被欺骗的变体。在这些测试中,与费马方法一样,通过选择一个随机整数a < n
并检查依赖于n
和a
的某些条件来测试整数n
的素性。(有关这种测试的示例,请参见练习 1.28。)另一方面,与费马测试相反,可以证明对于任何n
,除非n
是质数,否则大多数整数a < n
都不满足条件。因此,如果n
对某个随机选择的a
通过了测试,那么n
是质数的可能性甚至更大。如果n
对两个随机选择的a
通过了测试,那么n
是质数的可能性超过 4 分之 3。通过使用更多和更多随机选择的a
值运行测试,我们可以使错误的概率尽可能小。
对于可以证明错误几率变得任意小的测试的存在引起了对这种类型算法的兴趣,这种算法被称为概率算法。在这个领域有大量的研究活动,概率算法已经成功地应用到许多领域。
练习 1.21
使用smallest_divisor
函数找出以下数字的最小除数:199、1999、19999。
练习 1.22
假设有一个没有参数的原始函数get_time
,它返回自 1970 年 1 月 1 日星期四 UTC 时间 00:00:00 以来经过的毫秒数。以下timed_prime_test
函数在调用整数 n 时打印 n 并检查 n 是否为质数。如果 n 是质数,则该函数打印三个星号,然后是执行测试所用的时间量。
js
function timed_prime_test(n) {
display(n);
return start_prime_test(n, get_time());
}
function start_prime_test(n, start_time) {
return is_prime(n)
? report_prime(get_time() - start_time)
: true;
}
function report_prime(elapsed_time) {
display(" *** ");
display(elapsed_time);
}
使用这个函数,编写一个名为search_for_primes
的函数,它检查指定范围内连续奇数的素性。使用你的函数找到大于 1000 的三个最小素数;大于 10,000;大于 100,000;大于 1,000,000。注意测试每个素数所需的时间。由于测试算法的增长阶为Θ(),你应该期望在 10,000 左右的素数测试大约需要倍于在 1000 左右的素数测试。你的计时数据是否支持这一点?100,000 和 1,000,000 的数据对的预测有多好的支持?你的结果是否符合程序在你的机器上运行时间与计算所需步骤数量成正比的概念?
练习 1.23
本节开头显示的smallest_divisor
函数进行了许多不必要的测试:在检查数字是否可被 2 整除后,没有必要检查它是否可被任何更大的偶数整除。这表明用于test_divisor
的值不应该是 2, 3, 4, 5, 6, ...而应该是 2, 3, 5, 7, 9,为了实现这个改变,声明一个函数next
,如果它的输入等于 2,则返回 3,否则返回它的输入加 2。修改smallest_divisor
函数,使用next(test_divisor)
代替test_divisor + 1
。使用包含这个修改版本的smallest_divisor
的timed_prime_test
,对在练习 1.22 中找到的 12 个素数进行测试。由于这个修改减少了测试步骤的数量,你应该期望它运行大约快两倍。这个期望是否得到确认?如果没有,两个算法的速度比率是多少,你如何解释它与 2 不同的事实?
练习 1.24
修改练习 1.22 的timed_prime_test
函数,使用fast_is_prime
(费马方法),并测试你在那个练习中找到的 12 个素数。由于费马测试具有Θ(log n)
的增长,你会预期测试接近 1,000,000 的素数所需的时间与测试接近 1000 的素数相比如何?你的数据是否支持这一点?你能解释你发现的任何差异吗?
练习 1.25
Alyssa P. Hacker 抱怨我们在编写expmod
时做了很多额外的工作。她说,毕竟,既然我们已经知道如何计算指数,我们本来可以简单地写成
js
function expmod(base, exp, m) {
return fast_expt(base, exp) % m;
}
她是正确的吗?这个函数对我们的快速素数测试器是否有用?解释一下。
练习 1.26
路易斯·里森纳在做练习 1.24 时遇到了很大的困难。他的fast_is_prime
测试似乎比他的is_prime
测试运行得更慢。路易斯叫他的朋友伊娃·卢·阿托过来帮忙。当他们检查路易斯的代码时,他们发现他已经重写了expmod
函数,使用了显式乘法,而不是调用square
:
js
function expmod(base, exp, m) {
return exp === 0
? 1
: is_even(exp)
? ( expmod(base, exp / 2, m)
* expmod(base, exp / 2, m)) % m
: (base * expmod(base, exp - 1, m)) % m;
}
"我不明白那会有什么不同,"路易斯说。"我明白,"伊娃说。"通过那样写函数,你已经将Θ(log n)
的过程转变为Θ(n)
的过程。"解释一下。
练习 1.27
证明脚注 45 中列出的卡迈克尔数确实愚弄了费马测试。也就是说,编写一个函数,它接受一个整数n
,并测试对于每个a < n
,aⁿ
是否与a
模n
同余,并在给定的卡迈克尔数上尝试你的函数。
练习 1.28
一个无法被愚弄的费马测试的变体被称为米勒-拉宾测试 (Miller 1976; Rabin 1980)。这是从费马小定理的另一种形式开始的,该定理陈述了如果n
是一个质数,a
是小于n
的任意正整数,则a
的(n - 1)
次方与n
模同余于 1。通过米勒-拉宾测试来测试一个数n
的素性,我们选择一个随机数a < n
,并使用expmod
函数将a
的(n - 1)
次方模n
。然而,每当我们在expmod
中执行平方步骤时,我们检查是否发现了"非平凡的模n
的平方根",即不等于 1 或n - 1
的平方等于模n
的 1 的数。可以证明,如果存在这样一个非平凡的模 1 的平方根,那么n
不是质数。也可以证明,如果n
是一个不是质数的奇数,那么至少有一半的数a < n
,通过这种方式计算a^(n--1)
将会显示出模n
的非平凡的平方根。(这就是为什么米勒-拉宾测试无法被愚弄的原因。)修改expmod
函数以便在发现非平凡的模 1 的平方根时发出信号,并使用这个来实现米勒-拉宾测试,方法类似于fermat_test
。通过测试各种已知的质数和非质数来检查你的函数。提示:使expmod
发出信号的一个方便的方法是让它返回 0。
1.3 用高阶函数制定抽象
我们已经看到,函数实际上是描述数字的复合操作的抽象,与特定数字无关。例如,当我们声明
js
function cube(x) {
return x * x * x;
}
我们不是在谈论特定数字的立方,而是在谈论获得任何数字的立方的方法。当然,我们可以不声明这个函数,总是写诸如
js
3 * 3 * 3
x * x * x
y * y * y
并且从不明确提到cube
。这将使我们处于严重的不利地位,迫使我们总是在语言中原语的特定操作级别上工作(在这种情况下是乘法),而不是在更高级别的操作上工作。我们的程序可以计算立方,但我们的语言缺乏表达立方概念的能力。我们应该从一个强大的编程语言中要求的一件事是,通过为常见模式分配名称来构建抽象的能力,然后直接使用这些抽象。函数提供了这种能力。这就是为什么除了最原始的编程语言之外,所有的编程语言都包括声明函数的机制。
然而,即使在数值处理中,如果我们被限制为参数必须是数字的函数,我们在创建抽象的能力上也会受到严重限制。通常相同的编程模式会与许多不同的函数一起使用。为了将这样的模式表达为概念,我们需要构建可以接受函数作为参数或返回函数作为值的函数。操作函数的函数称为高阶函数。本节展示了高阶函数如何作为强大的抽象机制,大大增加了我们语言的表达能力。
1.3.1 函数作为参数
考虑以下三个函数。第一个计算从a
到b
的整数的和:
js
function sum_integers(a, b) {
return a > b
? 0
: a + sum_integers(a + 1, b);
}
第二个计算给定范围内整数的立方和:
js
function sum_cubes(a, b) {
return a > b
? 0
: cube(a) + sum_cubes(a + 1, b);
}
第三个计算序列中一系列项的和
这收敛到π/8
(非常慢):⁴⁹
js
function pi_sum(a, b) {
return a > b
? 0
: 1 / (a * (a + 2)) + pi_sum(a + 4, b);
}
这三个函数显然共享一个共同的基本模式。它们在大部分情况下是相同的,只是函数的名称不同,用于计算要添加的项的a
的函数不同,以及提供a
的下一个值的函数不同。我们可以通过在相同的模板中填充槽来生成每个函数:
js
function name(a, b) {
return a > b
? 0
: term(a) + name(next(a), b);
}
这种常见模式的存在是有力的证据,表明有一个有用的抽象正在等待被提出。事实上,数学家很久以前就确定了级数求和的抽象,并发明了"Σ符号",例如
来表达这个概念。Σ符号的力量在于它允许数学家处理求和概念本身,而不仅仅是特定的和,例如,制定关于独立于被求和的特定系列的一般结果。
同样,作为程序设计师,我们希望我们的语言足够强大,以便我们可以编写一个表达求和概念本身而不仅仅是计算特定和的函数。在我们的函数语言中,我们可以很容易地采用上面显示的常见模板,并将"插槽"转换为参数:
js
function sum(term, a, next, b) {
return a > b
? 0
: term(a) + sum(term, next(a), next, b);
}
注意,sum
以它的参数下限和上限a
和b
以及函数term
和next
作为参数。我们可以像使用任何函数一样使用sum
。例如,我们可以使用它(以及一个将其参数增加 1 的函数inc
)来定义sum_cubes
:
js
function inc(n) {
return n + 1;
}
function sum_cubes(a, b) {
return sum(cube, a, inc, b);
}
使用这个,我们可以计算 1 到 10 的整数的立方和:
js
sum_cubes(1, 10);
3025
借助一个计算项的恒等函数,我们可以定义sum_integers
:
js
function identity(x) {
return x;
}
function sum_integers(a, b) {
return sum(identity, a, inc, b);
}
然后我们可以计算 1 到 10 的整数的和:
js
sum_integers(1, 10);
55
我们也可以用同样的方法定义pi_sum
:⁵⁰
js
function pi_sum(a, b) {
function pi_term(x) {
return 1 / (x * (x + 2));
}
function pi_next(x) {
return x + 4;
}
return sum(pi_term, a, pi_next, b);
}
使用这些函数,我们可以计算π
的近似值:
js
8 * pi_sum(1, 1000);
3.139592655589783
一旦我们有了sum
,我们就可以将其用作构建进一步概念的基础。例如,函数f
在限制a
和b
之间的定积分可以使用以下公式在数值上近似:
对于小的dx
值。我们可以直接表示这个函数:
js
function integral(f, a, b, dx) {
function add_dx(x) {
return x + dx;
}
return sum(f, a + dx / 2, add_dx, b) * dx;
}
integral(cube, 0, 1, 0.01);
0.24998750000000042
integral(cube, 0, 1, 0.001);
0.249999875000001
(函数cube
在 0 和 1 之间的积分的确切值为 1/4。)
练习 1.29
辛普森法则是一种比上面所示方法更准确的数值积分方法。使用辛普森法则,函数f
在a
和b
之间的积分可以近似为
其中h = (b - a)/n
,对于一些偶数n
,y[k] = f(a + kh)
。 (增加n
会增加近似的准确性。)声明一个函数,它以f, a, b
和n
作为参数,并使用辛普森法则计算积分的值。使用你的函数来计算cube
在 0 和 1 之间的积分(n = 100
和n = 1000
),并将结果与上面所示的integral
函数的结果进行比较。
练习 1.30
上面的sum
函数生成一个线性递归。可以重写该函数,使求和以迭代方式执行。通过填写以下声明中的缺失表达式,展示如何做到这一点:
js
function sum(term, a, next, b) {
function iter(a, result) {
return 〈??〉
? 〈??〉
: iter(〈??〉, 〈??〉);
}
return iter(〈??〉, 〈??〉);
}
练习 1.31
-
a.
sum
函数只是许多类似抽象中最简单的一个。⁵¹编写一个名为product
的类似函数,它返回给定范围内点的函数值的乘积。展示如何用product
来定义factorial
。还使用product
来使用公式计算π
的近似值⁵² -
b. 如果你的
product
函数生成一个递归过程,写一个生成迭代过程的函数。如果它生成一个迭代过程,写一个生成递归过程的函数。
练习 1.32
-
a. 证明
sum
和product
(练习 1.31)都是更一般的称为accumulate
的特殊情况,它使用一些一般的累积函数来组合一系列项:jsaccumulate(combiner, null_value, term, a, next, b);
函数
accumulate
接受与sum
和product
相同的项和范围规范,以及一个combiner
函数(两个参数)作为参数,该函数指定如何将当前项与前面项的累积组合在一起,以及一个null_value
,用于指定项用完时要使用的基本值。编写accumulate
并展示如何将sum
和product
都声明为对accumulate
的简单调用。b. 如果您的
accumulate
函数生成递归过程,请编写一个生成迭代过程的函数。如果它生成迭代过程,请编写一个生成递归过程的函数。
练习 1.33
您可以通过引入对要组合的项进行筛选 的概念来获得accumulate
的更通用版本(练习 1.32)。也就是说,仅组合满足指定条件的范围中的值导出的那些项。生成的filtered_accumulate
抽象接受与 accumulate 相同的参数,以及指定筛选器的一个参数的附加谓词。编写filtered_accumulate
作为一个函数。展示如何使用filtered_accumulate
来表达以下内容:
-
a. 在区间
a
到b
中素数的平方的和(假设您已经编写了is_prime
谓词) -
b. 所有小于
n
的正整数的乘积,这些整数与n
互质(即,所有正整数i < n
,使得GCD(i, n) = 1
)。
1.3.2 使用 Lambda 表达式构建函数
在使用sum
时,似乎非常笨拙地声明诸如pi_term
和pi_next
之类的微不足道的函数,以便我们可以将它们用作高阶函数的参数。与其声明pi_next
和pi_term
,不如直接指定"返回其输入加 4 的函数"和"返回其输入的倒数乘以其输入加 2 的函数"更方便。我们可以通过引入lambda 表达式作为一种用于创建函数的语法形式来实现这一点。使用 lambda 表达式,我们可以描述我们想要的内容
js
x => x + 4
和
js
x => 1 / (x * (x + 2))
然后我们可以表达我们的pi_sum
函数,而不声明任何辅助函数:
js
function pi_sum(a, b) {
return sum(x => 1 / (x * (x + 2)),
a,
x => x + 4,
b);
}
再次使用 lambda 表达式,我们可以编写integral
函数,而无需声明辅助函数add_dx
:
js
function integral(f, a, b, dx) {
return sum(f,
a + dx / 2,
x => x + dx,
b)
*
dx;
}
通常,lambda 表达式用于创建函数,方式与函数声明相同,只是没有为函数指定名称,并且省略了return
关键字和大括号(如果只有一个参数,则可以省略参数列表周围的括号,就像我们看到的例子一样)。⁵³
js
(parameters) => expression
生成的函数与使用函数声明语句创建的函数一样。唯一的区别是它没有与环境中的任何名称关联。我们认为
js
function plus4(x) {
return x + 4;
}
等同于⁵⁴
js
const plus4 = x => x + 4;
我们可以按照以下方式阅读 lambda 表达式:
像任何具有函数作为其值的表达式一样,lambda 表达式可以用作应用程序中的函数表达式,例如
js
((x, y, z) => x + y + square(z))(1, 2, 3);
12
或者,更一般地,在我们通常使用函数名称的任何上下文中。⁵⁵ 请注意,=>
的优先级低于函数应用,因此这里括号是必要的。
使用const
创建本地名称
lambda 表达式的另一个用途是创建本地名称。我们在函数中经常需要本地名称,而不仅仅是已绑定为参数的名称。例如,假设我们希望计算函数
js
f (x, y) = x(1 + xy)² + y(1 -- y) + (1 + xy)(1 -- y)
我们也可以表达为
js
a = 1 + xy
b = 1 -- y
f (x, y) = xa² + yb + ab
在编写一个计算f
的函数时,我们希望将局部名称不仅包括x
和y
,还包括像a
和b
这样的中间量的名称。实现这一点的一种方法是使用辅助函数来绑定局部名称:
js
function f(x, y) {
function f_helper(a, b) {
return x * square(a) + y * b + a * b;
}
return f_helper(1 + x * y, 1 - y);
}
当然,我们可以使用 lambda 表达式来指定一个匿名函数来绑定我们的局部名称。然后函数体变成了对该函数的单个调用:
js
function f_2(x, y) {
return ( (a, b) => x * square(a) + y * b + a * b
)(1 + x * y, 1 - y);
}
通过在函数体内使用常量声明,更方便地声明局部名称的一种方法是使用常量声明。使用const
,函数可以写成
js
function f_3(x, y) {
const a = 1 + x * y;
const b = 1 - y;
return x * square(a) + y * b + a * b;
}
在块内部使用const
声明的名称将其所在的最近块的主体作为其作用域。⁵⁶^,⁵⁷
条件语句
我们已经看到,在函数声明中声明局部名称通常是有用的。当函数变得很大时,我们应该尽可能地保持名称的范围狭窄。例如考虑练习 1.26 中的expmod
。
js
function expmod(base, exp, m) {
return exp === 0
? 1
: is_even(exp)
? ( expmod(base, exp / 2, m)
* expmod(base, exp / 2, m)) % m
: (base * expmod(base, exp - 1, m)) % m;
}
这个函数是不必要的低效,因为它包含了两个相同的调用:
js
expmod(base, exp / 2, m);
虽然在这个例子中可以很容易地使用square
函数来修复这个问题,但在一般情况下并不容易。如果不使用square
,我们可能会尝试引入一个表达式的局部名称,如下所示:
js
function expmod(base, exp, m) {
const half_exp = expmod(base, exp / 2, m);
return exp === 0
? 1
: is_even(exp)
? (half_exp * half_exp) % m
: (base * expmod(base, exp - 1, m)) % m;
}
这将使函数不仅效率低下,而且实际上是非终止的!问题在于常量声明出现在条件表达式之外,这意味着即使满足基本情况exp === 0
,它也会被执行。为了避免这种情况,我们提供了条件语句 ,并允许返回语句出现在语句的分支中。使用条件语句,我们可以将函数expmod
写成如下形式:
js
function expmod(base, exp, m) {
if (exp === 0) {
return 1;
} else {
if (is_even(exp)) {
const half_exp = expmod(base, exp / 2, m);
return (half_exp * half_exp) % m;
} else {
return (base * expmod(base, exp - 1, m)) % m;
}
}
}
条件语句的一般形式是
js
if (predicate) { consequent-statements } else { alternative-statements }
对于条件表达式,解释器首先求值predicate
。如果它求值为true
,解释器按顺序求值consequent-statements
,如果它求值为false
,解释器按顺序求值alternative-statements
。返回语句的求值从周围的函数返回,忽略返回语句后的任何语句和条件语句后的任何语句。请注意,任何在任一部分中发生的常量声明都是该部分的局部声明,因为每个部分都被括号括起来,因此形成自己的块。
练习 1.34
假设我们声明
js
function f(g) {
return g(2);
}
然后我们有
js
f(square);
`4`
f(z => z * (z + 1));
`6`
如果我们(刻意地)要求解释器求值应用f(f)
会发生什么?解释。
1.3.3 函数作为一般方法
我们在 1.1.4 节介绍了复合函数作为一种抽象数值操作模式的机制,使其独立于特定的数字。通过高阶函数,比如 1.3.1 节的integral
函数,我们开始看到一种更强大的抽象:用于表达计算的一般方法,独立于特定的函数。在本节中,我们将讨论两个更复杂的例子------查找函数的零点和不动点的一般方法,并展示这些方法如何直接表达为函数。
通过半区间法找到方程的根
半区间方法 是一种简单但强大的技术,用于找到方程f(x)=0
的根,其中f
是一个连续函数。其思想是,如果我们给定了f(a)<0<f(b)
的点a
和b
,那么f
必须至少在a
和b
之间有一个零点。为了找到零点,让x
为a
和b
的平均值,并计算f(x)
。如果f(x)>0
,则f
必须在a
和x
之间有一个零点。如果f(x)<0
,则f
必须在x
和b
之间有一个零点。以此类推,我们可以确定f
必须有一个零点的更小的区间。当我们达到一个足够小的区间时,过程停止。由于不确定性的区间在过程的每一步都减半,所以所需的最大步数增长为Θ(log(L/T))
,其中L
是原始区间的长度,T
是误差容限(即我们将考虑"足够小"的区间的大小)。以下是实现这种策略的函数:
js
function search(f, neg_point, pos_point) {
const midpoint = average(neg_point, pos_point);
if (close_enough(neg_point, pos_point)) {
return midpoint;
} else {
const test_value = f(midpoint);
return positive(test_value)
? search(f, neg_point, midpoint)
: negative(test_value)
? search(f, midpoint, pos_point)
: midpoint;
}
}
我们假设我们最初给定了函数f
以及其值为负和正的点。我们首先计算两个给定点的中点。接下来,我们检查给定的区间是否足够小,如果是,我们就简单地返回中点作为我们的答案。否则,我们计算中点处f
的值作为测试值。如果测试值为正,则我们继续从原始负点到中点的新区间进行该过程。如果测试值为负,则我们继续从中点到正点的区间。最后,有可能测试值为 0,这种情况下中点本身就是我们正在寻找的根。为了测试端点是否"足够接近",我们可以使用类似于 1.1.7 节中用于计算平方根的函数:
js
function close_enough(x, y) {
return abs(x - y) < 0.001;
}
search
函数直接使用起来很麻烦,因为我们可能会无意中给出f
的值没有所需符号的点,这样我们就会得到错误的答案。相反,我们将通过以下函数使用search
,该函数检查哪个端点具有负函数值,哪个具有正函数值,并相应地调用search
函数。如果函数在两个给定点上具有相同的符号,则无法使用半区间方法,在这种情况下,函数会发出错误信号。
js
function half_interval_method(f, a, b) {
const a_value = f(a);
const b_value = f(b);
return negative(a_value) && positive(b_value)
? search(f, a, b)
: negative(b_value) && positive(a_value)
? search(f, b, a)
: error("values are not of opposite sign");
}
以下示例使用半区间方法在 2 和 4 之间近似π
作为sinx=0
的根:
js
half_interval_method(math_sin, 2, 4);
3.14111328125
以下是另一个例子,使用半区间方法在 1 和 2 之间搜索方程x³ - 2x - 3 = 0
的根:
js
half_interval_method(x => x * x * x - 2 * x - 3, 1, 2);
1.89306640625
查找函数的固定点
如果x
满足方程f(x)=x
,则称数字x
为函数f
的固定点 。对于一些函数f
,我们可以通过从初始猜测开始并重复应用f
来找到一个固定点,
js
f(x), f(f(x)), f(f(f(x))), ...
直到值变化不是很大为止。利用这个想法,我们可以设计一个函数fixed_point
,它以函数和初始猜测为输入,并产生函数的一个近似固定点。我们重复应用该函数,直到找到两个连续值的差小于某个预定的容差:
js
const tolerance = 0.00001;
function fixed_point(f, first_guess) {
function close_enough(x, y) {
return abs(x - y) < tolerance;
}
function try_with(guess) {
const next = f(guess);
return close_enough(guess, next)
? next
: try_with(next);
}
return try_with(first_guess);
}
例如,我们可以使用这种方法来近似余弦函数的固定点,从 1 开始作为初始近似值:
js
fixed_point(math_cos, 1);
0.7390822985224023
同样,我们可以找到方程y=siny+cosy
的解:
js
fixed_point(y => math_sin(y) + math_cos(y), 1);
1.2587315962971173
固定点过程让人想起了我们在 1.1.7 节中用于找平方根的过程。两者都基于反复改进猜测的想法,直到结果满足某些标准。实际上,我们可以很容易地将平方根计算公式化为固定点搜索。计算某个数x
的平方根需要找到一个y
,使得y² = x
。将这个方程转化为等价形式y = x / y
,我们意识到我们正在寻找函数⁶¹ y = x/y
的一个固定点,因此我们可以尝试计算平方根
js
function sqrt(x) {
return fixed_point(y => x / y, 1);
}
不幸的是,这种固定点搜索不会收敛。考虑一个初始猜测y[1]
。下一个猜测是y[2] = x/y[1]
,下一个猜测是y[3] = x/y[2] = x/(x/y[1]) = y[1]
。这导致了一个无限循环,其中两个猜测y[1]
和y[2]
一遍又一遍地重复,围绕答案振荡。
控制这种振荡的一种方法是防止猜测变化太大。由于答案总是在我们的猜测y
和x / y
之间,我们可以做一个新的猜测,这个猜测不像x / y
那么远离y
,通过平均y
和x / y
,这样在y
之后的下一个猜测就是1/2 (y + x / y)
而不是x / y
。这种猜测序列的过程只是寻找y -> 1/2 (y + x / y)
的固定点的过程。
js
function sqrt(x) {
return fixed_point(y => average(y, x / y), 1);
}
(注意y = 1/2 (y + x / y)
是方程y = x / y
的简单变换;为了推导它,将y
添加到方程的两边,然后除以 2。)
有了这个修改,平方根函数就可以工作了。实际上,如果我们展开定义,我们可以看到这里生成的平方根近似序列与我们原来的 1.1.7 节平方根函数生成的序列完全相同。这种平均连分数的方法通常有助于固定点搜索的收敛。
练习 1.35
证明黄金分割比ϕ
(1.2.2 节)是变换x -> 1 + 1/x
的一个固定点,并利用这一事实通过fixed_point
函数计算ϕ
。
练习 1.36
修改fixed_point
,以便它打印生成的近似值序列,使用练习 1.22 中显示的原始函数display
。然后通过找到xˣ = 1000
的固定点来找到解,即x -> log(1000)/ log(x)
。 (使用计算自然对数的原始函数math_log
。)比较使用和不使用平均阻尼所需的步骤数。(请注意,您不能从 1 开始fixed_point
,因为这将导致除以log(1) = 0
。)
练习 1.37
无限连分数是一个形式的表达式
例如,可以证明,所有N[i]
和D[i]
都等于 1 的无限连分数展开产生1/ϕ
,其中ϕ
是黄金分割比(在 1.2.2 节中描述)。近似无限连分数的一种方法是在给定项数后截断展开。这种截断------所谓的k
项有限连分数------具有形式
-
a.假设
n
和d
是一个参数(项索引i
)的函数,返回连分数项的N[i]
和D[i]
。声明一个函数cont_frac
,使得求值cont_frac(n, d, k)
计算k
项有限连分数的值。通过近似 1ϕ
检查您的函数jscont_frac(i => 1, i => 1, k);
对于连续的
k
值。为了获得精确到小数点后 4 位的近似值,您需要使k
有多大? -
b. 如果您的
cont_frac
函数生成递归过程,请编写一个生成迭代过程的函数。如果它生成迭代过程,请编写一个生成递归过程的函数。
练习 1.38
1737 年,瑞士数学家 Leonhard Euler 发表了一篇题为De Fractionibus Continuis 的备忘录,其中包括了 e - 2
的一个连分数展开,其中e
是自然对数的底数。在这个分数中,N[i]
都是 1,D[i]
依次为 1、2、1、1、4、1、1、6、1、1、8,编写一个程序,使用练习 1.37 中的cont_frac
函数来根据 Euler 的展开来近似e
。
练习 1.39
1770 年,德国数学家 J.H. Lambert 发表了正切函数的连分数表示:
其中x
以弧度为单位。声明一个函数tan_cf(x, k)
,根据 Lambert 的公式计算正切函数的近似值。与练习 1.37 一样,k
指定要计算的项数。
1.3.4 作为返回值的函数
上述例子展示了将函数作为参数传递的能力如何显著增强了我们编程语言的表达能力。通过创建其返回值本身是函数的函数,我们可以实现更多的表达能力。
我们可以通过再次查看第 1.3.3 节末尾描述的不动点示例来说明这个想法。我们将一个新版本的平方根函数表述为一个不动点搜索,从观察到√x
是函数y -> x / y
的不动点开始。然后我们使用平均阻尼使近似值收敛。平均阻尼本身是一个有用的通用技术。也就是说,给定一个函数f
,我们考虑其在x
处的值等于x
和f(x)
的平均值的函数。
我们可以通过以下函数表达平均阻尼的想法:
js
function average_damp(f) {
return x => average(x, f(x));
}
函数average_damp
以函数f
作为参数,并返回一个函数(由 lambda 表达式生成),当应用于数字x
时,产生x
和f(x)
的平均值。例如,将average_damp
应用于square
函数会产生一个函数,其在某个数字x
处的值是x
和x
²的平均值。将这个结果函数应用于 10 会返回 10 和 100 的平均值,即 55。
js
average_damp(square)(10);
55
使用average_damp
,我们可以重新表述平方根函数如下:
js
function sqrt(x) {
return fixed_point(average_damp(y => x / y), 1);
}
注意这种表述如何明确了方法中的三个思想:不动点搜索、平均阻尼和函数y = x/y
。比较这种平方根方法的表述与第 1.1.7 节中给出的原始版本是有益的。请记住,这些函数表达了相同的过程,注意当我们用这些抽象的术语表达过程时,这个想法变得更加清晰。一般来说,有许多方法可以将一个过程表述为一个函数。有经验的程序员知道如何选择特别明晰的过程表述,并且在有用的情况下,将过程的元素公开为可以在其他应用中重复使用的单独实体。作为重用的一个简单例子,注意x
的立方根是函数y = x/y²
的不动点,因此我们可以立即将我们的平方根函数推广为一个提取立方根的函数。
js
function cube_root(x) {
return fixed_point(average_damp(y => x / square(y)), 1);
}
牛顿法
当我们首次介绍平方根函数时,在第 1.1.7 节中,我们提到这是牛顿法 的一个特例。如果x -> g(x)
是一个可微函数,那么方程g(x) = 0
的解是函数x -> f (x)
的不动点,其中
和Dg(x)
是在x
处求导的g
的导数。牛顿法是使用我们上面看到的固定点方法来近似方程的解,通过找到函数f
的固定点。⁶⁴ 对于许多函数g
和对于足够好的x
的初始猜测,牛顿法收敛得非常快,以便解决g(x) = 0
。⁶⁵
为了将牛顿法实现为一个函数,我们必须首先表达导数的概念。注意,"导数"和平均阻尼一样,是将一个函数转换为另一个函数的东西。例如,函数x -> x³
的导数是函数x -> 3x²
。一般来说,如果g
是一个函数,dx
是一个小数,那么g
的导数Dg
是一个函数,其在任何数x
处的值(在小dx
的极限情况下)由
因此,我们可以将导数的概念(取dx
为 0.00001)表示为函数
js
function deriv(g) {
return x => (g(x + dx) - g(x)) / dx;
}
以及声明
js
const dx = 0.00001;
像average_damp
一样,deriv
是一个以函数作为参数并返回函数作为值的函数。例如,要近似计算x
³在 5 处的导数(其精确值为 75),我们可以计算
js
function cube(x) { return x * x * x; }
deriv(cube)(5);
75.00014999664018
借助于deriv
,我们可以将牛顿法表达为一个固定点过程:
js
function newton_transform(g) {
return x => x - g(x) / deriv(g)(x);
}
function newtons_method(g, guess) {
return fixed_point(newton_transform(g), guess);
}
newton_transform
函数表达了本节开头的公式,newtons_method
可以很容易地根据这个定义。它的参数是一个计算我们想要找到零点的函数的函数,以及一个初始猜测。例如,要找到x
的平方根,我们可以使用牛顿法来找到函数y -> y² -- x
的零点,从初始猜测为 1 开始。⁶⁶ 这提供了平方根函数的另一种形式:
js
function sqrt(x) {
return newtons_method(y => square(y) - x, 1);
}
抽象和一级函数
我们已经看到了两种将平方根计算表达为更一般方法的实例,一种是作为固定点搜索,另一种是使用牛顿法。由于牛顿法本身被表达为一个固定点过程,我们实际上看到了两种计算平方根作为固定点的方法。每种方法都以一个函数开始,并找到函数的某个变换的固定点。我们可以将这个一般的想法本身表达为一个函数:
js
function fixed_point_of_transform(g, transform, guess) {
return fixed_point(transform(g), guess);
}
这个非常一般的函数以一个计算某个函数的函数g
,一个转换g
的函数和一个初始猜测作为参数。返回的结果是转换函数的一个固定点。
使用这种抽象,我们可以将本节中第一个平方根计算(其中我们寻找y -> x / y
的平均阻尼版本的固定点)重新表述为这种一般方法的一个实例:
js
function sqrt(x) {
return fixed_point_of_transform(
y => x / y,
average_damp,
1);
}
同样,我们可以将本节中第二个平方根计算(牛顿法的一个实例,找到y -> y² -- x
的牛顿变换的固定点)表达为
js
function sqrt(x) {
return fixed_point_of_transform(
y => square(y) - x,
newton_transform,
1);
}
我们在 1.3 节开始时观察到,复合函数是一个关键的抽象机制,因为它们允许我们将计算的一般方法表达为编程语言中的显式元素。现在我们已经看到了高阶函数如何允许我们操纵这些一般方法以创建进一步的抽象。
作为程序员,我们应该警惕机会,识别程序中的基本抽象,并在其上构建和泛化,以创建更强大的抽象。这并不是说我们应该总是以最抽象的方式编写程序;专业程序员知道如何选择适合其任务的抽象级别。但是,重要的是能够以这些抽象的方式思考,以便我们可以准备在新的上下文中应用它们。高阶函数的重要性在于它们使我们能够将这些抽象明确地表示为我们编程语言中的元素,以便它们可以像其他计算元素一样被处理。
一般来说,编程语言对计算元素的操作方式施加了限制。具有最少限制的元素被称为第一类状态。第一类元素的一些"权利和特权"是:
-
它们可以使用名称来引用。
-
它们可以作为函数的参数传递。
-
它们可以作为函数的结果返回。
-
它们可以包含在数据结构中。
JavaScript,像其他高级编程语言一样,授予函数完全的第一类状态。这对于高效实现提出了挑战,但由此产生的表达能力的增强是巨大的。
练习 1.40
声明一个函数cubic
,它可以与newtons_method
函数一起使用,形式如下:
js
newtons_method(cubic(a, b, c), 1)
来近似三次方程x³+ax²+bx+c
的零点。
练习 1.41
声明一个函数double
,它以一个参数的函数作为参数,并返回一个应用原始函数两次的函数。例如,如果inc
是一个将其参数加 1 的函数,则double(inc)
应该是一个将其参数加 2 的函数。通过
js
double(double(double))(inc)(5);
练习 1.42
设f
和g
是两个一元函数。g
之后f
的组合 被定义为函数x -> f (g(x)
)。声明一个实现组合的函数compose
。例如,如果inc
是一个将其参数加 1 的函数,
js
compose(square, inc)(6);
49
练习 1.43
如果f
是一个数值函数,n
是一个正整数,那么我们可以形成f
的n
次重复应用,它被定义为其在x
处的值是f(f(...(f(x))...))
。例如,如果f
是函数x -> x + 1
,那么f
的n
次重复应用是函数x -> x + n
。如果f
是平方数的操作,那么f
的n
次重复应用是将其参数提高到2ⁿ
次幂的函数。编写一个函数,它以计算f
的函数和一个正整数n
作为输入,并返回计算f
的n
次重复应用的函数。您的函数应该能够像下面这样使用:
js
repeated(square, 2)(5);
625
提示:您可能会发现使用练习 1.42 中的compose
很方便。
练习 1.44
平滑函数的概念是信号处理中的重要概念。如果f
是一个函数,dx
是一个小数,那么f
的平滑版本是一个函数,其在点x
的值是f(x-dx)
、f(x)
和f(x+dx)
的平均值。编写一个函数smooth
,它以计算f
的函数作为输入,并返回一个计算平滑f
的函数。有时重复平滑一个函数(即平滑平滑的函数,依此类推)是有价值的,以获得n
次平滑函数。展示如何使用练习 1.43 中的smooth
和repeated
生成任何给定函数的n
次平滑函数。
练习 1.45
在 1.3.3 节中,我们看到通过天真地寻找y -> x / y
的不动点来计算平方根并不收敛,这可以通过平均阻尼来修复。同样的方法也适用于寻找立方根,作为平均阻尼y -> x / y²
的不动点。不幸的是,这个过程对于四次方根并不适用------单一的平均阻尼不足以使y -> x / y³
的不动点搜索收敛。另一方面,如果我们进行两次平均阻尼(即使用y -> x / y³
的平均阻尼的平均阻尼),不动点搜索就会收敛。进行一些实验,以确定计算n
次方根所需的平均阻尼次数,作为基于y -> x/y^(n--1)
的重复平均阻尼的不动点搜索。使用这个来实现一个简单的函数,使用fixed_point
、average_damp
和练习 1.43 的repeated
函数来计算n
次方根。假设你需要的任何算术运算都可以作为原语使用。
练习 1.46
本章描述的几种数值方法都是极其一般的计算策略迭代改进 的实例。迭代改进说,为了计算某事物,我们从一个初始猜测开始,测试猜测是否足够好,否则改进猜测并继续使用改进后的猜测作为新的猜测。编写一个函数iterative_improve
,它接受两个函数作为参数:一个用于判断猜测是否足够好的方法,一个用于改进猜测的方法。函数iterative_improve
应该返回一个函数作为其值,该函数接受一个猜测作为参数,并持续改进猜测,直到猜测足够好为止。以iterative_improve
的术语重写 1.1.7 节的sqrt
函数和 1.3.3 节的fixed_point
函数。