UCB-CS61AS-使用-Racket-进行-SICP-学习-全-
UCB CS61AS 使用 Racket 进行 SICP 学习(全)
0.1 - Racket 和 CS61AS 介绍
首日指南
在我们开始之前...
在我们开始第 0.1 课之前,我们需要设置您的实验室帐户。您可以在这里找到说明。如果您目前未注册 CS 61AS,请跳至第 11 步。
完成后,继续下一部分!
第 0.1 课:简介
欢迎!
欢迎来到 CS 61AS!在本课中,我们将探索 Racket 编程语言的基础知识,并了解本课程的核心思想。
关于本文
这个所谓的“教科书”包括 17 课,其中大部分基于经典文本《计算机程序的构造与解释》,这也是本课程的名称来源。
一课由多个部分或页面组成。请使用右侧的目录导航到相应的课程内容。
课程结构设计让您通过探索、犯错、提问和尝试来学习。
如果您在实验室阅读本课程,随时可以举手提问——实验室助理会立即前来帮助您。
计算机科学简介
注意:以下段落中的所有链接都是完全可选阅读的,仅供读者的兴趣和娱乐。
计算机科学是什么?
没有一个正确的答案。 计算机科学对于不同的人意味着很多不同的事情。 对一些人来说,它意味着构建一个允许您连接和跟上朋友的 Web 应用程序。 对于其他人来说,它意味着工程师自动驾驶汽车。 对于其他人来说,它意味着大量的数学。 还有其他的。
一般来说,计算机科学回答以下问题:
-
我们能计算什么?
-
我们如何计算它?
-
我们可以用它做什么?
在这个意义上,计算机科学不是关于计算机的——那更接近于电气工程——而且它实际上也不是一门科学——科学家们发现,我们发明。
计算机科学家就像工程师一样:我们构建很酷的东西,我们解决问题。
复杂性与抽象
计算机科学中所有有趣的问题都是固有的复杂问题——它们的解决方案也是如此。
请考虑您的网络浏览器,您正在用于查看此页面的程序。 显然,它解决了一个有趣的问题。 它也非常复杂:当您键入 URL 时,您的浏览器必须确定要联系哪个服务器,要求该服务器提供您正在查找的网页,下载网页,解释网页并在屏幕上显示它。 每个步骤都包含其自己的复杂部分。
所有这些如何组合在一起? 用一个词:抽象! 抽象使我们能够将一个复杂的过程视为一个单一单元,并将该单元用于更复杂的过程中。
我们已经在日常生活中使用抽象:为了驾驶汽车,我们必须知道如何转向和操作踏板,但我们不必知道引擎和传动链是如何工作的。 要烤一个苹果派,我们需要一个食谱和一个烤箱,但我们不需要种植我们自己的苹果或了解它们是如何生长的。 要使用互联网,我们不必了解请求网页的协议,我们的浏览器将该过程抽象出来,以便任何人都可以快速轻松地浏览网页。
在这门课程中,我们将探讨创建抽象以解决问题的技巧。
Racket 简介
介绍
请注意,在前一页中,几乎没有提到编程语言。这是因为在整个事物的大局中,编程语言并不重要。它们只重要是因为对于任何给定的问题,一种语言可能比另一种语言更少的代码行数来解决问题,或者一种语言可能让我们更有效地解决问题,等等。
那么如何教授计算机科学的问题呢?我们应该使用哪种语言?我们选择了 Racket,这是Lisp的一个方言。今天我们将展示语言的基础知识,之后您可以开始思考计算机科学。随着您学习更多计算机科学知识,我们将逐步向您展示更多语言的内容。
让我们开始。
基本规则
-
在 Racket 中,括号(也称为括号)很重要。
-
当你要求一个过程执行其操作时,你会调用它。这也被称为调用一个过程。每当你调用一个过程时,你必须将过程调用(对过程的调用)包裹在一对括号中。
-
每次我们调用一个过程时,我们必须遵循前缀表示法:我们调用的过程的名称始终是括号中最左边的项目。所有其他项目都是该过程的参数—我们向该过程提供的用于得到答案的东西。
这是一个演示上述三条规则的表达式示例:
(+ 1 2)
在这个例子中,我们将参数1和2传递给加法过程+,它会将数字相加。我们应该期望答案是 3。
Racket 解释器
当然,如果没有人说这种语言,那这种语言就没用了。对于编程语言来说,对话通常发生在程序员和计算机之间。解释器是一个将特定语言转换为计算机执行的操作和计算的程序。解释器是让计算机执行任务的一种方式,比如计算大素数或计算莎士比亚所有剧本中使用的所有不同单词。
让我们开始我们的 Racket 解释器。我们通过打开一个终端,然后输入racket并按回车键来做到这一点。您刚刚启动了 Racket 解释器!
您现在可以输入 Racket 表达式以供解释器评估:
-> (+ 1 2)
3
Racket 会输出什么?
将下面的每个示例输入解释器以尝试它。在输入每个示例之前,花点时间思考输出应该是什么。其中一些示例会导致错误—为什么会这样?(如果出现错误,解释器将输出错误消息。)
5
(+ 2 3)
(+ 5 6 7 8)
(+ (* 3 4) 5)
(+)
+
(sqrt 16)
(/ 3 2)
(/ 3 0)
'hello
(first 'hello)
(first hello)
(butfirst 'hello)
(bf 'hello)
(first (bf 'hello))
(first 274)
(+ (first 23) (last 45))
(define pi 3.14159)
pi
'pi
(+ pi 7)
'(good morning)
'(+ 2 3)
总结
在这一部分,我们学习了 Racket 的基础知识。我们也尝试了 Racket 解释器。
CS 61AS 背后的重要理念
这门课程充满了重要理念!以下是前两个:
-
本课程的目的是帮助你学习。
-
员工们在这里是为了帮助你成功。
如果在任何时候你觉得情况不是这样,请说出来!以下都是获取帮助的有效方法:
-
在实验室与工作人员交谈
-
在 Piazza 上发帖
-
参加助教的办公时间
-
给助教发邮件
重要理念
在 61AS 中,我们将探索所有计算机科学的重要理念。以下是其中一些重要的理念:
-
函数:首先,我们将把程序看作是函数的组合。我们将研究函数是什么,以及我们可以用它们做什么。
-
数据:数据是程序中另一个必不可少的东西。将数据作为我们程序的核心焦点会带来强大的结果。
-
状态:然后,我们将回答一个问题,“假设我们可以随时间改变事物,我们如何进行编程?”
-
解释器:我们深入探讨了解释器的工作原理,甚至将编写我们自己的解释器。我们还将考虑几种其他解释器,并查看它们之间的共同点。
-
编程范式:我们探索了关于程序的替代思考方式。
要点
在这一部分,我们学习了 CS 61AS 背后的重要理念。我们在这门课上讲的所有内容都属于这些范畴之一。
CS 61AS 的替代课程
CS 61AS 是一个以实验为中心的课程—没有讲座。学生通过阅读指导材料和参与讨论来学习。
尽管一些学生可能觉得这种格式很吸引人,但其他人可能不会。加州大学伯克利分校的计算机科学系提供另外两门介绍性的计算机科学课程,如下所列。
CS 61A
CS 61A是 CS 61AS 的姐妹课程。它采用传统的讲座-实验室-讨论形式,涵盖了与 61AS 相同的大观念,只是使用 Python 语言,并且有稍微不同的教学大纲。CS 61A 相当于 CS 61AS 的 1-4 单元。
了解更多关于 CS 61A 的信息,请访问:www-inst.eecs.berkeley.edu/~cs61a/。
CS 10
CS 10被称为“计算的美与乐趣”。它涵盖了 61AS 的 0 和 1 单元的内容,并旨在提供对计算机科学的初步介绍。CS 10 使用一种名为 Snap!的图形化语言,允许您通过拖放组件来编程。
了解更多关于 CS 10 的信息,请访问:inst.eecs.berkeley.edu/~cs10/。
那么有什么不同呢?
每门课程都有不同的特点。要查看 CS 10、CS 61A 和 CS 61AS 的有序比较,请查看此文档。
作业 0.1
作业 0.1 介绍
在这个作业中,你将运用到目前为止学到的知识来解决一些问题。你还将进行一些阅读和自我介绍。
记住:你可以在首页或截止日期表格上查看这个作业的截止日期。
模板
一个模板文件提供了一个作业任务的基本框架。
如果你在实验室电脑上,将以下命令输入到终端中,将模板复制到当前目录(注意末尾的句点):
cp ~cs61as/autograder/templates/hw0-1.rkt .
或者你可以在这里下载模板here。
语言声明
你可能已经注意到第一行说
#lang racket
这告诉 Racket 解释器你的文件包含 Racket 代码。这可能看起来多余,但 Racket 解释器也能理解其他 Lisp 家族语言,包括用户定义的语言。
底线是你必须在你写的每个 Racket 文件的顶部包含这行。如果不包含,你会看到这个错误信息:
default-load-handler: expected a `module' declaration
自动评分程序
自动评分程序是一个检查特定作业代码有效性的程序。
如果你在实验室电脑上工作,grader 命令将运行自动评分程序;详情请参见下文。如果你在自己的个人电脑上工作,你应该下载grader.rkt和HW 0-1 tests。
练习 0
首先,向工作人员介绍一下自己!
在你的作业文件中,回答以下问题:
-
你叫什么名字?
-
你的专业是什么?
-
你是一名老生吗?(也就是,上学期你是否选修了 61AS?)
-
是什么促使你选修 61AS?
-
告诉我们一些关于你自己的有趣事情。
现在,看看你能否在 Piazza 上找到一篇名为“Hello World!”的帖子。在那篇帖子上跟进并介绍自己。确保包括:
-
名字
-
专业和年级
-
关于你自己的一个有趣事实
-
你为什么选择这门课程
练习 1
这是定义过程的语法:
(define ([name of procedure] [variables]) [body of procedure])
例如,你看到了如何定义一个 square 过程:
(define (square x) (* x x))
在定义完后,你可以使用 square 过程来找到任何你想要的数字的平方:
-> (square 3)
9
使用 square,定义一个过程 sum-of-squares,它接受两个参数并返回这两个参数的平方和:
-> (sum-of-squares 3 4)
25
确保测试你的工作!
写完你的过程后,运行这个练习的自动评分程序,检查你是否正确定义了过程。如果你在实验室电脑上,将以下内容输入到终端中:
grader hw0-1 hw0-1.rkt sum-of-squares
如果你在自己的电脑上工作,将以下内容输入到终端中:
racket -tm grader.rkt -- hw0-1-tests.rkt hw0-1.rkt sum-of-squares
插曲
在我们介绍下一个练习之前,我们需要了解更多的 Racket 特性。参加 0 单元的学生应该将其视为一个预览——我们将在第 0.2 课中更深入地探讨这些特性。
单词和句子
我们向您展示了一些有趣的过程,让您可以对单词和句子进行操作:
-
'创建一个单词(例如,'pi)或一个句子(例如,'(good morning))。 -
first接受一个单词并返回该单词的第一个字母,或者接受一个句子并返回该句子的第一个单词。 -
butfirst(或bf)接受一个单词/句子并返回除了第一个字母/单词之外的所有内容。
将这些过程和概念记在脑后。它们将在以后的练习和课程中再次出现。
特殊形式
Racket 有一些控制功能,允许您根据测试选择下一步要执行的操作。这些功能是特殊形式的例子——具有特殊评估规则的过程。我们稍后会在课程中更多地讨论特殊形式。
if
在 Racket 中,if 是一个特殊形式,它接受三个参数。if 总是评估其第一个参数。如果该参数的值为true,则 if 评估其第二个参数并返回其值。如果第一个参数的值为false,则 if 评估其第三个参数并返回该值。
这是正确的 if 语法示例:
(if (= 5 (+ 2 3))
'yay!
(/ 1 0))
此示例表达式的结果是单词 'yay!。因为第一个表达式为真,所以 if 的最后一个参数不会被评估,这意味着我们不会得到除以零的错误。
cond
cond 是一个特殊形式,它的行为就像 if,但有多个选项。每个条件逐一测试,直到有一个求值为true。通常在最后使用 else 子句来捕获所有先前条件都求值为false的情况。
以下是一个示例:
(cond ((= 3 1) 'wrong!)
((= 3 2) 'still-wrong!)
(else 'yay))
在此示例中,前两个条件返回 false,因此整体表达式求值为单词 'yay!。
用于测试案例的一些好的过程是 >、< 和 =。
and
and 检查其所有参数是否都为true:
-> (and (> 5 3) (< 2 4))
#t
-> (and (> 5 3) (< 2 1))
#f
(注意 #t 和 true 可以互换使用,#f 和 false 也一样。)
为什么 and 是一个特殊形式?因为它对其参数进行求值,并在可以的情况下尽早停止,只要其中任何一个参数求值为false,它就会立即返回false。这事实上是很有用的。假设我们有以下内容:
(define (divisible? big small)
(= (remainder big small) 0))
(define (num-divisible-by-4? x)
(and (number? x) (divisible? x 4)))
然后我们可以这样做:
-> (num-divisible-by-4? 16)
#t
-> (num-divisible-by-4? 6)
#f
-> (num-divisible-by-4? 'aardvark)
#f
注意最后一次调用并没有失败。由于(number? 'aardvark)的求值结果是false,在评估其第二个参数之前,and会返回#f。调用(divisible? 'aardvark 4)将会导致错误:
-> (divisible? 'aardvark 4)
; remainder: contract violation
; expected: integer?
; given: 'aardvark
; argument position: 1st
; [,bt for context]
此消息只是说,由于 remainder 过程期望一个整数,但实际传入的是 'aardvark,所以报告了一个错误。
关于 and 的一个微妙之处:如果其所有参数都求值为true,那么它不会简单地返回#t,而是返回其最后一个参数的值。
-> (and #t (+ 3 5))
8
-> (and (- 2 1) 100)
100
任何不是 #f 的都是 #t。因此,100 是 true,'foo 是 true,等等。
or
or 检查其任一参数是否为true。
-> (or (> 5 3) (< 2 1))
#t
-> (or (> 5 6) (< 2 1))
#f
为什么 or 是一个特殊形式?它对其参数进行求值,并在其中一个参数求值为true时立即停止。
> (or #f #t (/ 1 0))
#t
关于or的一个微妙之处:与and一样,如果其任何一个参数求值为true,or将返回求值表达式的值,而不仅仅是#t。
-> (or #f (+ 1 2 3))
6
-> (or (* 3 4) (- 2 1))
12
练习 2
第一部分
花点时间阅读上面的内容,并在解释器中尝试一切。然后,编写一个过程can-drive,以一个人的年龄作为参数。如果年龄低于 16 岁,则返回句子'(Not yet)。否则,返回句子'(Good to go)。确保在解释器中测试您的代码。
完成此练习后,请在终端中键入以下内容,运行自动评分器检查代码是否正确:
grader hw0-1 hw0-1.rkt can-drive
或者,在您自己的计算机上:
racket -tm grader.rkt -- hw0-1-tests.rkt hw0-1.rkt can-drive
第二部分
编写一个过程fizzbuzz,接受一个数字并输出'fizz,如果数字可被 3 整除,则输出'buzz,如果数字可同时被 3 和 5 整除,则输出'fizzbuzz,否则输出数字本身。您可能会发现函数remainder有用。确保在解释器中测试您的代码。
完成此练习后,请在终端中键入以下内容检查您的解决方案:
grader hw0-1 hw0-1.rkt fizzbuzz
或者,在您自己的计算机上:
racket -tm grader.rkt -- hw0-1-tests.rkt hw0-1.rkt fizzbuzz
练习 3
为什么海象要穿越塞伦盖蒂?
要找到答案,请在 Piazza 上查找标记为“Homework 0-1 Exercise 3 答案”的帖子。
练习 4
看看当您将以下代码片段键入解释器时会发生什么:
(define (infinite-loop) (infinite-loop))
(if (= 3 6)
(infinite-loop)
(/ 4 2))
现在我们想看看我们是否可以编写一个行为与if完全相同的过程。以下是我们的尝试:
(define (new-if test then-case else-case)
(if test
then-case
else-case))
让我们试一试:
(new-if (= 3 6)
(infinite-loop)
(/ 4 2))
它不起作用!
这里是另一个示例,它的行为不对:
(new-if (= 3 6)
(/ 1 0)
(/ 4 2))
为什么new-if的行为不像if呢?从这个例子中你能学到什么关于if?思考一下并试着弄清楚。预计会再次遇到。
推荐阅读
推荐阅读如下:
手动测试
在运行自动评分器之前,您应该在 Racket 解释器中手动测试您的代码。这很重要,因为自动评分器不总是测试所有可能的情况。
要将单个定义加载到 Racket 中,请从终端启动 Racket 解释器,输入
racket
然后从您的文件中复制并粘贴定义到解释器中。
要将整个文件加载到 Racket 中,请使用
racket -it hw0-1.rkt
运行自动评分器
在提交任何作业之前,您需要进行两项检查:
-
您的作业必须加载到 Racket 解释器中。任何无法加载的提交将不会得到任何学分。
-
运行自动评分器检查您的答案。如果您无法使作业通过所有自动评分器测试,请不要担心。无论如何,请提交您的作业。请记住,作业是根据努力程度评分的。
要运行自动评分器,请在终端中键入以下内容:
grader <assignment name> <file name>
例如,要在这份作业上运行自动评分器,请在终端中键入以下内容:
grader hw0-1 hw0-1.rkt
提交你的作业!
对于说明,请参阅此指南。它涵盖了基本的终端命令和作业提交。
如果您在提交过程中遇到任何问题,请不要犹豫向助教求助!
0.2 - 更多与 Racket 的实践
第 0.2 课介绍
简介
这节课深入讲解了 Racket 编程语言,同时也扩展了函数式编程,这是通过函数求值解决问题的编程范式之一,也是我们在第 0-1 课中讨论的重要思想之一。
先决条件和预期内容
先决条件: 确保在开始本课程之前已经完全完成了第 0-1 课和作业 0-1。
预期内容: 在这节课中,我们将:
-
深入讲解 Racket 的基本语法
-
使用 Racket 解决简单问题
-
强调函数式编程的概念
Racket 简介
Racket 是 Lisp 的一个方言,即,
"有史以来设计的最伟大的单一编程语言" -- Alan Kay
我们为什么要学习它?
"学习 Lisp 是值得的,因为当你最终理解它时,你将获得深刻的启示体验;这种体验将使你在余生中成为更好的程序员 [...]" -- Eric Raymond
阅读材料
这里有一些过时的阅读材料(针对 Scheme 语言编写),可能对理解这节课有用,也可能没有。如果你觉得这些阅读材料令人困惑,可以跳过它们:
继续下一小节,学习 Racket 的基础知识!
表达式和评估
介绍
在这个小节中,你将学习更多关于函数式编程的知识。你还将学习关于表达式和评估的内容。
函数式编程
在我们深入研究 Racket 语言本身之前,我们将简要介绍本课程的重要概念。简而言之,它陈述了,在评估表达式时,我们可以将一个函数返回的值作为另一个函数的参数使用。通过这种方式“连接”两个函数,我们创造了一个新的第三个函数。例如,假设我们有一个函数,将字母 s 添加到单词的末尾(伪代码中):
add-s("run") = "runs"
还有另一个函数,将两个单词组合成一个句子:
sentence("day", "tripper") = "day tripper"
我们可以将它们组合起来创建一个表示动词第三人称单数形式的新函数:
third-person(verb) = sentence("she", add-s(verb))
当应用于特定动词时,一般的公式看起来像这样:
third-person("sing") = "she sings"
在 Racket 中我们这样说
(define (third-person verb)
(sentence 'she (add-s verb)))
如果这对你来说令人困惑或不直观,不要担心;你将在这个概念上得到大量练习。然而,结果将表明,我们可以通过以这种方式连接函数来表达各种计算算法。这种连接就是我们所说的函数式编程。
表达式
重要概念: 你可以向 Racket 提出“问题”,称为表达式。Racket 解释器会“思考”你的问题,或者评估你的表达式。然后你会得到答案,称为值。我们在 Racket 中键入的所有内容(不会出错)都是表达式。
当你想让 Racket 执行某些操作(例如将两个数字相加),你会以前缀表示法编写一个表达式。尽管所有非错误输入都是表达式,但最有趣的是调用过程。看下面的例子:
(+ 3 4)
在这个例子中:
-
+是过程,或者表达式的运算符 -
3是+的参数,或者表达式的操作数 -
4也是一个参数/操作数
这种语法允许我们嵌套表达式:
(* (max 2 3) (/ 8 4))
-
*、max和/都是过程 -
*是大表达式的运算符,而(max 2 3)和(/ 8 4)是大表达式的操作数 -
max是第一个子表达式的运算符,而2和3是第一个子表达式的操作数 -
/是第二个子表达式的运算符,而8和4是第二个子表达式的操作数
检验你的理解
以下哪些是有效的 Racket 表达式?选择所有适用的。
现在,在你的计算机上打开 Racket 解释器,尝试一些自己的表达式。
评估
Racket 使用应用序(在第 1 课中讲解)来评估表达式,遵循以下规则:
-
评估运算符和操作数
-
将运算符应用于操作数
Racket 实际上如何理解和评估表达式是相当复杂的,在第 11 课中会详细讲解。现在,让我们继续下一个小节!
单词和句子
介绍
当我们想到函数时,我们自动假设与数学和数字有关。实际上,在 Racket 和任何其他函数式编程语言中,我们可以有函数来操作非数值的值。
单词
假设你定义了一个名为square的过程:
(define (square x) (* x x))
但是以后想要访问实际单词'square而不是过程,我们只需输入'square(单引号后跟单词 square)即可获得文字单词。请注意,如果你只是处理一个单词,你不需要在表达式周围加上括号。
句子
句子只是用括号分组的单词集合。要创建一个句子,你需要在括号外面加上一个引号,就像这样'(hi hey hello)。尝试通过写一两个单词和句子来练习一下。
测试你的理解
在 Racket 解释器中尝试以下每一个。
'61AS
'(I love 61AS!)
('I 'love '61AS!)
quote
你在上面的部分看到的'实际上是一个名为quote的函数的缩写。这意味着:
-
'x等同于(quote x) -
'(hi hey hello)等同于(quote (hi hey hello))
quote与大多数其他过程不同,因为它不评估其参数。表现出这种行为的函数是特殊形式。你现在不需要理解特殊形式;我们将在后面的小节中更深入地讨论这个主题。现在,知道quote是一个接受一个参数并将其作为单词或句子返回的函数就足够了。看下面的例子:
-> (define x 4)
x
-> x
4
-> (quote x)
x
-> 'x
x
由于quote经常被使用,它被赋予了缩写',一个单引号。请记住,尽管在缩写形式中可能是这样,但quote只是一个函数,可以像其他函数一样在 Racket 中调用。
单词和句子选择器
在处理单词和句子时,拥有能够操作它们的过程会很有帮助。这些过程本身很简单。正确地组合它们以完成目标将是困难的部分。现在,这里是一些你可以用来从单词或句子中选择数据的过程列表。
first
first接受一个单词并返回单词的第一个字母,或接受一个句子并返回句子的第一个单词。
-> (first 'hello)
'h
-> (first '(hi hey hello))
'hi
last
last接受一个单词并返回单词的最后一个字母,或接受一个句子并返回句子的最后一个单词。
-> (last 'hello)
'o
-> (last '(hi hey hello))
'hello
butfirst或bf
butfirst或其缩写版本bf接受一个单词并返回除了第一个字母之外的所有字母,或接受一个句子并返回除了第一个单词之外的所有单词。
-> (butfirst 'hello)
'ello
-> (bf 'hello)
'ello
-> (butfirst '(hi hey hello))
'(hey hello)
-> (bf '(hi hey hello))
'(hey hello)
butlast或bl
butlast或其缩写版本bl接受一个单词并返回除了最后一个字母之外的所有字母,或接受一个句子并返回除了最后一个单词之外的所有单词。
-> (butlast 'hello)
'hell
-> (bl 'hello)
'hell
-> (butlast '(hi hey hello))
'(hi hey)
-> (bl '(hi hey hello))
'(hi hey)
item
item接受一个数字n和一个单词,并返回单词中的第n个字母。或者,它接受一个数字n和一个句子,并返回句子中的第n个单词。
-> (item 2 'hello)
'e
-> (item 2 '(hi hey hello))
'hey
检验你的理解
尝试猜测 Racket 将对以下表达式输出什么,然后用 Racket 解释器检查你的答案。
(first '(foo foo))
(bf '(foo foo))
(equal? (first '(foo foo)) (bf '(foo foo)))
equal? 是一个检查两个元素是否相同的函数。
单词和句子构造器
现在我们已经学会了如何拆分单词或句子,让我们学习如何将它们组合在一起。
word
word 接受任意数量的单词作为参数,将它们连接成一个大单词。
-> (word 'play 'ground)
'playground
-> (word 'fo 'o 'b 'ar)
'foobar
-> (word 'cs '61 'as)
'cs61as
sentence 或 se
sentence,或其缩写版本 se,接受任意数量的单词或句子作为参数,并创建一个包含所有参数的句子。
-> (sentence 'I 'love 'cs '61as!)
'(I love cs 61as!)
-> (se 'foo 'bar)
'(foo bar)
-> (se 'foo '(foo bar) 'bar)
'(foo foo bar bar)
空单词
还有一个空单词,你可以将其与其他单词组合在一起,使用时不会产生任何效果。这由 "" 表示。
-> (word 'foo "")
'foo
-> (word "" 'foo)
'foo
-> (word "" "")
""
空句子
还有一个空句子,你可以将其与其他句子组合在一起,使用时不会产生任何效果。这由 '() 表示。
-> (se 'hi 'there '())
(hi there)
-> (se '() 'hi 'there)
(hi there)
-> (se 'hi '() 'there)
(hi there)
-> (se '() '() '())
'()
目前可能不清楚为什么需要这些空单词和句子。现在记住它们,当我们学习 Lesson 0-3 中的递归时,它们将非常有用。
检验你的理解
注意: 这是你的作业中的练习 1。
让我们构建一些处理单词和句子的函数。我们将为你定义 second 过程 - 这个过程返回单词中的第二个字母,或句子中的第二个单词。
(define (second item)
(first (bf item)))
-
编写一个名为
first-two的过程,它以单词作为参数,返回一个包含参数的前两个字母的两个字母单词。 -
编写一个名为
two-first的过程,它接受两个单词作为参数,返回一个包含这两个参数的首字母的两个字母单词。 -
现在编写一个名为
two-first-sent的过程,它接受一个由两个单词组成的句子作为参数,返回一个包含这两个单词的首字母的两个字母单词。
陷阱
基本上,在处理单词和句子时,你可以使用的唯一标点符号是 ! 和 ?。你已经看到引号 ' 在 Racket 中有特殊含义。句号和逗号也有特殊含义,因此你也不能使用它们。
正如你在早前的练习中看到的,单词和包含一个单词的句子之间有区别。例如,人们经常错误地认为两个单词的句子的 butfirst,比如 (计算机科学) 是 '科学。实际上,它是一个包含一个单词的句子:(科学)。证明单词和一个单词句子之间的区别的另一种方法是通过对���们进行 count:
-> (bf '(computer science))
'(science)
-> (count (bf '(computer science)))
1 ;; because there is ONE word in the sentence.
-> (first (bf '(computer science)))
'science
> (count (first (bf '(computer science))))
7 ;; because there are SEVEN letters in the word 'science
要点
-
我们可以使用
word和sentence分别构建单词和句子。 -
我们也可以使用引号来构建单词和句子。
-
我们可以使用
first、butfirst、last和butlast等过程来检索单词或句子的部分。
定义变量和过程
介绍
想象一种语言,我们无法使用名称来引用计算对象。随着我们编写越来越复杂的程序,跟踪每一步计算的细节将变得越来越困难和不便。因此,我们通过使用define将值(计算对象)分配给变量(通过名称标识)来实现抽象。这是 Racket 最简单的抽象手段。
尽管你在前面的章节中看到了各种变量和过程定义,但我们还没有正式教过如何使用define。
首先,这里有几个使用define的示例表达式。在 Racket 解释器中尝试这些,看看它们的作用。
-> (define x 5)
-> (define (square x) (* x x))
-> (define y (square 3))
-> (define z (+ x y))
定义变量
变量定义的一般形式如下:
(define [name] [value])
[name]代表一个变量,其值被分配给。例如:
-> (define x 5)
-> x
5
x是一个变量,5是它的值。
[value]可以替换为任何类型的值,甚至是表达式。变量定义的一个重要特性是在分配给变量之前,定义的值完全被评估。
-> (define x (+ 5 5))
-> x
10
为什么x不是(+ 5 5)?因为当我们定义x时,我们必须首先将表达式(+ 5 5)评估为其最简形式10。然后我们将10分配给x。
测试你的理解
当我们调用(define x (/ 1 0))时会发生什么?
定义复合过程
过程定义是一种比变量定义更强大的抽象技术,我们可以给一个复合操作命名,因此可以将其作为一个单元引用。让我们从一个简单的例子开始,定义square过程:
(define (square x) (* x x))
我们可以这样理解:
(define (square x) ( * x x))
To square x, multiply x with x.
过程定义的一般形式如下:
(define ([name] [formal parameters]) [body])
注意,这与变量定义有一个显著的区别,即名称和参数由括号绑定。请记住,除了引号之外,一组括号表示一个过程调用。我们可以这样解释:当我们用[name]和[formal parameters]调用时,我们将执行[body]。
测试你的理解
复合过程可以有任意非负数量的形式参数,甚至可以是 0。我们如何正确定义一个名为foo的过程,它不接受任何参数,并返回5?
过程定义的一个重要特性是在调用过程之前不会评估过程的主体。这意味着当我们定义square时,我们还不知道我们需要将x乘以自身。只有当我们对某个数字,比如3,调用square时,我们才知道我们必须调用(* 3 3)。
测试你的理解
当我们调用(define (x) (/ 1 0))时会发生什么?
警告
创建复合过程时需要注意的一件事是命名。在命名我们的过程和形式参数时,我们需要非常小心。Racket 不会接受多个定义,这意味着任何已经定义的过程不能用作复合过程或形式参数的名称。这是一个不允许的复合过程定义的示例(在您的解释器中尝试以查看原因):
(define (foo sent word)
(word sent word))
在主体中的两个实例中,我们指的是哪个word,是参数还是内置过程?
嵌套过程
回到嵌套表达式的概念,我们还可以在其他过程定义中嵌套过程。就像你在作业 0-1 中所做的那样,我们可以使用过程square来定义过程sum-of-squares:
(define (sum-of-squares x y)
(+ (square x) (square y)))
大致翻译为:当我们在x和y上调用sum-of-squares时,我们将x的square加到y的square上。
总结
为了澄清,
(define foo 10)
-
这是一个变量定义。
-
foo是变量名。 -
10是分配给foo的值。
(define (square x) (* x x))
-
这是一个复合过程定义。
-
square是过程名称。 -
x是它唯一的形式参数。 -
(* x x)是它的主体。
-> (square 3)
9
-
这是一个表达式,也是一个过程调用。
-
square是过程,是这个表达式的运算符。 -
3是square的参数,并且是这个表达式的操作数。 -
9是返回值。
要点
-
我们学会了可以使用
define作为抽象的手段。 -
我们还学会了如何定义变量和复合过程。
关联值和操作到符号并稍后检索它们的可能性意味着 Racket 解释器必须具有某种形式的记忆来跟踪这些关联对。我们称这个记忆为环境,我们将在第 8 课中详细展开。
布尔值和谓词
布尔值:真和假
布尔值在形式上被定义为只有两个可能值的二进制变量:“真”和“假”。 在表达条件或根据测试结果选择操作的指令时,这些非常有用。 一个逻辑的例子是:如果我们缺少牛奶,那么去商店。 否则,加牛奶到我们的谷物中并享用。
为了测试我们是否缺少牛奶,我们需要使用布尔值。 Racket 中的“真”用#t或true表示,而“假”用#f或false表示。
-> (= 1 1)
#t
-> (= 1 2)
#f
> (if #t
'(the condition was true)
'(the condition was false))
(the condition was true)
> (if #f
'(the condition was true)
'(the condition was false))
(the condition was false)
> (if (= 1 1)
'(the condition was true)
'(the condition was false))
(the condition was true)
谓词
当调用时返回true或false的函数称为谓词。 例如,even?是一个用于测试一个数字是否为偶数的谓词。
-> (even? 2)
#t
-> (even? 3)
#f
谓词永远不会返回除#t或#f之外的值。 下面是 Racket 中一些有用的预定义谓词列表。 这个列表并不全面,您肯定会在未来的课程中发现更多的谓词。
数学运算符
Racket 具有您需要比较数值的标准数学运算符:
-
<如果第一个参数小于第二个参数,则返回#t。 -
>如果第一个参数大于第二个参数,则返回#t。 -
=如果两个参数相等,则返回#t。 -
<=如果第一个参数小于或等于第二个参数,则返回#t。 -
>=如果第一个参数大于或等于第二个参数,则返回#t。
警告: 这些谓词仅适用于数字。 使用这些谓词来比较单词、句子或任何其他类型的值将产生错误。
-> (= (+ 3 3) 6)
#t
-> (= 'foo 'foo)
; =: contract violation
; expected: number?
; given: 'foo
; argument position: 1st
; [,bt for context]
member?
member?,当给定一个字母和一个单词时,如果单词包含该字母,则返回#t,否则返回#f。 当member?给定一个单词和一个句子时,如果句子包含该单词,则返回#t,否则返回#f。
-> (member? 'a 'aeiou)
#t
-> (member? 'b 'aeiou)
#f
-> (member? 'foo '(foo bar baz))
#t
-> (member? 'foobar '(foo bar baz))
#f
empty?
谓词empty?接受任意类型的一个参数,并在参数为空单词""或空句子'()时返回#t,否则返回#f。
-> (empty? "")
#t
-> (empty? 'foo)
#f
-> (empty? '())
#t
-> (empty? '(foo bar baz))
#f
-> (empty? 3)
#f
equal?
equal? 接受两个任意类型的参数,并在它们相同时返回#t,否则返回#f。
-> (equal? (+ 1 1) 2)
#t
-> (equal? 3 1)
#f
-> (equal? 'foo 'foo)
#t
-> (equal? '(foo bar baz) '(foo bar baz))
#t
-> (equal? + +)
#t
类型检查器
Racket 还提供了检查值是否属于特定类型的谓词:
-
number?检查一个值是否为数字。 -
word?检查一个值是否为单词。 -
sentence?检查一个值是否为句子。 -
boolean?检查一个值是否为布尔值。
作为谓词的复合过程
您绝对可以创建自己的谓词,因为它们实际上是过程。 例如:
(define (vowel? letter)
(member? letter 'aeiou))
vowel? 检查其参数字母是否是元音字母。
测试你的理解
注意: 这是你的作业中的第 2 题。
编写一个名为teen?的谓词,它以一个数字作为参数,并在数字介于 13 和 19 之间(包括 13 和 19)时返回#t。
除了假之外的一切都是真
在评估表达式是真还是假时,重要的是要记住,除非是假,否则任何东西都被视为真。这意味着所有数字、单词、句子和过程都是真的,甚至包括 ""、'() 和 0。以下是一些令人费解的例子:
-> (false? "") ;; is "" false?
#f ;; no, it is not
-> (false? '())
#f
-> (false? 0)
#f
-> (false? false?) ;; is the procedure false? false?
#f ;; no, the procedure itself is not false
逻辑运算符
我们也可以在 Racket 中使用逻辑运算。
and
and 是一个谓词,接受任意数量的任何类型的参数。如果所有参数都不是假的,它将返回最后一个元素,否则返回 #f。例如:
-> (and 1 2 3)
3
-> (and (= 1 1) (member? 'a 'aeiou))
#t
-> (and (number? 'hi) 2 3)
#f
or
or 是一个谓词,接受任意数量的任何类型的参数。它返回第一个真元素,否则返回 #f。例如:
-> (or (even? 4) (= 1 1))
#t
-> (or 1 #f 2)
1
-> (or (even? 1) #f (number? 'foo))
#f
not
not 接受任何类型的单个参数,简单地否定它所接受的参数。例如:
-> (not #f)
#t
-> (not #t)
#f
-> (not 3)
#f
-> (not (and (and 3 3) (or #f #f)))
#t
nand
nand 等同于 (not (and ...。例如:
-> (nand #f #t)
...(not (and #f #t))
...(not #f)
#t
nor
nor 就是你猜到的,等同于 (not (or ...。例如:
-> (nor #f #t)
...(not (or #f #t))
...(not #t)
#f
xor
xor 接受任何类型的两个参数,如果恰好一个(不多不少)参数不是 #f,则返回该参数。否则,返回 #f。
-> (xor 11 #f)
11
-> (xor #f 11)
11
-> (xor 11 22)
#f
-> (xor #f #f)
#f
特殊形式
if子句
尽管我们在上一课中使用了if进行一些练习,这里是特殊形式的一般结构:
(if [test]
[then]
[else])
if是一个特殊形式,因为它不会评估其参数,除非被使用。以下是一些例子:
-> (if #t
'foo
'baz)
'foo
-> (if #f
'foo
'baz)
'baz
-> (if (= 1 1)
'foobar
(/ 1 0))
'foobar
最后一个例子展示了为什么if需要是一个特殊形式。由于(= 1 1)评估为#t,我们永远不会到达 else 情况,(/ 1 0),并成功返回'foobar。
cond子句
可以在if表达式内部嵌套if表达式,就像这样:
(define (roman-value letter)
(if (equal? letter 'i)
1
(if (equal? letter 'v)
5
(if (equal? letter 'x)
10
(if (equal? letter 'l)
50
(if (equal? letter 'c)
100
(if (equal? letter 'd)
500
(if (equal? letter 'm)
1000
'huh?))))))))
这对于具有许多子句的条件语句很有用。但是,子句越多,您的代码就会变得越混乱和不可读。嵌套if的简写是cond子句,它使用不同的语法来完成相同的任务。以下是使用cond语句编写的roman-value函数:
(define (roman-value letter)
(cond ((equal? letter 'i) 1)
((equal? letter 'v) 5)
((equal? letter 'x) 10)
((equal? letter 'l) 50)
((equal? letter 'c) 100)
((equal? letter 'd) 500)
((equal? letter 'm) 1000)
(else 'huh?)))
如您所见,cond子句允许您指定一系列条件和可能的值。结尾的else子句指定了当前述谓词都不为真时要返回的值。
翻译成英语,上面的代码读取为:
-
如果输入字母是"i",则值为 1。
-
如果输入字母是"v",则值为 5。
-
...
-
如果输入字母是"m",则值为 1000。
-
否则,当上述情况都不为真时,值为
'huh?。
一个cond子句的一般结构如下:
(cond ([test1] [then1])
([test2] [then2])
...
([testn] [thenn])
(else [else]))
特殊形式
特殊形式是不遵循正常评估步骤的过程。我们之前学过,表达式中的所有参数在过程应用到其参数之前都会被评估。这对于特殊形式不适用。到目前为止,我们讨论过的谓词和子句中,if、cond、or和and都是特殊形式。
测试您的理解
下面的表达式目前出错了。重新排列它们的参数,使表达式不会出错并返回正确的值。不要改变任何参数值。
(and (/ 1 0) #f #t)
(or #f (/ 1 0) #t)
假设我们决定编写自己的if过程称为new-if并定义如下:
(define (new-if test then else)
(if test
then
else))
这应该与if完全相同,因为它只是在主体中调用if。但是,由于这是一个复合过程,它不是一个特殊形式。当我们像这样调用new-if时会发生什么?
(new-if (= 1 1) 'foo (/ 1 0))
由于new-if不是一个特殊形式,它会在进入主体之前先评估所有的参数。
-
(= 1 1)返回#t -
'foo返回'foo -
(/ 1 0)返回-- 等一下...
由于(/ 1 0)出现错误,我们的new-if是重新创建if特殊形式的失败尝试。
if是可组合的
为了节省时间和代码空间,请记住像if和cond这样的函数可以在表达式中使用,而不是作为独立的表达式。为了演示这一点,考虑以下简单函数:
(define (what-am-i age)
(if (> age 21)
'(i am a grownup)
'(i am a child)))
相反,我们可以这样重写它:
(define (what-am-i age)
(se '(i am a) (if (> age 21)
'grownup
'child)))
不可否认,似乎没有太大的区别。这是可以理解的,考虑到这是一个简单的函数。当这种技术用于更复杂的函数时,我们通过避免重复节省时间。我们可以看到上面的重写函数只写了一次'(i am a),而原始定义则写了两次。
陷阱
cond语句的结构有非常严格的括号规则。如果你的代码出错了,很可能是你漏掉了一个括号或者多加了一个括号。因此,在使用cond语句时要注意括号!
另一个问题是,and和or不能像英语一样使用。为了澄清,假设我们有一个表达式,试图检查一个参数是否是'yes或'no:
(equal? argument (or 'yes 'no))`
这是错误的。or返回第一个不为假的参数,因此在此示例中将返回'yes。这个表达式最终被评估为:
(equal? argument 'yes)
如果你想要检查参数是'yes还是'no,你需要做以下操作:
(or (equal? argument 'yes) (equal? argument 'no))
最后,但绝对不是最不重要的,避免冗余代码是至关重要的。简单的代码是聪明的代码,将使复杂的程序更易于阅读和操作。
冗余代码示例:
(define (even? number)
(if (not (odd? number))
#t
#f))
这是糟糕的编码风格。我们可以简化为一行:
(define (even? number)
(not (odd? number)))
测试你的理解
注意: 这是你的作业中的第 3 个练习。
编写一个名为indef-article的过程,它接受一个单词作为唯一参数,并返回一个句子。参见下面的示例,了解indef-article应该如何工作。请记住,任何以辅音字母开头的词的不定冠词是"a",而以元音字母开头的词的不定冠词是"an"。您可以忽略任何边界情况。
-> (indef-article 'beetle)
'(a beetle)
-> (indef-article 'apple)
'(an apple)
作业 0.2
模板
在终端上键入以下命令,将模板文件复制到当前目录(注意末尾的句点):
cp ~cs61as/autograder/templates/hw0-2.rkt .
或者您可以在此处下载模板文件here。
自动评分程序
如果您在实验室计算机上工作,grader命令将运行自动评分程序。如果您在自己的个人计算机上工作,您应该下载grader.rkt和hw0-2-tests。
练习 0
表达式(+ 8 2)的值为10。它是由三个原子组成的复合表达式。对于这个问题,写出另外五个值也为10的 Racket 表达式:
-
一个原子
-
由三个原子组成的另一个复合表达式
-
由四个原子组成的复合表达式
-
由一个原子和两个复合子表达式组成的复合表达式
-
任何其他类型的表达��
练习 1
让我们构建一些处理单词和句子的函数。我们将给出上一个实验室的第二个过程。您可能还会发现 word 函数有用。
-
编写一个过程
first-two,接受一个单词作为参数,返回一个包含该参数前两个字母的两个字母单词。 -
编写一个过程
two-first,接受两个单词作为参数,返回一个包含这两个参数首字母的两个字母单词。 -
现在编写一个过程
two-first-sent,接受一个两个单词的句子作为参数,返回一个包含这两个单词首字母的两个字母单词。
-> (first-two 'ambulatory)
'am
-> (two-first 'brian 'epstein)
'be
-> (two-first-sent '(brian epstein))
'be
练习 2
编写一个谓词teen?,如果其参数在 13 到 19 之间(包括 13 和 19),则返回#t。
-> (teen? 19)
#t
-> (teen? (/ 39 2))
#f
练习 3
编写一个过程indef-article,接受一个单词作为唯一参数,并返回一个句子。请参考下面的示例,了解indef-article应该如何工作。请记住,以辅音字母开头的任何单词的不定冠词是"a",以元音字母开头的任何单词的不定冠词是"an"。您可以忽略任何边缘情况。
-> (indef-article 'beetle)
'(a beetle)
-> (indef-article 'apple)
'(an apple)
练习 4
编写一个过程insert-and,接受一个项目的句子,并返回一个在语法正确位置插入and的新句子。
-> (insert-and '(john bill wayne fred joey))
'(john bill wayne fred and joey)
练习 5
编写一个过程query,通过交换前两个单词并在最后一个单词末尾添加问号,将一个陈述句转换为疑问句。您可以忽略任何边缘情况。
-> (query '(you are experienced))
'(are you experienced?)
-> (query '(i should have known better))
'(should i have known better?)
-> (query '(you were there))
'(were you there?)
练习 6
编写一个过程european-time,将美国 AM/PM 制式时间转换为欧洲 24 小时制式时间。同时,编写american-time,实现相反的功能。
-> (european-time '(8 am))
8
-> (european-time '(4 pm))
16
-> (european-time '(12 am))
0
-> (american-time 21)
'(9 pm)
-> (american-time 12)
'(12 pm)
练习 7
编写一个过程describe-time,接受秒数作为参数,并返回该时间量的更有用描述。假设一年有 365.25 天。您只需要考虑一天内的时间段。
-> (describe-time 45)
'(45 seconds)
-> (describe-time 930)
'(15.5 minutes)
注意
您可能会注意到 Racket 对整数除法的处理有点奇怪:
-> (/ 1 2)
1/2
你可以通过在一个或多个参数中使用小数点来强制 Racket 返回带有小数点的数字(也称为浮点数):
-> (/ 1.0 2)
0.5
练习 8
以下程序无法正常工作。为什么?请修复它并解释原因。
(define (superlative adjective word)
(se (word adjective 'est) word))
这就是superlative应该如何工作的:
-> (superlative 'dumb 'exercise)
'(dumbest exercise)
提交你的作业!
欲了解说明,请参阅此指南。它涵盖了基本的终端命令和作业提交。
如果在提交过程中遇到任何问题,请毫不犹豫地向助教求助!
0.3 - 递归和 Racket
第 0.3 课 简介
递归简介
"为了理解递归,首先必须理解递归。"
"你是不是想说:递归"
先决条件和预期内容
先决条件: 确保你熟悉第 0-1 课和第 0-2 课中教授的概念。
预期内容: 在这节课中,我们将:
- 递归 - 计算机科学的核心思想之一。打开你的思维,准备好看到递归所展现的魔力!
阅读材料
这里有一些过时的阅读材料(针对 Scheme 语言编写),我们发现这些对理解递归很有帮助:
什么是递归
递归是一种编写解决某些类型问题的过程的方法。这些问题的解决方案取决于相同问题的较小实例的解决方案。通常情况下,递归让我们一遍又一遍地重复相同的过程。看起来有点像这样:

递归过程的特殊之处在于,为了调用该过程,我们需要让该过程调用自身,这将不得不调用自身,这将--让我们去看一个例子。
例子:阶乘
在数学中,非负整数[mathjaxinline]n[/mathjaxinline]的阶乘,用[mathjaxinline]n![/mathjaxinline]表示,是小于或等于 n 的所有正整数的乘积。例如,[mathjaxinline]3! = 3 * 2 * 1[/mathjaxinline]。此外,根据定义,[mathjaxinline]0! = 1[/mathjaxinline]。
我们如何在 Racket 中编写factorial过程?这是一个挑战,因为我们想要相乘的数字取决于我们想要找到阶乘的数字,参数。
因此,我们将使用递归。让我们将其分为两种可能情况:
-
如果[mathjaxinline]n ≥ 1[/mathjaxinline],那么[mathjaxinline]n! = n * (n-1)![/mathjaxinline]
-
如果[mathjaxinline]n = 0[/mathjaxinline],那么[mathjaxinline]n! = 1[/mathjaxinline]
递归在很大程度上依赖于条件语句。如果我们已经完成,返回某个值。否则,继续递归。对于阶乘的例子,我们递归直到达到[mathjaxinline]n = 0[/mathjaxinline],然后返回 1。我们可以利用这一点来编写factorial。看看下面的 Racket 解决方案中的factorial,看看你能否理解。也在解释器中尝试一下!如果你目前不理解代码,没关系。我们将在下一小节中更明确地讨论递归。
(define (factorial n)
(if (= n 0)
1
(* n (factorial (- n 1)))))
要点
这是我们在本小节中涵盖的内容:
-
什么是递归?
-
如何使用递归定义阶乘?
继续下一小节了解递归的工作原理。
递归是如何工作的
分解递归
让我们看看递归如何神奇地找到任何数字的阶乘。我们在下面复制了代码:
(define (factorial n)
(if (= n 0)
1
(* n (factorial (- n 1)))))
当n为0时,factorial返回1,否则返回n和n - 1的阶乘的乘积。
每个递归过程都使用条件语句,并且需要两种情况:
-
基本情况: 这种情况结束递归。递归过程的任何输入最终都会达到基本情况。
-
递归情况: 这种情况减小了问题的规模。递归情况总是尝试将问题变小,直到达到基本情况。
递归过程中可以有多个基本情况或递归情况,但为了使任何过程正确和递归,至少必须有一个基本情况和一个递归情况。
在我们的factorial过程中有一个基本情况和一个递归情况。你能识别出它们吗?
检验你的理解
当n为0时,是factorial的基本情况。考虑这个没有基本情况的factorial的替代定义:
(define (factorial n)
(* n (factorial (- n 1))))
这个替代定义有什么问题吗?
我们在factorial内部调用factorial的第二种情况是递归情况。请注意,递归调用解决的是一个比我们最初给定的问题更小的问题(即(factorial (- n 1)))。考虑factorial的另一种定义:
(define (factorial n)
(if (= n 0)
1
(factorial n)))
这个替代定义有什么问题?
对于你编写的每个递归过程,以下哪些陈述必须成立?选择所有适用的。
信仰的飞跃
此时,你可能仍然在想一个函数如何可以根据自身来定义。如果在定义factorial的过程中使用factorial,那么不应该会出现一个错误,说factorial还未定义吗?为了使其正常工作,你必须相信它可以工作。从某种意义上说,这是一种信仰的飞跃。
信仰的飞跃实际上是编写递归过程的一种技术。我们必须想象你正在编写的过程已经可以解决比当前正在解决的问题更小的问题。因此,当你考虑如何计算(factorial 5)时,想象(factorial 4)已经被解决。这将防止你的思维陷入无限循环。
在第 0-2 课时,我们提到了定义过程的一个重要属性,即在定义时不会评估过程体。这是递归可以工作的技术原因。因此,define是一个特殊形式,不会评估其参数,并且保持过程体不被评估。只有在定义之外调用过程时才会评估过程体。
检验你的理解
下列哪些表达式在 Racket 中会导致错误?选择所有适用的。
将每个表达式输入 Racket 解释器中,看看会发生什么。
重新审视factorial
让我们再次看看factorial的定义。
(define (factorial n)
(if (= n 0)
1
(* n (factorial (- n 1)))))
如果我们想要计算(factorial 6),那么我们会到达if语句的 else 情况,并将问题简化为(* 6 (factorial 5))。为了进一步简化这个问题,我们需要计算(factorial 5)。因此,我们得到(* 5 (factorial 4))。如果我们将这个替换到原始表达式中,我们得到(* 6 (* 5 (factorial 4)))。再经过几次递归调用,我们会得到类似这样的东西:
(factorial 6)
(* 6 (factorial 5))
(* 6 (* 5 (factorial 4)))
(* 6 (* 5 (* 4 (factorial 3))))
(* 6 (* 5 (* 4 (* 3 (factorial 2)))))
(* 6 (* 5 (* 4 (* 3 (* 2 (factorial 1))))))
(* 6 (* 5 (* 4 (* 3 (* 2 (* 1 (factorial 0)))))))
我们应该如何处理(factorial 0)?这是基本情况,我们应该返回1。因此,我们得到这个表达式:
(* 6 (* 5 (* 4 (* 3 (* 2 (* 1 1))))))
这只是一系列嵌套的乘法表达式,我们可以很容易地从内向外简化:
(* 6 (* 5 (* 4 (* 3 (* 2 1)))))
(* 6 (* 5 (* 4 (* 3 2))))
(* 6 (* 5 (* 4 6)))
(* 6 (* 5 24))
(* 6 120)
720
在 Racket 中,有一个非常有用的过程叫做trace,它接受一个过程作为参数,并在调用该过程时返回该过程的过程。
在您的 Racket 解释器中,在定义factorial过程后键入(trace factorial),然后调用(factorial 6)。你看到了什么?如果您不再想要跟踪该过程,只需键入(untrace factorial)。
例子:斐波那契数列
考虑计算斐波那契数列,其中每个数字是前两个数字的和:
\begin{align} 0, 1, 1, 2, 3, 5, 8, 13, 21 \end{align}
一般来说,斐波那契数可以通过以下规则定义:
\begin{align} Fib(n) = \begin{cases} 0, & \text{如果 n = 0} \ 1, & \text{如果 n = 1} \ Fib(n - 1) + Fib(n - 2), & \text{其他情况} \end{cases} \end{align}
我们可以立即将这个定义转换为一个递归过程,用于计算斐波那契数:
(define (fib n)
(cond ((= n 0) 0)
((= n 1) 1)
(else (+ (fib (- n 1))
(fib (- n 2))))))
考虑调用(fib 2)时会发生什么。该过程进行两次递归调用(fib 1)和(fib 0),分别返回1和0。这些数字相加,过程返回1。
也许你会想知道是否真的有必要有两个单独的基本情况。考虑一下,如果我们省略了n为1时的基本情况会发生什么。(fib 1)会调用(+ (fib 0) (fib -1))。(fib 0)会返回0,但(fib -1)永远不会达到基本情况,过程会无限循环。
例子:Pig Latin
也许你熟悉 Pig Latin,这是一个语言游戏,根据一组简单的规则改变英语单词:取英语单词的第一个辅音(或辅音簇)并将其移动到单词的末尾,然后在单词后附加“ay”。例如,“pig”变为“igpay”,“trash”变为“ashtray”,“object”变为“objectay”。
我们可以使用递归和辅助过程在 Racket 中编写 Pig Latin:
(define (pigl wd)
(if (pl-done? wd)
(word wd 'ay)
(pigl (word (bf wd) (first wd)))))
(define (pl-done? wd)
(vowel? (first wd)))
(define (vowel? letter)
(member? letter '(a e i o u)))
作为提醒,member?是一个 Racket 原始过程,它接受两个参数,一个字母和一个单词,并在字母在单词中时返回 true。
当找到元音时,Pig Latin 结束,因此基本情况是当pl-done?返回 true 时,它只是在单词末尾连接“ay”。否则,在递归情况下,它调用自身与单词的butfirst和单词的第一个的连接。想想如果单词不包含元音会发生什么。
使用你的 Racket 解释器尝试这个pigl的实现。不要忘记利用trace过程!
示例:sum-sent
假设我们有一个数字句子,如下所示:
(define sent '(1 2 3 4 5))
我们想要定义一个名为sum-sent的过程,它可以找到sent中所有数字的总和,但我们也希望sum-sent能够找到任何数字句子的总和。由于输出取决于输入句子的大小,我们将不得不使用递归!
让我们跨出信任的一步。想象一下sum-sent已经知道如何计算除第一个数字外的所有数字的句子,例如,'(2 3 4 5)。要找到这个,我们只需调用(sum-sent (bf sent)),并且我们应该相信它会给我们正确的总和。鉴于此,我们知道:
(sum-sent '(1 2 3 4 5)) ==> (+ 1 (sum-sent '(2 3 4 5)))
如果我们将这个推广到任何数字句子,这就给我们了递归情况:
(+ (first sent) (sum-sent (bf sent)))
测试你的理解
当我们停在这里并将sum-sent定义如下时会发生什么?
(define (sum-sent sent)
(+ (first sent) (sum-sent (bf sent))))
我们缺少基本情况!为了解决这个问题,我们必须添加一个处理空句子的情况。谓词empty?可用于检查空句子。这是sum-sent的完成版本:
(define (sum-sent sent)
(if (empty? sent)
0
(+ (first sent) (sum-sent (bf sent)))))
测试你的理解
假设我们有一个负数句子,'(-1 -3 -4 -6)。Racket 会输出什么?使用上面sum-sent的代码运行此示例,而不要将其输入到解释器中。然后,使用解释器检查你的工作。
随意在 Racket 解释器中尝试更多与sum-sent相关的示例。如果递归令人困惑,尝试查看trace输出。
练习
测试你的理解:count-ums
当你教课时,如果你说“嗯”太多次,人们会分心。编写一个名为count-ums的过程,它以单词句子作为参数,并计算该句子中“嗯”出现的次数:
-> (count-ums '(today um we are going to um talk about the um combining method))
3
递归地编写count-ums。
提示#1: 当句子为空时会发生什么?
提示#2: 当句子的第一个单词是“嗯”时会发生什么?
提示#3: 当句子的第一个单词不是“嗯”时会发生什么?
测试你的理解:倒计时
编写一个名为countdown的过程,它接受一个数字,并按以下方式工作:
-> (countdown 10)
'(10 9 8 7 6 5 4 3 2 1 blastoff!)
-> (countdown 3)
'(3 2 1 blastoff!)
-> (countdown 1)
'(1 blastoff!)
-> (countdown 0)
'blastoff!
常见的递归模式
“Every”模式
这里是一个将句子中的每个数字平方的过程:
(define (square-sent sent)
(if (empty? sent) `
'()
(se (square (first sent))
(square-sent (bf sent)))))
这里是一个将句子中的每个单词翻译为 Pig Latin 的过程:
(define (pigl-sent sent)
(if (empty? sent)
'()
(se (pigl (first sent))
(pigl-sent (bf sent)))))
这里的模式非常清晰。我们的递归情况将对句子的第一个进行一些简单的操作,比如将其平方或pigl,然后将其与对句子的butfirst进行递归调用的结果结合起来。
检验你的理解
注意: 这是你的作业中的第 5 题。
编写一个名为initials的过程,该过程以句子作为参数,并返回句子中每个单词的首字母组成的句子。例如:
-> (initials '(if i needed someone))
'(i i n s)
“保留”模式
在“every”模式中,我们收集将单词或句子的每个元素转换为其他内容的结果。这次我们将考虑一种不同类型的问题:选择一些元素并过滤掉其他元素。
首先,这里是一个从句子中选择三个字母单词的过程:
-> (define (keep-three-letter-words sent)
(cond ((empty? sent) '())
((= (count (first sent)) 3)
(se (first sent) (keep-three-letter-words (bf sent))))
(else
(keep-three-letter-words (bf sent)))))
-> (keep-three-letter-words '(one two three four five six seven))
'(one two six)
接下来,这里是一个从单词中选择元音字母的过程:
-> (define (keep-vowels wd)
(cond ((empty? wd) "")
((vowel? (first wd))
(word (first wd) (keep-vowels (bf wd))))
(else
(keep-vowels (bf wd)))))
-> (keep-vowels 'napoleon)
'aoeo
让我们看看“every”模式和“keep”模式之间的区别。首先,“keep”过程有三种情况,而不像大多数“every”过程那样只有两种情况。在“every”模式中,我们只需区分基本情况和递归情况。在“keep”模式中,仍然有一个基本情况,但有两个递归情况:我们必须决定是否保留返回值中的第一个可用元素。当我们保留一个元素时,我们保留元素本身,而不是元素的某个函数。
检验你的理解
编写一个名为numbers的过程,该过程以句子作为参数,并返回另一个只包含句子中数字的句子。您可能会发现number?谓词有用。
-> (numbers '(76 trombones and 110 cornets))
'(76 110)
“累积”模式
这里有两个遵循“累积”模式的函数的递归过程,它将参数的所有元素组合成一个单一结果:
-> (define (addup nums)
(if (empty? nums)
0
(+ (first nums)
(addup (bf nums)))))
-> (addup '(8 3 6 1 10))
28
-> (define (scrunch-words sent)
(if (empty? sent)
"" ; This is an empty word
(word (first sent)
(scrunch-words (bf sent)))))
-> (scrunch-words '(ack now ledge able))
'acknowledgeable
模式是什么?我们使用一些组合器(+或word)将我们正在处理的单词与递归调用的结果连接起来。基本情况测试空参数,但基本情况的返回值必须是组合器函数的单位元素。
检验你的理解
在这个小节中,我们通过各种函数作为递归的示例。以下哪些函数符合累积模式?选择所有符合条件的。
(define (pigl wd)
(if (pl-done? wd)
(word wd 'ay)
(pigl (word (bf wd) (first wd)))))
(define (count-ums sent)
(cond ((empty? sent) 0)
((um? (first sent)) (+ 1 (count-ums (bf sent))))
(else (count-ums (bf sent)))))
(define (fib n)
(cond ((= n 0) 0)
((= n 1) 1)
(else (+ (fib (- n 1)) (fib (- n 2))))))
作业 0.3
模板
在终端中键入以下命令将作业模板文件复制到当前目录(注意末尾的句点):
cp ~cs61as/autograder/templates/hw0-3.rkt .
或者,你可以在此处下载模板here。
练习 1
从作业 0-2 中编写describe-time过程的新版本。你只需要考虑一天内的时间段。它不应返回一个小数,而应该像这样:
-> (describe-time 22222)
'(6 HOURS 10 MINUTES 22 SECONDS)
-> (describe-time 550441)
'(6 DAYS 8 HOURS 54 MINUTES 1 SECONDS)
提示: 使用quotient!
看看你是否可以使程序足够智能,以知道何时使用复数;这不是必需的。
练习 2
这是过程remove-once应该如何工作的示例:
-> (remove-once 'morning '(good morning good morning))
'(good good morning)
(如果remove-once删除了另一个"morning",也没关系,只要它只删除一个。)
编写remove-once。
练习 3
编写过程differences,它以一组数字作为参数,并返回一个包含相邻元素之间差异的句子。(返回句子的长度比参数少一个。)
-> (differences '(4 23 9 87 6 12))
'(19 -14 78 -81 6)
练习 4
编写一个名为location的过程,它接受两个参数,一个单词和一个句子。它应该返回一个数字,指示单词在句子中的位置。如果单词不在句子中,则返回#f。如果单词出现多次,则返回第一次出现的位置。
-> (location 'me '(you never give me your money))
4
-> (location 'i '(you never give me your money))
#f
-> (location 'the '(the fork and the spoon))
1
练习 5
编写一个过程initials,它以一个句子作为参数,并返回句子中每个单词的首字母组成的句子。
-> (initials '(if i needed someone))
'(i i n s)
练习 6
编写一个过程copies,它接受一个数字和一个单词作为参数,并返回一个包含给定单词副本的句子。
-> (copies 8 'spam)
'(spam spam spam spam spam spam spam spam)
练习 7
编写一个GPA过程。它应该接受一个成绩句子作为参数,并返回相应的平均学分绩点。
提示: 编写一个名为base-grade的辅助过程,它以成绩作为参数并返回0、1、2、3或4,以及另一个名为grade-modifier的辅助过程,根据成绩是否有减号、加号或两者都没有,返回−.33、0或.33。
-> (gpa '(A A+ B+ B))
3.67
练习 8
编写repeat-words,它以一个句子作为参数。它返回一个类似于参数的句子,但是如果参数中出现一个数字,则返回值包含该数字的后续单词的多个副本。
-> (repeat-words '(4 calling birds 3 french hens))
'(calling calling calling calling birds french french french hens)
-> (repeat-words '(the 7 samurai))
'(the samurai samurai samurai samurai samurai samurai samurai)
提示: 你不必在一个过程中完成所有工作。使用一个辅助过程可能会有所帮助。
练习 9
编写一个谓词same-shape?,它接受两个句子作为参数。如果满足两个条件,则应返回#t:两个句子必须具有相同数量的单词,并且第一个句子中的每个单词必须与第二个句子中相应位置的单词具有相同数量的字母。
-> (same-shape? '(the fool on the hill) '(you like me too much))
#t
-> (same-shape? '(the fool on the hill) '(and your bird can sing))
#f
提示: 原始过程count可能会有用。
提交你的作业!
有关说明,请参阅此指南。它涵盖了基本的终端命令和作业提交。
如果你在提交作业时遇到任何问题,请不要犹豫向助教求助!
单元 1
1 - 函数和原始数据
第 1 课介绍
函数式编程
在这节课中,我们将深入探讨函数式编程和递归。一个递归过程通过使问题变得稍微小一些然后调用自身来解决一个大问题。当它调用自身时,它再次使问题变得更小。这种过程一直持续,直到问题变得足够小以至于可以轻松解决。
如果你以前从未使用过递归,递归可能会很难适应。在编写递归程序时要记住的一些事项包括:
-
记得要有一个基本情况。你的递归应该达到一个不再需要调用自身来获得答案的点。在某个时候,问题应该变得足够简单,只需输出一个答案。
-
总是让问题变小。每当你进行递归调用时,确保你的参数比开始时要小。如果不是,那么你可能会陷入一些恶性无限循环中。
-
最后,相信递归!不要过度思考问题。如果你的递归有意义并且遵循了提示 1 和 2,那么你可能有一个有效的代码。你并不总是需要追踪递归以确保你的过程按照你的期望工作。
先决条件和预期内容
对于这节课,你应该了解 Racket 的基础知识并掌握正确的语法。
在这节课中,你将学习递归。
阅读材料
这节课的相关阅读材料如下:
如果你想要更多资源,请查看单元 0 的所有阅读材料。
函数
在我们讨论计算机科学中的函数之前,让我们先谈谈数学中的函数。在数学中,一个函数 [mathjaxinline]f(x)[/mathjaxinline] 接受一个输入 [mathjaxinline]x[/mathjaxinline],对这个 [mathjaxinline]x[/mathjaxinline] 做一些"操作",然后返回一个新值。对于函数接受的每个 [mathjaxinline]x[/mathjaxinline],它只返回一个值,并且每次都返回相同的值。例如,如果 [mathjaxinline]f(x) = x + 2[/mathjaxinline],每次我们将 4 代入 [mathjaxinline]f(x)[/mathjaxinline],我们都会得到 6。在任何情况下,我们都不会输入 4 而得到 5、7 或任何不是 6 的值。
在计算机科学中也是一样的!一个函数被定义为一个[过程](https://preview.edge.edx.org/courses/uc-berkeley/cs61as- 1x/SICP/wiki/cs61as-1x/procedure/),其输出取决于输入--也就是说,当给定某个输入(们)给一个函数时,它每次都返回相同的输出。
(define (square x)
(* x x))
是一个函数,因为无论我们输入什么,我们总是得到输入乘以它自己。
除了函数外,Racket 还有一种更一般的数据类型称为过程。过程类似于函数,但并不一定要对于每个输入返回相同的输出。例如,square是一个函数,但random不是,因为对于相同的输入,我们每次调用random都可能得到不同的输出。
澄清一下:在 Racket 中,所有函数都是过程,但并非所有过程都是函数。
以下是本小节涵盖的一些内容:
-
函数--它们是什么,如何定义它们。
-
原始过程
-
特殊形式
接下来呢?
开始下一小节 1!
表达式
什么是表达式?
表达式是你输入到 Racket 解释器中的任何内容。
例如,
2
是一个表达式。另一个例子是
(+ 2 3)
如上所示的组合是一个表达式,其中使用括号显示何时调用一个过程。在这种情况下,+被称为运算符,而 2 和 3 被称为操作数。一个组合的值是通过将运算符应用于操作数得到的。
前缀表示法
你已经在第 0.1 单元介绍了前缀表示法,所以这里是一个快速回顾。
在 Racket 中,我们使用前缀表示法。因此,我们不是在解释器中输入2 + 3,而是输入(+ 2 3) --也就是说,运算符在操作数或参数之前。
这有几个好处。目前最明显的一个好处是它可以接受接受可变数量参数的过程,比如+或*。例如,在前缀表示法中,添加 5 个数字看起来像(+ 1 2 3 4 5),而在中缀表示法中,看起来像1 + 2 + 3 + 4 + 5。
另一个好处是它使得嵌套程序在彼此之间非常容易。例如,(+ (- 4 3) (/ 4 2))的计算结果为 3。这些表达式的深度可以任意扩展,因此
(+ (- (/ 4 2) (+ 3 4 2 (/ 4 3))) (* 4 (- 3 4)))
也是有效的 Racket 表达式,尽管这对我们人类来说非常难以理解。
另一个优点是它使得解析 Racket 非常容易,这在编写解释器时非常有用。如果你还不知道这意味着什么,不用担心。
即使是最复杂的表达式,解释器也会执行相同的操作:读取表达式,计算它,并将其打印到屏幕上。这被称为读取-求值-打印循环。
变量和环境
定义变量
我们使用define来为变量赋值。
例如,(define x 2)将值2赋给x:
-> (define x 2)
-> x
2
-> (+ x 5)
7
在调用(define x 2)之后,我们说x被绑定到2。变量绑定存储在环境中,我们将在第三单元更详细地讨论。
检查理解
假设以下内容被输入到 Racket 解释器中:
(define pi 3.14159)
(define radius 3)
(define area (* radius radius pi))
(define circumference (* 2 pi radius))
area会评估为什么?circumference会评估为什么?
评估组合
评估是什么意思?
当我们在解释器中键入类似(+ 2 3)的 Racket 表达式时,我们作为人类立即知道这实际上就是5。但计算机所看到的只是开括号、加号、两、三、闭括号。它是如何从 Racket 表达式得到值5的呢?它评估表达式并从中得到值5。它是如何评估的呢?
解释器如何评估事物
解释器评估事物的方式一开始可能有点令人困惑,但很快就会讲得通。要评估一个 Racket 表达式,首先要评估表达式的子表达式。换句话说,你首先要完全评估操作数,然后应用运算符。当你到达一个过程调用时,将运算符应用于操作数并重复。请注意,评估是递归的--为了评估一个表达式,我们需要首先评估其子表达式。为了评估子表达式,我们需要评估它们的子表达式,依此类推,直到达到一个过程。
例子:一个递归树
让我们尝试评估以下表达式:
(* (+ 2 (* 4 6))
(+ 3 5 7))
这是一个相当复杂的表达式,如果没有递归,将很难评估。评估这需要应用评估规则四次。如果我们将评估过程表示为一棵树,那么就会变得更容易理解。这棵树,不像真实的树,它的根在空中,它的分支伸入地面。
每个组合由一个具有对应子表达式的分支的节点表示。末端分支是运算符或数字。我们可以想象操作数的值向上游动,从树的底部开始,在每个分支处进行评估,并导致一个新值,该值在更高级别进一步评估。

wiki entry中对 eval 给出了更详细的解释,并将在关于替换模型的部分进一步解释。
定义?
那么define呢?原来,普通的评估规则对define不起作用,因为(define x 3)不将define应用于两个参数;它实际上将x的值存储为 3。Define 是所谓的特殊形式,特殊形式是评估规则的唯一例外。
以下哪个不是 Racket 中的原始函数?
复合过程
定义过程
你已经知道如何定义简单的过程,比如square。定义过程的标准方法是(define (name formal-parameters) body)。
术语:
-
复合过程:复合过程是根据 Racket 原始过程来定义的过程。
-
名称:过程的名称是用来指代该过程的符号。
-
形式参数:过程的形式参数是在过程体内用来指代实际参数的名称。
-
主体:过程的主体是过程的“主体部分”。它正式定义为“一个表达式,当将形式参数替换为过程应用的实际参数时,将产生过程应用的值”,但你可以将其视为计算机要遵循的指令。
在过程定义(define (square x) (* x x))中,名称是square,形式参数是x,主体是(* x x)。
假设我将一个过程定义为如下:(define (foo x y) (+ (* 3 x) (* 4 y)))。请回答以下问题。
上述过程的名称是什么?两个形式参数是什么?过程的主体是什么?
带有多个形式参数的过程
过程不一定只能有一个形式参数,比如square。它们也可以有多个形式参数。创建具有多个参数的过程的方法相当简单。看起来像这样:(define (foo x y z) (* x y z))。
我们还可以创建没有任何参数的过程!代码看起来像这样:(define (foo) 3))。现在,每当你调用(foo)时,它都会返回 3。
过程内嵌
编程最有用(也是最酷!)的部分之一是,一旦你定义了一个过程,不仅可以一遍又一遍地使用它,还可以用它来定义其他过程。
由于你现在可能已经厌倦了square,让我们用另一个函数作为例子。让我们定义一个谓词vowel?,并使用它来定义另一个过程:
(define (vowel? letter) (member? letter '(a e i o u))
现在我们有了vowel?,我们可以在不同的过程中使用它。例如,0.3 中的一个问题涉及到 Pig Latin。如果一个单词以元音开头,将该单词翻译成 Pig Latin 就很简单,只需在单词末尾添加“ay”。我们现在不会担心将单词翻译成 Pig Latin;我们只会定义另一个谓词来检查一个单词是否以元音开头。
(define (pig-complete? wd) (vowel? (first wd)))
如您所见,我们使用一个用户定义的过程(vowel?)来定义另一个过程。
过程应用的替换模型
应用复合过程
到目前为止,我们已经看到了 Racket 如何分解和评估诸如:
(+ 1 2)
(+ 3 4 (* 2 5))
通过以下步骤:
-
评估过程
-
评估参数
-
将过程应用于参数。
我们在第 3 步中有些含糊。如何确切地“应用”过程?对于像+、-、quote、or、and、not这样的原始函数,我们可以假设它们内置于解释器中。我们更感兴趣的是更复杂的东西:如何应用复合(即用户定义的)过程?由于我们可以定义任意多个复合过程,它们不能全部内置于解释器中。需要有一种共同的逐步方式来应用复合过程。一种思考这个问题的方法是替换模型,我们将在本小节中探讨。
替换模型
要使用替换模型应用复合过程,您需要用相应参数的值替换主体中的每个形式参数,并正常评估它。这实际上意味着什么?通过一个例子更容易理解:
考虑第一个实验室中的sum-of-squares过程,可以定义如下:
(define (sum-of-squares x y)
(+ (square x) (square y))) ;; This line is the 'body' of the procedure
(define (square x) (* x x))
替换模型如何处理(sum-of-squares 3 4)?
-
我们有一个形式参数 x,用参数 3 调用,另一个形式参数 y,用参数 4 调用。
-
我们在主体中用 3 和 4 分别替换 x 和 y 的每个出现
-
然后主体变为
(+ (square 3) (square 4)) -
使用 square 的定义,这简化为
(+ (* 3 3) (* 4 4))。 -
应用两个乘法得到
(+ 9 16) -
应用加法得到
25的结果
步骤 1 和 2 是替换模型最关键的部分:找到传递给函数的值,并用相应值替换主体中的每个变量。
形式参数的名称
到目前为止,您可能已经注意到形式参数的名称是任意的。例如,以下所有内容都是等效的:
(define (square x) (* x x))
(define (square apple) (* apple apple))
(define (square magikarp) (* magikarp magikarp))
替换模型处理这三个等效,尽管最好选择一个易于理解的名称(在这种情况下,第一个定义是理想的)。主要问题是在主体内保持一致。例如,以下内容可能会导致错误:
(define (square x) (* apple apple))

当我们使用上述定义的 Substitution Model 与(square 4)时,你会注意到事情并没有得到正确定义。过程square接受一个参数,这里是 4。在函数体内我们需要找到apple的值并执行(* apple apple)。apple的值是多少?我们不知道!我们只知道x是多少!
Substitution Model & Racket
Racket 是否实际使用 Substitution Model 来应用复合过程?并非完全如此。我们使用 Substitution Model 来帮助我们思考过程应用。Racket 做了稍微复杂一点的事情,我们将在第 3 和第 4 单元中探讨。后来,我们会发现 Substitution Model 无法解释 Racket 中的一些函数。这个模型将作为我们将构建的框架。
Applicative Order vs Normal Order
我们通过评估运算符、评估操作数,然后应用运算符的方法只是一种可能的评估规则。我们一直在使用的排序被称为“Applicative Order”。另一种评估方法是在需要值之前不评估操作数。这种方法被称为“Normal Order”。我们可以从以下示例中看到这两者之间的区别:
(square (+ 3 2))
请注意,square 的输入是(`+ 3 2)。
- Applicative Order:
(square (+ 3 2))
(square 5)
(* 5 5)
25
在 Applicative Order 中,你会在进入 square 的函数体(* x x)之前评估参数x。当你评估(+ 3 2)时,你得到5,这就是你传递给 square 的值。所以x绑定到5。
- Normal Order:
(square (+ 3 2))
(* (+ 3 2) (+ 3 2))
(* 5 5)
25
在 Normal Order 中,你不会评估(+ 3 2)直到绝对需要。所以在这种情况下,(square x)中的x绑定到(+ 3 2)。
请注意,在 Normal Order 中,由于你不评估x,即(+ 3 2),直到需要它时,你需要评估两次。另一方面,在 Applicative Order 中,由于你在应用过程之前评估操作数x,你只评估(+ 3 2)一次。
考虑以下代码片段:
(define (double_first a b) (+ a a))
(double_first (+ 1 1) (+ 2 2))
在 Applicative Order 中,(+ 1 1)被评估了多少次?
条件表达式和谓词
条件语句复习
我们在第一个实验中使用了if和条件语句。在本节中,我们将详细介绍更多细节。
当我们希望函数根据某个特定条件的不同行为时,通常使用if或cond。请注意,这两个函数在 Racket 中是特殊形式;我们不使用通常的“完全评估操作数,然后应用运算符”方法来评估它们。
cond示例
cond表达式的一般形式是:
(cond (<test1> <result1>)
(<test2> <result2>)
...
(<testn> <resultn>)
(else <default>)) ;; The 'else' case is optional
每个(<test> <result>)对称为子句。每对的第一部分(<test>)是谓词—必须评估为 true 或 false 的表达式。
要评估cond表达式,首先评估<test1>。如果为真,则评估并返回<result1>。如果<test1>为假,则重复<test2>,依此类推,直到没有更多测试。如果遇到else,则返回相应的值(“默认值”)。
您可以将cond表达式写为一系列“if”语句:
(if <test1>
<result1>
(if <test2>
<result2>
...
(if <testn>
<resultn>
<default>) ;; Closing parentheses omitted
练习
函数plural如下接受一个单词并返回其复数形式。例如,(plural 'carrot)返回'carrots,(plural 'body)返回'bodies。对于(plural 'boy),它的表现不正确,应该返回'boys;下面的错误版本却返回'boies。
(define (plural wd)
(if (equal? (last wd) 'y)
(word (bl wd) 'ies)
(word wd 's)))
选择在下面的空白处添加哪行代码,以便(plural 'boy)正确运行(即,应返回 boys)。假设vowel?如前所定义。
(define (plural wd)
(if __________________
(word (bl wd) 'ies)
(word wd 's)))
谓词和样式
谓词是任何返回 true 或 false 的表达式。一些示例包括(< 3 4),(> 10 -2)和(= 'apple 'orange)。您可以使用and,or和not来形成复合谓词。
这是一个谓词的示例:
(define (even? x)
(= (remainder x 2) 0))
在定义谓词时,习惯上以问号(?)结束过程的名称。
这是另一种定义even?的方法:
(define (even? x)
(if (= (remainder x 2) 0)
#t
#f))
尽管此定义等效于上面的原始定义,但它包含冗余。我们建议您避免编写这样的代码。冗余的代码可能会使您的程序更难理解,并且通常被认为是糟糕编程风格的示例。
练习
我们定义一个过程,它以三个数字作为参数,并返回两个较大数字的平方和
例如,(max-sum-squares 1 2 3)返回13,即4 + 9
为什么下面的代码不正确?
(define (square x) (* x x))
(define (max-sum-squares a b c) (max (+ (square a) (square b)) (+ (square b) (square c)) (+ (square a) (square c))))
练习问题:编写一个过程pigl,它以一个单词作为参数,并以 pig latin 形式返回该单词。以下是 pig latin 的规则:
如果输入的单词以元音字母开头,则我们在输入后附加“ay”。
如果输入的单词以辅音字母开头,则我们将所有起始辅音字母移至单词末尾��然后在末尾附加“ay”。
以下是一些示例:
(pigl 'hello) ; ellohay
(pigl 'open) ; openay
(pigl 'scheme) ; emeschay
如果我们的输入没有元音,像(pigl 'my)这样会发生什么?确保您的pigl检查单词是否没有元音,并直接返回该单词。
(pigl my) ; my
在你的 Racket 解释器中使用上面的示例检查你的答案!
过程作为黑盒抽象
过程作为抽象
到目前为止,我们已经定义了单独执行单个计算的函数(例如square,fib和factorial)。通过组合不同的函数,每个函数处理原始问题的一个子问题,您可以创建一个更复杂的函数。我们将在本节中构建一个此类函数的示例,并探讨“函数作为抽象”的概念。
扩展示例:最大正方形
查理有大量的方块(而不是条形)巧克力,他想通过将这些方块组织成尽可能大的正方形排列来向朋友展示!所以假设查理有 13 块巧克力。然后最大的正方形排列是 3x3 = 9(左图所示),剩下 4 块。

查理想知道“在给定一定数量的巧克力块的情况下,我的正方形的边可以有多大?”我们可以将这个问题表示为一个函数,(largest-square total guess)。函数largest-square接受两个参数:total,表示查理有多少块巧克力(在上面的示例中,total为 13),以及guess,表示您对最大边长的初始猜测。此函数将输出您的巧克力正方形可以拥有的最大边长(在本例中为 3)。我们将把这个函数分解为子问题,并在本节的其余部分组合所有这些部分。
最大正方形:概述
可能看起来奇怪的一件事是多余的参数guess。您可以编写一个只使用total参数完成相同工作的函数。我们包含了guess以增加问题的复杂性。将guess视为查理对他认为自己的边可以有多大的估计。在我们的原始示例中,有 13 块巧克力,假设查理认为最大边长为 2:
| guess | leftover | next guess |
|---|---|---|
| 2 | 13-4= 9 | 2+1= 3 |
| 3 | 13-9= 4 | 3+1= 4 |
| 4 | 13-16= -3 | 4+1= 5 |
对于每次对largest-square的函数调用,如(largest-square 13 2),我们将检查当前的猜测(在本例中为 2)是否足够好。我们如何检查我们的猜测是否足够好?如果我们的下一个猜测使用的巧克力块多于我们有的可用的数量,则猜测足够好。如果我们可以做出更好的猜测,那么我们将使用下一个猜测并递归调用largest-square。
最大正方形:骨架代码
根据我们在上一页的直觉,我们可以规范化我们的函数定义。如果您的guess足够好,请返回您的guess。如果您可以有更好的guess,请使用更好的guess调用largest-square
(define (largest-square total guess)
(if (good-enough? total guess)
guess
(largest-square total (next-guess guess))))
如果您按原样输入上述定义(没有定义'good-enough?'和'improve-guess'),会发生什么?如果之后您键入(largest-square 13 2),会发生什么?
"等等,你刚刚定义了一个函数,但它调用了其他尚未定义的函数!我们还没有定义'good-enough?'或'improve-guess'!"
是的,内部函数的定义是不完整的,但请注意,我们(程序员)可以理解函数在做什么!我们已经将找到'largest-square'的问题分解为一些小问题,比如'足够接近吗?'和'改进我们的猜测'。我们可以以不同的方式分解代码,比如每 3 行,每 5 行,但是每个子问题将没有一个可识别的任务。将它们分解为一个连贯的、可识别的任务是至关重要的。
这将是我们最后会再次讨论的一个关键思想,但首先让我们完成定义。
最大方块:子问题
是时候做必要的工作让函数正常工作了!
(define (largest-square total guess)
(if (good-enough? total guess)
guess
(largest-square total (next-guess guess))))
问题:
我们想要定义接受两个输入total(你拥有的巧克力块的总数)和guess(代表你当前的猜测)的函数good-enough?。它应该根据下一个整数是否大于total来报告#t或#f
(good-enough? 13 3) 应返回 #t。下一个猜测是 3+1=4,需要 16 个方块,超过了 13,总数
(good-enough? 13 2) 应返回 #f。下一个猜测是 2+1=3,需要 9 个方块,仍然低于 13,总数
(good-enough? 100 11) 应返回 #t。下一个猜测��11+1=12,需要 144 个方块,超过了 100,总数
(good-enough? 100 10) 应返回 #t。下一个猜测是 10+1=11,需要 121 个方块,超过了 100,总数
(good-enough? 100 9) 应返回 #f。下一个猜测是 9+1=10,需要 100 个方块,总共是 100
选择应填入空白处的代码:
(define (good-enough? total guess))
________________________)
接下来,我们定义接受你当前猜测的函数next-guess,并返回一个新的数字以供尝试下一个。
(next-guess 1) ;;应返回 2
(next-guess 3) ;;应返回 4
选择应填入空白处的代码:
(define (next-guess guess)
________________________)
函数作为抽象
我们从方形巧克力的例子中可以学到什么?记住,当我们仅仅定义largest-square时,我们可以理解过程在做什么,而不需要知道good-enough?或next-guess是如何实现的。我们可以将这些函数视为抽象;我们知道它们会输出什么,但我们不关心如何实现。只要它们做正确的事情,我们就满意!
你也可以将这种方法应用到现实生活中。当我们打开电视时,我们从不考虑"哦,电视之所以工作是因为我们将电子射到屏幕上,这些电子由电磁铁引导,这样我们就可以观看东西!"。我们通常更多地考虑"如果我按这个按钮,我就可以看电影"。我们不需要知道电视是如何工作的才能使用它;它的实现对我们来说是抽象的
内部定义
我们已经定义了一个相对复杂的过程,它依赖于其他过程。现在我们将看看是否可以改进代码的组织!
请注意,我们对good-enough?和next-guess的定义非常特定于largest-square问题;我们几乎找不到其他可能使用这些函数的函数。此外,当查理想要找到最大的正方形时,他将调用largest-square函数,而不会直接操作这两个辅助函数。在这种情况下,最好组织我们的代码,以便只有largest-square可以访问这两个辅助函数
如何做到这一点?我们可以在largest-square的主体内定义函数如下:
(define (largest-square total guess)
(define (next-guess guess) (+ guess 1))
(define (good-enough? total guess)
(< total (square (next-guess guess))))
(if (good-enough? guess)
guess
(largest-square total (next-guess guess))))
假设你只定义了上面的过程,当我们调用(next-guess 4)时会发生什么?
变量的作用域
(define (largest-square total guess)
(define (next-guess guess) (+ guess 1))
(define (good-enough? total guess)
(< total (square (next-guess guess))))
(if (good-enough? guess)
guess
(largest-square total (next-guess guess))))
之前我们提到good-enough?和next-guess函数仅在函数largest-square内定义。现在这些函数在largest-square内部,我们可以将其他多余的部分从函数中取出。请注意,next-guess和good-enough?接受传递给larger-square的相同total和guess。去除两个辅助函数中的多余参数结果为:
(define (largest-square total guess)
(define (next-guess) (+ guess 1))
(define (good-enough?)
(< total (square (next-guess))))
(if (good-enough?)
guess
(largest-square total (next-guess))))
如何跟踪函数可以访问什么,而不能访问什么?我们将在第 3 单元中花费大量时间来解决这个问题。当一个函数在另一个函数内定义时,内部函数可以访问外部函数的变量和参数。因为next-guess是在largest-square内定义的,所以next-guess可以访问largest-square的参数total和guess。
如果你觉得记忆提示有用,请将外部函数视为父母,将内部函数视为孩子。父母可以借给孩子他们的东西(比如手机),但孩子不会让父母拿走他的玩具。
 
常见错误
试一试自己动手!
将以下内容输入到 Racket 解释器中。其中大部分会生成错误。阅读错误信息并尝试理解其含义。这一部分不需要提交任何内容。
=> (require berkeley) ;this should not error out. if it does, ask a TA for help
=> (bar 9)
=> (first '())
=> (bf '())
=> (first (bf '(1)))
=> (define (foo x) (+ x 1))) ;notice the extra parenthesis at the end
=> (foo 2 4)
=> (foo)
=> (define baz 3)
=> (baz 8)
=> (se garbly 4)
=> (se 'garbly 4)
=> (se baz 4)
你还应该查看常见错误列表
作业 1
模板
在终端输入以下命令,将模板文件复制到当前目录(注意末尾的句点):
cp ~cs61as/autograder/templates/hw1.rkt .
或者你可以在这里下载模板。
如果你在这份作业上遇到困难,请查阅Lesson 0.3详细解释递归。
自动评分程序
如果你在实验室计算机上工作,grader命令将运行自动评分程序。如果你在自己的个人机器上工作,你应该下载grader.rkt和HW 1 tests。
练习 1
编写一个名为dupls-removed的过程,给定一个句子作为输入,返回从句子中删除重复单词后的结果。这个问题使用递归。
;; This should output (c a d e b)
(dupls-removed '(a b c a e d e b))
;; This should output (a b c)
(dupls-removed '(a b c)) ;;
;; This should output (b a)
(dupls-removed '(a a a a b a a))
作为提醒,你可以在实验室计算机上运行自动评分程序:
grader hw1 hw1.rkt dupls-removed
或者在你自己的机器上:
racket -tm grader.rkt -- hw1-tests.rkt hw1.rkt dupls-removed
练习 2
编写一个名为count-word的过程,以一个句子和一个单词作为参数,并输出句子中输入单词的出现次数。
;; This should output 2
(count-word '(i really really like 61as) 'really)
;; This should output 0
(count-word '(i lambda racket) 'love)
练习 3
解释如果在pigl过程中使用new-if(来自 Lab 0)而不是if会发生什么。
这是之前实验室中pigl的定义
(define (pigl wd)
(if (pl-done? wd)
(word wd 'ay)
(pigl (word (bf wd) (first wd)))))
(define (pl-done? wd)
(vowel? (first wd)))
(define (vowel? letter)
(member? letter '(a e i o u)))
练习 4
编写一个名为squares的过程,以一串数字作为参数,并返回这些数字的平方组成的句子。
;; This should output (1 4 9)
(squares '(1 2 3))
练习 5
编写一个名为switch的过程,以一个句子作为参数,并返回一个句子,其中每个I或me的实例都被替换为you,而每个you在句子开头以外的实例都被替换为me(句子中唯一应大写的词是I)。
;; This should output (I told you that you should wake me up)
(switch '(you told me that I should wake you up))
提示:考虑编写一个处理问题一般情况的辅助函数——也就是说,你的辅助函数不必担心“除了在句子开头”这部分。然后使用该辅助函数编写switch,并在switch的主体中处理特殊情况。
练习 6
编写一个名为ordered?的谓词,以一串数字作为参数,并在数字按升序排列时返回#t,否则返回#f。
(ordered? '(1 2 3)) ; #t
(ordered? '(2 1 3)) ; #f
(ordered? '(2)) ; #t
练习 7
编写一个名为ends-e的过程,以一个句子作为参数,并返回一个只包含以字母 E 结尾的单词的句子。
;; This should output (please the above the blue)
(ends-e '(please put the salami above the blue elephant))
练习 8
大多数 Lisp 版本提供了像我们见过的and和or过程。原则上,这些可以是普通过程,但某些 Lisp 版本将它们作为特殊形式。
假设,例如,我们评估(or (= x 0) (= y 0) (= z 0))。如果or是一个普通过程,那么在调用 or 之前,所有三个参数表达式都将被评估。但是如果变量x的值为 0,我们知道整个表达式必须为真,而不管y和z的值如何。一个 Lisp 解释器,其中or是一个特殊形式,可以逐个评估参数,直到找到一个为真的参数或者用完所有参数。
设计一个测试,告诉你 Racket 的and和or是特殊形式还是普通函数。这是一个有点棘手的问题,但它会让你更深入地思考评估过程。为什么对于解释器来说,将or视为特殊形式并逐个评估其参数可能是有利的?你能想到为什么将or视为普通函数可能是有利的吗?
提交你的作业
如果你在提交时遇到问题,请在 Piazza 上提问或联系助教。
提交前: 确保你的文件在 Racket 中加载。你可以通过在 Racket 中输入(enter! "hw1.rkt")来验证,其中“hw1.rkt”是你的作业文件的名称。如果作业在 Racket 中无法加载,你将不会得到任何学分。
要提交你的作业,你需要在任何实验室计算机上登录。如果你想从家里提交,你必须远程连接到实验室计算机。稍后会详细介绍。
现在,在左侧点击“终端”图标。终端是一个终端仿真器,一种通过文本命令直接与计算机交互的方法。它有点像你整个计算机的“解释器”。你可以用 xterm 做一些有用的事情,比如导航和操作文件系统(类似于 Windows 资源管理器),提交作业(就像我们现在正在做的),以及启动 Racket 解释器(通过racket)!
让我们提交一个作业。这需要以下步骤:
-
为作业创建一个文件夹(可选,但强烈建议,我们将看到原因)
-
在那个文件夹中完成作业(或者如果你已经完成了作业,将文件移动到那个文件夹中)
-
运行
submit命令 -
检查作业是否正确提交
我们将提交一个名为“units”的作业,这将告诉工作人员你要做多少单元。
创建一个文件夹
在终端中输入:
mkdir units
这告诉计算机创建一个名为units的目录(文件夹)。你可以通过运行ls来双重检查它是否存在(还可以看到当前目录中还有什么)。
现在我们需要导航到那个文件夹,所以我们会执行:
cd units
完成作业
为了完成这个任务,你必须创建一个名为units的文件(在名为units的目录中)。在那个文件中,写下你计划做哪些单元。例如,如果你要做 0、1、2 和 3 单元,你会写下
0 1 2 3
请不要包含任何额外的空格或空行!
提交
创建完文件后,你可以通过以下方式提交作业
submit units
这告诉计算机你想提交作业“units”。按照出现的任何指示操作。
检查你的提交
以下命令允许你查看你提交作业的时间:
glookup -t
现在就是这些。你可能会对在家连接以便处理所有这些事情感兴趣。有关详细信息,请查看顶部的资源链接!
2 - Lambda 函数和高阶函数
第 2 课介绍
介绍
"我 lambda Racket"
本周我们将学习一个新的特殊形式,lambda,它可以创建过程!确保你学会它,因为它将在本课程的其余部分中广泛使用。
先决条件和预期内容
先决条件: 在进行本课程之前需要完成第 1 课。你应该熟悉函数、过程和调用过程等概念。
预期内容: 在本课程中,我们将:
-
解释 lambda 和高阶函数
-
学习 Racket 中的一个基本概念,或者任何其他面向函数的编程语言 -- 使用其他函数来操作函数。
阅读材料
本课程的相关阅读如下:
预览
我们在第 1 课中学习了如何创建、修改和调用过程。每个过程都有一个名称、它的参数和一个主体,我们在其中告诉函数如何处理它的参数。
例如,这是一个过程 cube,它接受一个参数 x 并返回 x 的立方:
(define (cube x)
(* x x x))
我们定义了一个过程,它的名称是 cube,参数是 x,主体是 (* x x x)。你现在应该能够看出,主体将三个 x 相乘并返回 x 的立方。
cube 是一个过程,或者抽象,我们可以像盒子一样处理并传递,就像任何其他数字或符号一样。它有一个值,我们可以给它一个名称。
现在我们想想,像上面那样定义 cube 并不比像这样定义 var 差太远:
(define var 10)

在盒子 var 中,我们放置了 10。在盒子 (cube x) 中,我们放置了 (* x x x)。在一个盒子中,我们放置一个数字,而在另一个中,我们放置一个表达式。相当相似,对吧?如果我们不是将原始值或表达式放入盒子中,而是将一个函数放入其中呢?难以想象!
它可能看起来像这样:
(define f [some function])
[一些函数] 是我们将放置 lambda 的地方。继续阅读以了解更多!
使用 Lambda 构建过程
看一下下面对sum-doubles的定义,它接受两个数字a和b,并返回a和b之间所有数字的总和加倍。
(define (sum-doubles a b)
(define (double x) (* 2 x))
(if (> a b)
0
(+ (double a) (sum-doubles (+ a 1) b))))
由于double函数并没有为我们预先定义,我们不得不在我们的sum-doubles定义内部自己定义它。
但这样做太浪费了!在我们的sum-doubles定义之外,我们将永远无法使用那个double函数。有没有一种快速、简单的方法可以在不先定义、然后应用命名函数的情况下创建用户定义的函数?
Lambda:匿名函数
实际上,是的。让我们介绍lambda函数,也被称为匿名函数。这些神秘的函数将是我们将在未来课程中讨论的重要角色。
lambda 的一般形式如下:
(lambda (<param1> <param2> ... <paramn>) <body>)
让我们来解剖一下。在括号内,我们有三个主要部分:
-
一个标签,
lambda,告诉 Racket 这是一个 lambda 函数, -
一个参数列表(可以有很多个),
-
和主体—在参数列表之后的任何内容。
例如,过程double可以定义为以下 lambda 函数:
(lambda (x) (* 2 x))
换句话说,
(define double (lambda (x) (* 2 x)))
相当于:
(define (double x) (* 2 x))
在描述 lambda 时,你会称其为“返回[主体]的[参数]函数”。例如,“double是返回(* 2 x)的x函数。”
调用 Lambda
正如我们可以调用使用define创建的过程一样,我们也可以调用 lambda 函数。调用 lambda 的一般形式如下:
((lambda (<param1> <param2> ... <paramn>) <body>) <arg1> <arg2> ... <argn>)
因此,如果我们想要将(double 5)作为匿名函数调用,替换模型会给我们这个结果:
-> ((lambda (x) (* 2 x)) 5)
-> ((lambda (x) (* 2 5)) 5)
-> (* 2 5)
10
这里发生了什么?当我们调用 lambda 函数时,第一个参数对应于第一个参数,第二个参数对应于第二个参数,...,第 n 个参数对应于第 n 个参数。然后,在主体中,每个参数的每次出现都会被相应的参数替换。
让我们用一个示例表达式来说明这一点:
-> ((lambda (x y z) (+ x y x z)) 1 2 3)
在 lambda 的主体中,我们用1替换每个x的出现。我们用2替换每个y。每次看到z时,我们用3替换它。
-> ((lambda (x y z) (+ x y x z)) 1 2 3)
-> ((lambda (x y z) (+ 1 2 1 3)) 1 2 3)
-> (+ 1 2 1 3)
7
现在,我们可以将sum-doubles重写为:
(define (sum-doubles a b)
(if (> a b)
0
(+ ((lambda (x) (* 2 x)) a) (sum-doubles (+ a 1) b))))
注意:创建lambda返回的值是一个过程,就像使用define调用创建的过程一样。
在 Racket 解释器中尝试这些表达式:
(lambda (x) (+ x 3))
((lambda (x) (+ x 3)) 7)
(define add3 (lambda (x) (+ x 3)))
(add3 7)
(define (square x) (* x x))
(square 5)
(define sq (lambda (x) (* x x)))
(sq 5)
((lambda (x y) (+ (* 2 x) (* 5 y))) (* 100 100) (* 5 2))
使用 Let 创建局部变量
局部变量 是仅存在于局部环境中的变量。这里是一个例子:
(define (foo x)
(define a 5)
(+ x a))
局部环境是由函数 foo 创建的环境,局部变量是 a。请注意,x 不是 局部变量,即使它也不能在 foo 外部访问—它正式称为参数。
介绍 let
特殊形式 let 本质上是对 lambda 函数的调用,排列不同。例如,看下面的 lambda 函数调用:
-> ((lambda (x y z) (+ x y x z)) 1 2 3)
7
这等同于以下 let 语句:
-> (let ((x 1) (y 2) (z 3)) (+ x y x z))
7
这什么时候会有用呢?两个词:局部变量。我们很少会使用 let 语句来简单地调用 lambda 函数。相反,我们使用它在函数内部创建局部变量。
一个例子:多项式
假设我们想要使用 Racket 计算给定 x 和 y 的以下多项式:
[mathjax]f(x,y) = x(1+xy)² + y (1-y) + (1+xy)(1-y)[/mathjax]
将这个丑陋的多项式重写为一个丑陋的过程:
(define (f x y)
(+ (* x (+ 1 (square (* x y)))) (* y (- 1 y)) (* (+ 1 (* x y)) (- 1 y))))
呸。相反,我们可以使用一些替代:
[mathjax]\displaystyle a = 1 + xy[/mathjax]
[mathjax]\displaystyle b = 1 -y[/mathjax]
以便我们得到:
[mathjax]\displaystyle f(x,y) = xa² + yb + ab[/mathjax]
好吧,我想这样更好。在 Racket 中编写这个,我们将定义一个名为 f-helper 的辅助函数,以便我们可以进行替换:
(define (f x y)
(define (f-helper a b)
(+ (* x (square a))
(* y b)
(* a b)))
(f-helper (+ 1 (* x y))
(- 1 y)))
花一分钟确认这是否与之前对 f 的定义做的事情相同。正如我们在前一节中学到的,我们实际上不需要在 f 中再定义一个额外的函数。相反,我们可以使用 lambda:
(define (f x y)
((lambda (a b)
(+ (* x (square a))
(* y b)
(* a b)))
(+ 1 (* x y))
(- 1 y)))
遗憾的是,即使经过所有这些替换和重新组织,它仍然有点混乱。这就是 let 的用武之地!
(define (f x y)
(let ((a (+ 1 (* x y)))
(b (- 1 y)))
(+ (* x (square a)) (* y b) (* a b))))
最后,我们得到了一个更易读的初始多项式函数 f 版本。我们清楚地看到我们正在给 a 和 b 赋值,然后将其插入到 let 语句的主体中。
let:一般形式
let 语句的一般结构是
(let ((<var1> <exp1>)
(<var2> <exp2>)
...
(<varn> <expn>))
<body>)
记住,在底层,这只不过是一个 lambda 调用。上面的结构等同于
((lambda (<var1> <var2> ... <varn>) <body>)
<exp1> <exp2> ... <expn> )
在 Racket 解释器中尝试这些表达式(以及更多!)。
(注意:分号表示注释。Racket 将忽略分号后的行的其余部分。)
(define y 10)
(let ((y 0)) y) ;; notice that let overrides global vars
(let ((x 10)
(z x))
z) ;; this will break, translate to lambda to see why
(let ((a 1))
(let ((a 2))
(let ((a 3))
a))) ;; nested lets are valid.
(let ((test 'wait-what?))
5)
test ;; let only binds variables inside its body
(let ((a 1))
(+ a (let ((a 2))
(+ a (let ((a 3))
a))))) ;; challenge: figure out what that last one returns, before checking interpreter
HOFs - 将过程作为参数
高阶函数(HOF)是一个执行以下一项或两项操作的函数:
-
将一个函数作为参数。
-
将一个函数作为输出返回。
在我们深入之前,让我们快速复习一下。
重新审视替换模型
你应该已经非常熟悉定义这样的函数:
(define (f x)
(plus1 x))
在这个函数定义中,f的参数是x,它被作为参数传递给内置过程plus1。如果我们从第 1 课中恢复我们的替换模型,我们可以说对f的调用,比如(f 5),将通过以下步骤进行评估:
-> (f 5)
-> (plus1 5)
6
参数5被替换到f的主体中,我们调用plus1得到6。好吧,这太容易了。但是,如果我们不将x作为主体中的函数参数,而是将其作为函数呢?
一个简单的高阶函数
让我们看一个例子。
(define (f g)
(g 2))
你看到了g在前面吗?嗯。如果这次我们调用(f 5)会发生什么?
-> (f 5)
-> (5 2)
; application: not a procedure;
; expected a procedure that can be applied to arguments
; given: 5
; [,bt for context]
糟糕。看起来我们需要给f传递一个过程。
-> (f square)
-> (square 2)
4
我们也可以将f传递给一个 lambda 函数!
-> (f (lambda (x) (* x x)))
-> ((lambda (x) (* x x)) 2)
-> (* 2 2)
-> 4
看那个!我们刚刚定义了一个函数f,它接受一个过程g作为参数,并将g��用于2。这就是你的第一个高阶函数。尝试一下,看看你是否可以定义自己的过程,接受其他过程作为参数。
将函数作为参数传递的用途
现在我们已经看到函数如何被传递,让我们实际探讨一下这如何有用。
考虑以下三个函数:
(define (sum-doubles a b)
(if (> a b)
0
(+ (* 2 a) (sum-doubles (+ a 1) b))))
(define (sum-squares a b)
(if (> a b)
0
(+ (square a) (sum-squares (+ a 1) b))))
(define (sum-cubes a b)
(if (> a b)
0
(+ (cube a) (sum-cubes (+ a 1) b))))
这三个函数分别计算 a 和 b 之间所有整数的双倍、平方和立方的和。
例如,(sum-squares 5 8)计算 5² + 6² + 7² + 8²。
定义这三个函数似乎有点多余。你是否注意到这三个函数在定义上几乎相同,除了代码中的下划线部分?是时候建立一些抽象了。
我们知道,对于这三个函数中的每一个,我们对a和b之间的每个元素应用某种操作。因此,我们不再为每个操作定义一个特定的函数,而是将其抽象化并放入函数参数中!
因此,我们不再为每个可能的运算符专门定义sum-[op]函数,而是只有一个名为sum的通用函数:
(define (sum fn a b)
(if (> a b)
0
(+ (fn a) (sum fn (+ a 1) b))))
我们在上面的代码中划出了主要的差异。在这个sum的定义中,我们将一些输入函数fn应用于a和b之间的每个数字,正如你在递归调用中所看到的。
现在,我们可以这样做:
(sum (lambda (x) (* 2 x)) 5 8)
和这个:
(sum square 5 8)
还有这个:
(sum cube 5 8)
只编写了一个过程sum,我们就获得了上面所有三个过程的功能。多么划算啊!
如果愿意,可以使用sum重新定义初始的三个过程如下:
(define (sum-squares a b)
(sum square a b))
(define (sum-cubes a b)
(sum cube a b))
(define (sum-doubles a b)
(sum (lambda (x) (* 2 x)) a b))
在你的作业中,我们将通过一个非常有用且广为人知的高阶函数accumulate进一步抽象sum。确保你理解accumulate的工作原理,因为你将在未来的练习中用到它!
HOFs - 作为返回值的过程
我们已经看到如何编写一个接受另一个过程作为参数的过程。事实证明,我们也可以做相反的事情 - 我们可以创建一个返回过程的过程!返回过程是进一步抽象的好方法。我们可以让程序为我们创建过程,而不是直接创建过程!根据我们给程序的参数,它可以创建许多不同的过程。
例子:make-power
(我们实际上并没有制造幂。那将是滥用权力;)。)
假设我们想要定义一个过程sum-powers,它将计算a和b之间每个数字的第n次幂并将它们相加。我们已经有我们的procedure,如下所示:
(define (sum f a b)
(if (> a b)
0
(+ (f a) (sum f (+ a 1) b))))
根据我们迄今所学到的,它看起来像这样:
(define (sum-powers n a b)
(sum (lambda (x) (expt x n)) a b))
但是,如果我们创建一个名为make-power的新函数,该函数根据给定的幂n返回一个函数,该函数接受一个数字x并返回其第n次幂?它看起来像这样:
(define (make-power n)
(lambda (x) (expt x n)))
正如我们之前指出的,lambda 函数返回函数。这意味着如果我们将对make-power的调用定义为 lambda 函数,它将返回一个函数!现在我们可以这样做:
(define square (make-power 2))
(define cube (make-power 3))
现在我们可以这样重写我们的 sum-powers 函数:
(define (sum-powers n a b)
(sum (make-power n) a b))
注意我们在抽象方面取得了多大进步。在这个实验室的开始,我们为每种不同类型的求和定义了不同的过程:sum-doubles,sum-squares和sum-cubes。
但现在,我们已经将求和本身抽象化,这样我们可以用一行清晰地表达任何求和。
应用高阶函数
注意: 这一部分比课程的其他部分更加密集。如果你在这一部分遇到困难,不要担心——这比我们期望你掌握的大部分内容都要高级。
在这里,我们将使用到目前为止学到的工具来探索两个应用示例:fixed-point和iterate。
fixed-point
我们首先尝试表达函数的不动点的计算。如果一个数字x满足方程f(x) = x,则称其为函数f的不动点。
一个找到某些函数f的不动点的算法是,我们从一个初始猜测开始,然后重复应用f,直到连续的值非常接近。
x
(f x)
(f (f x))
...
利用这个想法,我们将制作一个过程fixed-point,它将一直应用一个函数,直到我们找到两个连续值的差小于某个预定的tolerance。看看我们下面对fixed-point的定义:
(define tolerance 0.00001)
(define (fixed-point f first-guess)
(define (close-enough? v1 v2)
(< (abs (- v1 v2)) tolerance))
(define (try guess)
(let ((next (f guess)))
(if (close-enough? guess next)
next
(try next))))
(try first-guess))
例如,我们可以使用这种方法来近似余弦函数的不动点,从 1 作为初始近似开始:
-> (fixed-point cos 1.0)
0.7390822985224024
为了展示用fixed-point抽象函数的强大之处,我们将开发一种只需 3 行 Racket 代码就能计算平方根的方法!
计算某个数字x的平方根需要找到一个y,使得y² = x。将这个方程转化为等价形式y = x / y,你会看到我们正在寻找函数(lambda (y) (/ x y))的不动点。在代码中:
(define (sqrt x)
(fixed-point (lambda (y) (/ x y))
1.0))
如果你碰巧有一个解释器,你会发现这��起作用。要了解原因,请看,比如,(sqrt 4)的连续猜测值:
1
4/1 = 4
4/4 = 1
4/1 = 4
...
它只是不断地振荡!如果你仔细想想,它对我们放入的任何数字都会这样做(除了 0 或 1)。
因此,我们不再通过1来改变猜测值,而是通过稍微小一点。为了做到这一点,我们将下一个猜测值与当前猜测值平均。也就是说,在y之后的下一个猜测值是(1/2)(y + x/y),而不是x/y。
制作这样一个猜测序列的过程就是简单地寻找y = (1/2)(y + x/y)的不动点的过程:
(define (sqrt x)
(fixed-point (lambda (y) (* 0.5 (+ y (/ x y))))
1.0))
通过这种修改,平方根过程可以工作。这种平均连续逼近解的方法,SICP 作者称之为average damping,通常有助于固定点搜索的收敛。
因此,让我们继续我们的抽象狂热,并将平均阻尼技术也抽象化:
(define (average-damp f)
(lambda (y) (* 0.5 (+ y (f y)))))
现在,一个新的sqrt:
(define (sqrt x)
(fixed-point (average-damp (lambda (y) (/ x y)))
1.0))
非常清晰,对吧?
注意:
y = (1/2)(y + x/y)是方程y = x / y的一个简单转换;为了推导它,将y添加到方程的两边,然后除以2。
你可能已经注意到,我们已经有效地推导出了用于计算平方根的牛顿法。但是...还有很多其他方法!如果你感兴趣,这里有一个很酷的链接。
iterate
现在我们将用另一个高阶函数结束这节课。
这将允许我们编写fixed-point和largest-square(来自第 1 课)。
你会问,为什么?因为它们都属于迭代改进的一般形式。也就是说,你从一个值开始,并不断改进它直到足够好为止。
注意这里有 3 件事情需要抽象出来:
-
起始值
-
用于改进的函数
-
用于测试是否足够好的函数
所以,有了这些,我们知道了我们的参数以及它应该做什么,那么让我们来写吧!
(define (iterate start improve good-enough?)
(if (good-enough? start)
start
(iterate (improve start) improve good-enough?)))
现在,我将用iterate来表达largest-square:
(define (largest-square total guess)
(iterate guess
(lambda (x) (+ x 1))
(lambda (x) (< total (square (+ x 1))))
))
我们的抽象狂热(大部分)到此为止,但要保持警惕。抽象是让程序员编写复杂但可读性强的系统的关键。
不要错过一个好的抽象机会。
作业 2
模板
在终端上输入以下命令将模板文件复制到当前目录(注意末尾的句点):
cp ~cs61as/autograder/templates/hw2.rkt .
或者您可以在这里下载模板。
自动评分器
如果您在实验室计算机上工作,grader命令将运行自动评分器��如果您在自己的个人计算机上工作,您应该下载grader.rkt和HW 2 tests。
练习 1
编写一个过程substitute,它接受三个参数:一个句子,一个旧单词和一个新单词。它应返回句子的副本,但是将每个旧单词的出现替换为新单词。
-> (substitute '(she loves you yeah yeah yeah) 'yeah 'maybe)
(she loves you maybe maybe maybe)
练习 2
将以下内容输入到 Racket 中,并注意结果。看看你能否在让 Racket 进行计算之前预测结果。
(lambda (x) (+ x 3))
((lambda (x) (+ x 3)) 7)
make-adder是一个返回另一个函数的函数。
(define (make-adder num)
(lambda (x) (+ x num)))
((make-adder 3) 7)
(define plus3 (make-adder 3))
(plus3 7)
(define (square x) (* x x))
(square 5)
(define sq (lambda (x) (* x x)))
(sq 5)
(define (try f) (f 3 5))
(try +)
(try word)
练习 3
考虑一个函数g,对于表达式
((g) 1)
在评估时返回值为3。
确定g有多少个参数。用一个词描述g返回的值的类型。
练习 4
对于以下每个表达式,为了使表达式的评估成功且不会引起错误,f必须是什么?对于每个表达式,给出一个f的定义,使得评估表达式不会引起错误,并说明根据你的定义,表达式的值是什么。明确地说,对于第一个,定义f1,对于第二个,定义f2,依此类推。
-
f1 -
(f2) -
(f3 3) -
((f4)) -
(((f5)) 3)
练习 5
找出以下表达式的值,其中add1是一个将其参数加一的原始过程,t定义如练习 5 中所示:
(define (t f)
(lambda (x) (f (f (f x)))) )
在计算机上尝试这些之前先解决它们。
-
((t add1) 0) -
((t (t add1)) 0) -
(((t t) add1) 0)
练习 6
找出以下表达式的值,其中t的定义如练习 5 中所示,s定义如下:
(define (s x)
(+ 1 x))
在计算机上尝试这些之前先解决它们
-
((t s) 0) -
((t (t s)) 0) -
(((t t) s) 0)
练习 7
编写并测试make-tester过程。给定一个单词w作为其参数,make-tester返回一个带有一个参数x的过程,如果x等于w则返回true,否则返回false。
-> ((make-tester 'hal) 'hal)
#t
-> ((make-tester 'hal) 'cs61a)
#f
-> (define sicp-author-and-astronomer? (make-tester 'gerry))
-> (sicp-author-and-astronomer? 'hal)
#f
-> (sicp-author-and-astronomer? 'gerry)
#t
练习 8
完成 SICP 练习1.31a,1.32a,1.33,1.40,1.41和1.43。对于其中一些问题,您需要阅读 SICP 文本的部分。
一些额外的指导方针:
-
对于 1.31a,你应该基于文本中较早的
sum函数构建你的product函数。它应该接受四个参数(term、a、next和b)。找到sum函数并弄清楚每个参数的作用。 -
对于 1.31a,估算π的函数应该被称为
estimate-pi(参见模板)。它不应接受任何参数,并且应使用给定公式的至少 100 个项来估算π。 -
对于 1.33,谓词应该是
filtered-accumulate的最后一个参数(参见模板)。 -
对于 1.33,你应该定义函数
sum-sq-prime和prod-of-some-numbers(参见模板)。 -
对于 1.40,不用担心学习牛顿法。只需完成
cubic,它接受三个参数(a、b和c)并返回另一个过程。该过程应接受一个输入x,并在x处计算问题中显示的三次方程。 -
对于 1.43,将你的过程命名为
my-repeated而不是repeated(参见模板)。
练习 9
上周你写了一个名为squares的过程,它会将其参数句子中的每个数字平方,并看到了pigl-sent,它会将其参数句子中的每个单词进行pigl处理。将这种模式概括为创建一个名为my-every的高阶过程,该过程将作为参数给定的任意过程应用于参数句子中的每个单词。
-> (my-every square '(1 2 3 4))
(1 4 9 16)
-> (my-every first '(nowhere man))
(n m)
练习 10
使用高阶函数,我们的simply-scheme库提供了上一练习中的every函数和我们课程中展示的keep函数的自己版本。在尝试在计算机上运行之前,通过解决这些示例来熟悉它们:
-
(every (lambda (letter) (word letter letter)) 'purple) -
(every (lambda (n) (if (even? n) (word n n) n)) '(781 5 76 909 24)) -
(keep even? '(781 5 76 909 24)) -
(keep (lambda (letter) (member? letter 'aeiou)) 'bookkeeper) -
(keep (lambda (letter) (member? letter 'aeiou)) 'syzygy) -
(keep (lambda (letter) (member? letter 'aeiou)) '(purple syzygy)) -
(keep (lambda (wd) (member? 'e wd)) '(purple syzygy))
提交你的作业!
有关说明,请参阅此指南。它涵盖了基本的终端命令和作业提交。
如果在提交作业时遇到任何问题,请不要犹豫向助教寻求帮助!
项目 1 - Chatterbot
现在是时候将你的技能付诸实践了。在我们的课程和作业中,我们创建了几行代码不超过的小项目。现在我们将完成一个更大的项目:Chatterbot。
chatterbot 是一个能够与用户“交谈”并模拟对话的程序。在这个项目中,我们将使用你在本单元学到的概念来实现一些简单的 chatterbot。
要获取此项目的框架代码,请在终端中输入:
cp -r ~cs61as/lib/chatterbot/ .
你也可以单独下载这些文件这里。
你应该会有一个名为chatterbot的目录,里面有四个文件:
-
readme.txt 包含此项目的说明和问题。
-
chatterbot.rkt 是 Chatterbot 的主文件。你将在这里编写所有的代码(不包括测试)。
-
grader.rkt 是此项目的自动评分程序。请注意,它并不全面。你将在这里编写自己的测试,并它们将计入项目成绩。
-
adjectives.rkt 包含一组形容词。请不要修改此文件!
这是一个个人项目。你可以向同学请教想法,但分享或复制他人的代码(或在网上找到的代码)被视为作弊。CS 61AS 学术诚信政策可以在教学大纲页面找到。
在提交之前,请确保你的文件能够无错误加载。加载不正确的提交将不会得到任何学分。
如果你有任何问题,请在 Piazza 上、办公时间或实验室期间提问。
祝你玩得开心!
3 - 递归,迭代,效率
第 3 课简介
资源和计算
计算机很强大,但它们也有限制。因此,程序员的一部分工作是有效地管理计算机的资源——如果程序员编写的程序效率太低,计算机将耗尽资源尝试执行它。程序的效率低下有两种广泛的方式:空间和时间。空间是计算机执行程序所需的“草稿纸”数量。时间是计算机完成运行程序所需的时间。在接下来的章节中,我们将详细研究这两个方面。
首先,我们将看看空间。一个效率低下的程序可能占用太多空间而导致计算机崩溃。我们将研究造成这种情况的原因以及如何通过一种特定类型的递归——尾递归来防止这种情况发生。
接下来,我们将考虑时间。有些程序可能运行的时间比宇宙的寿命还长。(例如,我们知道的最好的程序来找到国际象棋的完美解决方案。)我们将学习如何识别这类程序,并更普遍地介绍和实践一种方法来衡量我们的程序在时间上的效率。
最后,我们还将描述另一种新类型的递归——树递归。在我们的程序中利用树递归使我们能够解决以前无法解决的问题。我们将特别关注这种新技术带来的成本和收益。
阅读材料
您可以在书籍和讲座笔记中查阅额外的阅读材料。每节课的每个部分都有更具体的书籍链接,以便您在有疑问时查阅。
空间
递归过程
要开始探索过程如何使用空间,请考虑以下过程:
(define (factorial n)
(if (= n 0)
1
(* n (factorial (- n 1)))))
如果我们手动评估(factorial 5),写出每一步,我们会得到以下结果:
start with (factorial 5)
replaced by (* 5 (factorial 4))
replaced by (* 5 (* 4 (factorial 3)))
replaced by (* 5 (* 4 (* 3 (factorial 2))))
replaced by (* 5 (* 4 (* 3 (* 2 (factorial 1)))))
replaced by (* 5 (* 4 (* 3 (* 2 (* 1 (factorial 0))))))
replaced by (* 5 (* 4 (* 3 (* 2 (* 1 1)))))
replaced by (* 5 (* 4 (* 3 (* 2 1))))
replaced by (* 5 (* 4 (* 3 2)))
replaced by (* 5 (* 4 6))
replaced by (* 5 24)
replaced by 120
每一行描述了计算的一个新步骤——我们需要在那个时间步骤记住什么以便继续评估。这里是关键观察:如果我们选择一个足够大的输入,比如 10000,那么在某个步骤,我们将无法将整行记在脑中。
计算机以相同的方式评估过程调用。每个函数调用都存储在计算机的工作内存中——这是一个用于存储中间、不完整计算的地方。这个空间是有限的,可能会溢出。需要记住的重要一点是,这个问题只会在非常大的输入上发生。这个"工作内存"被称为"调用堆栈"。当程序使用完这个空间时,就会发生"堆栈溢出"。
迭代过程
有没有一种方法可以修复阶乘,使其在大输入时不会使计算机耗尽空间?考虑以下内容:
(define (factorial n)
(fact-iter 1 1 n))
(define (fact-iter product counter max-count)
(if (> counter max-count)
product
(fact-iter (* counter product)
(+ counter 1)
max-count)))
测试你的理解。为什么我们必须说(> counter max-count)?如果我们执行(fact-iter 1 1 3)会发生什么?现在如果我们用上面的代码来绘制(factorial 5),我们会得到以下结果:
start with (factorial 5)
replaced by (fact-iter 1 1 5)
replaced by (fact-iter 1 2 5)
replaced by (fact-iter 2 3 5)
replaced by (fact-iter 6 4 5)
replaced by (fact-iter 24 5 5)
replaced by (fact-iter 120 6 5)
replaced by 120
在这种情况下,我们在评估过程中需要保留的不完整计算量在每一步都没有增长。相反,我们通过参数传递计算的不完整答案,这样可以节省空间。换句话说,计算的完整状态保存在递归调用的参数中——调用(fact-iter 2 3 5)会产生与调用(fact-iter 1 1 5)相同的结果,而调用(factorial 3)会产生与调用(factorial 5)不同的结果。
注意,这个迭代过程仍然使用递归,但这与说它是一个递归过程是不同的。
比较迭代和递归过程
将这些函数并排看,我们可以确定这两个过程之间的关系。
| 递归 | 迭代 |
|---|
|
(define (factorial n)
(if (= n 0)
1
(* n (factorial (- n 1)))))
|
(define (factorial n)
(fact-iter 1 1 n))
(define (fact-iter product counter
max-count)
(if (> counter max-count)
product
(fact-iter (* counter product)
(+ counter 1)
max-count)))
|
对于这两个过程的重要观察:
-
递归版本中作为基本情况返回的值充当迭代版本中
product的起始点(1)。注意,调用迭代(factorial 1)会导致在fact-iter中触发基本情况,并返回 1。 -
在递归版本中,
*过程在递归调用之外被调用。在迭代版本中,所有参数在(或在)递归调用之前被转换。也就是说,(* counter product)首先发生,然后进行递归调用。这就是迭代版本更节省空间的关键所在。 -
作为一个推论,这意味着递归调用是在过程调用中被"最后"评估的表达式(与乘法相反)。
-
因为迭代版本需要跟踪更多的参数,它需要一个辅助过程,递归实际上发生在那里。这在迭代过程中经常发生。
尾递归和编写尾递归过程
在正式术语中,迭代factorial更加节省空间,因为 Racket 解释器实现了尾递归消除。在其他编程语言和其他未经过尾递归优化的解释器中,递归和迭代版本在运行时使用相同的空间。那么我们为什么要关心呢?
-
引入这些主题使我们更深入地理解递归、求值和编程语言,为其他主题提供了坚实的背景
-
这是一个练习思考资源使用和权衡的机会,这对软件工程和计算机科学通常很重要。
要编写尾递归过程,以下是一些建议。
-
弄清楚是否需要保留额外的参数来跟踪计算状态,以及是否需要辅助过程。几乎总是需要某种参数来跟踪"到目前为止的答案"。在
factorial中,这是product。 -
实际上,作为一个一般规则,过程的递归版本会尝试使其参数逐渐变小,而迭代版本则会构建一个结果。因此,在编写迭代过程时,考虑一下需要的起始值以便"构建"你的答案。在
factorial中,这是 1、1 和n,在(fact-iter 1 1 n)中。 -
确保递归调用发生在"最后一刻"。实际上,这意味着在进行递归调用之前处理参数(相加、相减、相乘、
butfirst等)。
练习
以下问题是为了帮助你理解。你不会被评分。你可以向工作人员核对你的答案。
-
迭代
factorial在fact-iter中跟踪三件事。那些事情是什么?我们能否再次重写factorial以便只跟踪两件事? -
如果我们使用迭代版本,调用
factorial会不会导致空间耗尽?
进一步阅读
时间
测量时间效率
为了衡量程序运行的速度,我们必须设计一种合理的方法。使用秒表来测量所需时间是行不通的,因为不同程序在后台运行时,计时会发生变化,随机波动,太阳耀斑等。此外,新计算机通常更快,旧的计时不适用。
更好的方法是计算一个过程所需的步骤数。专注于过程可以避免被绑定到任何特定计算机的问题。
计算步骤
以下是我们将考虑为“一步”的一些过程:
以下所有过程都只需一步。
-
基本算术运算
-
定义变量
-
定义过程
-
条件语句?
-
用户定义的过程调用
例子:计算步骤
(define (square x) (* x x)) ; takes a single step
(square 4) ; would take 2 steps (one for the procedure call, and one for the multiplication)
(square (+ 2 3)) ; 3 steps
然而,最有趣的问题是当我们比较一个过程和另一个过程时,问哪一个更快。为了进行这种比较,我们必须为每个过程问以下问题:
随着参数规模的增加,这个过程需要多少步才能运行?
换句话说,如果我们将一个过程所需的步骤数量(以输入为 x 轴)绘制成图表,那么图表的形状是什么?
例子:函数运行时间
-
对于
square,我们说这是一个常数时间的过程--(square 2)所需的步骤与(square 2000)相同。因此,随着输入规模的增加,步骤数量保持不变。 -
对于
last,这个找到句子最后一个单词的过程,我们说这是一个线性时间的过程--当我们在越来越大的输入上调用last时,步骤数量呈线性增长。
为了形式化这一点,我们必须学习一个叫做增长阶的数学构造。
增长阶
增长阶描述函数之间的关系。给定两个函数f(n)和g(n),当我们说f = Θ(g)时,我们的意思是存在两个数a和b,使得对于足够大的n值,ag(n) ≤ f(n) ≤ bg(n)。
例子
-
当f(n) = n和g(n) = 329n时,f = Θ(g)。
-
当f(n) = 4n²和g(n) = 2n²+n时,f = Θ(g)。
-
当f(n) = .0004n³和g(n) = 1000n²+30000n时,f不等于Θ(g)。
根据这些例子,我们有以下规则
-
我们可以忽略过程中的常数因子
-
我们可以忽略较低的项,例如在2n²+n中,我们只关心n²。
回到过程,我们可以正式说square是Θ(1),last是Θ(n)。
例子:指数
考虑计算b^n(b的n次方)的直接方法:将b乘以自身n次。以下是代码。
(define (expt b n)
(if (= n 0)
1
(* b (expt b (- n 1)))))
这在n变量方面以线性时间运行。我们之所以知道这一点,是因为有两个观察结果
-
如果
n为 2,我们会进行两次递归调用,如果n为 10,那么我们会进行 10 次递归调用。 -
在每次递归调用中,都会进行Θ(1)的工作。
我们能做得更好吗?原来有一个更聪明的指数算法,利用了连续平方的思想。
假设我们想要计算 b⁸。通常,我们会做 b * b * b * b * b * b * b * b。这需要 8 次乘法。相反,我们可以用 3 次完成:
如果指数是 2 的幂,这个方法很有效。如果我们使用规则
,我们也可以利用连续平方来计算一般的指数。
上述技巧给出了这个过程:
(define (fast-expt b n)
(cond ((= n 0) 1)
((even? n) (square (fast-expt b (/ n 2))))
(else (* b (fast-expt b (- n 1))))))
(define (even? n)
(= (remainder n 2) 0))
对每个偶数进行平方可以减少递归调用的数量。事实上,如果你仔细想一想,每隔一个递归调用,我们就把n减半。这种通过减半问题规模的模式意味着所需的递归调用数量与n的对数成比例。因此,fast-expt = Θ(log(n))。(如果这个解释不合理,请查看 Further Reading 中的 1.2.4。)
练习
这里有一些简短的、不计分的练习,用于练习查找函数的运行时间。
bar 的运行时间是多少?
define (bar n)
(if (zero? (remainder n 7))
'Bzzst
(bar (- n 1)) ))
排序的运行时间是多少?
(define (sort s)
(if (empty? s)
'()
(insert (sort (bf s)) (first s)) ))
(define (insert sorted-sofar n)
(if (empty? sorted-sofar)
(se n)
(if (< n (first sorted-sofar))
(se n sorted-sofar)
(se (first sorted-sofar) (insert (bf sorted-sofar) n)) )))
进一步阅读
树递归
一个新类的问题
有一些问题我们尚未明确描述递归模式。考虑以下问题:
我想爬一段有
n步的楼梯。每次我可以迈出 1 步或 2 步。我可以以多少种不同的方式爬上这段楼梯?
例如,当n为 5 时,有 8 种可能的方式:
`1 1 1 1 1
2 1 1 1
1 2 1 1
1 1 2 1
1 1 1 2
1 2 2
2 1 2
2 2 1`
为了解决这个问题,我们必须引入一种称为树递归的模式。树递归只是用来描述在递归情况下多次进行递归调用的短语。为什么我们需要在这里这样做?考虑上面问题的一个解决方案:
(define (count-stairs n)
(cond [(= n 1) 1]
[(= n 2) 2]
[else (+ (count-stairs (- n 1))
(count-stairs (- n 2)) ]) ))
将程序分解,有三个部分需要考虑
-
有两种基本情况,有两种不同的结果。
-
如果只有一步要爬,那么只有一种方式(通过迈出那一步)
-
如果有两步要爬,那么有确切两种方式(一步一步,或者两步)
-
-
否则,通过将问题分解为两个世界,问题变得更小
-
在第一个世界中,我们走一步,因此步数减少了一步
-
在第二个世界中,我们走两步,因此步数减少了两步
-
-
对这些较小问题进行两次递归调用,我们得到这些较小问题的答案,并将它们相加得到原始问题的答案。
count-stairs是树递归的,因为每当它被调用时,递归调用会分支出并形成一个倒置的树。例如,(count-stairs 5): .png)
计算找零
让我们考虑一个更难的问题:
有多少种不同的方式可以找零$1.00,给定半美元、25 美分、10 美分、5 美分和 1 美分?更一般地说,我们能否编写一个函数来计算使用任何货币面额的任何给定金额的找零方式数量?
我们以与上面类似的方式解决问题。通过仔细思考问题陈述,我们可以注意到我们必须跟踪两件事:我们当前的金额是多少,以及我们必须使用哪些硬币(我们可以在一个句子中跟踪这一点,例如'(50 25 10 5 1))。从那里,我们可以观察到关于我们基本情况的一些事情:
-
如果金额恰好为 0,我们应该将其视为一种找零的方式
- 这可能看起来有些违反直觉,但对于$0,有且仅有一种找零的方法--不使用任何硬币。
-
如果金额小于 0,我们应该将其视为 0 种找零的方式。
- 你不能为负数金额找零!
-
如果我们用完了要使用的硬币,我们应该将其视为 0 种找零的方式。
- 一旦我们考虑递归情况,这将变得更直观。
对于递归情况,我们再次必须进行两次递归调用。这两次递归调用将我们的问题分成两个世界:
-
在一个世界中,我们使用最大的硬币(
(50 25 10 5 1)中的第一个)- 为什么硬币是那个顺序?因为这样更容易推理。句子可以按不同顺序排列,虽然这会影响计算,但不会影响结果。
-
在另一个世界中,我们再也不使用最大的硬币了。
- 例如,如果我们再也不使用半美元,我们的新句子应该是
(25 10 5 1)。
- 例如,如果我们再也不使用半美元,我们的新句子应该是
当我们将这个转换成代码时,我们得到以下结果:
(define (count-change amount)
(cc amount `(50 25 10 5 1)))
(define (cc amount kinds-of-coins)
(cond [(= amount 0) 1]
[(or (< amount 0) (empty? kinds-of-coins)) 0]
[else (+ (cc amount
(bf kinds-of-coins))
(cc (- amount
(first kinds-of-coins))
kinds-of-coins))] ))
对于时间效率
树递归过程通常需要指数时间来计算。我们为什么要使用它们呢?
-
有些问题通过递归地思考更容易解决。尝试用另一种语言编写使用 for 循环的找零问题。
-
有些问题非常难以解决,意味着我们已知的最快算法在运行时间上仍然是指数级的。
-
结果我们可以优化树递归过程而不改变其形状,这是我们稍后会在课程中涵盖的内容。
进一步阅读
作业 3
模板
在终端输入以下命令将模板文件复制到当前目录(注意末尾的句点):
cp ~cs61as/autograder/templates/hw3.rkt .
或者你可以在这里下载模板。
自动评分程序
如果你在实验室计算机上工作,grader 命令将运行自动评分程序。如果你在自己的个人机器上工作,你应该下载grader.rkt和HW 3 tests。
练习 1: 快速指数不变量
这是之前课程中的fast-expt过程:
(define (even? n)
(= (remainder n 2) 0))
(define (fast-expt b n)
(cond ((= n 0) 1)
((even? n) (square (fast-expt b (/ n 2))))
(else (* b (fast-expt b (- n 1))))))
设计一个过程,演变出一个使用连续平方的迭代求幂过程,并使用对数数量的步骤,就像fast-expt一样。
(提示:利用观察到的事实(b^(n/2))² = (b²)^(n/2),保持,连同指数n和基数b,一个额外的状态变量a,并定义状态转换,使得乘积a b^n从一个状态到另一个状态保持不变。在过程开始时a被取为 1,答案由过程结束时的a的值给出。一般来说,定义一个在状态之间保持不变的不变量数量是思考设计迭代算法的强大方式。)
练习 2: 黄金比例(可选)
阅读 SICP 中关于查找函数的不动点的子章节,并完成练习 1.35。
练习 3: cont-frac
第 1 部分
无限连分数是一个形式为:
[mathjax]f=\frac{N_1}{D_1+\frac{N_2}{D_2+\frac{N_3}{D_3+\cdots}}}[/mathjax]
举例来说,可以证明
[mathjax]\frac{1}{\phi}=\frac{1}{1+\frac{1}{1+\frac{1}{1+\cdots}}}[/mathjax]
其中[mathjaxinline]\phi=\frac{1+\sqrt{5}}{2}[/mathjaxinline]是黄金比例。近似无限连分数的一种方法是在给定项数后截断展开。这种截断——所谓的[mathjaxinline]k[/mathjaxinline]项有限连分数——具有以下形式:
[mathjax]\frac{N_1}{D_1+\frac{N_2}{\ddots+\frac{N_k}{D_k}}}[/mathjax]
假设n和d是一个参数(项索引[mathjaxinline]i[/mathjaxinline])的过程,返回连分数的第[mathjaxinline]i[/mathjaxinline]项的[mathjaxinline]N[/mathjaxinline]和[mathjaxinline]D[/mathjaxinline]。定义一个过程cont-frac,使得评估(cont-frac n d k)计算[mathjaxinline]k[/mathjaxinline]项有限连分数的值。通过近似[mathjaxinline]\frac{1}{\phi}[/mathjaxinline]来检查你的过程
(cont-frac (lambda (i) 1.0)
(lambda (i) 1.0)
k)
对于连续的k值。为了获得精确到小数点后 4 位的近似值,你需要将k设定为多大?
第 2 部分
如果你的cont-frac过程生成了一个递归过程,请写一个生成迭代过程的过程。如果它生成了一个迭代过程,请写一个生成递归过程的过程。
第 3 部分
1737 年,瑞士数学家莱昂哈德·欧拉表明
[mathjax] e - 2=\frac{N_1}{D_1+\frac{N_2}{D_2+\frac{N_3}{D_3+\cdots}}} [/mathjax]
对于参数
[mathjax] \begin{cases} N_i = 1\ D_i = 1,2,1,1,4,1,1,6,1,1,8,\cdots \end{cases} [/mathjax]
其中[mathjaxinline]e[/mathjaxinline]是自然对数的底。编写一个程序,使用你的cont-frac过程来近似[mathjaxinline]e[/mathjaxinline],使用欧拉展开。
练习 4:next-perf
完美数被定义为等于所有小于自身的因子之和的数。例如,第一个完美数是 6,因为它的因子是 1、2、3 和 6,而 1+2+3=6。第二个完美数是 28,因为 1+2+4+7+14=28。第三个完美数是多少?
编写一个过程(next-perf n),从n开始测试连续整数,直到找到一个完美数。然后你可以评估(next-perf 29)来解决问题。注意,你的过程应该能够处理任何非负整数输入。
提示:你会需要一个sum-of-factors子过程。
注意:如果在系统负载繁重时运行此程序,可能需要半小时来计算答案!尝试跟踪辅助过程,确保你的程序正常运行,或者从计算(next-perf 1)开始,看看是否得到 6。
练习 5:交换基本情况
这是本课程中较早时的count-change程序的定义:
(define (count-change amount)
(cc amount `(50 25 10 5 1)))
(define (cc amount kinds-of-coins) (cond [(= amount 0) 1] [(or (< amount 0) (empty? kinds-of-coins)) 0] [else (+ (cc amount (bf kinds-of-coins)) (cc (- amount (first kinds-of-coins)) kinds-of-coins))] ))
解释在cc过程中交换基本情况检查顺序的影响。
也就是说,完全描述原始cc过程将返回不同值或行为不同于下面给出的cc过程编码的参数集,并解释返回值的差异。
(define (cc amount kinds-of-coins)
(cond
[(or (< amount 0) (empty? kinds-of-coins)) 0]
[(= amount 0) 1]
[else ... ] ) ) ; as in the original version
练习 6:求幂的不变量
这是本课程中较早时的迭代求幂过程:
(define (expt b n)
(expt-iter b n 1))
(define (expt-iter b counter product)
(if (= counter 0)
product
(expt-iter b
(- counter 1)
(* b product))))
给出一个代数公式,将迭代求幂过程的参数b、n、counter和product的值联系起来。
(我们要找的答案是“b、n和counter乘以product的和总是等于 37”。)
提交你的作业!
有关说明,请参见此指南。它涵盖了基本的终端命令和作业提交。
如果你在提交作业时遇到任何问题,请不要犹豫向助教求助!
单元 2
4 - 数据抽象和序列
第 4 课介绍
数据抽象和序列
还记得在第 0.1 课中关于抽象的所有花里胡哨的说法,以及在第 2 课中使用高阶函数预览抽象吗?在这节课中,你将终于尝试创建一些非常有趣的抽象数据类型。你将会学到,在编程时,控制复杂性和分层抽象将会使你拥有干净、专业的代码。
先决条件和预期内容
先决条件:学习计算机科学是渐进的。确保你了解所有先前的课程,特别是第 1 和第 2 课。
预期内容:编程可以用以下三个(非常非常通用的)类别概括:
-
存储数据
-
提取数据
-
操纵数据
在第 2 课中,我们学习了过程抽象。换句话说,我们学会了如何使用抽象创建通用的、“可定制的”过程。这属于上述第三类别,数据操作。例如,我们有一个数字列表 - 我们的数据 - 我们想要操作它以找到所有平方的和。我们使用我们抽象的过程sum对此进行了泛化。如果你不记得这个,我们建议你花一点时间回顾。
本课程是关于数据抽象的,属于前两个类别。我们将首先介绍用于存储数据的数据结构(对和列表),展示如何提取和操作它们(map),然后最终教你如何创建自己的抽象数据类型。
加油。
读物
以下是本课程的相关阅读材料:
-
讲座笔记(忽略关于 MapReduce 的部分 -- 这与 map 不同!)
表示序列
在我们开始实际的数据抽象之前,让我们先谈谈我们将用来存储数据的数据结构:对。到目前为止,我们知道存储信息的唯一方法是使用句子。在本节中,我们将介绍使用对来组合和存储数据的概念。对是多才多艺且易于构建的,因为它们可以相互嵌套以创建列表,这些数据结构与第 1 课中的句子非常相似。
对
一般来说,我们作为人类往往本能地将事物视为多个项目的集合或组合。一本书是纸上的文字集合。沙拉是叶子和其他美味食物的组合。现在,让我们改变这种观点。在 Racket 中,以及在计算机科学中的很多领域,事物被对表示。那么,如果一对只是两个项目,我们要如何存储多个项目呢?事实证明,一对的第二个项目通常是指向另一对的指针!而且,如果我们让一对指向其他一对,再指向其他一对,我们可以在这种数据结构中存储尽可能多的信息。这很好地遵循了计算机科学中的规则,即任何事物都可以用二进制表示。
创建对
在 Racket 中,我们使用函数cons创建一对,该函数接受两个任意类型的参数并返回一个对。为了在视觉上表示这一点,我们可以将一对看作一个有两半的盒子:

第一半被称为对的car,而第二半是cdr。它们各自有相应的同名选择器。过程car和cdr都以一对作为其唯一参数,并分别返回该对中的第一个和第二个项目。
让我们看下面的例子,我们创建了数字3和4的一对:
-> (cons 3 4)
(3 . 4) ;; notice how there is a period between 3 and 4
-> (car (cons 3 4))
3
-> (cdr (cons 3 4))
4
在视觉上,这对会看起来像这样:

这种视觉表示被称为盒子和指针图,是在未来更复杂时理解对的极其有用的工具。
让我们看另一个例子:
-> (cons 'hello 'world)
(hello . world)
-> (define greeting (cons 'hello 'world))
greeting ;; store the pair into a variable called greeting
-> (car greeting)
hello
-> (cdr greeting)
world
正如你所看到的,对可以存储任何类型的数据 - 数字、单词、过程,甚至更多的对!
测试你的理解
编写一个名为 func-pair 的过程,它接受一个 car 是一个参数的函数且 cdr 是一个数字的对。func-pair 返回当我们将该函数调用到该数字时返回的值。
首先在 Racket 解释器中尝试一下。然后,检查下面的答案。
对存储其他对是很常见的,因为这样可以让我们在一个对中存储尽可能多的信息。让我们看看这个嵌套对示例的盒子和指针图会是什么样子:
-> (cons (cons 1 2) 4)
((1 . 2) . 4)
注意这对的car是另一对,(cons 1 2),而cdr是4。在这种情况下,这应该是我们绘制盒子和指针图的方式:

你可以想象我们可以以多少种方式存储大量数据!
测试你的理解
给定以下代码片段:
(define z (cons (cons 1 2) 4))
以下表达式将返回什么?看看你是否可以在没有 Racket 解释器的情况下弄清楚,然后点击相应的气泡查看正确答案。
我们也可以让一对的cdr指向空列表,写作'()。例如,我们可以这样做:
-> (cons 1 '())
(1)

这有什么用呢?我们何时会想要将"nothing"存储到我们的对中?让我们耐心等待并看看下一个例子。假设我们在解释器中键入以下内容:
-> (cons 1 (cons 2 '()))
尝试自己绘制框和指针图,然后猜测 Racket 会打印出什么。然后,用解释器检查你的工作。
实际输出是否符合你的预期?你可能认为表达式会返回类似(1 . (2 . ()))的内容。相反,你得到了(1 2)。这是因为 Racket 有一种巧妙的方法简化嵌套对!由于(cons a (cons b (cons c (cons ...))))这种格式经常使用,每次 Racket 看到一个句点后跟着一个开括号,它会简化表达式如下:
(1 ~~. (~~2 ~~. ())~~)
(1 2)
这里有一些练习问题供你尝试。对于以下每个表达式,请尝试绘制相应的框和指针图,然后写出 Racket 解释器将打印出什么:
(cons 4 5)
(cons (cons 2 (cons 4 5)) (cons 6 7))
(cons 3 (cons (cons 1 4) (cons 5 '())))
(cons 1 (cons 2 (cons 3 '())))
以下表达式将返回什么?如果卡住了,请绘制一个框和指针图。
(car (cons 4 5))
(car (cdr (car (cons (cons (cons 4 5) (cons 6 7)) (cons 1 (cons 2 3))))))
(cdr (cdr (cdr (cons 1 (cons 2 (cons 3 '()))))))
一些简写
一系列car和cdr可能非常丑陋。在我们的 Racket 解释器中,有一种内置的简写表示法可以执行多次调用car和/或cdr。
(car (cdr a))等同于(cadr a)。
(car (cdr (car (car a))))等同于(cadaar a)。
注意在第一个例子中,如果我们取某个序列a的cadr,我们首先取a的cdr,然后取从中返回的内容的car。一般来说,你可以从一串car和cdr中提取a和d,并将它们以相同的顺序在一个c和一个r之间连接在一起。你可以做到cxxxxr(4 个x),其中x是a或d。
列表
测试你的理解
使用cons写出一个表达式,以便 Racket 打印出(5 6 7 8)。点击下面以显示答案。
反复使用这种cons模式可能会变得相当乏味。因为这种情况如此常见,Racket 还有另一个内置过程可以为我们创建嵌套的cons:list。list接受任意数量的任何类型的参数,并将其作为嵌套的cons或list返回。例如:
-> (cons 5 (cons 6 (cons 7 (cons 8 '()))))
(5 6 7 8)
-> (list 5 6 7 8) ;; this is identical to the expression above!
(5 6 7 8)
-> (list 'hello 'world 5 #t)
(hello world 5 #t)
我们可以使用以下递归定义正式定义list:列表要么是空列表,写作'(),要么是其cdr是另一个列表的对。请注意,这意味着如果我们不断地取任何列表的cdr,我们最终将始终得到空列表。
我们可以通过简单地将每个列表重写为嵌套的cons来为列表绘制框和指针图。例如,(list 1 2 3)的框和指针图与(cons 1 (cons 2 (cons 3 '())))的相同:

因此,我们学到了一个非常重要的关键思想:每个列表都是一对。但反之并不成立 - 并非所有的对都是列表。(cons 1 2)是一对,但它不是一个列表。
Append
现在我们几乎拥有在 Racket 中表示集合和序列所需的所有工具了!我们缺少的是一种轻松地将两个列表组合在一起的方法。例如,假设我们有列表(list 1 2 3)和(list 4 5 6),我们想将它们组合成一个形如(list 1 2 3 4 5 6)的大列表。Racket 有一个过程可以为我们做到这一点:append。给定任意数量的列表,append将返回一个包含其参数列表所有元素的列表。
在上面的例子中调用append时,使用框和指针图看起来是这样的。
我们从两个列表(1 2 3)和(4 5 6)开始:

然后,我们移除第一个列表末尾的空指针,并将其指向第二个列表的开头:

Append:幕后
这里是append在幕后是如何工作的。记得我们关于列表的递归定义告诉我们列表的最后一个cdr总是指向空列表吗?首先,append获取它的第一个参数列表,并沿着cdr指针直到找到列表的最后一对。然后,它用要append的第二个参数列表替换那个最后一对指向的cdr的值。这对你来说可能听起来像一堆废话。看看下面的例子,可能会更清楚一些:
-> (define list1 (list 1 2 3 4))
list1 ;; the last pair of list1 is (4 . ())
-> (define list2 (list 5 6 7 8))
list2 ;; the last pair of list2 is (8 . ())
-> (define list3 (list 9 10 11 12))
list3
-> (append list1 list2 list3) ;; we take the cdr of list1's last pair, which is the empty list '(), and point it to list2\. then, we take the cdr of list2's last pair, which is also '(), and point it to list3.
(1 2 3 4 5 6 7 8 9 10 11 12)
只有除了最后一个参数外其他参数都是列表时append才会起作用。你能解释为什么最后一个参数不必是列表吗?当你调用append时,最后一个参数不是列表时 Racket 会返回什么?
检验你的理解
以下哪个调用append会出错?
列表运算符和 HOFs
列表运算符
Racket 为列表提供了有用的原始过程:
list-ref
list-ref接受一个列表和一个数字n作为参数,并返回列表的第n个项目。列表的第一个元素被索引为0,意味着它是列表的第0个元素。这是list-ref的定义方式:
(define (list-ref lst n)
(if (= n 0)
(car lst)
(list-ref (cdr lst) (- n 1))))
这里是一个演示它如何工作的示例:
-> (define squares (list 1 4 9 16 25))
squares
-> (list-ref squares 3)
16
null?
null?接受一个列表作为参数,如果列表为空则返回#t。否则,返回#f:
(null? (list 1 3))
#f
(null? '())
#t
length
length接受一个列表作为参数,并返回列表中的项目数。这是length的定义方式:
(define (length items)
(if (null? items)
0
(+ 1 (length (cdr items)))))
这里是一个例子:
-> (define odds (list 1 3 5 7))
odds
-> (length odds)
4
高阶函数与列表
从现在开始,我们将主要使用列表和对,而不是句子。这很好,因为这意味着我们将能够更仔细地查看 Racket 如何表示数据。但是,这也意味着我们之前用句子定义的许多重要的高阶函数现在必须重新编写以适用于对。
every vs. map
回想一下 HOF every,它接受一个函数和一个句子,并返回一个应用到句子的每个元素的句子。使用对的等价物称为map,它接受一个函数和一个列表,并返回一个将函数映射到列表中每个元素的列表。map是一个递归定义的函数,你可以在这里看到:
(define (map proc items)
(if (null? items)
null
(cons (proc (car items))
(map proc (cdr items)))))
对于列表的过程null?类似于句子的过程empty?,检查给定的参数是否为空列表。这里有一些对map的例子:
-> (map square (list 1 2 3 4 5))
(1 4 9 16 25)
-> (map car (list (cons 1 2) (cons 3 4) (cons 5 6)))
(1 3 5)
keep vs. filter
我们在作业 2 中的filtered-accumulate问题中已经快速看到了filter,所以你应该已经对 HOF filter应该做什么有一些想法。filter接受两个参数,一个谓词和一个列表,并返回仅满足谓词的元素的列表。看一下正式定义:
(define (filter pred lst)
(cond ((null? lst) null)
((pred (car lst))
(cons (car lst) (filter pred (cdr lst))))
(else (filter pred (cdr lst)))))
这里有一些例子:
-> (filter odd? '(1 2 3 4 5))
(1 3 5)
-> (filter (lambda (x) (> x 2)) '(1 2 3 4 5))
(3 4 5)
accumulate
最后,有一个用于句子的过程accumulate。这个过程接受两个参数的函数,一个基本情况值和一个值的句子,并使用这个操作连续地组合列表中的值,并以基本情况值结束/开始。有两个等效的accumulate用于列表:foldl和foldr。两者都接受两个值的函数,一个基本情况值和一个列表。
fold-left从列表中的最后一个(最右边)元素开始,并递归地连续应用函数,直到达到列表的第一个元素。因此,它向左折叠。例如,这是评估对foldl调用的步骤:
-> (foldl cons '() '(1 2 3 4))
... (cons 4 (cons 3 (cons 2 (cons 1 '()))))
(4 3 2 1)
这是另一个例子:
-> (define combiner (lambda (x y) (cons (add1 x) y)))
combiner
(foldl combiner '() '(1 2 3 4))
... (combiner 4 (combiner 3 (combiner 2 (combiner 1 '()))))
... (5 . (4 . (3 . (2 . ()))))
(5 4 3 2)
另一方面,fold-right从列表中的第一个(最左边)元素开始,并递归地连续应用函数,直到达到列表的最后一个元素。因此,它向右折叠。举个例子:
-> (foldr cons '() '(1 2 3 4))
... (cons 1 (cons 2 (cons 3 (cons 4 '()))))
(1 2 3 4)
-> (foldr + 0 '(1 2 3 4))
... (+ 1 (+ 2 (+ 3 (+ 4 0))))
10
现在我们有两个版本的accumulate,其中foldl和foldr的值仅在它们被调用时与组合函数的顺序有关时才会有所不同。
高阶函数总结
为了使过渡更容易,这里有一个表格,展示了一些句子操作及其在列表中的等价操作。
| 句子 | 列表 |
|---|---|
se/sentence |
cons/list/append |
first |
car |
bf/butfirst |
cdr |
last |
没有对应项 |
bl/butlast |
没有对应项 |
count |
length |
item(从一开始索引) |
list-ref(从零开始索引) |
every |
map |
keep |
filter |
accumulate |
foldl/foldr |
概述 - 数据抽象
什么是数据抽象?
回想第 1 课 - 你还记得过程作为黑盒抽象吗?你不需要知道用作高阶函数参数的过程是如何实现的,只要它们能工作!这使我们能够创建通用的,“可定制”的函数,使我们的代码简洁,可读性强,灵活。
复合数据的类似概念称为数据抽象,它是一种方法论,使我们能够将复合数据对象的使用方式与如何从更基本的数据对象构造它的细节隔离开来。换句话说,你不需要知道汽车的发动机是如何工作的才能开车。
数据抽象的基本思想是结构化使用复合数据对象的程序,使其操作“抽象数据”。也就是说,我们的程序应该以一种不对数据存储或提取方式做任何假设的方式使用数据。因此,数据表示的方式是“具体的”,并且独立于使用它的程序。
专业程序员编写的程序和项目通常对不懂编程的公众开放。如果一个科技公司用 Python 编写了一个很酷的程序,他们不会期望他们的客户知道他们如何编写程序,甚至如何理解 Python,才能使用他们的产品。那么这些程序员如何让非程序员人群使用他们的作品呢?抽象。这就是编程的全部意义。
我们系统中这两部分之间的接口将是一组过程,称为构造函数和选择器:
-
构造函数 创建存储我们数据的对象。
-
选择器(selector(s)) 从构造函数创建的对象中提取您将使用的数据。
构造函数创建的对象称为抽象数据类型(ADT)。
例子:有理数
为了说明这种技术,让我们考虑如何设计一个用于操作有理数的接口。
有理数是可以表示为两个整数的商或分数(p/q)的任何数,其中q不为零。例如,3/4 是一个有理数,分母为 4,分子为 3,而π是一个无理数。
尽管 Racket 语言已经在其字典中容纳了分数,让我们尝试通过创建自己的抽象数据类型来表示它。在我们开始制作构造函数和选择器之前,让我们先看看我们需要的信息。
完整表示有理数所需的最小数据是分子和分母。因此,我们可以任意选择存储这两个数字的方式。这里我们选择将其存储在一个对中:
(define (make-rational numer denom)
(if (= 0 denom)
(error "Divisor cannot be 0!")
(cons numer denom)))
这就是我们的构造函数!它只是一个过程,当用适当的参数调用时,通过将其存储在一对中来“创建”一个有理数。当然,(3 . 4)看起来并不像一个分数,但这正是我们的选择器存在的原因。我们如何提取我们的抽象数据类型中的分子和分母?看一看:
(define (numerator rat)
(car rat))
(define (denominator rat)
(cdr rat))
第一个选择器numerator接受一个有理数作为其参数,并通过调用car来返回其分子。第二个选择器通过调用cdr来返回分母。现在,我们的抽象数据类型实现已经完成了!我们可以创建一个有理数并提取其数据,就像这样:
-> (define x (make-rational 3 4))
x
-> (numerator x)
3
-> (denominator x)
4
你是否注意到,在上面的调用中,没有任何内容透露了有理数是如何表示的?你已经抽象出了表示数据的方法,并留下了一个几乎任何人都可以使用的干净接口。
抽象数据类型的构造函数和选择器是相辅相成的。这个有理数实现的选择器对于不同的有理数实现是不起作用的。我们可以使用列表、句子、小数等。抽象的美妙之处在于我们不知道。
测试您的理解
考虑在平面中表示线段的问题。每个线段表示为一对点:一个起点和一个终点。
点被表示为一对坐标:
(define (make-point x y) (cons x y))
(define (x-coord point) (car point))
(define (y-coord point) (cdr point))
定义一个构造函数称为make-segment和选择器称为start-segment和end-segment,以点的形式定义线段的表示。您可以选择任何您希望存储数据的方法。
使用 ADT 的过程
为了扩展我们的有理数 ADT,让我们编写一些尊重我们实现抽象的过程。一个有用的过程是print-rat,它实际上让我们看到一个有理数的“外观”,给定其抽象表示。
-> (define (print-rat rat)
(word (numerator rat) '/ (denominator rat)))
-> (define x (make-rational 3 4))
x
-> (print-rat x)
3/4
这样我们就可以假装我们的有理数实际上不是一对。😃
如果我们不能对有理数进行数学运算,那有理数有什么用呢?在这里,我们为我们的 ADT 定义了一些简单的算术过程:
(define (add-rat rat1 rat2)
(make-rational (+ (* (numerator rat1) (denominator rat2))
(* (numerator rat2) (denominator rat1)))
(* (denominator rat1) (denominator rat2))))
(define (sub-rat rat1 rat2)
(make-rational (- (* (numerator rat1) (denominator rat2))
(* (numerator rat2) (denominator rat1)))
(* (denominator rat1) (denominator rat2)))))
(define (mul-rat rat1 rat2)
(make-rational (* (numerator rat1) (numerator rat2))
(* (denominator rat1) (denominator rat2))))
(define (div-rat rat1 rat2)
(make-rational (* (numerator rat1) (denominator rat2))
(* (denominator rat1) (numerator rat2)))))
(define (equal-rat? rat1 rat2)
(= (* (numerator rat1) (denominator rat2))
(* (numerator rat2) (denominator rat1))))
注意这些过程尊重抽象。在我们的代码中,我们从不调用cons来创建一个有理数,也不调用car/cdr来选择分子或分母。不这样做被称为数据抽象违反,但我们可以在后面的部分讨论这个问题。现在,让我们继续进行更大更好的示例!
测试您的理解
使用上面的线段实现,定义一个名为segment-length的过程,该过程接受一个线段并返回其长度。
例子 - 扑克牌
如果我们能够使用抽象数据类型来表示一款纸牌游戏,那将是多么酷啊?让我们创建一个比有理数更复杂的接口,允许我们表示卡片、手牌和牌堆。通过这些抽象,我们将能够玩一些简单的纸牌游戏!
创建卡片
当你看任何一张卡片时,将其识别为扑克牌的两个属性是其等级和花色。当然,你可以观察其他属性,比如它的矩形形状或它的塑料表面,但这些不是你用来识别卡片的重要特性。因此,这里有我们的make-card构造函数,它接受一个rank和一个suit:
(define (make-card rank suit)
(cons rank (first suit)))
这里是它的选择器:
(define (rank card)
(car card))
(define (suit card)
(cdr card))
因此,我们可以创建一张卡片并使用以下调用提取其属性:
-> (define c (make-card 13 'heart))
card
-> (rank c)
13
-> (suit c)
h
我们刚刚创建了红心王牌。
创建手牌
就像现实生活中一副牌是卡片的集合一样,在我们的抽象中,一副牌将是一组卡片的列表。我们下面定义了构造函数和选择器:
(define make-hand list) ;; constructor creates a list of cards
(define first-card car) ;; returns the first card in hand
(define rest-hand cdr) ;; returns the rest of the hand
(define empty-hand? null?) ;; checks if you have no cards in your hand
注意我们将make-hand定义为变量分配给过程list。这是因为我们不想指定make-hand应该接受多少个参数 - 我们可以创建任意长度的手牌。我们只希望make-hand接受任意数量的卡片并将它们存储到列表中。以下是我们 ADT 的一些示例调用:
-> (define my-hand (make-hand (make-card 1 'heart)
(make-card 5 'diamond)
(make-card 10 'diamond)
(make-card 13 'club)))
my-hand
-> (first-card my-hand)
(1 . h)
-> (rest-hand my-hand)
((5 . d) (10 . d) (13 . c))
使用我们的实现
这就是我们表示卡片所需的全部!你有卡片,你有一组卡片。其他一切都可以根据这两个对象来定义。例如,一副牌只是一手牌,其中包含每一种等级和花色的卡片(加上两张王牌,但我们现在先忽略它)。
现在是时候使用我们的实现编写一些程序了。对于大多数纸牌游戏,卡片的等级代表着其价值。让我们编写一个找到你手中所有卡片总价值的程序。total接受一手牌并返回你卡片价值的总和。
(define (total hand)
(if (empty-hand? hand)
0
(+ (rank (first-card hand)) (total (rest-hand hand)))))
这里有一个例子:
-> (total my-hand)
29
更改实现方式
如果我们改变了表示卡片的方式会发生什么?我们的total代码仍然有效吗?
答案是肯定的,total会正常工作,因为有一层抽象将其与牌或手牌的实现方式分隔开来。只要我们保持构造函数和选择器的名称不变,我们构建的所有程序都将继续工作。假设我们将表示卡片的方式更改为这样:
(define (make-card rank suit)
(cond ((equal? suit ’heart) rank)
((equal? suit ’spade) (+ rank 13))
((equal? suit ’diamond) (+ rank 26))
((equal? suit ’club) (+ rank 39))
(else (error "say what?")) ))
(define (rank card)
(remainder card 13))
(define (suit card)
(nth (quotient card 13) ’(heart spade diamond club)))
我们的total程序在这种实现方式下仍然适用。在 Racket 解释器上试试吧!
使用这种编程风格,我们可以创建更大的程序。
违反数据抽象
因此,创建抽象数据类型很方便,而且能够编写程序而不用担心实现细节很好。但是,是什么阻止我们越过抽象屏障并使用底层实现呢?
实际上什么也不会发生。如果您使用cons而不是make-rational,或者使用car而不是numerator,Racket 不会抱怨。我们在下面重新复制了有理数 ADT 的代码:
(define (make-rational numer denom)
(if (= 0 denom)
(error "Divisor cannot be 0!")
(cons numer denom)))
(define (numerator rat)
(car rat))
(define (denominator rat)
(cdr rat))
从技术上讲,如果我们进行以下调用,选择器仍然会返回我们期望的内容:
-> (define x (make-rational 3 4))
x
-> (define num (car x))
num
-> (= num (numerator x))
#t
那么,如果我们可以使用car而不是选择器,为什么我们要使用选择器呢?现在这样做没有问题,但是当我们以后更改构造器和选择器的实现时会出现问题。在抽象层下并假设数据结构的实现方式被称为数据抽象违规(DAV)。
例子:违反数据抽象
假设我们编写一个新函数expt-rat,它接受一个有理数和我们对该有理数取幂的指数,并返回另一个有理数到该指数的幂。这是我们编写的过程:
(define (expt-rat rat n)
(make-rational (expt (car rat) n)
(expt (cdr rat) n)))
你能发现 DAV 吗?根据我们当前的有理数实现,expt-rat应该可以无问题地工作:
-> (define x (make-rational 3 4))
x
-> (expt-rat x 2)
(9 . 16)
但这是危险的!如果以后决定更改实现,无法保证代码能正常工作。假设我们以这种方式重写实现:
(define (make-rational numer denom)
(lambda (m) (cond ((equal? m 'numerator) numer)
((equal? m 'denominator) denom)
(else (error "bad message to rational")))))
(define (numerator rat)
(rat 'numerator))
(define (denominator rat)
(rat 'denominator))
当我们现在调用expt-rat时会发生什么?
-> (define y (make-rational 5 6))
y
-> (expt-rat y 4)
; car: contract violation
; expected: pair?
; given: #<procedure>
; [,bt for context]
我们出现了错误!我们上面的expt-rat代码假设我们将有理数存储为一对,并愚蠢地调用car来检索分子和cdr来检索分母。因此,当我们尝试在 lambda 函数上调用这些过程时,Racket 会抛出错误。
抽象屏障

我们用构造器make-rat和选择器numerator和denominator来定义了有理数操作。一般来说,数据抽象的基本思想是为每种类型的数据对象确定一组基本操作(例如构造器和选择器),通过这些操作表达该类型的所有数据对象的操作,然后在操作数据时只使用这些操作。
我们可以将有理数系统的结构构想成下图所示。水平线代表隔离不同“层级”的抽象障碍。在每个层级,该障碍将使用数据抽象的程序(上方)与实现数据抽象的程序(下方)分开。使用有理数的程序仅通过有理数包提供的“公共使用”程序来操纵它们:add-rat、sub-rat、mul-rat、div-rat和equal-rat?。而这些程序则完全通过构造函数和选择器make-rat、numerator和denominator来实现,它们本身是通过对成对元素的操作实现的。成对元素的具体实现细节对于有理数包的其余部分是无关紧要的,只要能通过cons、car和cdr来操作成对元素即可。实际上,每个层级的程序都是定义抽象障碍并连接不同层级的接口。

这个简单的想法有很多优点。其中一个优点是使程序更容易维护和修改。任何复杂的数据结构都可以用编程语言提供的原始数据结构的各种方式来表示。
要理解为什么这一点如此重要,考虑一下在没有数据抽象的世界。当然,表示的选择会影响操作它的程序;因此,如果表示在以后某个时候发生变化,所有这样的程序可能都必须相应地进行修改。在大型程序的情况下,这个任务可能是耗时且昂贵的,除非按设计将对表示的依赖性限制在非常少的程序模块中。
幸运的是,如果数据在不违反数据抽象的情况下实现,那么修改整个程序将会非常容易 -- 你只需要修改构造函数和选择器。
作业 4
模板
在终端中键入以下内容,将模板文件复制到当前目录(注意末尾的句点):
cp ~cs61as/autograder/templates/hw4.rkt .
或者你可以在这里下载模板here。
自动评分程序
如果你在实验室计算机上工作,grader命令将运行自动评分程序。如果你在自己的个人机器上工作,你应该下载grader.rkt和HW 4 tests。
热身
尝试预测以下表达式将返回什么结果,然后用 Racket 解释器检查你的答案:
-
(define x (cons 4 5)) -
(car x) -
(cdr x) -
(define y (cons 'hello 'goodbye)) -
(define z (cons x y)) -
(car (cdr z)) -
(cdr (cdr z)) -
(cdr (car z)) -
(car (cons 8 3)) -
(car z) -
(car 3)
练习 1
前几个练习涉及到SICP 2.1.4。有关区间算术的详细信息,请参阅文本。
SICP 2.7
艾莉莎的程序不完整,因为她没有指定区间抽象的实现。这是区间构造函数的定义:
(define (make-interval a b) (cons a b))
定义选择器upper-bound和lower-bound以完成实现。
SICP 2.8
使用类似于艾莉莎的推理,描述如何计算两个区间的差。定义一个相应的减法过程,称为sub-interval。
SICP 2.10
专家系统程序员本·比特迪德尔(Ben Bitdiddle)看着艾莉莎的代码,并评论说不清楚什么意思是通过跨越零的区间进行除法。修改艾莉莎的代码以检查这种情况,并在发生时发出错误信号。
注意: 跨越零意味着一个边界<=零,另一个边界>=零!
SICP 2.12
定义一个构造函数make-center-percent,它接受一个中心和一个百分比容差,并生成所需的区间。你还必须定义一个选择器percent,为给定区间产生百分比容差。center选择器与上面显示的相同。
SICP 2.17
定义一个过程last-pair,返回只包含给定(非空)列表的最后一个元素的列表:
-> (last-pair (list 23 72 149 34))
(34)
SICP 2.20
过程+、*和list接受任意数量的参数。定义这样的过程的一种方法是使用带有点尾符号的define。在过程定义中,最后一个参数名之前有一个点的参数列表表示,当调用过程时,初始参数(如果有)将像往常一样具有初始参数的值,但最后一个参数的值将是任何剩余参数的列表。例如,给定定义
(define (f x y . z) <body>)
过程f可以接受两个或更多个参数。如果我们评估
(f 1 2 3 4 5 6)
然后在f的主体中,x将是1,y将是2,z将是列表'(3 4 5 6)。鉴于定义
(define (g . w) <body>)
过程g可以不带参数或带一个或多个参数调用。如果我们评估
(g 1 2 3 4 5 6)
然后在g的主体中,w将是列表'(1 2 3 4 5 6)。
使用这种表示法编写一个过程same-parity,它接受一个或多个整数,并返回与第一个参数具有相同奇偶性的所有参数的列表。例如,
-> (same-parity 1 2 3 4 5 6 7)
(1 3 5 7)
-> (same-parity 2 3 4 5 6 7)
(2 4 6)
SICP 2.22
路易斯·里森尼试图重写练习 2.21 的第一个square-list过程,以便演变出一个迭代过程:
(define (square-list items)
(define (iter things answer)
(if (null? things)
answer
(iter (cdr things)
(cons (square (car things))
answer))))
(iter items nil))
不幸的是,以这种方式定义square-list会产生与所需顺序相反的答案列表。为什么?
然后路易斯尝试通过交换cons的参数来修复他的错误:
(define (square-list items)
(define (iter things answer)
(if (null? things)
answer
(iter (cdr things)
(cons answer
(square (car things))))))
(iter items nil))
这也不起作用。请解释。
练习 2
编写一个过程my-substitute,它接受三个参数:一个列表,一个旧单词和一个新单词。它应返回列表的副本,但是将每个旧单词的出现替换为新单词,即使在子列表中也是如此。例如:
-> (my-substitute '((lead guitar) (bass guitar) (rhythm guitar) drums)
'guitar
'axe)
((lead axe) (bass axe) (rhythm axe) drums)
你可能会发现过程list?很有用:
-> (list? (list 1 2 3))
#t
-> (list? 'apple)
#f
-> (list? 4)
#f
练习 3
现在编写my-substitute2,它接受一个列表,一个旧单词列表和一个新单词列表;最后两个列表应该具有相同的长度。它应返回第一个参数的副本,但将出现在第二个参数中的每个单词替换为第三个参数中对应的单词:
-> (my-substitute2 '((4 calling birds) (3 french hens) (2 turtle doves))
'(1 2 3 4)
'(one two three four))
((four calling birds) (three french hens) (two turtle doves))
专家专用
如果你想挑战自己,可以尝试这些。这些不计入学分。
练习 4
编写过程cxr-function,其参数是以 c 开头,以 r 结尾,并在中间有一串字母 a 和/或 d 的单词,例如cdddadaadar。它应返回相应的函数。
练习 5
SICP Ex. 2.6。除了加法,还要发明非负整数的乘法和指数运算。如果你真的很热情,看看能否发明减法。(记住,这个游戏的规则是你只有 lambda 作为起点。)阅读~cs61as/lib/church-hint以获取一些建议。
练习 6
SICP Ex. 2.18;这需要一些思考,你应确保做对,但不要为此卡壳一个小时。注意:你的解决方案应该颠倒列表,而不是句子!也就是说,你应该使用cons、car和cdr,而不是first、sentence等。
提交你的作业!
有关说明,请参阅此指南。它涵盖了基本的终端命令和作业提交。
如果你在提交作业时遇到任何问题,请不要犹豫向助教求助!
项目 2 - 海龟图形
介绍
在第 4 课中,我们探讨了cons如何让我们构建任意复杂的列表结构。
在这个项目中,我们将创建一种简单的图形语言,使我们能够构建任意复杂的图形,就像下面显示的那样。请注意,图像由重复的元素组成,这些元素被移动和缩放。

当我们探索这种图形语言时,我们将看到如何将数据抽象和高阶过程结合起来,将简单的元素构建成复杂的系统。
要开始,请将文件~cs61as/lib/picture.rkt复制到您的目录中,或者从这里下载。您还需要安装一个 Racket 包,使我们能够绘制图片。如果您在实验室计算机上工作,请在终端中输入以下内容:
install-htdp
如果您想在自己的计算机上安装图片绘制包,请查看此 Piazza 帖子的第一个跟进讨论。
完成安装后,您应该能够在 Racket 中输入(require graphics/turtles)而不会出现错误。
图形语言
当我们在第 1 课开始学习编程时,我们强调了通过关注语言的基本元素、组合手段和抽象手段来描述语言的重要性。我们将在这里遵循这个框架。
这种图形语言的优雅之处在于只有一种元素,称为画家。画家绘制一个被移动和缩放以适应指定的平行四边形框架内的图像。例如,有一个我们将称之为wave-painter的原始画家,它制作了一个粗糙的线条图,如下所示。绘图的实际形状取决于框架——下面的四个图像都是由相同的wave-painter产生的,但是相对于四个不同的框架。

合并画家
要合并图像,我们使用各种操作从给定的画家中构建新的画家。例如,beside操作接受两个画家,并产生一个新的复合画家,在画面的左半部分绘制第一个画家的图像,在右半部分绘制第二个画家的图像。类似地,below接受两个画家,并产生一个复合画家,在第一个画家的图像下方绘制第二个画家的图像。
一些操作将单个画家转换为新的画家。例如,flip-vert接受一个画家,并产生一个将其图像倒置的画家,而flip-horiz产生一个从左到右翻转的原始画家图像的画家。
这是我们如何定义一个名为wave4的画家,它是从wave-painter开始分两个阶段构建的:
(define wave2 (beside wave-painter (flip-vert wave-painter)))
(define wave4 (below wave2 wave2))
下面显示了生成的图形:

抽象操作
一旦我们能够组合画家,我们希望能够抽象出典型的组合画家模式。我们将实现画家操作作为 Racket 过程。这意味着在图片语言中我们不需要特殊的抽象机制:由于组合方法是普通的 Racket 过程,我们自动具有可以对画家操作进行任何操作的能力。例如,我们可以将wave4中的模式抽象为
(define (flipped-pairs painter)
(let ((painter2 (beside painter (flip-vert painter))))
(below painter2 painter2)))
并将wave4定义为此模式的一个实例:
(define wave4 (flipped-pairs wave))
递归操作
我们还可以定义递归操作。这里有一个称为right-split的操作,使画家向右分裂和分支:
(define (right-split painter n)
(if (= n 0)
painter
(let ((smaller (right-split painter (- n 1))))
(beside painter (below smaller smaller)))))
这是(right-split painter n)的一般模板:

这里是(right-split wave-painter 4)的结果:

我们可以通过向上分支以及向右分支来产生平衡的模式:
(define (corner-split painter n)
(if (= n 0)
painter
(let ((up (up-split painter (- n 1)))
(right (right-split painter (- n 1))))
(let ((top-left (beside up up))
(bottom-right (below right right))
(corner (corner-split painter (- n 1))))
(beside (below painter top-left)
(below bottom-right corner))))))
这是(corner-split painter n)的一般模板:

这是(corner-split wave-painter 4)的结果:

通过适当放置四个corner-split的副本,我们获得了一个称为square-limit的模式:
(define (square-limit painter n)
(let ((quarter (corner-split painter n)))
(let ((half (beside (flip-horiz quarter) quarter)))
(below (flip-vert half) half))))
此页面顶部的第一个图形,如下所示,是(square-limit wave-painter 5)的视觉输出:

在开始练习 1 之前
首先,如果您因为想直接跳到第一个练习而略读或跳过上面的所有内容,请现在花一分钟仔细阅读上面的所有内容。这很重要!
此外,请注意,直到练习 6 之后,您将无法测试任何代码的视觉输出。我们描述的图片语言是不完整的—在我们开始使用它之前,您必须填补这些空白!
练习 1:up-split
定义由corner-split使用的过程up-split。它类似于right-split,只是它交换了below和beside的角色。
为了您的方便,right-split再次显示如下:
(define (right-split painter n)
(if (= n 0)
painter
(let ((smaller (right-split painter (- n 1))))
(beside painter (below smaller smaller)))))
高阶操作
除了抽象化组合画家的模式之外,我们还可以在更高的层次上工作,抽象化组合画家操作的模式。也就是说,我们可以将画家操作视为要操作的元素,并为这些元素编写组合方法——接受画家操作作为参数并创建新画家操作的过程。
例如,flipped-pairs和square-limit都将一个画家的图像排列成一个方形图案;它们之间的区别仅在于它们如何定位这些副本。抽象这种画家组合模式的一种方法是使用以下过程,它接受四个一元画家操作,并产生一个画家操作,用这四个操作来转换给定的画家,并将结果排列成一个方形。tl、tr、bl 和 br 分别是要应用于左上副本、右上副本、左下副本和右下副本的变换。
(define (square-of-four tl tr bl br)
(lambda (painter)
(let ((top (beside (tl painter) (tr painter)))
(bottom (beside (bl painter) (br painter))))
(below bottom top))))
然后flipped-pairs可以根据square-of-four定义如下:
(define (flipped-pairs painter)
(let ((combine4 (square-of-four identity flip-vert
identity flip-vert)))
(combine4 painter)))
类似地,square-limit可以表示为:
(define (square-limit painter n)
(let ((combine4 (square-of-four flip-horiz identity
rotate180 flip-vert)))
(combine4 (corner-split painter n))))
练习 2:split
right-split和up-split可以表示为通用分割操���的实例。定义一个具有以下属性的过程split,评估
(define right-split (split beside below))
(define up-split (split below beside))
生成了具有与已定义的相同行为的right-split和up-split过程。
框架
在我们展示如何实现画家及其组合方式之前,我们必须首先考虑框架。一个框架可以由三个向量描述——一个原点向量和两个边缘向量。原点向量指定了框架原点相对于平面中某个绝对原点的偏移量,而边缘向量指定了框架角落相对于其原点的偏移量。如果边缘是垂直的,框架将是矩形的。否则,框架将是一个更一般的平行四边形。
下图显示了一个框架及其相关的向量。根据数据抽象,我们暂时不需要具体说明框架在 Racket 中是如何表示的,除了说有一个构造函数make-frame,它接受三个向量并生成一个框架,以及三个相应的选择器:origin-frame、edge1-frame 和 edge2-frame

我们将使用单位正方形中的坐标来指定向量;也就是说,我们将使用空间区域,其中[mathjaxinline]0 \leq x,y \leq 1[/mathjaxinline]。
框架坐标(可选)
这个可选的小节描述了如何计算框架坐标。
对于每个框架,我们关联一个坐标映射,用于移动和缩放图像以适应框架。坐标映射使用以下公式将单位正方形中的向量转换为框架中的向量

其中[mathjaxinline]x[/mathjaxinline]和[mathjaxinline]y[/mathjaxinline]是输入向量的分量。
例如,向量(0, 0)被映射到框架的原点,(1, 1)被映射到原点对角的顶点,(0.5, 0.5)被映射到框架的中心。
在 Racket 中,我们可以使用以下过程创建一个框架的坐标映射:
(define (frame-coord-map frame)
(lambda (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)
因为向量(0, 0)映射到框架的原点。
练习 3:表示向量
从原点到点的二维向量[mathjaxinline]v[/mathjaxinline]可以表示为由 x 坐标和 y 坐标组成的一对。通过给出构造函数make-vect和相应的选择器xcor-vect和ycor-vect来为向量实现数据抽象。
然后,根据您的选择器和构造函数,实现执行向量加法、向量减法和向量乘以标量的过程add-vect、sub-vect和scale-vect:
[mathjax] \begin{align} (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)\ c \cdot (x, y) &= (cx, cy) \end{align} [/mathjax]
练习 4:表示框架
我们现在将按照上述描述实现框架。
这里有两种框架的可能构造函数:
(define (make-frame origin edge1 edge2)
(list origin edge1 edge2))
(define (make-frame-2 origin edge1 edge2)
(cons origin (cons edge1 edge2)))
对于每个构造函数,提供适当的选择器。
表示画家
画家表示为一个过程,给定一个框架作为参数,绘制一个特定的图像,经过平移和缩放以适应框架。也就是说,如果p是一个画家,f是一个框架,我们通过调用(p f)来在f中产生p的图像。
原始画家的实现细节取决于图形系统的特定特性和要绘制的图像类型。例如,假设我们有一个绘制线段在屏幕上连接两个指定点的过程draw-line。然后我们可以从线段列表创建线条绘制的画家,例如波浪画家,如下所示:
(define (segments->painter segment-list)
(lambda (frame)
(for-each
(lambda (segment)
(draw-line
((frame-coord-map frame) (start-segment segment))
((frame-coord-map frame) (end-segment segment))))
segment-list)))
线段使用相对于单位正方形的坐标给出。对于列表中的每个线段,画家使用框架坐标映射(见上文)转换线段端点,并在转换点之间画一条线。
将画家表示为过程在图片语言中建立了强大的抽象屏障。我们可以创建和混合各种基于各种图形功能的原始画家。它们的实现细节并不重要。任何过程都可以作为画家,只要它以框架作为参数并绘制适合框架的内容。
练习 5:表示段
平面上的有向线段可以表示为一对向量——从原点到线段起点的向量,以及从原点到线段终点的向量。使用上面的向量表示来定义具有构造函数make-segment和选择器start-segment和end-segment的段的表示。
练习 6:原始画家
使用segments->painter来定义以下原始画家:
-
x-painter,通过连接框架的对角线绘制一个“X”。 -
outline-painter,绘制指定框架的轮廓。 -
diamond-painter,通过连接帧边的中点绘制一个菱形。 -
wave-painter,绘制了这里所示的熟悉的"波浪"图案。你的绘图不必完全复制;只需确保它清晰地类似于原始的波浪图案。
为了确保你走在正确的轨道上,你应该在定义x-painter之后立即测试你的代码。在"测试"部分中有说明。
重要提示
请记住,segments->painter接受一个由相对于单位正方形定义的向量组成的线段列表。例如,你的代码可能看起来像这样:
(define diag-painter
(segments->painter
(list (make-segment (make-vect 1 0) (make-vect 0 1)))))
这会从左上角到右下角绘制一条对角线(类似于这里)。
测试
现在我们可以开始测试我们的代码了。请注意,以下命令在 SSH 上不起作用。
首先通过在终端中输入以下内容加载picture.rkt:
racket -it picture.rkt
现在使用cs("清屏")过程打开绘图窗口:
(cs)
你应该看到一个窗口出现,里面有一个小三角形。
现在,你可以通过将full-frame作为帧参数来告诉画家在绘图窗口中绘制。例如,尝试这样做:
(x-painter full-frame)
你应该在绘图窗口中看到一个 X 出现。如果没有任何东西出现,或者出现错误,不要担心。使用你迄今为止学到的调试技巧来定位错误并解决它。如果需要帮助,请在 Piazza 上发布或联系助教。
请注意,上面提到的画家转换(如beside和square-of-four)在这一点上不会起作用。在测试它们之前,你必须完成练习 8。目前,如果你的四个原始画家工作正常,请继续下一节。
转换和组合画家
对画家的操作(如flip-vert或beside)通过创建一个画家,根据参数帧派生的帧来调用原始画家。因此,例如,flip-vert不必知道画家如何工作才能翻转它——它只需要知道如何将一个帧颠倒过来:翻转后的画家只是使用原始画家,但在倒置的帧中。
画家操作基于transform-painter过程,它接受一个画家和关于如何转换帧的信息作为参数,并产生一个新的画家。转换后的画家在帧上调用时,会转换帧并在转换后的帧上调用原始画家。transform-painter的参数是点(表示为向量),指定新帧的角落:当映射到帧中时,第一个点指定新帧的原点,另外两个点指定其边缘向量的端点。因此,在单位正方形内的参数指定了一个包含在原始帧内的帧。
(define (transform-painter painter origin corner1 corner2)
(lambda (frame)
(let ((m (frame-coord-map frame)))
(let ((new-origin (m origin)))
(painter
(make-frame new-origin
(sub-vect (m corner1) new-origin)
(sub-vect (m corner2) new-origin)))))))
这是如何垂直翻转画家图像的方法:
(define (flip-vert painter)
(transform-painter painter
(make-vect 0.0 1.0) ; new origin
(make-vect 1.0 1.0) ; new end of edge1
(make-vect 0.0 0.0))) ; new end of edge2
使用transform-painter,我们可以轻松定义新的转换。例如,我们可以定义一个将其图像缩小到给定帧的右上角的画家:
(define (shrink-to-upper-right painter)
(transform-painter painter
(make-vect 0.5 0.5)
(make-vect 1.0 0.5)
(make-vect 0.5 1.0)))
其他变换将图像逆时针旋转 90 度...
(define (rotate90 painter)
(transform-painter painter
(make-vect 1.0 0.0)
(make-vect 1.0 1.0)
(make-vect 0.0 0.0)))
...或将图像压缩至框架中心:
(define (squash-inwards painter)
(transform-painter painter
(make-vect 0.0 0.0)
(make-vect 0.65 0.35)
(make-vect 0.35 0.65)))
框架变换也是定义合并两个或多个画家手段的关键。例如,beside过程接受两个画家,将它们转换为分别在参数框架的左半部分和右半部分绘制,并产生一个新的复合画家。当复合画家得到一个框架时,它调用第一个转换后的画家在框架的左半部分绘制,并调用第二个转换后的画家在框架的右半部分绘制:
(define (beside painter1 painter2)
(let ((split-point (make-vect 0.5 0.0)))
(let ((paint-left
(transform-painter painter1
(make-vect 0.0 0.0)
split-point
(make-vect 0.0 1.0)))
(paint-right
(transform-painter painter2
split-point
(make-vect 1.0 0.0)
(make-vect 0.5 1.0))))
(lambda (frame)
(paint-left frame)
(paint-right frame)))))
观察画家数据抽象,特别是将画家表示为过程的表示,使得实现beside变得容易。beside过程不需要了解组件画家的任何细节,只需知道每个画家将在其指定的框架中绘制一些东西。
练习 7:flip-horiz和旋转
定义变换flip-horiz,将画家水平翻转。
然后定义rotate180和rotate270,分别将画家逆时针旋转 180 度和 270 度。
练习 8:below
为画家定义below操作。below接受两个画家作为参数。得到的画家在给定框架下,用第一个画家在框架底部绘制,用第二个画家在框架顶部绘制。以两种不同方式定义below—首先编写一个类似于上面给出的beside过程的过程,然后根据beside和适当的旋转操作(来自上面的练习)定义below。
用于稳健设计的语言层次
我们的图形语言现在已经完成。让我们退后一步,评估一下。
图形语言练习了我们介绍的关于过程和数据抽象的一些关键思想。基本数据抽象,画家,是使用过程表示实现的,这使得语言能够以统一的方式处理不同的基本绘图能力。组合手段也是过程,这使我们能够轻松构建复杂的设计。最后,所有用于抽象过程的工具都可以用于抽象画家的组合手段。
我们还了解了关于语言和程序设计的另一个关键思想。这就是分层设计的方法,即复杂系统应该被构造为使用一系列语言描述的一系列级别。每个级别通过组合在该级别被视为原始的部分来构建,而在每个级别构建的部分在下一个级别被用作原语。分层设计的每个级别使用适合该详细级别的原语、组合手段和抽象手段。
分层设计渗透到复杂系统的工程中。例如,在计算机工程中,电阻器和晶体管被组合在一起(并使用模拟电路语言描述)以产生诸如与门和或门之类的部件,这些部件构成了数字电路设计语言的基本元素。这些部件被组合起来构建处理器、总线结构和存储系统,然后再使用适合计算机体系结构的语言将它们组合起来形成计算机。计算机被组合起来形成分布式系统,使用适合描述网络互连的语言,依此类推。
分层设计有助于使程序更加健壮——也就是说,这样做可以使规范中的微小变化很可能需要相应地对程序进行小的更改。例如,假设我们想要根据wave-painter修改图像。我们可以在最低级别上改变wave-painter元素的详细外观;我们可以在中间级别上改变corner-split复制波浪的方式;或者我们可以在最高级别上改变square-limit如何排列四个角的副本。一般来说,分层设计的每个级别都提供了一个不同的词汇表来表达系统的特征,并提供了不同类型的改变能力。
练习 9:正方形限制
通过在上述每个级别上进行工作来改变波浪绘图器的正方形限制。特别是:
-
向练习 6 中的原始
wave-painter添加一些段(例如添加一个微笑)。 -
改变由
corner-split构建的模式(例如,只使用up-split和right-split图像的一个副本而不是两个)。 -
修改使用
square-of-four的square-limit版本,以便以不同的模式组装角落。(例如,您可以使波浪图案从正方形的每个角落向外看。)
最后步骤
遵循上面“测试”部分的说明,测试其余的绘图器。确保还测试所有的绘图器转换过程。
要提交您的项目,请在终端中导航到您的项目目录,然后键入
submit proj2
只是为了好玩:导出绘图
不想丢失您的杰作?渴望在 Facebook 或 Instagram 上分享您的作品?您现在可以使用以下命令将您的绘图保存为 PNG 文件:
(export "filename.png")
5 - 分层数据和 calc.scm
第 5 课介绍
介绍
本课程建立在分层结构的概念之上——由部分组成的结构,这些部分本身又由部分组成,依此类推。我们将利用这些想法在课程结束时在 Racket 中制作一个简单的计算器。
警告
本课程比以前的课程更加密集和耗时。确保自己掌握进度,以免落后。
先决条件和预期内容
在进行本课程之前需要掌握第 4 课的内容。
在扩展使用cons和list构建更复杂结构之前,我们将简要介绍它们。
阅读材料
这节课的相关阅读材料如下:
建立层次结构
组合对和列表
之前我们看到如何使用cons来“组合”一对值,例如(cons 1 2),它返回一对(1 . 2)。我们也可以使用list来组合任意数量的数据。例如,如果您在解释器中键入(list 1 2 'bagel 4),Racket 将打印列表(1 2 bagel 4)。请注意,我们可以在其中放置任何类型的数据,甚至其他对和列表!
现在让我们创建一对列表:
(cons (list 1 2) (list 3 4))
这对中的第一个项目是列表(1 2),第二个是列表(3 4)。我们可以用以下的盒子和指针图展示这个结构:

(如果您不熟悉绘制和解释盒子和指针图,请返回并查看第 4 课中的部分。)
您还可以使用小 t 树来表示结构((1 2) 3 4):

在小 t 树中,序列中的每个元素都是一个节点。在上面的例子中,(1 2)是((1 2) 3 4)的一个元素,因此它是一个节点。但它也是一个有两个子节点的树——每个元素一个。
为什么我们称之为“小 t 树”?在本课的后面,我们将讨论“大 T 树”数据类型,这与小 t 树数据类型完全不同。我们使用这种表示法是为了保持一致性和清晰度。
我们也可以将小 t 树称为深度列表(因为它们是列表中的列表中的列表中的列表...),这样不太含糊,但也不太描述列表的树状结构。
测试您的理解
假设我们评估表达式(list 1 (list 2 (list 3 4)))。当我们输入这个表达式到解释器时会返回什么?为自己绘制相应的盒子和指针结构以及相应的小 t 树。
要点
在本节中,我们讨论了嵌套的cons结构。我们还介绍了小 t 树。
在我们继续之前...
回顾一下shorthand notation中car和cdr的用法。这会很有用!
层次结构 - 小 t 树
小 t 树概述
让我们讨论一些小 t 树的一般特性。我们已经看到像(cons (list 1 2) (list 3 4))这样的结构可以用树状结构表示:

小 t 树由分支和叶子组成。上面的树有五个分支;它们对应上图中的线条。请注意,一个分支可以导致一个子树 - 一个包含在更大树中的树。在这种情况下,分支((1 2) 3 4)包含子树(1 2)。一个叶子没有从它连接的分支。上面的树有 4 个叶子:1、2、3和4。叶子位于树的“底部”,也称为边缘。
与现实世界中的树相比,计算机科学中的树往往是颠倒的!
使用小 t 树进行递归
在处理树时,通常有助于进行递归思考。例如,让我们编写一个函数count-leaves来计算树中叶子的数量。
我们将从用普通英语非正式概述我们的函数将要做的事情开始。这称为编写伪代码。在我们理解count-leaves函数应该如何行为之后,我们将为其编写实际的 Racket 代码。这是解决问题的一个很好的一般技术。
伪代码
回想一下我们如何定义length,它找到列表中的元素数量:
-
空列表的
length为 0。 -
非空列表
x的length是x的cdr的length加 1。
基本情况对于count-leaves是相同的:
- 空列表的
count-leaves为 0。
我们的递归情况略有不同。在length中,我们保证列表的car是一个单个元素,因此我们将其长度计为 1。但是对于count-leaves,它的car可能包含一个或多个树,因此其长度不总是 1。因此,我们需要递归地找到树的car的count-leaves!因此,我们的递归调用是:
- 树的
count-leaves是树的car的count-leaves加上树的cdr的count-leaves。
最终我们将car自己到树的叶子,因此我们的第二个基本情况将是:
- 叶子的
count-leaves是1。
pair?谓词
当我们在树上调用car时,我们必须确定它是否返回另一个树(一个对),还是一个叶子(一个单个元素,技术上称为原子)。我们如何检查呢?Racket 有一个内置的谓词pair?,用于测试其参数是否是cons的结果。例如:
-
(pair? (cons 1 2))返回#t。 -
(pair? (cons 1 (cons 2 3)))返回#t。 -
(pair? 2)返回#f。 -
(pair? 'pear)返回#f。 -
(pair? '())返回#f。
真实代码
使用pair?和上面的伪代码,我们可以编写count-leaves的完整代码:
(define (count-leaves x)
(cond ((null? x) 0) ;; is the tree is empty?
((not (pair? x)) 1) ;;is the tree a single element?
(else (+ (count-leaves (car x)) ;; else, call count-leaves on the car
(count-leaves (cdr x))))) ;; and cdr of x and add them up.
示例:scale-tree
在第 4 课中,我们看到了函数scale-list,它将列表中的每个项目乘以给定的数字因子。我们将编写一个类似的函数scale-tree,它接受一个深层列表和一个数字因子,并将深层列表中的所有元素乘以该因子。
这里是一个示例调用:
> (scale-tree (list 1 (list 2 (list 3 4) 5) (list 6 7)) 10)
(10 (20 (30 40) 50) (60 70))
检验你的理解
下面是scale-tree的未完成定义。我们需要哪些基本情况才能正确定义scale-tree?
(define (scale-tree tree factor)
(cond ;;Your answer here.
(else
(cons (scale-tree (car tree) factor)
(scale-tree (cdr tree) factor)))))
现在,用一些你自己的例子在解释器中尝试scale-tree!
例子:deep-reverse
让我们解决一个结构类似的问题。这一次,我们想要编写一个名为deep-reverse的函数,它将深层列表中所有元素的顺序颠倒。例如:
> (define x (list (list 1 2) (list 3 4)))
((1 2) (3 4))
> (deep-reverse x)
((4 3) (2 1))
注意,不仅(1 2)和(3 4)交换位置,它们的元素也是如此。deep-reverse也应该适用于不包含其他列表的列表。
检验你的理解
下面是deep-reverse的未完成定义。我们需要哪些递归调用才能正确定义deep-reverse?
(define (deep-reverse d-l)
(cond ((null? d-l) null)
;;Your answer here.
))
在你的 Racket 解释器中尝试一下!
总结
树可以包含子树,因此在解决涉及树的问题时,递归可能非常有帮助。
层次结构 - 大写 T 树
大写 T 树简介(抽象数据类型)
在继续阅读之前,请注意,我们在本节讨论的大写 T 树与 SICP 中的树不同。在 SICP(以及之前的章节)中,树只是深列表的花哨说法。在本节中,我们介绍了一个新概念,树,它是一种抽象数据类型(ADT)。这些树必须遵守某些抽象屏障。当你听到大多数计算机科学家在现实世界中谈论树时,他们通常是在谈论这个 ADT。
与列表和句子一样,我们也可以在树数据结构中存储数据。它们通常用于提供层次结构、排序和组合。

名称来源于向下分支结构,类似于真实树木但是倒置的。节点是树上的一个点。每个节点包含一个数据("美国","加利福尼亚"是一些数据)。请注意,一个节点可以包含另一个树。具有"加利福尼亚"的节点可以被视为顶部为"加利福尼亚"的树。因此,节点和树是相同的东西!我们通常使用'Tree'来指代整个结构���节点的另一个同义词是子树。
-
树的根节点是最顶部的节点。所有树只有一个根。在这种情况下,它是"美国"
-
一个节点的父节点是直接在其上方的节点。所有节点都只有一个父节点,除了根节点没有父节点。
-
一个节点的子节点是直接在其下方的节点。"加利福尼亚"的子节点是"伯克利"和"圣何塞"。
-
分支节点是至少有一个子节点的节点(如"美国","加利福尼亚"和"马萨诸塞州")。
-
叶子节点是没有子节点的节点(如"伯克利","圣何塞"和"波士顿")
树 ADT
我们有自己的 ADT 来表示树,我们将在本课程的其余部分中使用它,但没有官方的表示树的方式。为什么?这是因为在创建树 ADT 时有几种不同的设计选择:
-
分支节点可能有数据,也可能没有
-
二叉树(2 个分支)与 N 路树(N 个分支)
-
子节点的顺序
-
树可以为空吗?
-
... 还有许多
不同的树表示将给您不同的限制、特性和功能。
这里是树的内置构造函数和选择器:
-
构造函数:
make-tree接受两个参数,一个数据和其子节点的列表,并创建一个树 ADT。 -
选择器:
datum接受一个节点并返回节点存储的数据。 -
选择器:
children接受一个节点并返回其子节点的列表。
底层结构
实现上述树的一种方法是使用以下定义:
(define (make-tree datum children)
(cons datum children))
(define (datum node)
(car node))
(define (children node)
(cdr node))
选择器children接受一个节点作为其唯一参数,并返回其子节点,一组树的列表。一组树的列表称为森林。记住,树和森林是两种不同的数据结构!此外,你应该将森林视为树的列表,但你不应该将树视为一堆cons、car和cdr。
重申一下,森林的构造函数和选择器是list、car和cdr,而树的(此 ADT 的)构造函数和选择器是make-tree、datum和children。
另外,由于叶子是没有子节点的节点,我们可以使用类似这样的谓词来检查一个节点是否是叶子:
(define (leaf? node)
(null? (children node)))
记住,使用列表只是表示树的一种方式。我们不能假设设计 ADT 的人会使用列表。例如,如果maple是一棵树,我们不能假设(cdr maple)会给我们子节点。相反,我们必须尊重数据抽象,并使用他们为我们提供的构造函数和选择器。
抽象屏障

我们再次强调,你不能对树的 ADT 实现做出任何假设。在处理树时,你只能使用提供的构造函数/选择器。由于森林被实现为树的列表,你可以使用森林的car找到第一个树,或者使用cdr找到其余树的列表。
测试你的理解
假设pine指的是一棵树,以下哪一项是数据抽象违规(DAV)?
对树进行映射
我们经常对树执行的有用操作之一是将某种操作映射到它,类似于在列表上进行映射。我们可以通过以下方式实现这一点:
(define (treemap fn tree)
(make-tree (fn (datum tree))
(map (lambda (t) (treemap fn t))
(children tree) )))
我们将该函数应用于我们的数据,并递归地将该函数映射到子节点上。
确保你盯着上面的代码直到它有意义。
互递归
这是定义treemap的另一种方法,它在整个树上应用函数fn。请注意整个过程是递归的,但treemap并不直接调用自身。treemap将负责将fn应用于单个树的数据。谁处理森林?嗯,treemap将调用一个辅助过程forest-map,它将fn应用于森林中的所有元素。
(define (treemap fn tree)
(make-tree (fn (datum tree))
(forest-map fn (children tree))))
forest-map如何将fn应用于森林?嗯,森林只是树的列表,我们知道有treemap处理单个树。所以,我们只需要在森林中的所有树上递归调用treemap即可!
(define (forest-map fn forest)
(if (null? forest)
'()
(cons (treemap fn (car forest))
(forest-map fn (cdr forest)))))
注意treemap调用forest-map,而forest-map调用treemap。A调用B,B调用A的模式称为互递归。

count-leaves
让我们使用相互递归来编写过程 count-leaves,它返回树中叶子节点的数量。让我们一步一步来。由于我们使用相互递归,这意味着我们需要一个管理树的过程 count-leaves,以及一个管理森林的过程 count-leaves-in-forest。
count-leaves:
-
基本情况: 如果节点是叶子节点,则只需返回 1。
-
递归调用: 否则,它调用
count-leaves-in-forest。
这是 count-leaves 的代码:
(define (count-leaves tree)
(if (leaf? tree)
1
(count-leaves-in-forest (children tree))))
count-leaves-in-forest:
-
基本情况: 如果森林是
null?,则返回 0。 -
递归调用: 否则,我们需要找到森林中所有树的叶子总数。
-
我们在森林的
car上调用count-leaves,以找出森林中第一个树中有多少叶子。 -
我们在森林的
cdr上递归调用count-leaves-in-forest,以找到森林中其余部分的叶子数量。 -
最后,我们将这两个值相加以找到叶子节点的总数。
-
这是 count-leaves-in-forest 的代码:
(define (count-leaves-in-forest forest)
(if (null? forest)
0
(+ (count-leaves (car forest))
(count-leaves-in-forest (cdr forest)))))
树遍历
我们已经看到了如何在树中存储和查找元素。现在,许多使用树数据结构的情况涉及访问所有节点并对所有元素执行某些操作。显而易见的方法是从上到下、从左到右,但我们还有许多其他遍历树的方法。
深度优先搜索
深度优先搜索(DFS)是指在查看兄弟节点之前探索节点的子节点。该名称源于您在查看其他分支之前尽可能深入一个分支的事实。下面的 gif 演示了这一点。数字表示访问节点的顺序。

请注意,在探索其他分支之前,它会完成对一个分支的探索。
我们可以在 Racket 中演示这一点。假设我们想打印每个节点。我们的树 ADT 实际上遵循相同的结构,因此我们的 dfs 实现相当简单:
(define (depth-first-search tree)
(print (datum tree))
(for-each depth-first-search (children tree)))
广度优先搜索
广度优先搜索(BFS)在探索子节点之前探索兄弟节点。更容易想象这是在'层次'中查看图。首先我们看树的根,然后是它的子节点,接着是它的孙子节点,依此类推。下面的 gif 演示了这一点:

在 Racket 中实现 BFS 稍微困难,因为我们的 ADT 以与 BFS 遍历顺序不同的顺序存储信息。解决这个问题的一种方法是使用另一种称为队列的数据结构,它按顺序存储将要检查的节点。
(define (breadth-first-search tree)
(bfs-iter (list tree)))
(define (bfs-iter queue)
(if (null? queue)
'done
(let ((task (car queue)))
(print (datum task))
(bfs-iter (append (cdr queue) (children task))))))
BFS 示例
让我们通过以下示例树来逐步了解上面的代码如何工作。图中的箭头表示父节点 --> 子节点的关系。
当首次调用bfs-iter时,整个树被放入queue中。为了简化问题,让我们用树的根来表示一棵树。
queue: F
它出队节点 F,打印节点 F 的值,并递归调用bfs-iter与队列的其余部分和节点 F 的子节点。队列的其余部分为空,但节点 F 的子节点是 B G。
queue: B G
bfs-iter将打印队列中第一棵树的节点 B,并递归调用bfs-iter与队列的其余部分 G 和节点 B 的子节点 A D。
queue: G A D
依此类推,直到队列为空。一旦队列为空,我们将精确打印出每个节点的数据一次。
注意兄弟节点总是先进入队列,而子节点是从后面进入的。这确保了在检查子节点之前首先检查兄弟节点。
深度优先搜索 vs 广度优先搜索

一个比另一个更好吗?这取决于你尝试对树做什么以及你如何在树中存储元素。
下面的树代表房子里的东西。在一个“房子”里你可以找到“厨房”和“猫粮”。在一个“厨房”里你可以找到“抽屉”、“垃圾桶”等。叶子包含食物,越深入,食物越丰富。

测试你的理解
考虑一个结构类似上面的树。想象一下你是一只饥饿的猫,正在寻找任何食物以尽快填饱肚子。哪种树遍历对于以下情况更合适?
你仍然是一只猫,但现在你正在寻找房子里最美味的食物。哪种树遍历将帮助你最快地找到它?
要点
以下是本小节的要点:
-
记住你的构造函数和选择器(
make-tree、datum和children)。 -
要对树进行映射,我们使用相互递归,其中两个过程相互调用。通常,其中一个过程接受一棵树,另一个接受一片森林。
-
广度优先搜索首先查看同一层级的节点,而深度优先搜索会一直遍历每个分支直到达到叶子节点。
序列作为常规接口
序列作为抽象
让我们更深入地探讨抽象的概念。使用抽象的一个主要好处是帮助我们清理代码并增加可读性。我们为序列编写的一些函数可以使用高阶函数进行泛化和抽象。这个想法可以通过以下步骤总结:
-
在我们的代码中找到一个重复的模式
-
使用 HOF 抽象化模式中的每个元素
-
重新定义我们的代码使用抽象
这里有两个示例函数,将有助于演示这个想法。
sum-odd-squares 接受一个包含数字的树,并将树中每个奇数元素的平方相加:
(define (sum-odd-squares tree)
(cond ((null? tree) 0)
((not (pair? tree))
(if (odd? tree) (square tree) 0))
(else (+ (sum-odd-squares (car tree))
(sum-odd-squares (cdr tree))))))
even-fibs 接受一个数字 n,并返回包括 n 在内的偶数斐波那契数的列表:
(define (even-fibs n)
(define (next k)
(if (> k n)
nil
(let ((f (fib k)))
(if (even? f)
(cons f (next (+ k 1)))
(next (+ k 1))))))
(next 0))
乍一看这两个函数,我们可能会说“这两个函数没有任何共同之处!”当然,这两个函数看起来完全不同,但它们确实共享相同的逻辑:

我们想法的第一步是在我们的代码中找到一个重复的模式。从我们在之前课程中描述递归的方式,你可能会通过基本情况和递归调用来剖析 sum-odd-squares 和 even-fibs。现在,让我们从不同的角度看看每个函数的作用:
sum-odd-squares:
-
枚举树的叶子节点
-
过滤掉具有偶数数据的节点,仅留下奇数值节点
-
映射函数
square到剩余的每个节点,最后 -
累积结果通过将它们相加,从 0 开始。
even-fibs:
-
枚举从 0 到
n的整数 -
映射函数
fib到每个整数 -
过滤掉奇数,仅留下偶数斐波那契数,最后
-
累积使用
cons,从空列表开始。
我们在这里看到了什么模式?起初看起来非常不同的两个函数现在可以总结为四个主要部分:枚举、过滤、累积和计算。这很棒,因为现在我们可以使用 HOF 来抽象化我们的代码。这将引导我们进入我们抽象化想法的第二步。但在那之前,让我们来看看一些 HOF。
映射
我们在第 4 课中讨论了 map HOF。您可能希望返回快速复习一下。
过滤
filter 接受两个参数,predicate 和 sequence,并返回仅满足 predicate 的元素的序列。
(define (filter predicate sequence)
(cond ((null? sequence) nil)
((predicate (car sequence))
(cons (car sequence)
(filter predicate (cdr sequence))))
(else (filter predicate (cdr sequence)))))
测试您的理解
以下表达式返回什么?
(filter (lambda (x) (= (remainder x 2) 0)) (list 0 1 2 3 4 5))
(filter equal? '(bongo celia momo laval laburrita bongo))
累积
accumulate 接受一个操作 op,一个起始值 initial 和一个 sequence。从 initial 开始,accumulate 使用 op 将 sequence 中的所有值组合成一个值。以下是一些示例:
> (accumulate + 0 '(1 2 3 4 5))
15
> (accumulate append null '((1 2) (3 4) (5 6)))
(1 2 3 4 5 6)
这是我们如何定义 accumulate:
(define (accumulate op initial sequence)
(if (null? sequence)
initial
(op (car sequence)
(accumulate op initial (cdr sequence)))))
这个 HOF 的工作原理可能有点令人困惑,所以让我们明确写出评估步骤:
考虑表达式:
(accumulate + 0 (list 1 2 3 4 5))
递归步骤将按以下方式进行:
(+ 1 (accumulate + 0 (list 2 3 4 5)))
(+ 1 (+ 2 (accumulate + 0 (list 3 4 5))))
(+ 1 (+ 2 (+ 3 (accumulate + 0 (list 4 5)))))
(+ 1 (+ 2 (+ 3 (+ 4 (accumulate + 0 (list 5))))))
(+ 1 (+ 2 (+ 3 (+ 4 (+ 5 (accumulate + 0 (list)))))))
(+ 1 (+ 2 (+ 3 (+ 4 (+ 5 0)))))
(+ 1 (+ 2 (+ 3 (+ 4 5))))
(+ 1 (+ 2 (+ 3 9)))
(+ 1 (+ 2 12))
(+ 1 14)
15
枚举
enumerate做什么?enumerate制作一个元素的序列/列表。我们对filter、map和accumulate的定义是为序列设计的,但请记住,我们的一个函数sum-odd-squares是在树上调用的。我们可以通过只有不同的enumerate函数来区分它们,而不是制作几个版本的累加、映射和筛选。
列举列表
枚举将在给定下限和上限范围内返回一个列表。
-
(enumerate-interval 0 5)返回(0 1 2 3 4 5) -
(enumerate-interval 10 13)返回(10 11 12 13)
您可以定义枚举(用于列表)如下:
(define (enumerate-interval low high)
(if (> low high)
nil
(cons low (enumerate-interval (+ low 1) high))))
枚举树
对于我们树形版本的枚举,我们需要一个接受树的函数,并返回一个包含所有叶子的列表,以便与我们的其他高阶函数兼容。
(define (enumerate-tree tree)
(cond ((null? tree) nil)
((not (pair? tree)) (list tree))
(else (append (enumerate-tree (car tree))
(enumerate-tree (cdr tree))))))
将所有内容整合在一起
在这里,我们达到了我们抽象概念的最后一步。通过我们定义的所有辅助函数,我们可以定义一个更模块化、可读性更强、更紧凑的sum-odd-squares和even-fibs的版本:
(define (sum-odd-squares tree)
(accumulate +
0
(map square
(filter odd?
(enumerate-tree tree)))))
我们在这里做了什么?我们找到树中的所有叶子(枚举),保留所有奇数(筛选),对剩下的所有内容进行平方(映射),然后将结果相加(累加)。
类似地,我们可以如下定义even-fibs:
(define (even-fibs n)
(accumulate cons
nil
(filter even?
(map fib
(enumerate-interval 0 n)))))
这次发生了什么?我们从 0 到n制作一个列表(枚举),为它们找到斐波那契数(映射),保留所有偶数(筛选),然后将它们放在一起成为一个列表(累加)。
要点
序列为使用不同组合的map、filter、accumulate和enumerate提供了强大的抽象基础。即使函数看起来具有不同结构,就像我们在这里使用的示例一样,我们可能能够使用类似的过程信号来分解它们。
嵌套映射
序列中的嵌套映射
在前一小节中,我们看到如何结合 enumerate、map、filter 和 accumulate 来生成更复杂的函数。在本小节中,我们将探讨一个嵌套映射的例子:在列表上两次调用 map。
棋盘网格

Jack 是一个大力支持国际象棋的人。他想要编写一个函数,将一个 4x4 的棋盘上所有的坐标列出来。更具体地说,他想要一个输出如下的函数:
( (1 . 1) (1 . 2) (1 . 3) (1 . 4)
(2 . 1) (2 . 2) (2 . 3) (2 . 4)
(3 . 1) (3 . 2) (3 . 3) (3 . 4)
(4 . 1) (4 . 2) (4 . 3) (4 . 4) )
请注意,坐标被表示为一对 x 和 y 坐标,并且代码输出了一个这样的坐标列表。我们将逐步介绍 Jack 如何使用我们已经学到的列表操作技术编写这个函数。
棋盘网格:第一行

首先,让我们考虑问题的一个小部分,并逐步解决:让我们编写一些代码,返回来自第一行的坐标列表,即 ( (1 . 1) (2 . 1) (3 . 1) (4 . 1) )。我们如何实现这个呢?嗯,我们注意到 x 坐标从 1 开始到 4 结束,而 y 坐标始终为 1。因此,如果我们有一个列表 (1 2 3 4),我们可以将每个元素与 1 进行 cons。我们可以写成这样:
> (map (lambda (x) (cons x 1))
(enumerate 1 4))
((1 . 1) (2 . 1) (3 . 1) (4 . 1))
到目前为止一切顺利。Jack 很高兴。
棋盘网格:所有行

所以我们有一些代码返回了第一行的坐标列表。由于只有 4 行,我们可以在技术上为每行都复制一个。
(map (lambda (x) (cons x **1**))
(enumerate 1 4))
(map (lambda (x) (cons x **2**))
(enumerate 1 4))
(map (lambda (x) (cons x **3**))
(enumerate 1 4))
(map (lambda (x) (cons x **4**))
(enumerate 1 4))
这一切都很好,但我们知道复制和粘贴代码通常是一个坏主意。(如果棋盘是 1000x1000 呢?)我们想要保留相似的部分,并尽可能地少改变。注意到从 row1、row2、row3 和 row4 的代码中唯一的区别是你与之 cons 的数字。我们可以应用之前的方法:
(map (lambda (y) (map (lambda (x) (cons x y))
(enumerate 1 4)))
(enumerate 1 4))
注意内部 lambda 如何处理单行中的每个瓦片,而外部 lambda 则处理棋盘中的每一行。万岁!我们完成了,对吧?
棋盘网格:展开

运行我们当前代码时,我们得到了这个:
> (map (lambda (y)
(map (lambda (x) (cons x y))
(enumerate 1 4)))
(enumerate 1 4))
( ((1 . 1) (2 . 1) (3 . 1) (4 . 1))
((1 . 2) (2 . 2) (3 . 2) (4 . 2))
((1 . 3) (2 . 3) (3 . 3) (4 . 3))
((1 . 4) (2 . 4) (3 . 4) (4 . 4)) )
这看起来与我们期望的结果非常相似:
( (1 . 1) (2 . 1) (3 . 1) (4 . 1)
(1 . 2) (2 . 2) (3 . 2) (4 . 2)
(1 . 3) (2 . 3) (3 . 3) (4 . 3)
(1 . 4) (2 . 4) (3 . 4) (4 . 4) )
有什么不同吗?我们当前的代码返回了一个坐标列表的列表。我们想要的是一个坐标列表。那么我们如何“展开”这个列表呢?我们可以调用 accumulate,并用 append:
(accumulate append
nil
(map (lambda (y)
(map (lambda (x) (cons x y))
(enumerate 1 4))
(enumerate 1 4))
展平映射
用 append 调用 accumulate 是如此常见,以至于我们将这个过程实现为 flatmap:
(define (flatmap proc seq)
(accumulate append nil (map proc seq)))
使用这个定义,我们最终可以编写出 Jack 想要的函数:
(flatmap (lambda (y)
(map (lambda (x) (cons x y))
(enumerate 1 4)))
(enumerate 1 4))
要点
当你想要遍历某个列表并匹配它们的元素时,嵌套映射是很有用的。为了正确编写函数,强烈建议像我们在这里做的那样将问题分解。flatmap 是一个“展平”列表的函数。
表示集合
集合简介
我们已经看到如何使用列表或树来引入结构的层次结构。有时我们不关心结构的层次结构;我们只需要知道某个数据是否在结构中。对于这个问题,一个有用的结构是集合 - 一组唯一的数据。换句话说,集合永远不会包含两个相同的元素。例如,{cats dogs bears squirrels cats}不是一个集合,因为"cats"出现了两次。相反,{cats dogs bears squirrels}是一个集合。
在本课程中,我们将使用列表来表示一个集合 ADT。这意味着我们可以使用list创建集合,并使用car和cdr从集合中选择。���集将由一个空列表null表示。展望未来,这里是我们将为集合 ADT 定义的一些函数:
-
element-of-set?检查某个数据是否在集合中 -
adjoin-set向集合添加新数据。 -
intersection-set给定两个集合,返回一个只包含两个集合中都有的元素的新集合
element-of-set?
element-of-set?接受两个参数,一个元素x和一个set,如果x在set中,则返回#t,否则返回#f:
(define (element-of-set? x set)
(cond ((null? set) #f)
((equal? x (car set)) #t)
(else (element-of-set? x (cdr set)))))
这段代码类似于memq。我们使用equal?,因为集合的成员可以是数字、符号或其他任何东西。
测试你的理解
假设 set1 和 set2 的长度都为 n。
下面的函数调用的运行时间是多少?
(element-of-set? (car set1) set2)
adjoin-set
让我们继续学习adjoin-set!这个函数再次接受一个元素x和一个set。如果x是set的成员(我们可以使用我们的element-of-set?函数来检查),则不执行任何操作。否则,将x添加到set中:
(define (adjoin-set x set)
(if (element-of-set? x set)
set
(cons x set)))
intersection-set
intersection-set稍微有些挑战。给定两个集合set1和set2,我们需要找到它们的交集。我们将不得不以递归方式完成这个任务。让我们将这个问题分为几种情况:
-
如果任一集合为空,则返回
null。 -
检查
(car set1)是否在set2中。-
如果是这样,请将该元素包含在我们的答案中,并在
(cdr set1)和set2上递归调用intersection-set。 -
如果不在
set2中,则在(cdr set1)和set2上递归调用intersection-set。
-
这是intersection-set的代码:
(define (intersection-set set1 set2)
(cond ((or (null? set1) (null? set2)) '())
((element-of-set? (car set1) set2)
(cons (car set1)
(intersection-set (cdr set1) set2)))
(else (intersection-set (cdr set1) set2))))
测试你的理解
假设 set1 和 set2 的长度都为 n。
下面的函数调用的运行时间是多少?
(intersection-set set1 set2)
有序列表作为集合
你可能已经注意到,我们之前实现的集合 ADT 相对较慢。找到两个集合的交集可能会相对于它们的大小具有二次运行时间。如果一个集合很大,这种实现将非常慢。但不用担心,我们可以通过使用有序集合来加快速度,其中数据必须按递增顺序存储。例如,(1 3 4)是一个有序集合,而(1 5 3 4)不是。
类似于按字母顺序排列名称将使名册更容易搜索,使用有序列表来表示集合将使搜索和操作它们变得更快。
element-of-set?
有序列表的一个优点是,我们不必总是探索整个集合以找到某个特定元素。让我们对element-of-set?进行必要的更改,以利用有序属性。
element-of-set?仍然接受两个参数,一个是元素x,另一个是set。由于我们知道set中的元素是有序的,这意味着我们只需要从左到右扫描集合。
-
如果
(car set)等于x,那么我们停止并返回#t。 -
否则,如果
(car set)大于x,那么我们知道集合中的所有其他元素都将大于x,我们可以在这里停止并返回#f。 -
否则,如果
(car set)小于x,我们将不得不继续到set中的下一个元素并重复此过程。
这意味着,如果我们幸运地x小于或等于set的第一个元素,我们甚至可以在不看set的其余部分的情况下自动返回#f或#t!
我们的element-of-set?代码将如下所示:
(define (element-of-set? x set)
(cond ((null? set) false)
((= x (car set)) true)
((< x (car set)) false)
(else (element-of-set? x (cdr set)))))
注意: 我们假设我们集合中的所有元素都是数字。如果不是这种情况,上述代码将出错。
检验你的理解
假设set1和set2是有序列表,且长度都为 n。
下面的函数调用的运行时间是多少?
(element-of-set? (car set1) set2)
intersection-set
我们之前对于无序列表的intersection-set的实现将一个集合的每个元素与另一个集合的每个元素进行比较,导致其总运行时间为Θ(n²)。为了加快使用有序列表的此功能的速度,让我们使用不同的方法来实现此函数。我们可以将intersection-set分为基本情况(与以前相同):
- 如果任一集合为空,则返回
null
以及以下递归情况:
-
(= (car set1) (car set2)): 由于它们共享相同的元素,我们在答案中包含此元素,从两个集合中删除此元素,并通过调用(intersection-set (cdr set1) (cdr set2))检查其余部分。 -
(< (car set1) (car set2)): 由于(car set2)是set2中的最小元素,我们可以得出结论(car set1)小于set2的所有元素,因此不能在set2中。我们可以通过调用(intersection-set (cdr set1) set2)继续使用set1中的下一个最大成员进行搜索。 -
(> (car set1) (car set2)): 这是上述情况的镜像。由于(car set1)是set1中最小的成员,我们知道(car set2)小于set1的所有元素,因此不能在set1中。我们可以通过调用(intersection-set set1 (cdr set2))继续使用set2中的下一个最大成员进行搜索。
intersection-set的完整代码可以如下所示:
(define (intersection-set set1 set2)
(if (or (null? set1) (null? set2))
'()
(let ((x1 (car set1)) (x2 (car set2)))
(cond ((= x1 x2)
(cons x1
(intersection-set (cdr set1)
(cdr set2))))
((< x1 x2)
(intersection-set (cdr set1) set2))
((< x2 x1)
(intersection-set set1 (cdr set2)))))))
检验你的理解
假设set1和set2是有序列表,且长度都为 n。
下面的函数调用的运行时间是多少?
(intersection-set set1 set2)
作为二叉树的集合
"我想要更快"
在追求速度的过程中,我们必须摆脱将我们与线性联系在一起的列表的束缚。换句话说,如果我们希望我们的集合 ADT 工作得更快,我们将不得不使用与列表不同的数据结构。使用树如何?
一个二叉树就像我们在本课前面描述的树一样,除了一个重要的属性:二叉树的每个节点最多可以有两个分支。
使用二叉树表示集合是简单直观的。二叉树中每个节点的入口(类似于数据)将是集合的一个元素。每个节点还将有一个左分支和一个右分支;节点的左分支或右分支可以为空。如果两个分支都为空,那么该节点是一个叶子。这种数据结构必须遵循一个规则:节点的左分支必须指向具有比节点入口更小的条目的子树。右分支必须指向具有比节点入口更大的条目的子树。换句话说,节点左侧的所有值必须小于节点,节点右侧的所有值必须大于节点。

这个规则引入了对数运行时间的概念。自己尝试在上面最左侧的树中找到11。我们从树的根开始,注意到11大于7,所以我们沿着右分支向下移动。11大于9,所以我们再次沿着右分支向下移动。我们在树中找到了11,可以返回#t。
好的,现在让我们尝试证明这种对数运行时间。如果你不想看整个证明,可以直接跳到TL;DR部分。
看看下面的最坏情况树:

-
你将不得不探索的节点的最大数量,永远等于树的深度,或者树有多少层。 (根的深度为 1。)花点时间确认一下。
-
我们已知集合的大小为
n,因此树中有n个节点。 -
在深度 1 处,有 1 个
(2¹ - 1)节点。在深度 2 处,有 3 个(2² - 1)总节点。在深度 3 处,有 7 个(2³ - 1)总节点,...最后,在深度d处,有(2^d - 1)总节点。
我们在第 3 课中学到,在渐近分析中,我们可以忽略常数,所以让我们说在深度d处,树中有2^d个节点。这意味着任何深度为d的树将在树中有2^d个节点。
我们知道树中总共有n个节点。这意味着2^d = n。
经过一些很酷的代数魔术,我们得到d = log(n)。
记得我们说过我们将探索的节点的最大数量等于树的深度吗?这意味着在二叉树中查找节点的运行时间是Θ(d) = Θ(logn)。因此,我们证明了使用二叉树表示集合的对数运行时间。哦!
总结: 二叉树结构的排序允许我们在每次比较后忽略一半的树。这意味着我们需要探索的节点数最多等于树的深度。这导致我们的运行时间为 Θ(log n)。例如,在具有 8 个节点的树中,我们最多只需要 3 次比较,直到达到任何叶子节点。在具有 16 个节点的树中(是前一个的两倍),我们只需要 4 次比较(只多了 1 次比较!)直到达到任何叶子节点。
实现二叉树
一种实现二叉树的方法是使用一个列表,其中第一项是元素,第二项是左子树,第三项是右子树。
(define (entry tree) (car tree))
(define (left-branch tree) (cadr tree))
(define (right-branch tree) (caddr tree))
(define (make-tree entry left right)
(list entry left right))
element-of-set?
我们可以通过以下代码形式化我们的算法,以查找集合中是否存在元素:
(define (element-of-set? x set)
(cond ((null? set) false)
((= x (entry set)) true)
((< x (entry set))
(element-of-set? x (left-branch set)))
((> x (entry set))
(element-of-set? x (right-branch set)))))
超级简单的东西。
adjoin-set
如何向二叉树添加元素?由于你需要决定将 x 添加到左子树的叶子还是右子树的叶子,让我们遵循上面的相同算法 element-of-set?。
-
如果树为空,则创建一个具有节点条目
x和空左右分支的树。 -
如果 x 等于树的节点,则返回树。(这意味着
x已经在树中,不需要进行任何更改。) -
如果 x 小于树的节点,则进入左子树。
-
如果 x 大于树的节点,则进入右子树。
这是 adjoin-set 的正式算法:
(define (adjoin-set x set)
(cond ((null? set) (make-tree x '() '()))
((= x (entry set)) set)
((< x (entry set))
(make-tree (entry set)
(adjoin-set x (left-branch set))
(right-branch set)))
((> x (entry set))
(make-tree (entry set)
(left-branch set)
(adjoin-set x (right-branch set))))))
不平衡树

上面的图像是将元素 1、2、3、4、5、6 和 7 按顺序添加到空树的结果。(确保你用纸和笔尝试一下,看看是否是这样)。我们说这棵树是不平衡的,因为树的一侧有比另一侧更多的元素。
测试你的理解
在上面的不平衡树中,查找一个元素(例如,7)的运行时间是多少?n 是树中的节点数,其中 n = 7。
挑战: 你能想到一种顺序添加相同数字的方法,以创建一个平衡的树吗?
不同类型的树
之前,我们看到树中的节点可以有任意数量的子节点。这些类型的树有时被称为N 路树。对于 N 路树,我们将树的子节点存储为一个森林,或者一组树。我们通过选择器 (children <tree>) 检索这个森林。
在前一节中,我们看到了一种更具体的树类型,即二叉树。这棵树中的每个节点最多有 2 个子节点。它们可以通过 (left-branch <tree>) 和 (right-branch <tree>) 访问。二叉树的构造器也与 N 路树的构造器不同。
当你在处理树时,要弄清楚你正在处理的是什么类型的树,并注意问题提供的构造器和选择器。二叉树的构造器和选择器在 N 路树上根本不起作用!
要点
集合是一种特定的数据结构,其中每个元素只出现一次。有多种表示集合的方式(就像基本上所有数据结构一样)。表示的选择会影响不同函数的运行时间。
引用
本节是对 quote 函数(更常见的是 ')及其功能的简短回顾和概述,以帮助你准备下一节 calc.rkt。
复习
自从这门课程开始,我们一直使用引号 ' 作为创建单词和句子的快捷方式。下面的例子应该非常简单易懂:
> (define a 3)
3
> a
3
> 'a
a
使用单引号实际上是一个快捷方式 - 'a 等同于 (quote a)。类似地,'(a 1 b 2) 等同于 (quote (a 1 b 2))
测试你的理解
函数 quote(')是一个特殊形式。
谓词和引用
要检查相等性,我们可以使用原始函数 eq?
> (eq? 'a 'a)
#t
> (eq? 'a 'b)
#f
> (eq? 'a (first 'afro))
#t
处理符号/引用的另一个有用的原始函数是 memq。memq 接受两个参数,一个符号和一个列表。如果符号不包含在列表中(即它与列表中的任何项都不是 eq?),那么 memq 返回 false。否则,它返回列表的子列表,从符号的第一次出现开始:
>(memq 'apple '(banana raspberry windows android))
#f
>(memq 'apple '(banana raspberry windows apple android))
(apple android)
>(memq 'apple '(banana raspberry windows (apple android))
#f
注意最后一个例子返回 #f,因为 (eq? 'apple '(apple android)) 返回 #f。因此,memq 在深层列表上不起作用。
你可以用以下定义来实现 memq:
(define (memq item x)
(cond ((null? x) false)
((eq? item (car x)) x)
(else (memq item (cdr x)))))
测试你的理解
以下表达式返回什么?
(memq 'everything '(sugar spice (everything nice)))
(memq 'chicken '(cow chicken cow and chicken))
Racket 会打印什么?
对于以下每个表达式,预测 Racket 会打印什么,而不使用解释器。然后,使用解释器检查你的答案。
* `(list 'a 'b 'c)`
* `(list (list 'george))`
* `(cdr '((x1 x2) (y1 y2)))`
* `(cadr '((x1 x2) (y1 y2))`
* `(pair? (car '(a short list)))`
* `(memq 'red '((red shoes) (blue socks)))`
* `(memq 'red '(red shoes blue socks))`
要点
在本小节中,你学到了:
-
'hi是(quote hi)的简写。 -
memq是一个判断符号是否在列表中的谓词。
calc.rkt
概述
在本小节中,我们将玩弄一个用 Racket 编写的计算器程序。
我们的计算器程序将以与 Racket 相同的语法进行算术运算。为什么我们这样做?我们想增加对 Racket 如何评估事物的理解。在下一个实验中,我们将为其添加更多功能,但目前,它只执行算术运算。
您可以从这里下载该文件。您还可以通过在终端中输入以下内容将其复制到您的课程帐户中:
cp ~cs61as/lib/calc.rkt .
注意末尾的'.'。这将把.rkt文件复制到当前目录。
'READ'函数
在我们深入研究计算器之前,有一个函数我们应该了解:read函数。当您调用(read)时,它将提示您输入一些��容。
> (read)
123
123
在上面的示例中,我们在解释器中输入了123。解释器显示的下一个123是read返回的值。那么它用于什么?试试这个:
> (define a (read))
123
a
> a
123
在这里,我们将a赋值为您输入的值。因此,当我们再次输入123时,我们将该值存储在变量a中。尝试下一个更有趣的内容:
> (define a (read))
(+ 1 2)
a
> a
(+ 1 2)
> (equal? a '(+ 1 2))
#t
这一次,当解释器询问我们要将什么值放入a时,我们输入了'(+ 1 2)'。a最终的值是'(+ 1 2)'而不是 3。下一行测试a是否等于带引号的列表'(+ 1 2)'。我们可以从中学到什么?(read)接受用户输入作为符号;它们不会被评估。
有了这个,让我们进入计算器程序!
Calc:它是如何工作的?
让我们运行程序并了解实际发生了什么。在 Racket 解释器中加载calc.rkt(通过输入(enter! "calc.rkt")),然后调用函数(calc):
> (calc)
calc:
请注意,我们通常的提示符“>”被“calc:”取代。这是一个简单的方法,可以知道您输入的表达式将由calc.rkt评估。现在,尝试输入一些算术运算如(+ 10 20),或一些数字如300,并进行操作!
它是如何知道如何评估数学运算的?让我们看看calc函数的作用。其定义如下:
(define (calc)
(display "calc: ")
(flush)
(print (calc-eval (read)))
(calc))
第一行说(display "calc: "),告诉解释器在“屏幕”/输出上显示“calc:”。
flush告诉解释器显示我们在“屏幕”输出上输入的任何内容(现在可以忽略这一点)。
下一行,(print (calc-eval (read)))告诉解释器使用用户输入调用calc-eval并打印结果。
最后一行是对calc的递归调用,将我们带回开始。这就是读取-评估-打印-循环(REPL):它要求用户输入一些内容,评估它,打印结果,然后循环。
这就是calc的全部内容。计算器的魔法发生在calc-eval中。
Calc:数字输入
那么calc-eval是做什么的呢?考虑一种情况,我们在calc中输入一个数字如下:
calc: 42
42
那不是一个非常令人兴奋的结果,但在幕后,许多事情正在互动。因为用户输入为 42,calc-eval将被调用为(print (calc-eval '42))。(记住(read)返回一个带引号的符号。)让我们看看calc-eval如何处理这个。它的代码如下。
(define (calc-eval exp)
(cond ((number? exp) exp)
((list? exp)
(calc-apply (car exp)
(map calc-eval (cdr exp))))
(else (error "Calc: bad expression:" exp))))
calc-eval的主体是一个cond,因为形式参数exp被调用为 42,第一个条件(number? exp)将被满足,calc-eval将返回exp,即 42。所有数字都被同样对待。这里一个微妙的点是这是基本情况。对于任何算术计算,可以传递的最简单参数都是数字。
Calc:一个运算符
我们接下来要尝试的表达式是一个单运算符函数调用,如(+ 1 1),(* 2 3 10)或(- 100 50 20 10)。
calc: (* 2 3 10)
这将调用calc-eval为(print (calc-eval '(* 2 3 10)))。(再次记住read将用户输入视为符号。)calc-eval如何处理这个?
测试你的理解
下面为您复制了calc-eval代码:
(define (calc-eval exp)
(cond ((number? exp) exp)
((list? exp)
(calc-apply (car exp)
(map calc-eval (cdr exp))))
(else (error "Calc: bad expression:" exp))))
当我们调用以下表达式时会发生什么:
(calc-eval '(* 2 3 10))
Calc-Apply
我们将简单表达式(* 2 3 10)传递给calc,作为(calc-apply '* '(2 3 10))传递给calc-apply。接下来会发生什么?这是calc-apply的代码:
(define (calc-apply fn args)
(cond ((eq? fn '+) (accumulate + 0 args))
((eq? fn '-) (cond ((null? args) (error "Calc: no args to -"))
((= (length args) 1) (- (car args)))
(else (- (car args) (accumulate + 0 (cdr args))))))
((eq? fn '*) (accumulate * 1 args))
((eq? fn '/) (cond ((null? args) (error "Calc: no args to /"))
((= (length args) 1) (/ (car args)))
(else (/ (car args) (accumulate * 1 (cdr args))))))
(else (error "Calc: bad operator:" fn))))
注意calc-apply中的形式参数fn只接受 4 个值:'+', '-', '*', 或'/。其他任何值都会导致错误。Calc-apply可以描述为"找到函数是什么,然后做正确的事情"。在这种情况下,因为fn是'*',calc-apply将在args上调用accumulate,即'(2 3 10),并返回60。
说服自己,对于fn的 4 个可接受参数和任何数字列表args,calc-apply都会执行正确的计算。
Calc:嵌套运算符
测试你的理解
让我们通过调用更复杂的表达式来测试我们的计算器程序。下面为您复制了calc-eval代码:
(define (calc-eval exp)
(cond ((number? exp) exp)
((list? exp)
(calc-apply (car exp)
(map calc-eval (cdr exp))))
(else (error "Calc: bad expression:" exp))))
当我们调用以下表达式时会发生什么:
(calc-eval '(+ 4 5 (* 10 2) 7))
复合表达式
那么我们的计算器程序如何评估复合表达式?它在更简单的表达式上调用calc-eval,并递归重复这个过程,直到表达式足够简单(只是数字)以便简单地返回表达式。我们知道calc-eval和calc-apply适用于数字和具有一个运算符的表达式。其他一切都只是组合。相信递归!
测试你的理解
在calc中,以下哪个不是可能的调用?
要点
在本小节中,您了解了calc.rkt,它接受一个算术表达式(操作)作为符号,并像简化的科学计算器一样对其进行评估。
作业 5
模板
在终端中键入以下内容,将模板文件复制到当前目录(注意末尾的句点):
cp ~cs61as/autograder/templates/hw5.rkt .
或者你可以在这里下载模板here。
自动评分程序
如果你在实验室的计算机上工作,grader命令将运行自动评分程序。如果你在自己的个人机器上工作,你应该下载grader.rkt和HW 5 tests。
练习 1:SICP 2.26
假设我们定义了x和y是两个列表:
(define x (list 1 2 3))
(define y (list 4 5 6))
评估以下表达式时,解释器打印的结果是什么?
(append x y)
(cons x y)
(list x y)
练习 2:SICP 2.29
二叉手机由两个支架组成,左支架和右支架。每个支架都是一根长度不同的杆,悬挂在杆上的是一个重量或另一个二叉手机。我们可以用复合数据来表示一个二叉手机,通过从两个支架构造它(例如,使用列表):
(define (make-mobile left right)
(list left right))
一个支架由一个长度(必须是一个数字)和一个结构构成,该结构可以是一个数字(代表一个简单的重量)或另一个手机:
(define (make-branch len structure)
(list len structure))
a. 编写相应的选择器left-branch和right-branch,它们返回手机的支架,以及branch-length和branch-structure,它们返回支架的组件。
b. 使用你的选择器,定义一个过程total-weight,它返回手机的总重量。
c. 如果一个手机是平衡的,那么它的左上支的扭矩等于右上支的扭矩(也就是说,如果左侧杆的长度乘以悬挂在该杆上的重量等于右侧对应的乘积),并且每个悬挂在其支架上的子手机都是平衡的。设计一个谓词,测试一个二叉手机是否平衡。
d. 假设我们改变手机的表示形式,使得构造函数为
(define (make-mobile left right) (cons left right))
(define (make-branch len structure)
(cons len structure))
你需要对你的程序进行多少改变才能转换成新的表示形式?
练习 3:SICP 2.30, 2.31
a. 定义一个类似于square-list过程的过程square-tree。也就是说,square-tree应该有以下行为:
> (square-tree (list 1 (list 2 (list 3 4) 5) (list 6 7)))
(1 (4 (9 16) 25) (36 49))
b. 将你的答案抽象化,产生一个过程tree-map,具有square-tree可以被定义为:
(define (square-tree tree) (tree-map square tree))
练习 4:SICP 2.36
过程accumulate-n类似于accumulate,只是它将作为第三个参数的序列序列,假定它们都有相同数量的元素。它应用指定的累积过程来组合所有序列的第一个元素,所有序列的第二个元素,依此类推,并返回结果的序列。例如,如果 s 是包含四个序列的序列,((1 2 3) (4 5 6) (7 8 9) (10 11 12)),那么(accumulate-n + 0 s)的值应该是序列(22 26 30)。填写以下accumulate-n定义中缺失的表达式:
(define (accumulate-n op init seqs)
(if (null? (car seqs))
'()
(cons (accumulate op init <??>)
(accumulate-n op init <??>))))
练习 5
假设我们将向量v = (v[i])表示为数字序列,将矩阵m = (m[i,j])表示为向量序列(矩阵的行)。例如,矩阵

被表示为序列((1 2 3 4) (4 5 6 6) (6 7 8 9))。通过这种表示,我们可以使用序列操作简洁地表达基本的矩阵和向量操作。这些操作(在任何关于矩阵代数的书中都有描述)如下:

我们可以将点积定义为
(define (dot-product v w)
(accumulate + 0 (map * v w)))
填写以下用于计算其他矩阵操作的过程中缺失的表达式。(accumulate-n过程在前一个练习中定义)
(define (matrix-*-vector m v)
(map <??> m))
(define (transpose mat)
(accumulate-n <??> <??> mat))
(define (matrix-*-matrix m n)
(let ((cols (transpose n)))
(map <??> m)))
练习 6:SICP 2.38
accumulate过程也被称为fold-right,因为它将序列的第一个元素与组合所有右侧元素的结果结合起来。还有一个fold-left,类似于fold-right,只是它以相反方向组合元素:
(define (fold-left op initial sequence)
(define (iter result rest)
(if (null? rest)
result
(iter (op result (car rest))
(cdr rest))))
(iter initial sequence))
以下是以下值:
(fold-right / 1 (list 1 2 3))
(fold-left / 1 (list 1 2 3))
(fold-right list nil (list 1 2 3))
(fold-left list nil (list 1 2 3))
描述op应该满足的一个属性,以确保fold-right和fold-left对于任何序列都会产生相同的值。
练习 7:SICP 2.54
如果两个列表包含相同顺序排列的相等元素,则它们被认为是相等的。例如,
(equal? '(this is a list) '(this is a list))
是真的,但
(equal? '(this is a list) '(this (is a) list))
是错误的。更准确地说,我们可以通过基本的eq?符号相等性来递归地定义equal?,即如果 a 和 b 都是符号并且符号是eq?,或者如果它们都是列表,(car a)等于(car b)且(cdr a)等于(cdr b),那么它们就是equal?。利用这个想法,实现equal?作为一个过程。
注意:你现在应该知道equal?也是一个内置过程。这意味着你的定义将覆盖内置定义。
练习 8
我们可以将集合表示为不同元素的列表,并且我们可以将集合的所有子集表示为列表的列表。例如,如果集合是(1 2 3),那么所有子集的集合是(() (3) (2) (2 3) (1) (1 3) (1 2) (1 2 3))。完成以下生成集合子集的过程定义,并清楚解释为什么它有效:
(define (subsets s)
(if (null? s)
(list nil)
(let ((rest (subsets (cdr s))))
(append rest (map <??> rest)))))
练习 9
扩展calc.rkt以包含单词作为数据,提供操作first, butfirst, last, butlast, and word。与 Racket 不同,你的计算器应将单词视为自评估表达式,除非它们作为复合表达式的运算符出现。也就是说,它应该像以下示例一样工作:
calc: foo
foo
calc: (first foo)
f
calc: (first (butfirst hello))
e
记住,你可以通过输入以下命令获取程序
cp ~cs61as/lib/calc.rkt .
或者从这里下载。
练习 10:专家级别的额外练习
如果你愿意,可以这样做。这不计入学分。
阅读[第 2.3.4 节](http://mitpress.mit.edu/sicp/full- text/book/book-Z-H-16.html#%25_sec_2.3.4)并完成练习 2.67 - 2.72。
练习 11:专家级别的额外练习
如果你愿意,可以这样做。这不计入学分。
编程示例:在某些编程系统中,你不需要编写算法,只需提供程序行为示例,语言会自动推导算法:
> (define pairup (regroup '((1 2) (3 4) ...)))
> (pairup '(the rain in spain stays mainly on the plain))
((the rain) (in spain) (stays mainly) (on the))
写下regroup。阅读~cs61as/lib/regroup.problem获取详细信息。
提交你的作业!
有关说明,请参阅此指南。它涵盖了基本的终端命令和作业提交。
如果在提交作业时遇到任何问题,请不要犹豫向助教求助!
6 - racket1.rkt 和通用运算符
第 6 课介绍
介绍
在这节课中,我们开始探讨通用操作符——可以在不同数据类型上调用的过程。通用操作符背后的基本思想是,我们有不同类型的数据是“智能”的——它们知道如何操作自己。过程则被允许是“愚蠢”的——它们不需要知道任何关于数据类型的信息,但一切仍然能够运作。
接下来我们将介绍Racket-1,一个用 Racket 编写的简单 Racket 解释器。虽然它不能做到 Racket 所能做的一切,但它展示了构成解释器的基本组件。
先决条件
你应该对calc.rkt和数据抽象有很好的理解。
阅读材料
以下是本课程的相关阅读材料:
标记数据
标记数据简介
在我们创建通用操作符之前,我们首先必须能够跟踪数据类型。为什么呢?
回想一下第 4 课,我们实现了有理数。我们决定将有理数存储为一对,其中car保存分子,cdr保存分母。与此同时,我们的敌人,本·比特迪德尔,实现了复数。他也将这些数字表示为一对:car保存实部,cdr保存虚部。

现在,给定一个car是 3 且cdr是 4 的对,我们如何知道给定的数据表示有理数[mathjaxinline]\frac{3}{4}[/mathjaxinline]还是复数[mathjaxinline]3+4i[/mathjaxinline]?我们得到的原始数据可以以任何方式解释,所以我们无法确定!实际上,这对可能既不是这两者,实际上可能代表某种其他数据类型。这就是为什么我们需要一种将数据与其特定类型关联的系统。
(顺便说一句:此时,你可能会对本感到生气——他为什么要使用与我们相同的表示方式?!但是,他真的没有错。即使他使用了不同的内部表示,我们也不能使用此区别来检查数据的类型:我们将突破数据抽象屏障!)
解决方案是使用标记数据:每个数据片段都携带关于其类型的信息。我们可以通过给所有数据附加标记来实现这一点。为了做到这一点,我们需要一个构造函数来标记我们的数据(attach-tag),并选择器来从标记数据片段中获取标记和数据(type-tag和contents)。
下面是处理标记数据的可能实现。
(define (attach-tag tag data)
(cons tag data))
(define (type-tag tagged-data)
(if (pair? tagged-data)
(car tagged-data)
(error "Not tagged data")))
(define (contents tagged-data)
(if (pair? tagged-data)
(cdr tagged-data)
(error "Not tagged data")))
你能想出另一组构造函数和选择器来使用不同的内部表示实现数据标记吗?
为有理数和复数标记
现在我们已经实现了标记数据,我们可以修复我们的有理数和复数数据类型的实现。我们的旧代码看起来像这样:
(define (make-rational numer denom)
(cons num denom))
(define (make-complex real imag)
(cons real imag))
但现在我们可以这样做:
(define (make-rational numer denom)
(attach-tag 'rational (cons num denom)))
(define (make-complex real imag)
(attach-tag 'complex (cons real imag)))
请注意,我们完全可以用attach-tag替换函数cons,代码仍然可以正常工作。但这违反了我们创建的数据抽象屏障!
然后,我们可以使用contents编写选择器。例如,对于有理数:
(define (numer n)
(car (contents n)))
(define (denom n)
(cdr (contents n)))
为标记数据编写过程
我们的目标是编写一个通用的加法过程。它应该适用于有理数和复数。
第一步是编写特定于输入数据类型的加法过程。使用我们在上一节刚刚编写的构造函数和选择器,这应该相当简单。
尝试以下操作:
-
编写
add-rational,它接受两个有理数,并返回一个等于两个输入之和的有理数。记得通过使用适当的构造函数和选择器来尊重数据抽象。 -
编写
add-complex,它接受两个复数,并返回等于两个输入之和的复数。请记住 mathjaxinline + (c+di) = (a+c)+(b+d)i[/mathjaxinline]。 -
假设我们已经编写了一个过程
add-rational-complex,它按顺序接受有理数和复数,并正确地将它们相加。 -
现在编写一个名为
add-numbers的通用加法操作,它接受两个数字,每个数字可以是有理数或复数。我们应该依靠标记将我们的数据指向上面的正确过程。
在下面检查你的答案。
解决方案
你的add-rational应该是这样的:
(define (add-rational x y)
(make-rational (+ (* (numer x) (denom y))
(* (numer y) (denom x)))
(* (denom x) (denom y))))
你的add-complex应该是类似的。请注意,由于make-rational、numer和denom创建的抽象屏障,我们不必担心标记。
现在是add-numbers:
(define (add-numbers num1 num2)
(cond ((and (equal? (type-tag num1) 'rational)
(equal? (type-tag num2) 'rational))
(add-rational num1 num2))
((and (equal? (type-tag num1) 'complex)
(equal? (type-tag num2) 'complex))
(add-complex num1 num2))
((and (equal? (type-tag num1) 'rational)
(equal? (type-tag num2) 'complex))
(add-rational-complex num1 num2))
(else
(add-rational-complex num2 num1))))
太棒了!我们现在可以使用一个通用过程来添加数字了!
反思
让我们思考一下我们学到了什么:
-
我们甚至不需要知道标记是如何实现的就能编写这个
add-numbers!这是因为我们正确地抽象了这些细节。所以我们只需使用选择器type-tag来告诉我们正在处理的数据类型。 -
如果我们想要将另一种类型的数字添加到我们的系统中,我们将不得不更改我们通用函数的定义,添加大量的额外条件来处理新的数据类型。在我们的情况下,修改将很简单,但这在较大的系统中不起作用。换句话说,我们的系统缺乏可扩展性。
尽管add-numbers的例子有点牵强,但实际上有许多系统确实使用了带标记的数据。事实上,Racket 解释器使用标记的数据来评估你的代码!
带标记数据的弱点
正如我们上面所暗示的,带标记的数据系统有几个关键弱点。
其中一个弱点是,每种数据类型都必须被识别并手动纳入每个通用过程中。例如,假设我们想要将新类型的数字纳入我们的系统。我们需要用一个类型来标识这个新的表示,然后编辑所有通用过程(add-numbers、multiply-numbers、divide-numbers等)以检查新类型并执行相应的操作。
另一个弱点是,即使单独设计了各个表示和相应的过程,我们也必须保证整个系统中没有两个过程具有相同的名称。这就是我们创建了新过程add-numbers的原因,它调用了add-rational、add-complex和add-rational-complex。
这两个弱点的根本问题是,实现通用接口的技术不具备可扩展性——实现通用程序的人必须每次添加新的表示或类型时修改这些程序。此外,最初编写有理数系统和复数系统的人现在必须修改他们的代码以避免名称冲突。在这些情况下,必须对代码进行的更改是直接的,但仍然必须进行,这是不便和错误的根源。
数据导向编程
什么是数据导向编程?
你必须加载并运行以下文件才能完成本节内容。不用担心其中定义了什么。
cp ~cs61as/lib/data_directed_programming.rkt .
数据导向编程是一种通过进一步模块化数据类型来增加代码灵活性的方法。我们不是使用cond子句在函数内部控制有关数据类型和运算符(过程)的信息,而是将这些信息记录在一个我们可以添加和检索的数据结构中。你被赋予了这样做的工具:put来设置数据结构和get来检查它。直观地说,我们只是向一个类似表的数据结构中添加条目。
> (get 'foo 'baz)
#f
> (put 'foo 'baz 'hello)
> (get 'foo 'baz)
hello
在上面的代码中,我们尝试检索具有键'foo和'baz的条目。因为该条目不存在(我们还没有put它!),我们得到了#f。下一行将一个条目放入具有键'foo和'baz的表中。在最后一行,我们检索我们刚刚放入的信息。
一旦你把东西放进表里,它就会一直在那里。(这是我们从函数式编程中第一次分离出来的部分,我们允许你进行赋值。但我们的意图是在计算开始时设置表,然后将其视为常量信息,而不是可变的东西。)暂时我们把put和get当作原语;我们会在第三单元中看到如何构建它们。
要理解所有这些与数据导向编程的关系,首先观察到我们有一些操作,我们希望能够对各种类型的数据应用。根据输入数据的类型,我们调用不同的过程来执行相同的基本操作。例如,添加两个有理数使用不同的过程,与添加两个复数不同。我们基本上正在处理一个二维表,该表包含一个轴上的可能操作和另一个轴上的可能类型。请注意,可能的类型实际上可能是类型列表,如果过程需要多个参数的话。
使用这种范式,向系统添加一个新类型不需要更改任何现有的过程;我们只需要向表中添加新条目。
一个新的示例系统
如上一节所述,我们表的“键”必须是类型列表,如果我们想继续使用我们的算术示例。现在我们不打算处理这个不必要的复杂性,我们将切换到一个更友好的示例,这应该稍微容易些。但是,所有的大想法都完全相同。
我们的数据类型将是正方形和圆;我们的操作将是面积和周长。为了进行比较(和复习),这些操作的标记数据版本将被编写为:
(define pi 3.141592654)
;;this is the tagged-data version where types are processed by the generic procedure being called
(define (make-square side)
(attach-tag 'square side))
;;this is the tagged-data version where types are processed by the generic procedure being called
(define (make-circle radius)
(attach-tag 'circle radius))
;;this is the tagged-data version where types are processed by the generic procedure being called
(define (area shape)
(cond ((eq? (type-tag shape) 'square)
(* (contents shape) (contents shape)))
((eq? (type-tag shape) 'circle)
(* pi (contents shape) (contents shape)))
(else (error "Unknown shape -- AREA"))))
;;this is the tagged-data version where types are processed by the generic procedure being called
(define (perimeter shape)
(cond ((eq? (type-tag shape) 'square)
(* 4 (contents shape)))
((eq? (type-tag shape) 'circle)
(* 2 pi (contents shape)))
(else (error "Unknown shape -- PERIMETER"))))
你应该能完全理解上面的代码!我们将在本课程的其余部分中始终使用这个以正方形和圆为例的示例。
“put”一切都放在一起
使用页面顶部介绍的数据结构,现在系统可以处理任意数量的类型,而无需更改现有代码!以下是新代码的样子(构造函数保持不变):
;;this is the data-directed version where types and operations
;;are handled by a data structure that stores the information
(put 'square 'area (lambda (s) (* s s)))
(put 'circle 'area (lambda (r) (* pi r r)))
(put 'square 'perimeter (lambda (s) (* 4 s)))
(put 'circle 'perimeter (lambda (r) (* 2 pi r)))
请注意,表格中每个单元格的条目都是一个函数,而不是一个符号。现在我们可以重新定义通用操作符(“通用”是因为它们适用于任何类型):
;;this is the data-directed version where types and operations
;;are handled by a data structure that stores the information
(define (area shape-obj)
(operate 'area shape-obj))
(define (perimeter shape-obj)
(operate 'perimeter shape-obj))
(define (operate op obj)
(let ((proc (get (type-tag obj) op)))
(if proc
(proc (contents obj))
(error "Unknown operator for type"))))
魔法发生在operate过程中。给定一个操作和一些数据,它会查找应用于该数据的正确过程。如果存在条目(这意味着我们知道如何处理该操作),那么我们只需应用该过程。否则,我们会抛出错误。
数据导向编程的澄清
不要认为数据导向编程只是指操作符和类型名称的二维表!DDP 是一个非常通用、伟大的想法。它意味着将系统的细节放入数据中,而不是放入程序中,这样你就可以编写通用程序,而不是非常具体的程序。
从前,每当一家公司获得一台计算机时,他们都必须雇佣一群程序员为他们编写诸如工资单程序之类的东西。他们不能只是使用别人的程序,因为细节会有所不同,例如,员工编号中有多少位数字。如今,你可以使用通用业务软件包,每家公司都可以根据自己的特定目的“调整”程序,使用数据文件。
另一个展示数据导向编程通用性的例子是编译器。过去,如果你想发明一种新的编程语言,你必须从头开始编写一个编译器。但现在我们有形式化符号来表达语言的语法。(请参阅课程阅读器背面的 Scheme 报告第 7.1 节,第 38 页。)一个程序可以读取这些形式化描述,并编译任何语言。
消息传递
什么是消息传递?
在传统风格中,运算符被表示为知道不同类型的函数;类型本身只是数据。在数据导向编程中,运算符和类型都是数据,并且有一个通用的 operate 函数来完成工作。我们还可以颠倒传统的风格,将类型表示为函数,将操作仅表示为数据。
实际上,不仅类型是函数,而且单个数据本身也是函数。也就是说,有一个函数(如下所示的make-circle),表示圆形类型,当你调用该函数时,它会返回一个代表你给定的特定圆形的函数作为其参数。每个圆形都是一个对象,表示它的函数是一个分发过程,它以一个消息作为其参数,该消息说明要执行哪个操作。
下面是make-square和make-circle的新定义。
(define (make-square side)
(lambda (message)
(cond ((eq? message 'area)
(* side side))
((eq? message 'perimeter)
(* 4 side))
(else (error "Unknown message")))))
(define (make-circle radius)
(lambda (message)
(cond ((eq? message 'area)
(* pi radius radius))
((eq? message 'perimeter)
(* 2 pi radius))
(else (error "Unknown message")))))
(define square5 (make-square 5))
(define circle3 (make-circle 3))
为什么消息传递很重要?
消息传递可能看起来是处理这个形状问题的过于复杂的方式,但我们将在下一课中看到,它是创建面向对象编程的关键思想之一。当与我们下周将学习的局部状态的概念相结合时,消息传递变得更加强大。
我们似乎放弃了带标记的数据;每种形状类型只是一些函数,很难确定给定函数代表哪种类型的形状。如果需要,我们可以通过添加每个对象都理解的type消息来将消息传递与带标记的数据结合起来。
(define (make-square side)
(lambda (message)
(cond ((eq? message 'area)
(* side side))
((eq? message 'perimeter)
(* 4 side))
((EQ? MESSAGE 'TYPE) 'SQUARE)
(else (error "Unknown message")))))
Racket-1
入门指南
通过在终端中输入以下内容,将 Racket-1 解释器的源代码复制到当前目录:
cp ~cs61as/lib/racket1.rkt .
或者,你可以在此处下载代码 here。
要启动 Racket-1,在 Racket 中输入以下内容:
;; Load Racket-1 file
(require "racket1.rkt")
;; Start interpreter
(racket-1)
熟悉 Racket-1,通过评估一些表达式来了解它。尝试输入常规的 Racket 表达式,看看会发生什么!
你可能会注意到,在 Racket-1 中,你不能做到在正常的 Racket 中可以做的一切:
-
你有所有用于算术和列表操作的 Racket 原语。
-
你有
lambda,但没有高阶函数。 -
你没有
define。
要停止 Racket-1 解释器并返回 Racket,只需评估一个非法表达式,如()。
解释器是什么?
为了在计算机上运行程序,计算机中的某些东西必须理解代码的意图,执行必要的计算,然后返回结果。这个东西充当了程序员思想和计算机硬件之间的中介。其中一种中介是解释器。
racket 是 Racket 的解释器。它将 Racket 源代码转换为计算机指令,然后告诉计算机执行它们。它具有读取输入和显示输出的功能。
Racket-1 也是一个解释器。它适用于 Racket 的纯函数子集。Racket-1 是用 Racket 编写的这个事实很有趣但并不重要。我们也可以用其他语言(比如 Python)编写 Racket-1,但对我们作为用户真正重要的是解释器做什么,而不是它的源代码是什么样子。
我们将在接下来的几堂课中更多地讨论解释器。现在,让我们讨论一下 Racket-1 的工作原理。
Racket-1 是如何工作的?
racket-1
racket-1 遵循以下规则:
要评估一个组合:
-
评估组合的子表达式
-
将最左边子表达式(运算符)的值应用于其他子表达式(操作数)的值。要将复合过程应用于参数,用每个形式参数替换对应的参数来评估过程的主体。
示例:
Racket> ((lambda (x) (* x x)) (- 2 (* 4 3)))
100
这里发生了什么?根据规则,手动逐步评估。
读-求值-打印循环
解释器需要一个循环,允许它执行它所做的所有事情。每次键入命令时,racket-1 解析和执行您的输入,返回输出,然后等待另一个命令。这称为读取-求值-打印循环(REPL)。
这就是全部的 racket-1:
(define (racket-1)
(newline)
(display "Racket-1: ")
(flush-output)
(print (eval-1 (read)))
(newline)
(racket-1)
)
前三行简单地打印提示符“Racket-1: ”。第四行是重要的一行。在这里,输入被读取、解析并发送到 eval-1 进行评估。在 eval-1 处理执行代码后,打印其结果。最后,racket-1 调用自身重新启动该过程,显示另一个“Racket-1: ”并接受另一个命令。
Eval-1
Eval-1 负责返回作为 exp 传递的任何计算的结果。它由一个 cond 组成,其中包含它可以解释和计算的每个子句。请注意,特殊形式是在 Eval-1 中捕获和处理的。
(define (eval-1 exp)
(cond ((constant? exp) exp)
((symbol? exp) (eval exp)) ; use underlying Racket's EVAL
((quote-exp? exp) (cadr exp))
((if-exp? exp)
(if (eval-1 (cadr exp))
(eval-1 (caddr exp))
(eval-1 (cadddr exp))))
((lambda-exp? exp) exp)
((pair? exp) (apply-1 (eval-1 (car exp)) ; eval the operator
(map eval-1 (cdr exp))))
(else (error "bad expr: " exp))))
Apply-1
当需要将一个过程应用于其参数时,eval-1 会调用 apply-1。Apply-1 处理两种情况:
-
racket-1 原语。在这个上下文中,原语是指非用户定义的过程。所有 Racket 过程都是 racket-1 原语。
-
Lambda 函数,或用户定义的过程。
(define (apply-1 proc args)
(cond [(procedure? proc) ; use underlying Racket's APPLY
(apply proc args)]
[(lambda-exp? proc)
(eval-1 (substitute (caddr proc) ; the body
(cadr proc) ; the formal parameters
args ; the actual arguments
'()))] ; bound-vars
[else (error "bad proc: " proc)]))
互递归(在 racket-1 中)

与 Racket-1 练习
好吧,即使你刚刚完成盯着代码看了一会儿,你可能还没有完全理解其中的所有内容。让我们通过一些练习来更好地了解这个程序。
-
手动详细跟踪 racket-1 如何处理以下过程调用:
((lambda (x) (+ x 3)) 5)
特别是,逐步跟踪 racket-1 必须调用的所有函数来评估此表达式。
-
尝试发明高阶过程;由于没有 define,你将不得不使用 Y-组合器技巧,就像这样:
Racket-1: ((lambda (f n) ; this lambda is defining MAP ((lambda (map) (map map f n)) (lambda (map f n) (if (null? n) '() (cons (f (car n)) (map map f (cdr n))) )) )) ;end of lambda defining MAP first ; the argument f for MAP '(the rain in spain)) ; the argument n for MAP (t r i s) -
由于所有的 Racket 原语在 racket-1 中都是自动可用的,你可能会认为你可以使用 Racket 的原始 map 函数。尝试这些例子:
Racket-1: (map first '(the rain in spain)) Racket-1: (map (lambda (x) (first x)) '(the rain in spain))
解释结果。
- 修改解释器以添加
and特殊形式。测试你的工作。确保一旦计算出一个 false 值,你的and就会返回#f,而不会继续评估任何其他参数。
作业 6
在终端键入以下命令将模板文件复制到当前目录(注意末尾的句点):
cp ~cs61as/autograder/templates/hw6.rkt .
或者你可以在这里下载模板。
自动评分程序
要在计算机上运行自动评分程序,请在此处下载测试文件:here。按照之前课程的说明操作。
练习 0
练习 0 包括来自课程的问题。强烈建议完成。这不计入学分。
从文件中加载 racket-1 解释器
~cs61as/lib/racket1.rkt
要启动解释器,请键入(racket-1)。通过评估一些表达式来熟悉它。记住:你有所有 Racket 的算术和列表操作原语;你有 lambda 但没有高阶函数;你没有定义。要停止 racket-1 解释器并返回 Racket,只需评估一个非法表达式,例如()。
0a. 详细跟踪一个简单的过程调用,例如
((lambda (x) (+ x 3)) 5)
在 racket-1 中处理。
0b. 尝试发明高阶过程;由于你没有定义,你将不得不使用 Y 组合子技巧,就像这样:
Racket-1:
((lambda (f n) ; this lambda is defining MAP
((lambda (map) (map map f n))
(lambda (map f n)
(if (null? n)
'()
(cons (f (car n)) (map map f (cdr n))) )) )) ;end of lambda defining MAP
first ; the argument f for MAP
'(the rain in spain)) ; the argument n for MAP
(t r i s)
0c. 由于 racket-1 中自动可用所有 Racket 原语,你可能认为可以使用 Racket 的原始 map 函数。尝试这些示例:
Racket-1:
(map first '(the rain in spain))`
Racket-1:
(map (lambda (x) (first x)) '(the rain in spain))
解释结果。
0d. 修改解释器以添加 and 特殊形式。测试你的工作。确保一旦计算出 false 值,你的 and 会返回 #f 而不再评估任何其他参数。
练习 1
完成以下内容:
阿贝尔森与苏斯曼,练习 2.74, 2.75, 2.76,2.77, 2.79, 2.80,2.81, 2.83
注意:其中一些是思考练习;你不需要实际运行任何 Scheme 程序!(有些不要求你编写过程;其他要求修改不在线上的程序。)
练习 2
为 racket-1 编写一个 map 原语(称为 map-1,以便你和 Racket 不会混淆哪个是哪个),它对所有映射过程都能正确工作。
练习 3
修改 racket-1 解释器以添加 let 特殊形式。提示:像过程调用一样,let 将必须使用 substitute 替换某些变量为它们的值。不要忘记评估提供这些值的表达式!
练习 4
这会有所帮助:SICP 2.3.3
练习 5
文件~cs61as/lib/bst.scm包含了 SICP 2.3.3 中的二叉搜索树过程。使用 adjoin-set,构建第 156 页上显示的树。
专家专用:练习 6
如果你愿意的话可以做这个。这不计入学分。
处理类型问题的另一种方法是类型推断。例如,如果一个过程包括表达式(+ n k),则可以推断n和k具有数值。类似地,表达式(f a b)表明 f 的值是一个过程。编写一个名为 inferred-types 的过程,给定一个 Scheme 过程的定义作为参数,返回有关过程参数的信息列表。信息列表应该对应每个参数一个元素;每个元素应该是一个包含参数名称和推断参数类型的两个元素列表。可能的类型:
? (the type can't be inferred)
procedure (the parameter appeared as the first word in an unquoted expression or as the first argument of map or every)
number (the parameter appeared as an argument of +, -, max, or min)
list (the parameter appeared as an argument of append or as the second argument of map or member)
sentence-or-word (the parameter appeared as an argument of first, butfirst, sentence, or member?, or as the second argument of every)
x (conflicting types were inferred)
对于这个问题,你应该假设要检查的过程体中不包含任何if或cond的出现,尽管它可能包含任意嵌套和引用的表达式。(一个更雄心勃勃的推断过程既会检查更全面的一组过程,还可以推断条件,比如“非空列表”。)以下是你的推断过程应该返回的示例。
(inferred-types
'(define (foo a b c d e f)
(f (append (a b) c '(b c))
(+ 5 d)
(sentence (first e) f)) ) )
应该返回
((a procedure) (b ?) (c list) (d number)
(e sentence-or-word) (f x))
如果你真的雄心勃勃,你可以维护一个推断参数类型的数据库,并在你正在检查的过程被另一个过程调用时使用它!
提交你的作业!
有关说明,请参阅此指南。它涵盖了基本的终端命令和作业提交。
如果你在提交作业时遇到任何问题,请不要犹豫向助教求助!
单元 3
7 - 面向对象编程
第 7 课简介
重要提示:欢迎来到 Scheme
从现在开始,我们将使用 Scheme 语言。 如果您正在阅读此消息,这意味着我们仍在努力过渡到本节的 Racket。以下课程以及之后的所有课程将来自我们的旧课程,并且是用 Scheme 编写的。
请查看此指南以获取有关如何在笔记本电脑上设置 STk 的说明。
对于我们的课程,Scheme 的语义几乎相同,除了以下主要差异:
-
要运行 STk 解释器,请在终端中键入
stkw或stk-simply。如果您使用的是 Windows 计算机,则只有stk-simply会起作用。 -
不要使用
require来引入文件,而应该使用load。例如,将(load "hw7.scm")键入到解释器中将会将文件加载到 STk 中。 -
允许多个定义。在您的
.scm文件中,Scheme 允许您两次定义函数,并且还允许您定义覆盖内置函数的函数,例如,(define (map f lst) 5)将覆盖map高阶函数。
当然,STk 和 Racket 之间的差异不仅限于这些,但上述差异是您绝对需要了解的。
感谢您的耐心!
面向对象编程(OOP)
使用通用运算符的主要优势之一是可以设计新模块并将其添加到现有模块中,而无需修改现有代码。面向对象编程是另一种具有这种优势的技术。这是我们学习的第二种主要编程范式,继续函数式编程之后。
面向对象编程的重要思想是让数据知道如何对自身进行计算。例如,一个数字可以被表示为一个对象,该对象知道如何与另一个数字相加、相减、相乘或相除。这使得程序员可以独立构建模块。要创建新的数据表示,程序员创建一个类,类似于对象的蓝图,指定了存储在该类对象中的数据,以及可以在这些对象上执行的计算。使面向对象编程成为可能的三个主要思想是消息传递、局部状态和继承。
要在 Sublime 中使用 OOP 语言,您必须首先在 Stk 解释器中输入以下内容:
(load "~cs61as/lib/obj.scm")
之后,您将能够根据需要调用define-class和ask。
先决条件
您需要完成第 2 单元,特别是“数据导向”和“消息传递”子章节。
阅读材料
本课程的大部分内容取自此笔记。
这是一个方便的速查表,我们建议您在作业和测验中使用。
你还应该查看旧的讲座笔记这里。
对象
当我们在 OOP 中编码时,我们处理的是对象或“智能数据”,它们知道如何在内部执行操作以及如何与其他对象交互。例如,我们可以有一个类型为 human 的对象 Fred。他内部知道如何吃其他对象,比如说,dumpling。然后,OOP 允许我们“让 Fred 吃 dumpling”。
行话
使用面向对象编程语言的程序员有特殊的词汇来描述 OOP 的不同组成部分。在上面的例子中,Fred 是一个 实例,而 human 的一般类别是一个 类。
Scheme 不直接支持 OOP,但我们有一个提供 OOP 给 Scheme 的扩展。这个实验将专注于 OOP 的“上层”抽象。我们将看到如何设计一个类并使用给定的框架创建对象。如果你对 OOP 在 Scheme 中是如何实现的感兴趣,不用担心,我们未来的课程将会详细介绍。
要点
在本小节中,你学到了以下术语的一般概念:
-
面向对象编程(OOP)
-
对象
-
实例
-
类
下一步是什么?
是时候学习如何玩转面向对象编程(OOP)并定义自己的类了!
局部状态
消息传递
在面向对象编程中让事情发生的方式是“请求”它们为您执行某些操作。我们这样做的方式类似于我们在第 6 课中进行的“消息传递”。在面向对象编程的术语中,我们如何做到这一点?
假设我们有两个对象:Matt-Account和Brian-Account,它们是bank-account类的实例。它们分别持有Matt和Brian拥有的金额。(你现在还不能在 Scheme 中输入这些!我们假设之前已经创建了这些对象。)
> (ask Matt-Account 'balance)
1000
> (ask Brian-Account 'balance)
10000
> (ask Matt-Account 'deposit 100)
1100
> (ask Brian-Account 'withdraw 200)
9800
> (ask Matt-Account 'balance)
1100
> (ask Brian-Account 'withdraw 200)
9600
ask
我们使用ask过程告诉对象执行某个动作。在上面的例子中,银行账户对象接受 3 条消息:
-
balance -
deposit -
withdraw
对于这 3 条消息,银行账户对象知道需要执行哪些操作。请注意,有些消息需要额外的信息:
- 对于balance,它不需要任何额外的参数。它返回账户中的金额。
> (ask Matt-Account 'balance)
1000
- 对于deposit和withdraw,我们需要另一个参数来指定我们要存入或取出的金额。
> (ask Matt-Account 'deposit 50000)
51000
这个比喻是一个对象“知道如何”执行某些任务。这些任务被称为方法。
测试你的理解
假设我们有一个 Max 的银行账户,并输入以下表达式:
(ask max-account 'balance)
1000
(define withdraw 'deposit)
从以下表达式返回什么?
(ask max-account 'withdraw 100)
如果,而不是之前的表达式,我们调用这个表达式:
(ask max-account withdraw 100)
状态
考虑这些调用:
> (ask matt-account 'balance)
500
> (ask brian-account 'balance)
9999
> (ask matt-account 'deposit 500)
1000
> (ask matt-account 'balance)
1000
> (ask matt-account 'withdraw 200)
800
> (ask matt-account 'balance)
800
> (ask brian-account 'balance)
9999
测试你的理解
我们多次调用(ask matt-account 'balance),每次返回不同的值。这告诉我们关于面向对象编程的什么?
matt-account和brian-account都返回每个人拥有多少钱。Matt 对他的账户的操作(对matt-account的方法调用)如何影响 Brian 的账户?
面向对象编程范式 vs. 函数式编程范式
在第一个问题中,我们看到 Matt 的余额在每次取款和存款时都会发生变化。这对我们来说感觉很自然,因为这就是银行账户的工作方式。但是,根据我们迄今为止使用的函数式编程范式,我们期望相同的调用返回相同的值。
在面向对象编程范式中,对象具有状态。也就是说,它们对过去发生的事情有一些了解。在这个例子中,一个银行账户有一个余额,当你存款或取款时,余额会发生变化。
局部状态变量
在第二个问题中,我们看到尽管 Matt 有他的'balance',Brian 有他的'balance',但它们互不干扰。
在面向对象编程术语中,我们说'balance'是一个局部状态变量,或者实例变量。实例变量对于不同的实例将具有不同的值。
我们可以在这里与以下定义进行类比
(define (square x)
(* x x))
和
(define (cube x)
(* x x x))
两个定义都使用了 x,但它们是独立的。
类
要在面向对象编程中创建对象,您需要实例化一个类。matt-account和brian-account是“account”类的一部分。
> (define Matt-Account (instantiate account 1000))
Matt-Account
> (define Brian-Account (instantiate account 10000))
Brian-Account
instantiate函数以其第一个参数作为类,并返回该类的新对象。instantiate可能需要根据特定类的不同而需要额外的参数:在这个例子中,创建账户时必须指定账户的初始余额。
定义一个类
面向对象程序中的大部分代码都是各种类的定义。一个类可以被视为某种对象的蓝图:“这种类型的对象应该能做什么?每个对象应该知道什么变量?”下面是账户类的定义。我们现在只实现一个方法,以后会添加更多内容。关于这段代码有很多要说的,我们会逐一解释。
(define-class (account balance) ;; define a class called account
(method (deposit amount)
;; objects of this class will have one method called deposit
(set! balance (+ amount balance))
balance)
;; deposit sets the balance the the current value plus the deposit amount and then returns the new balance
)
define-class
有一个新的特殊形式,define-class。define-class的语法类似于define的语法。在你期望看到你正在定义的过程的名称的地方,是你正在定义的类的名称。在过程的参数位置,是类的初始化变量:这些是必须作为额外参数给出的本地状态变量的初始值。在下面的例子中,初始化变量“balance”设置为 1000。
(define Matt-Account (instantiate account 1000))
类的主体由任意数量的子句组成;在这个例子中只有一种类型的子句,即方法子句,但我们以后会学习其他类型的子句。
方法
定义方法的语法也被选择为类似于定义过程的语法。方法的“名称”实际上是用于访问方法的消息。当我们说(ask matt-account 'deposit 50)时,实质上是说“在matt-account中,找到名称为'deposit 的方法,并用参数 50 调用该方法”。换句话说,matt-account将调用(deposit 50)。
有了我们现在的类定义,我们实际上可以执行(ask matt-account 'balance)。有些人可能会说:“但我们还没有为余额定义任何方法!”这是真的,但上面的代码仍然有效。对于类中的每个局部状态变量,都会自动定义一个同名的方法。这些方法没有参数,它们只返回该名称变量的当前值。因为我们在实例化matt-account时有状态变量balance,所以我们免费获得了同名的方法'balance。这是我们可以创建state的一种方式;我们将看到稍后的另一种方法。
SET!
在deposit的主体中,我们引入了一个新的过程,set!。这个过程改变了状态变量的值。它的第一个参数是未求值的;它是你希望改变值的变量的名称。第二个参数是已求值的;这个表达式的值成为变量的新值。set!改变了变量的值,但不返回任何东西。
> (define a 3)
a
> a
3
> (set! a (+ 2 4))
okay ; What Scheme prints when nothing is returned
> a
6
> (set! a (+ a a))
okay
> a
12
"set!"中的"!"是 Scheme 中用于改变某些东西的函数的约定(就像以"?"结尾的过程返回#t 或#f 的约定一样)。
测试你的理解
这看起来很像 define,但意义略有不同。Define 创建一个新变量,而 set!更改现有变量的值。看一下下面的代码:
(define a 10)
(define (change x)
(define a 20)
x)
(change 30)
现在a的值是多少?
相反,我们决定执行以下代码片段:
(define a 10)
(define (change x)
(set! a 20)
x)
(change 30)
现在a的值是多少?
如果我们尝试set!一个未定义的变量会发生什么?
(set! c 10)
在 STk 解释器上试一试。
定义'Withdraw'方法
我们定义了deposit方法,现在让我们看看如何定义withdraw方法。请注意,这些方法在类定义中出现的顺序并不重要。
(define-class (account balance)
(method (deposit amount)
(set! balance (+ amount balance))
balance)
(method (withdraw amount)
(if (< balance amount)
"Insufficient Fund"
(begin
(set! balance (- balance amount))
balance)))
再次,withdraw是一个接受一个参数amount的方法。如果balance中没有足够的钱,则返回"资金不足"。否则,减少balance的值并返回剩余的balance。我们正在使用一个新的特殊形式,begin。它实际上是做什么?
我应该从哪里开始?
想象一下,如果我们不使用begin特殊形式。你认为会发生什么?
(if (< balance amount)
"Insufficient Fund"
(set! balance (- balance amount))
balance)
if只接受 3 个参数:一个条件,然后情况,和否则情况。如果我们不使用begin,我们将有四个参数,解释器会抛出错误。到目前为止,在每个过程中,我们只评估一个表达式,以提供该过程的返回值。然而,一个过程仍然只能返回一个值。现在,有时我们想要评估一个表达式的执行而不是返回值,例如改变一个变量的值。对begin的调用表明(set! amount (- amount balance))和balance一起形成 if 的单个参数。
在下一节中,我们将讨论一个类如何从另一个类继承属性。
继承
介绍
您可以想象,随着我们的程序在 OOP 中变得越来越大,您将定义更多的对象和类。一些类将具有类似的特征。例如,您可能有一个box类,一个safety-deposit-box类和一个locked-box类。它们都需要了解类似的方法,比如向其中添加项目和从中移除项目。为每个类似的箱类重复编码将是多余的。我们想要的是定义一个通用类(如box类),它知道通用方法,比如open,然后让更具体的类(如safe-deposit-box类)从通用box类继承。
父母和子女
假设我们想创建一个checking-account类。支票账户与常规银行账户相同,只是您还可以在人与人之间提取支票。但是每次写支票时都要收取十美分的费用。
> (define Hal-Account (instantiate checking-account 1000))
Hal-Account
> (ask Hal-Account 'balance)
1000
> (ask Hal-Account 'deposit 100)
1100
> (ask Hal-Account 'withdraw 50)
1050
> (ask Hal-Account 'write-check 30)
1019.9
实现checking-account的一种方法是复制我们为account类编写的所有代码,但如果我们需要更改我们的account,那么我们需要记得更改我们的checking-account。
在面向对象编程中,一个类是另一个类的专业化是非常常见的:新类将拥有旧类的所有方法,以及一些额外的方法,就像在这个银行账户示例中一样。为了描述这种情况,我们使用对象类族的比喻。原始类是父类,专业化版本是子类。我们说子类继承了父类的方法。(有时也使用子类和父类来表示子类和父类。)
父母
这是我们如何创建账户类的子类:
(define-class (checking-account init-balance)
**(parent (account init-balance))**
(method (write-check amount)
(ask self 'withdraw (+ amount 0.10)) ))
这个示例介绍了define-class中的父类。在这种情况下,父类是account类。请注意,因为account类需要一个实例化变量,我们也需要提供该参数(因此为(account init-balance))。
每当我们向checking-account对象发送消息时,对应的方法从哪里来?如果该名称的方法在checking-account类中定义,它将被使用;否则,OOP 系统将在父账户类中查找方法。如果父类没有该方法,我们将查看父类的父类,依此类推。
检验你的理解
这些问题跟随我们上面的account和checking-account类定义。
(define sam (instantiate checking-account 500))
这些中的哪一个将返回错误?
'self'关键字
write-check应该做什么?它应该将账户余额减少指定金额和额外费用。我们已经知道如何减少余额,那就是withdraw方法!要从另一个方法的主体中调用我们已经定义的方法,我们使用self,因此为(ask self 'withdraw (+ amount 0.10))。每个对象都有一个本地状态变量self,其值是对象本身。
范围
在某个类中定义的方法只能访问同一类中定义的局部状态变量。例如,在checking-account类中定义的方法不能引用在account类中定义的balance变量;同样,account类中的方法也不能引用init-balance变量。
这个规则对应于通常的 Scheme 规则关于变量作用域的规定:每个变量只在定义它的块内有效。(顺便说一句,并非每个面向对象的编程实现都是这样的。)
测试你的理解
类很棒!它们使对象有序。继承很棒!它们使类有序。要注意子类具有哪些状态以及哪些状态被更新。
>(define nick (instantiate checking-account 500))
>(ask nick 'init-balance)
500
>(ask nick 'balance)
500
>(ask nick 'deposit 50)
550
以下表达式返回什么?
(ask nick 'balance)
以下表达式返回什么?
(ask nick 'init-balance)
假设我们现在有以下代码片段:
(define-class (checking-account init-balance)
(parent (account init-balance))
(method (write-check amount)
(ask self 'withdraw (+ amount 0.10)) )
(method (show-balance) balance) )
(define jeffrey (instantiate checking-account 500))
我们在类中添加了一个新方法,show-balance。那么(ask jeffrey 'show-balance)会返回什么?
要点
本小节的几个要点:
-
有些类将是另一个类的更“专业化”或“具体化”版本。在这些情况下,我们希望将特定类作为“父”类的“子”类。
-
子类继承父类的所有方法。
-
要跟踪哪个变量实际上在你的类中作用域。
接下来是什么?
我们将学习一个类可以拥有哪些类型的变量。
三种本地状态变量
概述
到目前为止,我们看到的唯一本地状态变量是实例化变量。在本小节中,我们将看到另外两种类型:实例变量和类变量。
实例变量
回想一下checking-account类:
(define-class (checking-account init-balance)
(parent (account init-balance))
(method (write-check amount)
(ask self 'withdraw (+ amount 0.10)) ))
每当我们写支票时,我们都会向账户收取额外的 10 美分。所有checking-accounts都以 10 美分的费用开始,但现在我们希望能够在进行过程中更改费用。一种方法是将check-fee作为一个实例化变量添加。
(define-class (checking-account init-balance check-fee)
(parent (account init-balance))
(method (write-check amount)
(ask self 'withdraw (+ amount check-fee)) )
(method (set-fee! fee)
(set! check-fee fee)) ))
(define lily (instantiate checking-account 1000 0.10))
(define ted (instantiate checking-account 1000 0.10))
(define barney (instantiate checking-account 9999 0.10))
但这种格式稍微冗余,因为我们必须每次指定check-fee,即使我们总是希望它从 10 美分开始。我们将引入一个新的子句,instance-vars,解决我们的问题。
(define-class (checking-account init-balance)
(parent (account init-balance))
**(instance-vars (check-fee 0.10))**
(method (write-check amount)
(ask self 'withdraw (+ amount check-fee)))
(method (set-fee! fee)
(set! check-fee fee)) )
实例变量 vs. 实例化变量
实例化变量也是实例变量;也就是说,每个实例都有它们的私有值。唯一的区别在于表示法以及何时设置初始值。对于实例化变量,你在调用实例化时给出一个值,但对于其他实例变量,你在类定义中给出值。
类变量
第三种本地状态变量是类变量。与实例变量的情况不同,类变量对于整个类只有一个值。类的每个实例共享这个值。例如,假设我们想要有一个所有在同一项目上工作的worker类。也就是说,每当他们中的任何一个工作时,完成的工作总量都会增加。另一方面,每个工人在工作时都会单独感到饥饿。因此,类有一个共同的work-done变量,每个实例有一个单独的hunger变量。
(define-class (worker)
(instance-vars (hunger 0))
(class-vars (work-done 0))
(method (work)
(set! hunger (+ hunger 1))
(set! work-done (+ work-done 1))
'whistle-while-you-work ))
> (define brian (instantiate worker))
brian
> (define matt (instantiate worker))
matt
> (ask matt 'work)
whistle-while-you-work
> (ask matt 'work)
whistle-while-you-work
> (ask matt 'hunger)
2
> (ask matt 'work-done)
2
> (ask brian 'work)
whistle-while-you-work
> (ask brian 'hunger)
1
> (ask brian 'work-done)
3
> (ask worker 'work-done)
3
正如你所看到的,要求任何worker对象工作会增加work-done变量。相反,每个工人都有自己的hunger实例变量,所以当 Brian 工作时,Matt 不会感到饥饿。你可以询问任何实例类变量的值,或者你可以询问类本身。这是通常规则的一个例外,即消息必须发送给实例,而不是类。
测试你的理解
我们将设计一个“Dog”类。对于以下每一项,决定它们应该是狗类的“子类”、“父类”、“实例变量”、“类变量”还是“方法”。
(例如,如果我们问“cat?”,你认为猫应该是狗的父类,请输入“parent”)
关于狗类,“name”是一个:
关于狗类,“age”是一个:
关于狗类,“wag-tail”是一个:
关于狗类,“Animal”是一个:
总结
有三种本地状态变量:实例化、实例和类。
-
当你使用
instantiate创建对象时,会指定一个实例化变量。 -
实例变量是每个对象都有的变量,彼此独立;改变一个的值不会影响其他的。
-
一个类变量是一个被该类的所有对象共享的变量;改变类变量的值,该类的每个对象都会注意到这个变化。
初始化和默认
初始化
浏览penguin类:
(define-class (penguin name)
(class-vars (all-penguin nil)
(favorite-food 'tuna))
(instance-vars (hunger 50)
(weight 350))
(method (eat)
(set! hunger (- hunger 1))
(set! weight (+ weight 5))
(se 'That favorite-food '(was delicious!))))
> (define jack (instantiate penguin 'jack))
> (ask jack 'eat)
(that tuna was delicious!)
> (ask jack 'hunger)
49
> (ask jack 'weight)
355
企鹅有 2 个实例变量:它的hunger和weight。企鹅类有 2 个类变量:它的最爱食物是tuna,以及all-penguin,它是一个包含所有已创建企鹅名称的列表。当前,all-penguin从不更新。在某些情况下,比如这种情况,我们希望我们的对象在创建时执行某个特定的操作。我们可以使用initialize子句来实现这一点。

在实例化后
一旦企鹅对象被实例化,我们希望它:
-
说出他的名字并且
-
将自己添加到
all-penguin列表中。这是我们如何使用初始化子句来做到的:
(define-class (penguin name)
(class-vars (all-penguin nil)
(favorite-food tuna))
(instance-vars (hunger 50)
(weight 350))
**(initialize (print (se '(hi my name is) name))
(set! all-penguin (cons name all-penguin)))**
(method (eat)
(set! hunger (- hunger 1))
(set! weight (+ weight 5))
(se 'That favorite-food '(was delicious!))))
> (define jack (instantiate penguin 'jack))
(hi my name is jack)
> (define jennie (instantiate penguin 'jennie))
(hi my name is jennie)
> (ask penguin 'all-penguin)
(jennie jack)
> (ask jack 'all-penguin)
(jennie jack)
默认方法
所以,这是迄今为止我们对企鹅类的定义:
(define-class (penguin name)
(class-vars (all-penguin nil)
(favorite-food 'tuna))
(instance-vars (hunger 50)
(weight 350))
(initialize (print (se '(hi my name is) name))
(set! all-penguin (cons name all-penguin)))
(method (eat)
(set! hunger (- hunger 1))
(set! weight (+ weight 5))
(se 'That favorite-food '(was delicious!))))
让我们假设我们调用了以下方法:
> (define jack (instantiate penguin 'jack))
(hi my name is jack)
> (ask jack 'favorite-food)
tuna
> (ask jack 'eat)
(That tuna was delicious!)
> (ask jack 'back-flip)
*** Error: No method back-flip in class penguin
看起来jack不知道如何进行back-flip。目前我们的企鹅只知道一小部分消息,但作为企鹅类的设计者,我们不希望它们对每条其他消息都抛出错误。相反,如果一个企鹅看到它不理解的消息,我们希望它吃东西。我们可以使用default-method子句来实现这一点。看看我们企鹅类的添加:
(define-class (penguin name)
(class-vars (all-penguin nil)
(favorite-food 'tuna))
(instance-vars (hunger 50)
(weight 350))
(initialize (print (se '(hi my name is) name))
(set! all-penguin (cons name all-penguin)))
(method (eat)
(set! hunger (- hunger 1))
(set! weight (+ weight 5))
(se 'That favorite-food '(was delicious!)))
**(default-method
(print (se '(I dont know how to) message '(I will eat instead)))
(ask self 'eat)))**
现在我们调用这些方法:
> (define jack (instantiate penguin 'jack))
(hi my name is jack)
> (ask jack 'back-flip)
(I dont know how to back-flip I will eat instead)
(that tuna was delicious!)
> (ask jack 'weight)
355
> (ask jack 'fly)
(I don't know how to fly I will eat instead)
(that tuna was delicious!)
> (ask jack 'weight)
360

消息和参数
注意,在上面的 default-method 中,我们使用消息来找出我们传递给对象的消息是什么。类似地,我们也可以使用 args 来找出其他参数被传递为列表的情况。
例如,如果我们调用
(ask jack 'do-math 1 2 5 10)
然后,变量message将指向'do-math,而变量args将指向(1 2 5 10)。
明确使用父类的方法
调用父类
我们的企鹅类有点拥挤!为了整理一下,让我们为它创建一个名为emperor-penguin的子类。它可以做所有penguin能做的事情,只是当它吃东西时,一个emperor-penguin在吃东西之前会说'(bon apetit)。以下定义是否有效?
(define-class (emperor-penguin name)
(parent (penguin name))
(method (eat)
(print '(bon apetit!))
(ask self 'eat)))

测试你的理解
让我们将napoleon定义如下:
(define napoleon (instantiate emperor-penguin 'napoleon))
当我们调用(ask napoleon 'eat)时会发生什么?
通常
调用父类方法的正确方式是使用usual关键字。
(define-class (emperor-penguin name)
(parent (penguin name))
(method (eat)
(print '(bon apetit!))
**(usual 'eat)))**
usual接受一个或多个参数,第一个是消息,其他的是消息需要的任何参数。然后将这个消息和必要的参数传递给父类。这样,一个emperor-penguin对象将引用penguin的eat方法。
调用通常就像是使用(ask self ...)一样,只是参数相同,只有在祖先类(父类、祖父类等)中定义的方法才能被使用。在没有父类的类中调用通常是错误的。
命名直觉
你可能会觉得usual是这个函数的一个有趣的名字。这是名字背后的想法:我们将子类视为特殊化。也就是说,父类代表一些广泛的事物类别,而子类是一个特殊化的版本。(想想支票账户与accounts的关系。)子对象几乎以与其父类相同的方式执行所有操作。子类有一些特殊的方式来处理一些消息,与通常方式(父类的方式)不同。但子类可以明确决定以通常(类似父类)的方式执行某些操作,而不是以自己的特殊方式。
多重超类
多重超类
一个类可以有多个父类是可能的。我们可以有一个TA类,一个singer类和一个TA-singer类。
(define-class (singer)
(method (introduce) '(I am aiming for MTV awards!))
(method (sing) '(tralala lalala)))
(define-class (TA)
(method (introduce) '(GO BEARS!))
(method (teach) '(Let me help you with that box-and-pointer diagram)) )
(define-class (singer-TA)
(parent (singer) (TA)) )
(define-class (TA-singer)
(parent (TA) (singer)) )
> (define rohin (instantiate singer-TA))
> (define mona (instantiate TA-singer))
> (ask rohin 'introduce)
(I am aiming for MTV awards!)
> (ask rohin 'sing)
(tralala lalala)
> (ask rohin 'teach)
(Let me help you with that box-and-pointer diagram)
> (ask mona 'introduce)
(GO BEARS!)
> (ask mona 'sing)
(tralala lalala)
> (ask mona 'teach)
(Let me help you with that box-and-pointer diagram)
请注意,TA-singer和singer-TA都继承了TA类和singer类,但顺序不同。当我们向这两个类的实例发送相同的消息时,第一个父类优先。
测验备忘单
在测验中,除了你通常拥有的单面双面备忘单之外,你可能还需要一份这份备忘单。
作业 7
在终端中键入以下内容将模板文件复制到当前目录(注意末尾的句点):
cp ~cs61as/autograder/templates/hw7.scm .
或者你可以从这里下载。
练习 1
修改模板中给出的 person 类,添加一个 repeat 方法,用于重复上次说的话。例如:
> (define brian (instantiate person 'brian))
brian
> (ask brian 'repeat)
()
> (ask brian 'say '(hello))
(hello)
> (ask brian 'repeat)
(hello)
> (ask brian 'greet)
(hello my name is brian)
> (ask brian 'repeat)
(hello my name is brian)
> (ask brian 'ask '(close the door))
(would you please close the door)
> (ask brian 'repeat)
(would you please close the door)
练习 2
这个练习让你了解 usual。
假设我们想定义一个名为 double-talker 的类来代表总是说两遍话的人。例如,看看以下对话。
> (define mike (instantiate double-talker 'mike))
mike
> (ask mike 'say '(hello))
(hello hello)
> (ask mike 'say '(the sky is falling))
(the sky is falling the sky is falling)
考虑以下三个对于 double-talker 类的定义:
(define-class (double-talker name)
(parent (person name))
(method (say stuff) (se (usual 'say stuff) (ask self 'repeat))) )
(define-class (double-talker name)
(parent (person name))
(method (say stuff) (se stuff stuff)) )
(define-class (double-talker name)
(parent (person name))
(method (say stuff) (usual 'say (se stuff stuff))) )
确定这些定义中哪些是按预期工作的。还确定三个版本对哪些消息会有不同的响应。
练习 3
对于一个统计项目,你需要计算各种范围内的大量随机数。(回想一下,(random 10) 返回 0 到 9 之间的随机数。)此外,你需要跟踪每个范围内计算的随机数数量。你决定使用面向对象编程。random-generator 类的对象将接受两个消息:number 和 count。number 消息意味着“给我一个你范围内的随机数”,而 count 意味着“你已经请求了多少个数字?”该类有一个实例化参数,指定此对象的随机数范围,因此:
(define r10 (instantiate random-generator 10))
将创建一个对象,使得 (ask r10 'number) 将返回 0 到 9 之间的随机数,而 (ask r10 'count) 将返回 r10 创建的随机数数量。
练习 4
定义 coke-machine 类。coke-machine 的实例化参数是机器中可以容纳的可乐数量和可乐的价格(以分为单位)。例如,
(define my-machine (instantiate coke-machine 80 70))
创建一个可以容纳 80 瓶可乐并以每瓶 70 分的价格出售的机器。coke-machine 对象必须接受以下消息:
-
(ask my-machine 'deposit 25)意味着存入 25 分。你可以存入多枚硬币,机器应该记住总额。 -
(ask my-machine 'coke)意味着按下可乐按钮。然后机器会打印出 1) "Not enough money",2) "Machine empty",或者 3) 返回找零的金额。错误信息应该使用display打印(例如,(display "Machine empty"))。(成功交易后,机器内不会留下任何钱;即找零不会留在机器内。) -
(ask my-machine 'fill 60)意味着向机器中添加 60 瓶可乐。
这里是一个例子:
> (ask my-machine 'fill 60)
> (ask my-machine 'deposit 25)
> (ask my-machine 'coke)
"Not enough money"
> (ask my-machine 'deposit 25) ;; Now there's 50 cents in there.
> (ask my-machine 'deposit 25) ;; Now there's 75 cents.
> (ask my-machine 'coke)
5 ;; return val is 5 cents change.
你可以假设可乐机有无限的找零,并且最初不含任何可乐。
练习 5
我们将使用对象来表示一副牌。你将获得包含标准顺序中的 52 张牌的列表 ordered-deck:
(define ordered-deck '(AH 2H 3H ... QH KH AS 2S ... QC KC))
你还得到一个用于打乱列表元素的函数:
(define (shuffle deck)
(if (null? deck)
'()
(let ((card (nth (random (length deck)) deck)))
(cons card (shuffle (remove card deck))) )))
一个deck对象响应两个消息:deal和empty?。它通过从牌堆中移除顶部的牌来响应deal,如果牌堆为空,它通过返回()来响应deal。它通过返回#t或#f来响应empty?,根据是否所有牌都已发出。为deck编写一个类定义。当实例化时,一个deck对象应该包含一个洗过的 52 张牌的牌堆。
练习 6
我们希望在我们的对象之间促进礼貌。编写一个类miss-manners,它以一个对象作为其实例化参数。新的miss-manners对象应该只接受一个消息,即please。please消息的参数应该是,首先,原始对象理解的消息,其次,该消息的参数。(假设对原始对象的所有消息都需要额外一个参数。)
这里是一个使用即将推出的冒险游戏项目中的人物类的示例:
> (define BH (instantiate person 'Brian BH-office))
BH
> (ask BH 'go 'down)
BRIAN MOVED FROM BH-OFFICE TO SODA
> (define fussy-BH (instantiate miss-manners BH))
> (ask fussy-BH 'go 'east)
ERROR: NO METHOD GO
> (ask fussy-BH 'please 'go 'east)
BRIAN MOVED FROM SODA TO PSL
专家专用
如果你想挑战一下,可以做这些。这些不计入学分。
练习 7
多重继承技术在"面向对象编程 - 上线视图"的第 9 和 10 页有描述。该部分讨论了解决继承模式的模糊问题,并特别提到,从第二选择父类直接继承的方法可能比从第一选择祖父类继承的方法更好。
设计一个这样的情况的例子。描述你的例子的继承层次结构,列出每个类提供的方法。还描述为什么在这个例子中,对象从第二选择父类继承一个给定方法而不是从第一选择祖父类继承更为合适。
提交你的作业!
有关说明,请参阅此指南。它涵盖了基本的终端命令和作业提交。
如果你在提交作业时遇到任何问题,请不要犹豫向助教求助!
8 - 任务、状态和环境
第 8 课介绍
警告
这节课内容阅读密集,需要进行重大修订。如果您发现这些材料中有任何令人困惑或难以理解的地方,请毫不犹豫地提出问题。
先决条件和期望
确保您在本课之前理解所有材料,尤其是消息传递。
在本节中,我们将讨论变量和环境状态的变化。您将绘制漂亮的图表,生动地说明 Scheme 存储变量和过程的环境。准备好接受大量信息并加以综合!
阅读
在本节中,我们将涵盖以下阅读材料:
随时阅读提前!
此外,这里是旧讲座视频的网络直播。环境图的唯一大例子在网络直播中,因此我们强烈建议您观看:
模块化
我们通常将世界视为由独立对象组成,每个对象都有随时间变化的状态。如果一个对象的行为受其历史影响,则称该对象“具有状态”。例如,银行账户具有状态,因为对于问题“我可以取出 100 美元吗?”的答案取决于存款和取款交易的历史。我们可以通过一个或多个状态变量来描述对象的状态,其中包含足够的关于历史的信息,以确定对象的当前行为。在一个简单的银行系统中,我们可以通过当前余额来描述账户的状态,而不是记住整个账户交易历史。
在由许多对象组成的系统中,对象很少是完全独立的。通过相互作用,每个对象可能通过影响其他对象的状态来影响其他对象的状态,这些相互作用将一个对象的状态变量与其他对象的状态变量耦合在一起。事实上,当系统的状态变量可以分组为紧密耦合的子系统,并且这些子系统只与其他子系统松散耦合时,将系统视为由独立对象组成的观点是最有用的。
这种对系统的看法可以是组织系统的计算模型的有力框架。为了使这样的模型模块化,它应该被分解成模拟系统中实际对象的计算对象。每个计算对象必须有自己的局部状态变量来描述实际对象的状态。由于系统中被模拟对象的状态随着时间的推移而改变,因此相应的计算对象的状态变量也必须改变。如果我们选择通过计算机中经过的时间来模拟系统中的时间流动,那么我们必须有一种方法来构造在我们的程序运行时行为会发生变化的计算对象。特别是,如果我们希望通过编程语言中的普通符号名称来模拟状态变量,那么语言必须提供赋值运算符,以使我们能够更改与名称关联的值。
在面向对象编程中,对象模型的七个基本原则之一。模块化原则指出程序应该由一组内部协调的单元组成,称为对象,它们可以相互通信和互操作,而无需了解其内部结构的信息。
要点
在这个小节中,你学习了模块化的定义。
接下来呢?
前往下一个小节,看看如何使用模块化进行编程!
本地状态变量
预览
让我们快速浏览一下我们将在本节中讨论的内容:

(define (make-account balance)
(define (withdraw amount)
(set! balance (- balance amount)) balance)
(define (deposit amount)
(set! balance (+ balance amount)) balance)
(define (dispatch msg)
(cond
((eq? msg 'withdraw) withdraw)
((eq? msg 'deposit) deposit) ) )
dispatch)
你觉得呢?你对这个函数有什么想法吗?
提款
让我们从银行账户中取钱。我们将使用一个名为withdraw的过程来实现这一点,该过程以要提取的金额作为参数。如果账户中有足够的钱来容纳提款,则withdraw应返回提款后剩余的余额。否则,withdraw应返回消息"余额不足"。例如,如果我们账户中有$100,我们应该使用withdraw获得以下响应序列:
(withdraw 25)
75
(withdraw 25)
50
(withdraw 60)
"Insufficient funds"
(withdraw 15)
35
注意到表达式(withdraw 25),被评估两次,产生不同的值。
等等,但我以为特定的带有相同参数的函数调用会返回相同的值!
到目前为止,它一直是这样,但这是一个过程的新行为类型。我们所有的过程都可以看作是通过垂直线测试的函数。对过程的调用计算应用于给定参数的函数的值,并且对具有相同参数的同一过程的两次调用总是产生相同的结果。但在这种情况下,每次交易后都需要改变余额。否则,我们都会变得富有!
要实现withdraw,我们可以使用一个变量balance来表示账户中的余额,并将withdraw定义为一个访问余额的过程。提款过程检查余额是否至少与请求的金额一样多。如果是,withdraw将余额减少金额并返回余额的新值。否则,withdraw返回余额不足的消息。这里是balance和withdraw的定义:
(define balance 100)
(define (withdraw amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Insufficient funds"))
通过表达式来减少余额
(set! balance (- balance amount))
这使用了set!特殊形式,其语法为
(set! [name] [new-value])
这里[name]是一个符号,[new-value]是任何表达式。Set!改变[name],使其值为通过评估[new-value]获得的结果。在这种情况下,我们正在更改余额,使其新值为从余额的先前值中减去金额的结果。
Withdraw还使用begin特殊形式,在 if 测试为真的情况下导致两个表达式被评估:首先减少balance,然后返回balance的值。一般来说,评估表达式
(begin [exp1] [exp2] ... [expk])
导致表达式[exp1]到[expk]按顺序求值,并将最终表达式[expk]的值作为整个begin形式的值返回。
在你的 STk 解释器上使用withdraw、set!和begin进行玩耍!
有些不对劲...
在我们继续之前,再次检查withdraw和balance是如何定义的:
(define balance 100)
(define (withdraw amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Insufficient funds"))
你看到可能会引起问题的地方了吗?
检测到问题
问题出在变量balance上。如上所述,balance是在全局环境中定义的名称,并且可以被任何过程自由访问以进行检查或修改。如果我们能够使balance在withdraw内部,那将更好,这样withdraw将是唯一可以直接访问balance的过程,而任何其他过程只能间接访问balance(通过调用withdraw)。这将更准确地模拟balance是withdraw使用的局部状态变量,用于跟踪账户状态的概念。
我们可以通过以下方式将balance变为withdraw内部:
(define new-withdraw
(let ((balance 100))
(lambda (amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Insufficient funds"))))
我们在这里所做的是使用let来建立一个具有局部变量balance的环境,其初始值为 100。在这个局部环境中,我们使用lambda创建一个以amount为参数并像我们先前的withdraw过程一样运行的过程。这个过程——作为评估let表达式的结果返回——是new-withdraw,它的行为与withdraw完全相同,但其变量balance不可被任何其他过程访问。
> (new-withdraw 10)
90
> (new-withdraw 30)
60
在 STk 解释器上尝试使用new-withdraw,确保你理解它是如何工作的。
make-account
这里是 SICP 中make-account过程的简化版本:
(define (make-account balance)
(define (withdraw amount)
(set! balance (- balance amount)) balance)
(define (deposit amount)
(set! balance (+ balance amount)) balance)
(define (dispatch msg)
(cond ((eq? msg 'withdraw) withdraw)
((eq? msg 'deposit) deposit) ) )
dispatch)
现在,让我们尝试使用局部状态变量重写这个。填写下面代码中的空白,使结果与上面的make-account过程完全相同。也就是说,它响应相同的消息并产生相同的返回值。两个过程之间的区别在于上面的make-account内部被下面的let语句包围,并且make-account的参数名称不同。
(define (make-account init-amount)
(let (______________________)
(define (withdraw amount)
(set! balance (- balance amount)) balance)
(define (deposit amount)
(set! balance (+ balance amount)) balance)
(define (dispatch msg)
(cond ((eq? msg 'withdraw) withdraw)
((eq? msg 'deposit) deposit) ) )
dispatch) )
现在,修改make-account的任一版本,使其在给定消息balance时返回当前账户余额,并在给定消息init-balance时返回账户最初创建时的金额。例如,
> (define acc (make-account 100))
acc
> (acc 'balance)
100
进行另一个修改,使得在给定消息 transactions(任何存款或取款)时,它返回自账户开户以来所做的所有交易的列表。例如:
> (define acc (make-account 100))
acc
> ((acc 'withdraw) 50)
50
> ((acc 'deposit) 10)
60
> (acc 'balance)
60
> (acc 'transactions)
((deposit 10) (withdraw 50))
在查看下面的整个解决方案之前,在 STk 解释器中尝试你的定义,并确保你理解make-account的整个代码。
这是我们的解决方案:
(define (make-account init-amount)
(let ((balance init-amount)
(transactions '()))
(define (withdraw amount)
(set! balance (- balance amount))
(set! transactions (cons (list 'withdraw amount) transactions))
balance)
(define (deposit amount)
(set! balance (+ balance amount))
(set! transactions (cons (list 'deposit amount) transactions))
balance)
(define (dispatch msg)
(cond ((eq? msg 'withdraw) withdraw)
((eq? msg 'deposit) deposit)
((eq? msg 'balance) balance)
((eq? msg 'transactions) transactions) ) )
dispatch) )
评估的替换模型
鉴于这个定义:
(define (plus1 var)
(set! var (+ var 1))
var)
遵循替换模型来找到计算结果
(plus1 5)
即,展示将var替换为5后在plus1主体中得到的表达式,然后计算结果的值。
现在,在 STk 解释器中尝试一下。你得到了相同的答案吗?为什么?
引入赋值带来了相当大的代价。此时,你可能意识到我们不能再使用替换模型进行评估,因为它会产生错误的值。问题在于,替换最终基于我们语言中的符号本质上是值的名称这一概念。但是一旦我们引入set!和变量的值可以改变的概念,一个变量就不再仅仅是一个名称。现在一个变量在某种程度上指向一个可以存储值的位置,而存储在这个位置的值可以改变。
那么我如何评估这些过程呢?
新的评估模型在下一小节等着你。
要点
在本节中,你学到了:
-
如何实现局部状态变量
-
赋值的代价
-
如何使用
set!和begin
接下来是什么?
让我们去下一小节,学习关于新的评估模型!
评估的环境模型
评估的环境模型

在前一小节中,我们了解到一旦使用赋值,就不能再使用替换评估模型。从现在开始将使用的新模型称为评估的环境模型。
让我们通过示例看看这个新模型是如何工作的。我们定义一个简单的square过程,并在7上调用它:
> (define (square x) (* x x))
square
> (square 7)
49
发生了什么?替换模型陈述:
-
在函数体中用实际参数值替换形式参数值。
-
评估结果表达式。
在这个例子中,在(* x x)中将7替换为x得到(* 7 7)。在第 2 步中,我们评估该表达式以获得结果49。
现在,让我们将替换模型放在一边,看看更完整和全面的环境模型:
-
创建一个带有形式参数绑定到实际参数值的框架。
-
使用此框架来扩展词法环境。
-
在结果环境中评估函数体。
框架是名称-值关联或绑定的集合。在我们的示例中,框架有一个将x绑定到7的绑定。
让我们暂时跳过第 2 步,思考第 3 步。这个想法是,我们将评估表达式(* x x),但我们正在细化我们对“评估”表达式的理解。表达式不再在真空中评估,而是每次评估都必须与某个环境相关。
环境可以描述为名称和值之间的一些绑定集合。当我们评估(* x x)并看到符号x时,我们希望能够在我们的绑定集合中查找x并找到值7。查找绑定到符号的值是我们以前使用全局变量做过的事情。新的是,我们现在有可能有局部环境。符号x并不总是7。这只在这次调用square期间才是这样。因此,第 3 步意味着以我们一直理解的方式评估表达式,但在特定位置查找名称。
第 2 步是什么?关键是我们不能在只有x到7绑定的环境中评估(* x x),因为我们还必须查找符号*的值(即乘法函数)。因此,在第 1 步中创建一个新框架,但该框架本身不是一个环境。相反,我们使用新框架来扩展已经存在的环境。
我们扩展哪个旧环境?在square示例中,只有一个候选者,全局环境。但在更复杂的情况下,可能有几个可用的环境。
环境模型的规则
现在,我们将讨论不同情况下环境模型的规则。在继续之前,请记住:
-
每个表达式要么是原子,要么是列表。
-
任何时候都有一个当前框架,最初是全局框架。
表达原子
让我们对如何表达原子值有一些看法:
-
数字,字符串,
#t和#f是自评估的。 -
如果表达式是一个符号,找到第一个可用绑定。(也就是说,在当前框架中查找;如果在那里找不到,就在当前框架“后面”的框架中查找;直到达到全局框架为止。)

过程调用
那么过程怎么办?评估如何处理调用过程的表达式?
-
评估所有子表达式(使用相同的规则)。
-
将过程(第一个子表达式的值)应用于参数(其他子表达式的值)。
-
如果过程是复合的(用户定义的):
-
创建一个帧,其中过程的形式参数绑定到实际参数值。
-
用这个新框架扩展过程的定义环境。
-
评估过程体,使用新框架作为当前框架。
-
-
如果过程是原始的:
- 通过魔法应用它。只有复合过程调用创建新框架。
-
一个例子
(define (square x)
(* x x))
(define (sum-of-squares x y)
(+ (square x) (square y)))
(define (f a)
(sum-of-squares (+ a 1) (* a 2)))

全局框架中的过程对象。

通过评估(f 5)创建的环境。
特殊形式
-
lambda创建一个双泡泡形式的过程。左圆圈指向lambda表达式的文本;右圆圈指向定义环境(即,在看到lambda时的当前环境)。只有 lambda 创建过程。 -
define向当前框架添加一个新绑定。 -
set!更改第一个可用绑定。 -
let是带有调用的lambda。 -
(define (...) …)=lambda+define -
其他特殊形式遵循自己的规则(
cond,if)。
一个例子
(define (make-withdraw balance)
(lambda (amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Insufficient funds")))

在全局环境中定义make-withdraw的结果。

评估(define W1 (make-withdraw 100))的结果。
要点
在这一小节中,我们学习了如何用环境模型评估过程。
接下来呢?
转到下一小节,学习如何绘制环境图!
如何绘制环境图
EnvDraw
EnvDraw 是一个很酷的程序,你可以在你的课程账户上运行它来帮助你绘制环境图。要使用它:
-
在终端中键入
envdraw。(如果这行不通,首先 SSH 进入 torus,然后再键入命令。)然后此命令应该会打开一个 STk 解释器。 -
在解释器中,输入
(envdraw)。 -
你应该会看到一个新的 EnvDraw 窗口打开。在
EnvDraw>提示符下,尝试定义square函数。然后,看看 EnvDraw 窗口会发生什么!
概述
现在你要开始绘制你自己的环境图了!我们将从基础开始逐步构建。你需要确保在完成本课程时你已经了解了前面所有的规则。
我们在本节中向你展示的所有示例环境图都来自 EnvDraw 程序。
定义
让我们从在 Scheme 中定义一个变量并查看相应的环境图开始。具体来说,我们将尝试绘制以下图表:
(define x 3)
第一步始终是绘制全局环境。确保对其进行标记!接下来,我们需要弄清楚如何处理 define。通过查看前面部分的规则,你应该了解到 define 将一个新的绑定添加到当前帧。让我们将其绘制出来:
就是这么简单!只需在全局环境中写上 "x" 并用箭头指向 3。 (注意:在这门课程的你自己的环境图中,你不需要写 "[other bindings]"。EnvDraw 会为了完整性而这样做。)
现在我们要开始定义过程了。当你在 STk 解释器中输入以下代码时会发生什么?我们将继续前面图表的工作。
(define (square x) (* x x))
我们要做的第一件事是更改上面的代码,使其使用 lambda:
(define square (lambda (x) (* x x)))
注意,此表达式现在具有与 (define x 3) 相同的所有基本部分。因此,我们遵循完全相同的过程:在全局框架中写上 "square" 并画一个指向 lambda 的箭头。我们将 lambda 绘制为双气泡。第一个气泡指向参数和主体。第二个气泡指向定义环境,或者在看到 lambda 时的当前环境。这就是你的图表现在应该的样子。
现在你完成了!回顾一下,首先你需要画 lambda。使第一个气泡指向参数和主体,第二个指向定义环境。接下来,只需在全局环境中简单地写上 "square" 并使其指向 lambda。
在下一节中,我们将介绍如何实际调用我们刚刚定义的 square 函数。
在这些示例中,有一个非常重要的点我们忽略了:define 并不总是将事物添加到全局环境中。相反,它将其添加到当前帧(在上述情况下恰好是全局环境)。我们将在后面的部分中详细介绍如何确定当前帧。
应用原始过程
现在,让我们为以下内容绘制环境图:
(define y (+ 3 4))
与之前的示例不同之处在于,在我们为y赋值之前,我们必须首先将+过程应用于3和4。你可以假设所有原始过程都是通过魔法应用的。对于它们不需要绘制任何内容。因此,完整的环境图将简单地如下所示:
应用用户定义的过程
假设我们现在想要实际调用我们之前定义的square函数。我们将使用以下代码调用它:
(square 5)
要调用用户定义的过程,我们按照以下步骤进行:
- 创建一个带有过程形式参数的框架,将其绑定到实际参数值。

- 用这个新框架扩展过程的定义环境。

- 使用新框架作为当前框架,评估过程主体。
最后一步实际上并不涉及更改环境图。相反,这是当我们最终找到调用的值时。要评估square的主体,我们必须首先弄清楚x的值。我们总是使用变量的第一个可用绑定。这意味着我们在当前环境中查找x -> 5的绑定,而不是在全局环境中查找x -> 3的绑定。一旦我们弄清楚了x的值,我们将其乘以自身(记住,你可以假设这是通过魔法完成的)。现在我们完成了!我们将5乘以自身,得到25的答案。
记住,只有复合过程调用才会创建一个新的环境!
原子表达式
评估原子表达式的技巧(例如找���符号的值)取决于确定哪个框架是当前框架。在我们深入讨论之前,请记住评估原子表达式的规则是:
-
数字、字符串、#t 和 #f 是自求值的。
-
如果表达式是一个符号,找到第一个可用的绑定。(也就是说,在当前环境中查找;如果找不到,则在“当前环境”之前的环境中查找;直到找到全局环境为止。)
所有的辛苦工作都在上面的第 2 种情况中。回想一下我们上一节的环境图:

记住,只有在调用用户定义的过程时才会绘制新的环境。因此,当前环境只有在你在另一个函数的范围内时才会与全局环境不同。虽然这是一个非常重要的观点,但现在不要太担心。确保你理解到目前为止的所有示例。我们将在课程的后面介绍更复杂的例子。
测试你的理解
回想一下,我们迄今为止的环境图如下:
如果我现在在解释器中输入x,它的值将是多少?
我现在错误地定义函数cube如下:
(define (cube x) (* y y y))
首先,绘制与此定义对应的环境图。将此定义添加到我们迄今为止绘制的环境图中。
绘制从评估代码中得到的结果环境图:
(cube 2)
它输出什么?
免费加载框架
此时,我们的环境图现在有三个框架,全局框架,E1 和 E2。 E1 和 E2 是通过对square和cube的调用创建的。然而,一旦这些函数返回(或完成),我们创建的框架 E1 和 E2 就变得无用!它们不再可达,它们的绑定也不再重要。
这并不总是这样。在接下来的章节中,我们将讨论一些使这些框架在初始过程调用之后仍然有用的代码。
使用set!
现在让我们看看如何处理set!。你可能记得set!会改变第一个可用绑定。记住,我们通过查看当前框架,然后查看“在”该框架后面的框架来找到第一个可用绑定。
测试你的理解
让我们试一试!从头开始,绘制与以下代码行对应的环境图:
(define x 3)
(define (change x n)
(set! x n))
(change x 5)
x的值是多少?
你如何修复change过程,使全局环境中的x值改变?指出所有可能的修复方法。
使用let
使用let往往会让很多学生感到困扰。但不要绝望!每当你在使用let时遇到困难时,请记住这些简单的规则:
- 将
let转换为 lambda 语句加调用。
例如,你可以重写
(let ((x 7)
(y 10))
(+ x y))
作为
((lambda (x y) (+ x y)) 7 10)
-
绘制相应的
lambda。记住,lambda只是一个带有正确箭头的双气泡。 -
使用适当的参数调用
lambda。记住,这包括绘制新框架并将形式参数绑定到实际参数值。
如果你能记住这些简单的规则,你就不会有任何困难!
测试你的理解
为以下代码绘制环境图:
(let ((x 7)
( y 10))
(+ x y))
现在让我们尝试一些更复杂的东西。为以下代码绘制结果环境图:
(define (make-withdraw initial-amount)
(let ((balance initial-amount))
(lambda (amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Insufficient funds"))))
(define W1 (make-withdraw 100))
(W1 50)
要点
此时,你已经掌握了绘制任何环境图所需的一切知识,无论多么复杂!当我们在未来解决问题时,不要忘记基础!即使是最复杂的代码片段也可以归结为简单的规则。
如果到目前为止有任何一件事你不理解,请寻求帮助!环境图是许多学生觉得困难的主题之一。
接下来是什么?
利用我们对评估环境模型的新认识,在下一节中,我们将通过巧妙地使用 lambda 和 let 来实现面向对象编程。
对于测验
当你参加第 8 次测验时,你可以携带一份环境图规则的副本以及你的双面作弊纸。
下线面向对象编程
下线

利用你新学到的环境知识,理解面向对象编程将会更加容易!我们称之为“below-the-line”,因为我们实现了面向对象编程的功能,而没有使用任何特殊的过程(比如 define-class)。
消息传递
让我们看一下以下代码:
(define (make-rectangular x y)
(define (dispatch m)
(cond ((eq? m 'real-part) x)
((eq? m 'imag-part) y)
((eq? m 'magnitude)
(sqrt (+ (square x) (square y))))
((eq? m 'angle) (atan y x))
(else
(error "Unknown op -- MAKE-RECTANGULAR" m))))
dispatch)
在这个例子中,复数对象由一个分派过程表示。该过程以消息作为其参数,并将数字作为其结果返回。然而,dispatch 可以返回一个过程而不是一个数字,并且允许额外的参数传递给我们称之为响应消息的方法。
用户说
((acc 'withdraw) 100)
评估这个表达式需要一个两步过程:
-
调用分派过程(命名为 acc)并将消息 withdraw 作为其参数。
-
dispatch过程返回withdraw方法过程,然后第二个过程以 100 作为其参数被调用以执行实际工作。
一个对象的所有活动都来自于调用其方法过程;对象本身的唯一工作是在收到消息时返回正确的过程。
任何使用消息传递模型的面向对象编程系统都必须有一种下线机制,用于将方法与消息关联起来。在 Scheme 中,由于其一级过程,使用 dispatch 过程作为关联机制非常自然。在其他一些语言中,对象可能被表示为消息-方法对的数组。如果我们将对象视为抽象数据类型,使用对象的程序不应该知道我们恰好将对象表示为过程。调用方法的两步符号违反了这种抽象屏障。为了解决这个问题,我们发明了 ask 过程:
(define (ask object message . args)
(let ((method (object message))) ; Step 1: invoke dispatch procedure
(if (method? method)
(apply method args) ; Step 2: invoke the method
(error "No method" message (cadr method)))))
ask 执行的基本步骤与文本中使用的显式符号相同。首先,它使用消息作为参数调用分派过程(即对象本身)。这应该返回一个方法(另一个过程)。第二步是使用提供给 ask 的任何额外参数调用该方法过程。ask 的主体看起来比之前的版本复杂,但其中大部分与错误检查有关:如果对象无法识别我们发送的消息怎么办?这些细节并不是很重要。ask 使用了 Scheme 中我们之前未讨论过的两个特性:
在 ask 的形式参数列表中使用的点符号表示它接受任意数量的参数。前两个与形式参数对象和消息相关联;所有剩余的参数(零个或多个)都被放在一个列表中,并与形式参数 args 相关联。
过程apply接受一个过程和一组参数,并将该过程应用于这些参数。我们之所以在这里需要它,是因为我们事先不知道方法将会接收多少个参数;如果我们写成(方法 参数),我们将为方法提供一个参数,即一个列表。
在我们的面向对象编程系统中,通常将消息发送给实例,但也可以将一些消息发送给类,即检查类变量的消息。当您向类发送消息时,就像向实例发送消息一样,您会收到一个方法。这就是为什么我们可以同时向实例和类使用 ask。 (当您要求它创建一个新实例时,面向对象编程系统本身也会向类发送一个实例化消息。)因此,类和每个实例都由一个分派过程表示。类定义的整体结构看起来像这样:
(define (class-dispatch-procedure class-message)
(cond ((eq? class-message 'some-var-name) (lambda () (get-the-value)))
(...)
((eq? class-message 'instantiate)
(lambda (instantiation-var ...)
(define (instance-dispatch-procedure instance-message)
(cond ((eq? instance-message 'foo) (lambda ...))
(...)
(else (error "No method in instance")) ))
instance-dispatch-procedure))
(else (error "No method in class")) ))
(请注意,这并不是一个类真正的样子。在这个简化版本中,我们省略了许多细节。这里唯一关键的一点是有两个分派过程,一个在另一个内部。)在每个过程中,都有一个包含每个允许的消息的cond。每个子句的结果表达式是一个定义相应方法的lambda表达式。
本地状态(再次)
在前一小节中,您学会了如何为过程提供一个本地状态变量:在另一个建立变量的过程内定义该过程。因此,它可以被写成以下示例:
(define new-withdraw
(let ((balance 100))
(lambda (amount)
(if (>= balance amount)
(begin (set! balance (- balance amount)) balance)
"Insufficient funds"))))
在面向对象编程系统中,有三种类型的本地状态变量:类变量、实例变量和实例化变量。虽然实例化变量只是上面实例变量的一种特殊类型,但它们的实现方式不同。这里是另一种简化的类定义视图,这次省略了所有消息传递的内容,专注于变量:
(define class-dispatch-procedure
(LET ((CLASS-VAR1 VAL1)
(CLASS-VAR2 VAL2) ...)
(lambda (class-message)
(cond ((eq? class-message 'class-var1) (lambda () class-var1))
...
((eq? class-message 'instantiate)
(lambda (INSTANTIATION-VARIABLE1 ...)
(LET ((INSTANCE-VAR1 VAL1)
(INSTANCE-VAR2 VAL2) ...)
(define (instance-dispatch-procedure instance-message)
...)
instance-dispatch-procedure)))))))
类变量的作用域包括类分派过程、实例分派过程以及这些方法中的所有内容。实例变量的作用域不包括其方法中的类分派过程。每次调用类instantiate方法都会产生一组新的实例变量,就像书中每个新的银行账户都有自己的本地状态变量一样。
为什么类变量和实例变量使用let来实现,而实例化变量不是?原因在于类和实例变量由类定义本身给出它们的(初始)值。这就是let的作用:它建立了名称和值之间的关联。然而,实例化变量直到创建类的每个特定实例时才会获得值,因此我们将这些变量实现为将被调用以创建实例的lambda的形式参数。
继承和委托
继承是子类对象可以使用父类方法的机制。理想情况下,所有这些方法都应该成为子类的一部分;父类的过程定义将被“复制到”子类的 Scheme 实现中。我们的 OOP 系统的实际实现,尽管目的相同,但使用了一种稍微不同的技术,称为委托。每个对象的分发过程只包含其自身类的方法条目,而不包括其父类的方法。但每个对象在一个实例变量中有一个其父类的对象。为了更容易谈论所有这些对象和类,让我们看一个之前看过的例子:
(define-class (checking-account init-balance)
(parent (account init-balance))
(method (write-check amount)
(ask self 'withdraw (+ amount 0.10)) ))
让我们创建该类的一个实例:
(define Gerry-account (instantiate checking-account 20000))
那么名为Gerry-account的对象将具有一个名为my-account的实例变量,其值是账户类的一个实例。(变量“my-whatever”是由define-class自动创建的。)
这个父实例有什么好处呢?如果Gerry-account的分发过程无法识别某个消息,那么它将到达cond的else子句。在没有父类的对象中,该子句将生成一个错误消息。但如果对象有一个父类,else子句将把消息传递给父类的分发过程:
(define (make-checking-account-instance init-balance)
(LET ((MY-ACCOUNT (INSTANTIATE ACCOUNT INIT-BALANCE)))
(lambda (message)
(cond ((eq? message 'write-check) (lambda (amount) ...))
((eq? message 'init-balance) (lambda () init-balance))
(ELSE (MY-ACCOUNT MESSAGE)) ))))
(当然,这是一个极为简化的图景。我们省略了类分发过程等其他细节。在实现中并没有一个名为make-checking-account-instance的过程;这个过程实际上是类的实例化方法,正如我们之前解释的那样。)
当我们向Gerry-account发送一个write-check消息时,它会以我们谈论的直接方式处理。但当我们向Gerry-account发送一个存款消息时,我们到达cond的else子句,消息被委托给父账户对象。该对象(即其分发过程)返回一个方法,而Gerry-account也返回该方法。
关键的理解是为什么else子句没有说
(else (ask my-parent message))
Gerry-account分发过程以消息作为其参数,并以方法作为其结果。你会记得,Ask执行一个两步过程,首先获取方法,然后调用该方法。在分发过程中,我们只想获取方法,而不是调用它。(在某处,有一个等待Gerry-account分发过程返回方法的ask调用,然后ask将调用该方法。)
委托技术存在一个缺点。当我们要求Gerry-account存入一些钱时,deposit方法只能访问account类的本地状态变量,而不能访问checking-account类的状态变量。同样,write-check方法也无法访问账户本地状态变量如balance。你可以看到为什么会出现这种限制:每个方法都是在一个类过程的范围内定义的过程,Scheme 的词法作用域规则限制了每个方法只能访问其范围包含的变量。继承和委托之间的技术区别在于基于继承的面向对象编程系统没有这种限制。我们可以通过使用请求另一个类(子类请求父类,反之亦然)返回(或修改)其变量的消息来绕过这种限制。write-check方法中的(ask self 'withdraw ...)就是一个例子。
花里胡哨
到目前为止所展示的简化 Scheme 实现隐藏了实际面向对象编程系统中的几个复杂之处。到目前为止,我们解释的确实是实现中最重要的部分,你不应该让接下来的细节让你对核心思想感到困惑。我们对这些事情给出了相当简要的解释,省略了令人讨厌的细节。
一个复杂之处在于多重继承。与将未知消息委托给一个父类不同,我们必须尝试多个父类。真正的else子句调用一个名为get-method的过程,该过程接受任意数量的对象(即分派过程)作为参数,除了消息之外。Get-method依次尝试在每个对象中找到一个方法;只有当所有父类都未提供方法时,它才会给出错误消息。(每个父类都将有一个"my-whatever"变量。)
影响else子句的另一个复杂之处是类定义中可能使用default-method的情况。如果使用了这个可选特性,default-method子句的主体将成为对象的else子句的一部分。
当一个实例被创建时,instantiate过程会向其发送一个initialize消息。每个调度过程都自动有一个对应的方法。如果在define-class中使用initialize子句,则方法包括该代码。但即使没有initialize子句,OOP 系统也有一些自己的初始化任务要执行。特别是,初始化必须为 self 变量提供一个值。每个initialize方法都以所需的 self 值作为参数。如果没有涉及父级或子级,则self只是对象自己的调度过程的另一个名称。但是,如果一个实例是某个子实例的my-whatever,那么self应该表示该子实例。解决方案是,子类的initialize方法使用子类自己的 self 作为参数调用父类的initialize方法。(子类如何获得自己的 self 参数?这是由instantiate过程提供的。)
最后,usual涉及一些复杂情况。每个对象都有一个send-usual-to-parent方法,它基本上复制了ask过程的工作,但它只在父级中查找方法,就像else子句所做的那样。调用usual会导致调用此方法。
计数器示例
现在让我们实现一个简单的计数器。每次调用counter时,它会增加自己的局部变量和所有计数器的全局变量。以下是我们计数器类的代码:
(define make-counter
(let ((glob 0))
(lambda ()
(let ((loc 0))
(lambda ()
(set! loc (+ loc 1))
(set! glob (+ glob 1))
(list loc glob))))))
它的工作方式如下:
> (define counter1 (make-counter))
counter1
> (define counter2 (make-counter))
counter2
> (counter1)
(1 1)
> (counter1)
(2 2)
> (counter2)
(1 3)
> (counter1)
(3 4)
类变量glob是在包围外部 lambda 创建的环境中创建的,该 lambda 表示整个类。实例变量loc是在类 lambda 内部的环境中创建的,但在表示类的实例的第二个 lambda 之外。
检验你的理解
上面的示例展示了环境如何支持 OOP 中的状态变量,但简化了实例不是消息传递调度过程的事实。简而言之,这不是非常现实。
目前,我们调用计数器而没有参数,并返回该计数器的局部和全局变量的列表。更改此类以使计数器具有两种方法,要么是'local'或'global'。这两种方法中的每一种都接受正好一个参数:增加计数器的数字。你的代码应该像这样工作:
> (define counter1 (make-counter))
counter1
> (define counter2 (make-counter))
counter2
> ((counter1 'global) 5)
5
> ((counter2 'global) 3)
8
> ((counter1 'local) 2)
2
作业 8
模板
在终端上键入以下命令,将模板文件复制到当前目录:
cp ~cs61as/autograder/templates/hw8.scm .
或者您可以在这里下载。
练习
完成以下内容:
注意: 对于练习 3.3 和 3.4,您应该创建一个名为make-password-account而不是make-account的函数。
专家级额外内容
如果您愿意,请执行此操作。这不计入学分。
环境模型的目的是表示变量的作用域;当您在程序中看到一个x时,它指的是哪个变量x?解决这个问题的另一种方法是重命名所有局部变量,以便永远不会有两个同名变量。编写一个名为unique-rename的过程,它以一个(quoted) lambda 表达式作为参数,并返回一个将变量重命名为唯一的等效 lambda 表达式:
> (unique-rename '(lambda (x) (lambda (y) (x (lambda (x) (y x))))))
(lambda (g1) (lambda (g2) (g1 (lambda (g3) (g2 g3)))))
请注意,原始表达式中有两个名为x的变量,在返回的表达式中,从名称中清楚地知道哪个是哪个。您将需要一个修改后的计数器对象来生成唯一的名称。
您可以假设没有quote、let或define表达式,因此每个符号都是一个变量引用,变量只能通过lambda创建。
描述如何使用unique-rename来允许仅有一个(global)帧的 Scheme 程序的评估。
提交您的作业!
有关说明,请参阅此指南。它涵盖了基本的终端命令和作业提交。
如果您在提交作业时遇到任何问题,请不要犹豫向助教求助!
项目 3 - 冒险游戏
介绍
冒险开始!
是时候动手进行一些面向对象编程了。在这个项目中,你将帮助我们编写一个冒险游戏,你将创建人物、地点、物品,然后让它们互动。
这个项目设计为由两个伙伴并行工作,然后将你们的结果合并为一个完整的产品。此后,两个伙伴分别称为 A 人和 B 人。你们将为你们的小组提交一个合并完成的项目。
项目从两个伙伴都应该完成的两个练习开始;这些练习不需要新的编程,而是让你熟悉我们提供的程序的整体结构。之后,每个伙伴有各自的练习。最后有一个所有人都需要完成的练习,需要将两个伙伴的工作结合起来。(因此,你可能应该记下你在项目中修改的所有过程,这样你就可以注意到两个伙伴独立修改的过程。)
这是一个长期项目,你应该相应地安排时间。一旦你完成整个项目的所有练习,你的小组应该提交一个作业,包括你修改过的adv.scm程序以突出修改部分,并测试你的工作的记录。你必须在你的adv.scm文件顶部注明谁是 A 伙伴,谁是 B 伙伴。请确保这一点非常突出和易于找到!
计分
每个伙伴解决九个问题。其中三个问题(编号 1、2 和 9)是两个伙伴共同的;其他问题是分开的。你需要针对每个问题提交一个解决方案。两个伙伴都会得到为问题 1、2 和 9 组分配的分数;每个伙伴会得到自己问题 3 到 8 的分数。这意味着你的项目分数主要基于个人工作,但也在一定程度上依赖于你的组员。对于前两个问题,你可以让伙伴来完成,但你不应该这样做,因为这些问题对帮助你理解整个项目的结构是必要的。问题 9 要求两个伙伴已经完成各自的工作,并一起会面了解彼此的解决方案,因此除非两者都完成了自己的工作,否则任何一方都不会得到分数。
如果你找不到伙伴和/或希望独自工作,请与助教联系。
致谢
这个任务在某种程度上基于麻省理工学院课程版本中的一项家庭作业。但由于这是伯克利,我们已经改变了它以符合政治正确性;角色们不再互相残杀,而是一直在享用美食。注: 除非你是一个顽固的雅皮士,否则你可能会觉得吃美食并不能表达对无家可归者困境的适当敏感性。但这是一个开始。
在这个实验任务中,我们将探索两个关键思想:模拟一个世界,其中对象由一组状态变量来表征,以及使用消息传递作为一种编程技术,用于模块化对象相互交互的世界。
关于合作的注意事项
这里有一些建议来与你的合作伙伴分享你的工作:
-
Dropbox:易于设置。小心不要覆盖你的合作伙伴的文件!
-
Google Docs:易于设置。不太可能覆盖代码。
-
电子邮件:指定一个合作伙伴。将你所有的代码发送给那个合作伙伴,他们将负责将它们合并在一起。
-
其他:你可以使用任何你喜欢的方法,只要它促进学术诚实并成功地在合作伙伴之间共享代码。
入门指南
入门指南
在这个实验任务中,我们将探索两个关键想法:
-
对象由一组状态变量来表征的世界的模拟
-
使用消息传递作为一种编程技术,用于模块化对象相互作用的世界。
面向对象编程正变得越来越受欢迎,这是一种适用于涉及计算实体相互作用的任何应用程序的方法论。
一些示例包括:
-
操作系统(进程作为对象)
-
Windows 系统(窗口作为对象)
-
游戏(如小行星、太空船、大猩猩作为对象)
-
绘图程序(形状作为对象)
项目文件
要开始,将项目所需的文件复制到您的目录中:
cp -r ~cs61as/lib/adventure/ .
| 文件名 | 目的 |
|---|---|
1.obj.scm |
我们面向对象系统的代码。 |
2.adv.scm |
冒险游戏程序。它包含了对象类的定义。 |
3.tables.scm |
您将在问题 A5 和 B4 中需要的 ADT。 |
4.adv-world.scm |
冒险游戏中对象(即人、地点和物品)的具体实例。 |
5.small-world.scm |
一个更小、更简化的世界,您可以用于调试。 |
要在此项目上工作,您必须按照上表中的确切顺序将这些文件加载到 STk 中。只加载adv-world.scm或small-world.scm之一,但不是两者都加载。您被要求完成的工作是指adv-world.scm;提供small-world.scm是为了让您更愿意在较小的世界中调试一些可能更不复杂、也更快速加载的过程。
要加载一个 Scheme 文件,例如,obj.scm,请输入
(load "obj.scm")
到解释器中。
将冒险游戏分为adv.scm和adv-world.scm的原因是,当您对adv.scm中的类定义进行任何更改时,您可能需要重新加载整个世界,以使您更改后的版本生效。拥有两个文件意味着您不必再重新加载第一批过程。
程序简介
在此程序中有三个主要类别:人、地点和物品。
以下是从adv-world.scm中选取的一些示例:
;;; construct the places in the world
(define Soda (instantiate place 'Soda))
(define BH-Office (instantiate place 'BH-Office))
(define art-gallery (instantiate place 'art-gallery))
(define Pimentel (instantiate place 'Pimentel))
(define 61A-Lab (instantiate place '61A-Lab))
(define Sproul-Plaza (instantiate place 'Sproul-Plaza))
(define Telegraph-Ave (instantiate place 'Telegraph-Ave))
(define Noahs (instantiate place 'Noahs))
(define Intermezzo (instantiate place 'Intermezzo))
(define s-h (instantiate place 'sproul-hall))
;;; make some things and put them at places
(define bagel (instantiate thing 'bagel))
(ask Noahs 'appear bagel)
(define coffee (instantiate thing 'coffee))
(ask Intermezzo 'appear coffee)
;;; make some people
(define Brian (instantiate person 'Brian BH-Office))
(define hacker (instantiate person 'hacker Pimentel))
;;; connect places in the world
(can-go Soda 'up art-gallery)
(can-go art-gallery 'west BH-Office)
(can-go Soda 'south Pimentel)
构建了这个世界之后,我们现在可以通过向物体发送消息来与之交互。以下是一个简短的示例:
; We start with the hacker in Pimentel.
> (ask Pimentel 'exits)
(NORTH SOUTH)
> (ask hacker 'go 'north)
HACKER moved from PIMENTEL to SODA
我们可以把物体放在不同的地方,然后人们可以拿走这些物体:
> (define Jolt (instantiate thing 'Jolt))
JOLT
> (ask Soda 'appear Jolt)
APPEARED
> (ask hacker 'take Jolt)
HACKER took JOLT
TAKEN
你可以从其他人那里拿走物体,但是管理者不对后果负责...(很遗憾,这是一个幻想游戏,在苏打楼里并没有真的自动售货机存放 Jolt。)
双方练习
说明
本部分的前两个练习应由双方合作完成!只要双方完全理解练习和所涵盖的概念,就可以成对工作。 (仍然应只有一个解决方案,双方都同意。)剩余的练习将有诸如“A3”之类的数字,这对应于“合作伙伴 A 的练习 3”。
项目分为两个主要部分。 在双方都完成了第 1 部分的练习后,将您的工作合并在一起,并确保其中一个合作伙伴了解另一个人的工作。 由于项目的第 2 部分取决于第 1 部分的工作代码,请小心地组合您的adv.scm和adv-world.scm的代码。
问题 1
实例化一个新的 Person 对象来代表你自己。 把自己放在一个叫做dormitory(或者你住在哪里)的新地方,并将其连接到校园,以便可以到达。 创建一个叫做kirin的地方,位于soda的北面。 (实际上它在 Solano 大道上。)在那里放一个叫做potstickers的东西。 然后给出必要的命令,将您的角色移动到kirin,拿起potstickers,然后将自己移动到Brian所在的地方,放下potstickers,并让Brian拿走。 然后回到实验室,继续工作。 (有传言说你在现实生活中这样做会得到这门课的 A!)所有这些只是为了确保您知道如何讲冒险程序的语言。
列出在此情节中发送的所有消息。 在脑海中尝试解决这个问题是个好主意,至少对于一些发生的动作来说是这样,但是您也可以跟踪ask过程以获取完整的列表。 您不必提交此消息列表。 (您必须提交一份不包含跟踪的情节转录。)此练习的目的是使您熟悉不同对象在执行工作时如何互相发送消息的方式。
[提示:我们提供了一个move-loop过程,您可能会发现它作为调试工作的辅助工具。 您可以使用它来重复移动一个人。]
问题 2
很重要的是,您考虑并理解参与冒险游戏的对象的类型。 请回答以下问题:
a) 变量Brian的值是什么类型的东西? 提示:在以下情况下 STk 返回什么:
> Brian
b) 列出地点了解的所有消息。 (您可能希望为自己的每种对象类型维护这样的列表,以帮助调试工作。)
c) 我们一直在定义一个变量来保存我们世界中的每个对象。 例如,我们说:
> (define bagel (instantiate thing 'bagel))
这只是为了方便起见。 并非每个对象都必须具有顶层定义。 每个对象都必须构建并连接到世界。 例如,假设我们这样做了:
> (can-go Telegraph-Ave 'east (instantiate place 'Peoples-Park))
;;; assume Brian is at Telegraph
> (ask Brian 'go 'east)
以下表达式返回什么以及为什么?
> (ask Brian 'place)
> (let ((where (ask Brian 'place)))
(ask where 'name))
> (ask Peoples-park 'appear bagel)
d) 所有这一切的含义是对象可以有多个名称。一个名称是对象内部name变量的值。此外,我们可以在顶层定义一个变量来引用一个对象。此外,一个对象可以有另一个对象的私有名称。例如,Brian有一个变量place,它当前绑定到代表人民公园的对象。一些需要思考的例子:
> (eq? (ask Telegraph-Ave 'look-in 'east) (ask Brian 'place))
> (eq? (ask Brian 'place) 'Peoples-Park)
> (eq? (ask (ask Brian 'place) 'name) 'Peoples-Park)
好的。假设我们在 STk 中输入以下内容:
> (define computer (instantiate thing 'Durer))
以下哪个是正确的?为什么?
(ask 61a-lab 'appear computer)
或者
(ask 61a-lab 'appear Durer)
或者
(ask 61a-lab 'appear 'Durer)
(computer 'name)返回什么?为什么?
e) 我们提供了一个不使用手册中描述的面向对象编程语法的 Thing 类的定义。将其翻译成新的符号。
f) 有时候以交互方式调试一个对象是不方便的,因为它的方法返回对象,而我们想要看到对象的名称。你可以创建辅助程序供交互使用(与对象方法内部使用相对),以便以可打印形式提供所需信息。例如:
(define (name obj) (ask obj 'name))
(define (inventory obj)
(if (person? obj)
(map name (ask obj 'possessions))
(map name (ask obj 'things))))
编写一个名为whereis的过程,它以一个人作为参数,并返回该人所在地的名称。编写一个名为owner的过程,它以一个物品作为参数,并返回拥有它的人的名称。(确保它适用于没有任何所有者的物品。)
类似这样的过程在调试项目的后期非常有帮助,所以请随意为自己编写更多。现在是时候对冒险游戏进行第一次修改了。这是你们分工的时候了。
伙伴 A 的练习
问题 A3
你会注意到,每当一个人去一个新地方时,这个地方会收到一个'enter消息。此外,这个人之前所在的地方会收到一个'exit消息。当地方收到消息时,它会调用其entry-procedures或exit-procedures列表中的每个过程。地方有以下用于操作这些过程列表的方法:add-entry-procedure、add-exit-procedure、remove-entry-procedure、remove-exit-procedure和clear-all-procs。你可以在代码中阅读它们的定义。
Sproul Hall 附有一个特别讨厌的退出过程。修复sproul-hall-exit,使其在第三次调用后停止讨厌。
请记住,exit-procs列表包含的是过程,而不是过程的名称!重新定义sproul-hall-exit是不够的,因为 Sproul Hall 的退出过程列表仍然包含旧的过程。最好的做法是再次加载adv-world.scm,这将定义一个新的 Sproul Hall 并添加新的退出过程。
问题 A4:第 1 部分
我们已经为人们提供了使用消息'talk和'set-talk来说话的能力。正如你可能已经注意到的,这个校园周围的一些人在别人经过时开始交谈。我们想要模拟这种行为。在任何这样的互动中,涉及到两个人:一个是已经在这个地方的人(以下简称为talker),另一个是刚刚进入这个地方的人(listener)。我们已经提供了一个机制,让listener在进入时向这个地方发送一个enter消息。此外,每个人都准备接受一个notice消息,意味着这个人应该注意到有新人来了。talker应该收到一个notice消息,然后开始交谈,因为我们让一个人的notice方法发送一个talk消息给自己。(稍后我们会看到一些特殊类型的人有不同的notice方法。)
你的任务是修改地方的enter方法,以便除了正在进入的人之外,还向该地方的每个人发送一个notice消息。notice消息应该有新进入的人作为参数。(现在你不会对该参数做任何操作,但以后会用到。)
将以下内容添加到adv-world.scm中:
(define singer (instantiate person 'rick sproul-plaza))
(ask singer 'set-talk "My funny valentine, sweet comic valentine")
(define preacher (instantiate person 'preacher sproul-plaza))
(ask preacher 'set-talk "Praise the Lord")
(define street-person (instantiate person 'harry telegraph-ave))
(ask street-person 'set-talk "Brother, can you spare a buck")
尝试四处走动到sproul-plaza和telegraph-ave,看看是否触发了消息。
你必须包含一个脚本,其中你的角色四处走动并触发这些消息。
问题 A4:第 2 部分
到目前为止,程序假设任何人都可以去任何地方。在现实生活中,许多地方都有上锁的门。
为地方发明一个may-enter?消息,它接受一个人作为参数,并始终返回#t。然后发明一个locked-place类,在其中may-enter?方法如果地方是未锁定的则返回#t,如果地方是锁定的则返回#f。(它应该最初是锁定的。)locked-place类还必须有一个unlock消息。为简单起见,编写此方法时不带参数,并始终成功。在真实的游戏中,我们还会发明钥匙,并且需要��个机制,要求人必须有正确的钥匙才能打开门。(这就是为什么may-enter?接受人作为参数的原因。)
修改person类,以便在从一个地方移动到另一个地方之前检查是否有权限进入。如果一个人无法进入,返回一个错误。然后创建一个锁定的地方并进行测试。
注意: 一个锁定的地方应该有一个实例化变量,即它的名称。
(define warehouse (instantiate locked-place 'warehouse))
问题 A5
四处走动很棒,但有些人从远处通勤,所以他们需要把车辆停放在车库里。车辆只是一个thing,但你需要发明一种特殊类型的地方,称为garage。车库有两种方法(除了所有地方都有的方法):park和unpark。你还需要一种特殊类型的thing,称为ticket;它的特殊之处在于它有一个number作为实例化变量。
park方法接受一个车辆(一个thing)作为参数。首先检查车辆是否实际上在车库里。(拥有车辆的人会进入车库,然后要求停放车辆,所以在发送park消息之前,车辆应该与人一起进入车库。)然后生成一个带有唯一序列号的ticket。(序列号计数器应该在所有车库之间共享,这样我们以后不会因为尝试从一个车库取车在另一个车库停放的车辆而陷入麻烦。)每张票据都应该有名称ticket。
你将把票据号码与车辆关联在一个键值表中,就像我们在第 6 课中使用get和put一样。然而,get和put是针对所有操作使用单个固定表格;在这种情况下,我们需要为每个车库使用单独的表格。文件tables.scm包含了表格抽象数据类型的实现:
constructor: (make-table) returns a new, empty table.
mutator: (insert! key value table) adds a new key-value pair to a table.
selector: (lookup key table) returns the corresponding value, or #f if
the key is not in the table.
你将学习如何在SICP 3.3.3 (pp. 266-268)中实现表格。现在,暂时将它们视为原始的。
创建一个以票据号码为键,车辆为值的表格条目。然后要求车辆的所有者丢失车辆并带走票据。
unpark方法接受一个票据作为参数。首先确保你得到的对象实际上是一个票据(通过检查名称)。然后在车库的表格中查找票据号码。如果找到车辆,要求票据的所有者丢失票据并带走车辆。此外,在该票据号码的表格中插入#f,以防止人们两次取车。
一个真实的车库会有限定的容量,并且会收取停车费,但为了简化项目,你不必模拟车库的这些方面。
一定不要将任何东西命名为"car"!这会搞乱一切!
注意:
-
一个停车票只有一个实例化变量,即一个序列号。(例如,
(instantiate ticket 120))。 -
一个停车票是一个带有名称
'ticket的物品。 -
一个车库需要一个实例化变量,即它的名称。(例如,
(instantiate garage 'soda-garage))。 -
不要为车辆定义一个新的类。你可以假设 park 被正确的参数调用。
-
停放一个无主的车辆应该返回一个错误。
-
解除停车一个未停放的车辆应该返回一个错误。
伙伴 B 的练习
问题 B3
为人们定义一个 take-all 方法。如果给出该消息,一个人应该拿走当前位置尚未被任何人拥有的��有东西。
> (ask someperson 'take-all)
问题 B4:第 1 部分
任何人都可以从任何人那里拿走东西是不现实的。我们想给我们的角色一个 strength,然后只有第一个人的 strength 大于第二个人的时候,第一个人才能从第二个人那里拿东西。
然而,我们不会通过添加本地 strength 变量来使人类类变得混乱。这是因为我们可以预期随着程序的进一步开发,我们可能会想要添加更多属性。人们可以拥有 魅力 或 智慧;事物可以是 食物 或不是;地方可以是 室内 或不是。因此,你将创建一个名为 basic-object 的类,它保留一个名为 properties 的本地变量,其中包含一个属性值表,就像我们在第 6 课中使用 get 和 put 一样。然而,get 和 put 指的是所有操作的一个固定表;在这种情况下,我们需要为每个对象单独的表。文件 tables.scm 包含了表抽象数据类型的实现:
-
构造函数:
(make-table)返回一个新的空表。 -
修改器:
(insert! key value table)向表中添加一个新的键值对。 -
选择器:
(lookup key table)返回相应的值,如果键不在表中,则返回#f。
你将在 SICP 3.3.3 (pp. 266-268) 中学习表是如何实现的。现在,只需将它们视为原始的。
你将修改 person、place 和 thing 类,使它们从 basic-object 继承。这个对象将接受一个 put 消息,以便以下调用做正确的事情:
> (ask Brian 'put 'strength 100)
此外,basic-object 应该将任何未被识别为该名称属性请求的消息视为请求。
> (ask Brian 'strength)
100
应该在不必在类定义中显式编写 strength 方法的情况下工作。
不要忘记,如果你要求列表中没有的属性,属性列表机制会返回 #f。这意味着即使我们没有在对象中 put 该属性,以下调用也不应该出错:
> (ask Brian 'charisma)
这对于真假属性很重要,除非我们明确为它们 put 一个 #t 值,否则它们将自动为 #f(但不会出错)。
给人们一些合理的初始力量。(每个新实例化的人对象应该是相同的。)后来,他们可以通过进食变得更强。
问题 B4:第 2 部分
你会注意到类型谓词 person? 检查参数的类型是否是列表 '(person police thief) 的成员。这意味着 person? 过程必须保持一个从 person 继承的所有类的列表,如果我们创建一个新的子类,这将很麻烦。
我们将利用属性列表来实现一个更好的类型检查系统。如果我们向 person 类添加一个名为 person? 的方法,并让它始终返回 #t,那么任何属于 person 类型的对象都将自动继承此方法。没有从 person 继承的对象将找不到 person? 方法,并且在其属性表中找不到 person? 条目,因此它们将返回 #f。
同样,地方应该有一个 place? 方法,东西应该有一个 thing? 方法。
> (ask brian 'person?)
#t
添加这些类型方法,并将类型谓词过程的实现更改为这种新实现(在 adv.scm 的底部)。不要忘记添加 place? 的定义。
新的类型谓词应该执行以下操作:
> (person? brian)
#t
> (place? soda)
#t
> (thing? coffee)
#t
记住 person? 应该适用于从 person 继承的类,如稍后定义的 thief 和 police。place? 和 thing 也是如此。
问题 B5:第 1 部分
在现代,许多地方允许您连接到网络。将 hotspot 定义为一种允许网络连接的地方。每个热点应该有一个 name 和一个 password 作为必须知道的实例变量以便连接。
> (define library (instantiate hotspot 'library 1234))
;name of hotspot is library, password is 1234
(注意:我们设想的是每个网络一个密码,而不是像 AirBears 那样每个人一个密码。)热点有一个 connect 方法,带有两个参数,一个 laptop(一种即将发明的东西)和一个密码。如果密码正确,并且笔记本在热点中,则将其添加到已连接笔记本的列表中,否则返回错误。当笔记本离开热点时,将其从列表中移除。
> (ask library 'connect somelaptop 1234)
热点还有一个 surf 方法,带有两个参数,一个笔记本和一个文本字符串,例如
"http://www.cs.berkeley.edu"
如果笔记本连接到网络,则 surf 方法应该
(system (string-append "lynx " url))
当 URL 是文本字符串参数时(注意在 "lynx " 中的 x 后面有一个空格),否则返回错误。
> (ask library 'surf somelaptop "http://www.cs.berkeley.edu")
问题 B5:第 2 部分
现在发明 laptop 类。笔记本有一个实例变量,即其名称。
> (define somelaptop (instantiate laptop 'somelaptop)
笔记本是一种具有两个额外方法的东西:connect,带有密码作为参数,向笔记本所在的地方发送一个 connect 消息。如果密码错误,则返回错误。
> (ask somelaptop 'connect 1234)
笔记本还有另一个方法,surf,带有一个 URL 文本字符串作为参数,向其所在的地方发送一个 surf 消息。因此,每当笔记本进入新的热点时,用户必须请求连接到该热点的网络;当笔记本离开热点时,它必须自动断开网络连接。(如果它在除热点之外的地方,surf 消息将不被理解;如果它在热点中但未连接,则返回错误)。
> (ask somelaptop 'surf "www.berkeley.edu")
合并第 1 部分的工作
合并工作
你已经完成了冒险游戏的第 1 部分!此时,你应该已经完成了问题 1、2、A3-A5 和 B3-B5。现在是将这些答案合并到一个文件中的时候了。确保在合并后一切仍然正常运行。
测试
确保制作一个展示项目运行情况的记录!为了完整,它应该充分测试所有的修改,对于两位合作伙伴都要测试。如果有什么东西无法运行,请在记录中包含,因为你可能会因有意的错误而获得部分学分。
未来展望
项目的第二部分包括每位合作伙伴三个练习,但是你们必须在中途阅读对方的代码,因为一位合作伙伴的第 7 和第 8 题建立在另一位合作伙伴的第 6 题之上。最后,第 9 题要求两位合作伙伴的工作合并。你们将不得不创建一个包含两位合作伙伴修改的adv.scm版本。这可能需要一些思考!如果两位合作伙伴在同一个对象类的相同方法上进行修改,你们将不得不编写一个包含两个修改的方法版本。
更多给 A 伙伴的练习
adv.scm包括thief类的定义,它是person的子类。thief是一个试图从其他人那里偷食物的角色。当然,伯克利不能容忍这种行为太久。你的任务是定义一个police类;police对象抓住thieves并直接送他们进jail。为此,你需要了解thiefs的工作原理。
由于thief是person的一种,每当另一个person进入thief所在的地方时,thief会从地方收到一个notice消息。当thief注意到一个新的人时,根据他内部behavior变量的状态,他会做两件事中的一件。如果这个变量设置为steal,小偷会四处查看是否有食物。如果有食物,小偷会从当前持有者那里拿走食物,并将他的行为设置为run。当小偷的行为是run时,他会在notice到有人进入他当前位置时随机移动到一个新的地方。run行为使得抓住小偷变得困难。
注意,一个thief对象将许多消息委托给它的person对象。
问题 A6:第一部分
为了帮助警察开展工作,你需要创建一个名为jail的地方(即,jail是place的一个实例)。Jail 没有出口。此外,你需要为人和小偷创建一个名为go-directly-to的方法。go-directly-to不要求new-place与current-place相邻。因此,通过调用(ask thief 'go-directly-to jail),警察可以将小偷送进监狱,无论小偷当前位于何处,假设变量thief绑定到被逮捕的小偷。
问题 A6:第二部分
小偷有时会尝试朝随机选择的方向离开他们所在的地方。事实证明,如果那个地方没有出口,比如jail,这样做是行不通的。修改thief类,使得小偷不会尝试离开没有出口的地方。
结合工作
在继续之前,请让你的伙伴解释问题 B6 及其解决方案。同时,向你的伙伴解释问题 A6 及其解决方案。
问题 A7:第一部分
现在我们要创造餐厅对象。人们将通过在那里购买食物与餐厅互动。首先,我们必须让人们能够购买东西。给person对象一个money属性,这是一个数字,表示他们有��少美元。请注意,money不是一个对象。我们将其实现为一个数字,因为与椅子和锅贴等对象不同,一个人需要能够花一些钱而不是全部。原则上,我们可以有像quarter和dollar-bill这样的对象,但这会使找零过程变得复杂,没有充分的理由。
为了简化生活,我们让每个person从$100开始。(其实我们应该让人们一无所有,然后再发明银行、工作等等,但我们不会这样做。)为人们创建两个方法,get-money和pay-money,每个方法都接受一个数字作为参数,并适当更新人的money值。Pay-money必须根据人是否有足够的钱返回true或false。
> (ask brian 'money)
100
> (ask brian 'get-money 20) ;increases money
> (ask brian 'money)
120
> (ask brian 'pay-money 30) ;decreases money. Returns #t if has enough money
#t
> (ask brian 'money)
90
问题 A7:第 2 部分
冒险游戏的另一个问题是,诺亚家只有一个百吉饼。一旦有人拿走了那个百吉饼,他们就破产了。其他餐厅也是一样。
为了解决这个问题,我们将发明一种新类型的地方,称为restaurant。(也就是说,restaurant是place的子类。)每个restaurant只提供一种食物。(当然,这是一个简化,当然,我们可以看到如何扩展项目以允许食物种类的列表。)当实例化一个restaurant时,除了所有地方都有的参数外,它还应该有两个额外的参数:这家餐厅销售的食品对象类别,以及这种类型的一个物品的价格:
> (define-class (pasta) (parent (food ...)) ...)
> (define somerestaurant (instantiate restaurant 'somerestaurant pasta 7))
注意,餐厅的参数是一个class,而不是一个特定的百吉饼(实例)。这里是意大利面食品类的一个例子。你的伙伴应该在问题 B6 的一部分中定义了一些食品类的示例。
> (define pesto-pasta (instantiate pasta))
> (ask pesto-pasta 'calories)
150
餐厅应该有两个方法。menu方法返回一个包含餐厅销售的食品名称和价格的列表。sell方法接受两个参数,想要购买东西的person和人想要的食品的name。sell方法必须首先检查餐厅是否实际销售正确类型的食品。如果是这样,它应该要求买家以适当的金额pay-money。如果成功,该方法应该实例化食品类并返回新的食品对象。如果人无法购买食物,该方法应返回#f。
以下是一些例子:
> (ask somerestaurant 'menu)
(pasta 7)
> (ask somerestaurant 'sell someperson 'pasta) ;note that pasta is the name
问题 A8
现在我们需要一个人的buy方法。它应该以我们想要购买的食品的name作为参数:
> (ask Brian 'buy 'bagel)
该方法必须向餐厅发送一个sell消息。如果成功(也就是说,从sell方法返回的值是一个对象而不是#f),新食物应该被添加到人的财产中。如果人无法购买,返回一个错误。
合作伙伴 B 的更多练习
问题 B6:第 1 部分
我们让人们从餐馆拿食物的方式在几个方面都不切实际。本周我们的总体目标是解决这个问题。作为第一步,你要创建一个 food 类。我们将给被视为食物的物品两个属性,一个 edible? 属性和一个 calories 属性。如果对象是食物,edible? 属性将具有值 #t。如果一个 person 吃了一些 food,那么食物的 calories 将被添加到人的 strength 中。
(请记住,由于问题 B4 中属性的实现方式,除了食物以外的对象的 edible? 属性将自动为 false。你不必到处告诉其他所有东西不可食用。)
编写一个使用 thing 作为父类的 food 类的定义。当你向它发送一个 edible? 消息时,它应该返回 #t,并且它应该正确地响应一个 calories 消息。
在原始的 adv.scm 中用一个新版本替换名为 edible? 的过程,利用你创建的机制,而不是依赖于内置的食物类型列表。
> (define pesto-pasta (instantiate food 'pasta 150))
;name is pesto-pasta, calories is 150
> (ask pesto-pasta 'calories)
150
> (ask pesto-pasta 'edible?)
#t
> (edible? pesto-pasta)
#t
问题 B6:第 2 部分
现在你有了 food 类,为特定种类的食物发明一些子类。例如,创建一个从 food 继承的 pasta 类。pasta 不应该有任何实例化变量。给 pasta 一个名为 name 的类变量,其值为单词 pasta。(当我们后面发明 restaurant 对象时,我们会需要它。)
使用你的意大利面类,现在可以像下面这样实例化上面的香蒜酱意大利面。
> (define pesto-pasta (instantiate pasta))
> (ask pesto-pasta 'calories)
150
问题 B6:第 3 部分
为人类制作一个 eat 方法。你的 eat 方法应该查看你的财产,并筛选出所有可食用的物品。然后将食物的 calories 值加到你的 strength 中。然后它应该使食物消失(不再是你的财产,也不再在你的位置)。
结合工作
在继续之前,请让你的合作伙伴解释问题 A6 及其解决方案。同时,向你的合作伙伴解释问题 B6 及其解决方案。
问题 B7
你的任务是定义 police 类。当警察注意到一个新的人进入他所在的地方时,警察会检查那个人是否是小偷。如果那个人是小偷,警察会说“犯罪是不值得的”,然后夺走小偷的所有财产,并将小偷直接送进监狱。
给小偷和警察默认的强度。小偷的起始强度应该比人更强,但警察应该比小偷更强。当然,如果你吃得足够多,你应该能够积累足够的 strength 来夺走小偷的食物。
请测试你的代码,并提交一份显示小偷偷走你的食物、你追赶小偷和警察逮捕小偷的记录。如果你还没有注意到,我们在 Sproul Plaza 放了一个小偷来帮助测试你的代码。
> (define somepolice (instantiate police 'grammarpolice soda))
问题 B8
现在我们希望重新组织take,以便查看谁之前拥有所需的对象。如果其拥有者是'no-one,那么就像往常一样拿走它。否则,调用:
> (ask thing 'may-take? receiver)
对于属于某人的物品的may-take?方法应比较其所有者的力量与请求人的力量,以决定是否可以拿走它。如果接收者与持有者的力量相同,则接收者可以拿走该物品。如果物品没有持有者,则接收者可以拿走该物品。
如果人不能拿走东西,该方法应返回#f,或者如果人可以拿走东西,则返回物品本身。目前这比必要的要复杂一些,但我们正在为可能出现的情况做准备,例如,一个对象可能想要为一个人制作自己的克隆品。
注意这里正在进行的消息传递的大量活动。我们向拿取者发送消息。它向物品发送消息,物品向两个人发送消息以了解他们的实力。
合并第二部分的工作
合并工作
你已经完成了冒险游戏的第二部分!此时,你应该已经完成了问题 1、2、A3-A8 和 B3-B8。合并你的答案,并确保你的代码仍然能够正确运行。
测试
检查你的转录是否准确反映了你的代码运行方式。即使你的答案是正确的,也要在你的转录中包含演示该错误的过程。
未来展望
还有一个必答问题和一些可选的额外问题需要合作完成。你的代码必须合并并且能够正常运行,才能完成问题 9。
最终练习(适用于双方)
问题 9
进行必要的更改,以便当警察要求购买食物时,餐厅不收取任何费用。(这样可以使游戏更加真实...?)
(请注意,pay-money和get-money应该表现出相同的行为。不要更改它们的实现方式。)
> (ask somepoliceman 'money)
100
> (ask somepoliceman 'buy 'pasta)
> (ask somepoliceman 'money)
100
专家专用(可选)
正如您可以想象的那样,这是一个真正开放的项目。如果您有时间和意愿,您可以用新类型的人(例如大学生、儿童、耐火龙女王)、地点(雅各布大厅、图书馆、死斗擂台)以及特别是物品(电话、书籍、龙玻璃剑)来填充您的世界。哦,可能性无限!
为了您的享受,我们开发了一个创建迷宫的程序,您可以探索迷宫。要做到这一点,请加载文件~cs61as/lib/labyrinth.scm。(注意:labyrinth.scm可能需要一些修改才能与您在项目第二部分开发的程序配合使用。)
传说有这样一个广阔的房间系列位于斯普劳广场下面。这些房间里散落着过去的食物和相当多的小偷。您可以在斯普劳广场找到通往地下的秘密通道。
您可能希望修改fancy-move-loop,以便您可以在进入附近房间之前四处张望,以避开小偷。您可能还希望您的角色在其属性列表上维护一个访问过的房间列表,以便您可以找到回到地表的路。
提交你的项目
提交文件
你需要提交这个项目的文件有:
-
adv.scm -
adv-world.scm -
transcript
这些文件应该包括对问题 1-9 的解答对于两位合作伙伴。确保你已经在代码中添加了注释,突出显示你所做的所有更改。你对问题 2 的回答可以在adv-world.scm中,也可以在你的转录中,只要适当注释掉即可。
确保你清楚地在adv.scm的顶部指明哪位合作伙伴是 A,哪位是 B。
通过终端提交
只有一个合作伙伴应该提交这个项目。 进入你的adventure目录,然后在终端中输入以下内容:
submit proj3
当你提交时,它会提示你输入你合作伙伴的登录信息。(如果它提示你输入另一个登录信息,输入。然后按回车。)
为了确保提交顺利进行,双方合作伙伴都应该输入
glookup -t
用于检查提交情况。这个命令会给你一个列出所有已提交作业以及提交时间的列表。
如果由于任何原因你需要重新提交你的项目,让之前的同一个人提交文件。如果这不可能,给你的读者发送一封电子邮件提醒情况。
项目结束
恭喜,你完成了!出去找 Brian 买一个 Noah's 的百吉饼,在 Sproul Hall 吃掉。等等,adv.scm之外还有生活吗?
9 - 可变数据和向量
第 9 课介绍
可变数据和向量
第二单元涉及复合数据,作为构造具有多个部分的计算对象的手段。我们抽象了它们的构造函数和选择器,并看到它们可以通过嵌套对和列表来形成。但是,我们从第 7 课中学到,数据还有另一个方面,第二单元没有涉及。我们现在能够用 set! 改变数据,对于对也有类似的操作。我们将探讨 mutators,即修改数据对象的操作。
先决条件
对于这个实验室,确保你理解了第二单元的所有材料,特别是操作列表和结构层次的部分。我们将看到如何改变对的元素和结构。
阅读材料
看看以下阅读材料:
-
这些笔记来自旧的 CS 61A 讲座,涵盖了变异和向量。
可变列表结构
改变对
在第 2 单元中,我们使用对作为存储数据的��据结构的基础。我们已经看到我们现在正在进入一个可以改变数据的领域。也会有时候我们想要改变存储在数据结构中的内容。
(define x (cons 1 2))

set-car!
让x的car代表我摔下楼梯的次数,cdr代表我去错洗手间的次数。我刚刚摔了一层楼梯,所以我应该更新x的car为2。我们如何在不创建新对的情况下实现这一点?Scheme 允许我们使用以下函数set!来设置某个项目的值:
(set-car! x 2)
正如其名称所示,set-car!接受一个对和一个值,并将其car更改为指向第二个参数指定的值。

一般形式是
(set-car! <pair> <value>)
set-cdr!
正如你所期望的,Scheme 还为我们提供了函数set-cdr!,它接受一个对和一个值,并将该对的cdr指针更改为指向该值。继续上一个例子,调用
(set-cdr! x 3)
将会改变成下面显示的对。

一般的语法是
(set-cdr! <pair> <value>)
改变指针
让我们看看set-car!和set-cdr!如何处理更复杂的列表。我们有以下对:
(define x (cons (list a b) (list c d)))
(define y (list e f))

对set-car!和set-cdr!的接下来几次调用是独立的,并且将基于这个原始配置。对于接下来的几个问题,强烈建议画出盒指针。
示例 1
调用的效果
(set-car! x y)
对原始配置进行如下更改将得到以下盒指针图:

这个调用改变了x上的 car 指针,它最初指向(list a b),现在指向y指向的地方:(list e f)。那么带有a和b的列表会发生什么?实际上对它没有任何影响,但由于它没有被引用的指针,这个列表就不再可达了。
测试你的理解
在上面的示例 1 中,x会打印什么?
假设我们有来自示例 1 的原始配置,现在我们决定调用以下表达式:
(set-car! (car x) 'z)
y会打印什么?
示例 2
从原始配置开始,我们现在调用
(define z (cons y (cdr x)))
这给我们以下盒指针图:

-
x将打印出((a b) c d) -
y将打印出(e f) -
z将打印出((e f) c d)
测试你的理解
根据示例 2 中显示的配置,我们现在决定调用
(set-cdr! (cdr z) nil)
现在x会打印什么?
根据示例 2 中显示的配置,我们应该调用什么才能使z返回((e f) b)?
创建新的对
使用set-car!和set-cdr!修改现有的对。而cons和list等过程则创建新的对。那么append呢?它是否通过更改其中一个列表的cdr指针来“合并”两个列表?记住我们一直在使用的append的定义:
(define (append x y)
(if (null? x)
y
(cons (car x) (append (cdr x) y))))
append通过cons连接x和y的元素形成一个新列表。这告诉我们append返回一个新列表。

测试你的理解
当输入到 STk 时,以下代码片段会打印什么?先做一个有根据的猜测,然后在解释器中尝试。 > (define a (list 1 2 3 4 5)) a > (set-cdr! (cdr a) (cdddr a)) okay > a
过程append!类似于append,但它是一个变异器而不是一个构造器。它通过将它们拼接在一起来附加列表,修改x的最后一个对,使其cdr现在是y。(使用空的x调用append!是错误的。)例如,
(define (append! x y)
(set-cdr! (last-pair x) y)
x)
last-pair过程接受一个列表并返回列表的最后一个对:
(define (last-pair x)
(if (null? (cdr x))
x
(last-pair (cdr x))))
现在,让我们看看这段代码:
> (define x (list 'a 'b))
x
> (define y (list 'c 'd))
y
> (define z (append x y))
z
> z
(a b c d)
(cdr x)返回什么?在将其放入 STk 之前,请自行尝试。现在,看看以下对append!的调用
> (define w (append! x y))
w
> w
(a b c d)
现在(cdr x)返回什么?
共享和身份
前面的练习引起了一个重要的警示,即了解何时共享和创建对是重要的。在上面的代码中,x和y指向相同的对,而z则创建了一个具有相同元素的不同对。

(define x (cons 1 2))
(define y x)
(define z (cons 1 2))
记得equal?谓词吗?它可以检查两个对是否包含相同的元素。
(equal? x y)、(equal? x z)、(equal? y z)都返回#t,因为它们在相同位置持有相同的元素。是否可能区分x和z指向不同的结构?是的!Scheme 有eq?谓词,它接受 2 个参数并检查这两个参数是否指向同一个对。
> (eq? x y)
#t
> (eq? x z)
#f
> (eq? y z)
#f
要点
set-car!和set-cdr!分别改变car和cdr指针。像cons、list和append这样的过程会创建新的对。知道哪些对在不同列表之间共享对于确定改变一个是否会影响另一个至关重要。绘制框和指针图将非常有帮助。
表示队列
队列数据结构
使用set-car!和set-cdr!允许我们创建一种以前无法有效实现的数据结构:队列。队列是一种序列,在其中项目从一端(称为队列的后端)插入,从另一端(前端)删除。因为项目总是按照插入的顺序被移除,所以有时称队列为 FIFO(先进先出)。

队列实例
假设我们有函数make-queue,它返回一个新队列,insert-queue!,它向队列添加一个新元素,以及delete-queue!,它从队列中删除一个元素(我们将很快实现它们!)。让我们来看看队列的机制。
| 操作 | 结果队列 |
|---|---|
(define q (make-queue)) |
|
(insert-queue! q 'a) |
a |
(insert-queue! q 'b) |
a b |
(delete-queue! q) |
b |
(insert-queue! q 'c) |
b c |
(insert-queue! q 'd) |
b c d |
(delete-queue! q) |
c d |
在数据抽象方面,我们可以将队列视为以下一组操作定义的:
-
一个构造函数:
(make-queue)返回一个空队列(不包含任何项目的队列)。 -
两个选择器:
-
(empty-queue? <_queue_>)测试队列是否为空。 -
(front-queue <_queue_>)返回队列前端的对象,如果队列为空,则发出错误信号。它不修改队列。
-
-
两个改变者:
-
(insert-queue! <_queue_> <_item_>)将项目插入到队列的后端,并将修改后的队列作为其值返回。 -
(delete-queue! <_queue_>)删除队列前端的项目,并将修改后的队列作为其值返回,如果在删除之前队列为空,则发出错误信号。
-
作为列表的队列
因为队列是一系列项目的列表,我们可以用普通列表来表示它。队列的前端将是列表的car,插入新元素将相当于在末尾添加新对。删除项目只是cdr。为什么我们不采用这种实现?问题在于运行时间。要将项目添加到列表的末尾,我们必须经过一系列的cdr。如果列表真的很长,我们将花费很长时间找到最后一对。这样做的运行时间为Θ(n),其中n是列表的长度。
列表允许我们在常数时间内访问第一个项目,但当需要找到最后一对时,它需要很长时间。我们可以通过存储和更新指向最后一对的指针来解决这个问题。

查看上面的队列,我们可以看到我们存储了两个指针:一个指向列表的前端,一个指向后端。如果我们尝试向队列添加一个新项目,'d,则结构将更改为以下内容:

当我们想要找到q的最后一对时,我们可以按照(cdr q)指针。
实现
要定义队列操作,我们使用以下过程,这些过程使我们能够选择和修改队列的前端和后端指针:
(define (front-ptr queue)
(car queue))
(define (rear-ptr queue)
(cdr queue))
(define (set-front-ptr! queue item)
(set-car! queue item))
(define (set-rear-ptr! queue item)
(set-cdr! queue item))
现在我们可以实现实际的队列操作。如果其前端指针是空列表,则我们将考虑队列为空:
(define (empty-queue? queue)
(null? (front-ptr queue)))
make-queue构造函数返回一个初始为空队列的对,其car和cdr都是空列表:
(define (make-queue)
(cons '() '()))
要选择队列前端的项目,我们返回由前端指针指示的对的car:
(define (front-queue queue)
(if (empty-queue? queue)
(error "FRONT called with an empty queue" queue)
(car (front-ptr queue))))
添加到队列
我们将遵循之前概述的一般算法:
-
cons一个包含新项的新对 -
如果队列为空,我们将其
front-ptr和rear-ptr设置为这个新对 -
如果队列不为空,我们找到最后一对,将其
cdr更改为新生成的对,并更新rear-ptr。(define (insert-queue! queue item) (let ((new-pair (cons item '()))) (cond ((empty-queue? queue) (set-front-ptr! queue new-pair) (set-rear-ptr! queue new-pair) queue) (else (set-cdr! (rear-ptr queue) new-pair) (set-rear-ptr! queue new-pair) queue))))
从队列中删除
要从队列中删除,我们可以简单地将front-ptr更改为指向下一个对。
(define (delete-queue! queue)
(cond ((empty-queue? queue)
(error "DELETE! called with an empty queue" queue))
(else
(set-front-ptr! queue (cdr (front-ptr queue)))
queue)))

如果从上面的队列开始我们决定删除第一个项目,更改只会发生在front-ptr指向的位置:

收获
set-car!和set-cdr!允许我们更有效地实现一个新的数据结构(队列),而不仅仅是cons、car和cdr所能构建的。
表示表
简介
我们在第 2 单元中提到,我们可以使用二维表存储数据,并且,给定 2 个键,可以获取所需数据。我们可以使用可变列表来表示这种数据结构,首先构建一个一维表,然后扩展这个想法。
在我们开始之前:assoc
在我们深入讨论表之前,我们必须探索另一个 Scheme 复合过程,assoc,它将发挥重要作用。assoc接受一个key和一组对,并返回第一个将key作为其car的对。如果不存在这样的对,则返回#f。查看下面的一系列示例,以了解assoc的作用。
> (assoc 1 '((1 2) (3 4)))
(1 2) ;returns the pair with car 1
> (assoc 'cupcake '((1 2) (3 4) (cupcake donut) (galaxy star)))
(cupcake donut) ;anything can be a key.
> (assoc 2 '((1 2) (3 4)))
#f ;No pair has 2 as its car, hence returns #f
> (assoc 'froyo '((cupcake donut eclair)
(froyo gingerbread honeycomb)
(sandwich jellybean kitkat)))
(froyo gingerbread honeycomb) ;Pairs can be of any length
这是assoc的正式定义:
(define (assoc key records)
(cond ((null? records) false)
((equal? key (caar records)) (car records))
(else (assoc key (cdr records)))))
一维表
在一个一维表中,值存储在单个键下。表将设计为一对对的列表。每对的car保存每个值的键。

在上表中,键和值之间的关系如下所示。
| 键 | 值 |
|---|---|
a |
1 |
b |
2 |
c |
3 |
为什么我们的表指向一个不包含任何键值对的对?我们设计我们的表,使第一个对包含符号*table*,这表示我们正在查看的当前列表结构是一个表。
make-table
这是我们表的简单构造函数:
(define (make-table)
(list '*table*))
lookup
要从表中提取信息,我们使用lookup选择器,它接受一个键作为参数,并返回关联的值(如果该键下没有存储值,则返回#f)。以下是我们对lookup的定义
(define (lookup key table)
(let ((record (assoc key (cdr table))))
(if record
(cdr record)
false)))
> (lookup 'b table) ;table refers to the table made above
2
insert!
要在表中插入一个键值对,我们遵循这个简单的算法:
-
如果键已经在列表中,只需更新值
-
否则,创建一个新的键值对并将其附加到表中
(define (insert! key value table) (let ((record (assoc key (cdr table)))) (if record (set-cdr! record value) (set-cdr! table (cons (cons key value) (cdr table))))) 'ok)
二维表
在一个二维表中,每个值由两个键指定。我们可以构建这样一个表,作为一个一维表,其中每个键标识一个子表。假设我们有 2 个表:"math"和"letters",具有以下键值对。
math:
+ : 43
- : 45
* : 42
letters:
a : 97
b : 98
我们可以将它们放入一个大表中:

lookup
要在二维表中查找一个值,你需要 2 个键。第一个键用于找到正确的子表。第二个键用于在该子表中找到正确的值。
(define (lookup key-1 key-2 table)
(let ((subtable (assoc key-1 (cdr table))))
(if subtable
(let ((record (assoc key-2 (cdr subtable))))
(if record
(cdr record)
#f))
#f)))
insert
要插入到二维表中,你还需要 2 个键。第一个键用于尝试找到正确的子表。如果第一个键对应的子表不存在,则创建一个新的子表。如果表存在,则使用我们为一维insert!所使用的完全相同的算法。
(define (insert! key-1 key-2 value table)
(let ((subtable (assoc key-1 (cdr table))))
(if subtable
(let ((record (assoc key-2 (cdr subtable))))
(if record
(set-cdr! record value)
(set-cdr! subtable
(cons (cons key-2 value)
(cdr subtable)))))
(set-cdr! table
(cons (list key-1
(cons key-2 value))
(cdr table)))))
'ok)
本地表
上述定义的lookup和insert!操作将表作为参数传递。这使我们能够使用访问多个表的程序。处理多个表的另一种方法是为每个表单独拥有lookup和insert!过程。我们可以通过过程化地表示表格,将其视为维护内部表格作为其本地状态一部分的对象来实现这一点。当发送适当的消息时,“表对象”将提供用于在内部表格上操作的过程。以下是以这种方式表示的二维表格的生成器:
(define (make-table)
(let ((local-table (list '*table*)))
(define (lookup key-1 key-2)
(let ((subtable (assoc key-1 (cdr local-table))))
(if subtable
(let ((record (assoc key-2 (cdr subtable))))
(if record
(cdr record)
false))
false)))
(define (insert! key-1 key-2 value)
(let ((subtable (assoc key-1 (cdr local-table))))
(if subtable
(let ((record (assoc key-2 (cdr subtable))))
(if record
(set-cdr! record value)
(set-cdr! subtable
(cons (cons key-2 value)
(cdr subtable)))))
(set-cdr! local-table
(cons (list key-1
(cons key-2 value))
(cdr local-table)))))
'ok)
(define (dispatch m)
(cond ((eq? m 'lookup-proc) lookup)
((eq? m 'insert-proc!) insert!)
(else (error "Unknown operation -- TABLE" m))))
dispatch))
get和put
在第二单元的“数据导向”子节中,我们使用一个二维表格来存储两个键下的一个值,使用了get和put过程。
(put <key-1> <key-2> <value>)
(get <key-1> <key-2>)
现在我们可以使用我们的表来定义这些过程!
(define operation-table (make-table))
(define get (operation-table 'lookup-proc))
(define put (operation-table 'insert-proc!))
get以两个键作为参数,put以两个键和一个值作为参数。这两个操作都访问相同的本地表,该表封装在调用make-table的对象中。
向量
向量
到目前为止,我们主要是成对编程,我们用它们来创建链表。我们使用列表来表示序列,这是一种抽象数据类型。虽然列表很棒,但它们有一个很大的缺点 - 引用列表的第 n 个元素需要Θ(n)的时间,因为我们必须调用cdr n次。
我们希望能够在常数时间(Θ(1))内引用序列的第 n 个元素。在 Scheme 中,向量提供了这样做的机制。如果你在 Java 或其他类似 C 的语言中编程过,这本质上与数组的概念相同。
不幸的是,向量有一个缺点。在一个链表中(这基本上是你这学期一直在使用的列表结构),在列表末尾添加元素只需要Θ(1)的时间,因为我们只需在列表末尾cons。然而,在向量中添加元素需要Θ(n)的时间,其中n是向量的长度。
向量的工作原理
向量是如何工作的?是什么黑魔法让你能够在常数时间内引用元素?事实上,原来并不是黑魔法。
当你创建一个向量时,你必须指定你想要的向量大小。创建大小为 n 的向量会分配一个 n 大小的内存块。由于我们知道第一个内存块的地址,我们可以将 k 添加到该地址以获得向量的第 k 个元素。这就是我们如何在常数时间内访问任何元素的方式!
不利之处在于,为了将所有元素放在单个内存块中,我们必须一次性分配整个块。这就是为什么向向量添加元素需要Θ(n)的时间 - 我们必须分配一个新的内存块(即创建一个新数组)并复制所有旧元素!
向量原语
注意:向量从 0 开始索引。
这意味着第一个元素被称为第 0 个元素。这意味着在向量#(1 2 3 4)中,1 位于第 0 个索引,2 位于第 1 个索引,依此类推。
一些向量原语类似于列表的原语:
| 向量 | 列表 |
|---|---|
(vector a b c d...) |
(list a b c d...) |
(vector-ref vec n) |
(list-ref lst n) |
(vector-length vec) |
(length lst) |
但是cons和append呢?由于向量添加元素需要Θ(n)的时间,所以没有原语可以在向量末尾添加元素。不过,有不同的构造器。
正如之前讨论的,向量的主要弱点之一是我们在创建时必须声明向量的长度。因此,创建长度为len的空向量的方法是(make-vector len)。如果你希望所有元素最初都设置为某个值,你可以使用(make-vector len val)。
到目前为止,我们可以创建一个带有空元素或所有相同元素的向量。这并不是很有用。那么,我们如何改变向量的元素呢?我们使用变异!具体来说,我们使用 (vector-set! vec n value) 来将向量的第 n 个元素设置为特定值。这类似于 set-car! 和 set-cdr!。
注意: 存在将两种类型之间转换的过程 list->vector 和 vector->list。但是,在课程和作业中,您不会使用这些过程,因为本课程的目的是学习向量。
向量编程
当您使用向量进行编程时,通常会使用迭代过程来循环遍历向量。以下是一些使用向量编码的示例,让您可以尝试一下。
这是列表的 map 函数:
(define (map fn lst)
(if (null? lst)
'()
(cons (fn (car lst))
(map (cdr lst)))))
现在让我们为向量编写相同的函数,称为 vector-map:
(define (vector-map fn v)
(define (loop newvec i)
(if (< i 0)
newvec
(begin (vector-set! newvec i (fn (vector-ref v i)))
(loop newvec (- i 1)))))
(loop (make-vector (vector-length v))
(- (vector-length v) 1)))
这比列表的 map 要复杂得多!首先,我们的 vector-map 有一个额外的索引变量 i,始终跟踪我们在向量中的位置。我们还必须知道我们向量的长度,因为这是我们的函数知道何时停止的方式。
列表的 map 是通过递归完成的,而向量的 vector-map 是通过迭代完成的。在学期初,我们提到递归通常被认为比迭代更优雅。希望您现在明白为什么了。
检验您的理解
编写一个函数 vector-addup,它接受一个数字向量并返回所有数字的总和。在 STk 中测试一下以检查您的答案。
向量 vs. 列表
这里是列表和向量过程的运行时间比较。
| 操作 | 列表 | 向量 |
|---|---|---|
| 查找第 n 个元素 | (list-ref lst n) 运行时间为 Θ(n) |
(vector-ref vec n) 运行时间为 Θ(1) |
| 添加元素 | cons 运行时间为 Θ(1) |
N/A 运行时间为 Θ(n) |
| 查找长度 | (length lst) 运行时间为 Θ(n) |
(vector-length vec) 运行时间为 Θ(1) |
表示序列没有一种最佳方式 - 向量和列表适用于不同的情况。如果您要经常添加和删除序列中的元素,最好使用列表,因为 cons 运行时间为常数。另一方面,如果您将有固定数量的元素但计划更改许多元素,则向量更好,因为 vector-ref 运行时间为常数。
示例:洗牌
假设我们有一副牌,我们想要洗牌。什么样的序列最适合表示这个过程?
首先,让我们使用一个列表,并使用变异来洗牌。
(define (list-shuffle! lst)
(if (null? lst)
'()
(let ((index (random (length lst))))
(let ((pair ((repeated cdr index) lst))
(temp (car lst)))
(set-car! lst (car pair))
(set-car! pair temp)
(list-shuffle! (cdr lst))
lst))))
这样做可以达到我们想要的效果,但非常慢 - Θ(n²) 时间。事实上,任何基于列表的解决方案都会花费 Θ(n²) 时间,因为找到一个随机元素需要 Θ(n) 时间,而我们必须这样做 n 次。
让我们尝试相同的方法,但使用向量而不是列表。
(define (vector-shuffle! vec)
(define (loop n)
(if (= n 0)
vec
(let ((index (random n))
(temp (vector-ref vec (- n 1))))
(vector-set! vec (- n 1) (vector-ref vec index))
(vector-set! vec index temp)
(loop (- n 1))
(loop (vector-length vec)))
这本质上是相同的算法,但是在向量上执行而不是在列表上执行。然而,这需要Θ(n)的时间,因为它执行 n 个常量时间操作,由于vector-ref是在常量时间内完成的。
测验提示
与向量一起工作可能一开始会感觉有些不同,特别是有了所有新的函数。我们强烈建议在你的备忘单上写下我们使用的各种函数原语(例如make-vect,vector-ref等),以及你将在作业练习中定义的辅助程序(例如vector-append)和这些笔记(例如vector-map)。
作业 9
模板
在终端上键入以下命令将模板文件复制到当前目录(注意末尾的句点):
cp ~cs61as/autograder/templates/hw9.scm .
或者您可以在此处下载模板here。
练习 1
假设已提供以下定义。
(define x (cons 1 3))
(define y 2)
一位 CS 61AS 学生打算将x的值更改为一个car等于1且cdr等于2的对,他输入表达式(set! (cdr x) y)而不是(set-cdr! x y),结果出现错误。请解释原因。
练习 2
a) 在下面的空白处为两个set-cdr!操作提供参数,以产生对list1和list2的指定效果。不要创建任何新对;只重新排列现有对的指针。
> (define list1 (list (list 'a) 'b))
list1
> (define list2 (list (list 'x) 'y))
list2
> (set-cdr! ________ ________)
okay
> (set-cdr! ________ ________)
okay
> list1
((a x b) b)
> list2
((x b) y)
b) 在填写上述代码中的空白并对list1和list2产生指定效果后,绘制一个解释表达式(set-car! (cdr list1) (cadr list2))求值效果的框和指针图。
练习 3
完成阿贝尔森和苏斯曼的3.13 和 3.14 练习。
练习 4
在阿贝尔森和苏斯曼的3.16, 3.17、3.21、3.25 和 3.27中完成练习。
注意: 您不需要为练习 3.27 绘制环境图;使用trace过程提供所请求的解释。将表过程lookup和insert!视为原始;即不要跟踪它们调用的过程。此外,假设这些过程在常数时间内工作。我们对memo-fib被调用的次数感兴趣。
练习 5
编写vector-append,它接受两个向量作为参数,并返回一个包含两个参数元素的新向量,类似于列表的append。
不要将列表用作中间值。(也就是说,任何时候都不要将向量转换为列表!)
练习 6
编写vector-filter,它接受一个谓词函数和一个向量作为参数,并返回一个仅包含谓词返回true的参数向量元素的新向量。新向量应该正好足够容纳所选元素。将您的程序的运行时间与此版本进行比较:
(define (vector-filter pred vec)
(list->vector (filter pred (vector->list vec))))
不要将列表用作中间值。(也就是说,任何时候都不要将向量转换为列表!)
练习 7
对向量进行排序:
a) 编写bubble-sort!,它接受一个数字向量并重新排列为递增顺序。(你将修改参数向量;不要创建新的。)使用以下算法进行定义:1. 遍历数组,一次查看两个相邻的元素,从元素 0 开始。如果较早的元素大于较晚的元素,则交换它们。然后查看下一个重叠的对(0 和 1,然后 1 和 2,依此类推)。2. 递归地bubble-sort除了最后一个元素之外的所有元素(现在是最大的元素)。3. 当只有一个元素需要排序时停止。
b) 证明这个算法确实对向量进行了排序。提示:证明第 2 步中的括号声明。
c) 这个算法的运行时间增长顺序是多少?
不要使用列表作为中间值。(也就是说,任何时候都不要将向量转换为列表!)
专家级额外练习:练习 8
如果你愿意的话可以做这个。这不计入学分。
Abelson & Sussman,练习3.19和3.23。
练习 3.19 尤为具有挑战性,所以如果你解决了它,那就太棒了。你需要查看一些你可能在本节中跳过的其他练习。练习 3.23 稍微容易一些,但要注意Θ(1)运行时间要求。
专家级额外练习:练习 9
如果你愿意的话可以做这个。这不计入学分。
编写过程cxr-name。它的参数将是由cars和cdrs组合而成的函数。它应返回该函数的适当名称:
> (cxr-name (lambda (x) (cadr (cddar (cadar x)))))
CADDDAADAR
提交你的作业!
有关说明,请参阅此指南。它涵盖了基本的终端命令和作业提交。
如果提交时遇到任何问题,请不要犹豫向助教求助!
10 - 流(Streams)
第 10 课简介
先决条件和期望
在继续之前,您应该了解如何操作列表。考虑复习map和filter等关键过程。
在本节中,我们将学习有关流及其一些应用的知识。
读物
本课程基于SICP 3.5。
流介绍
我们对赋值作为建模工具有了很好的理解,也对赋值引起的复杂问题有了一定的认识。现在是时候问问我们是否可以以不同的方式去做事情,以避免其中一些问题。在本节中,我们探讨了一种基于称为流的数据结构的替代建模状态的方法。正如我们将看到的,流可以减轻建模状态的一些复杂性。
让我们退一步,回顾这种复杂性的根源。为了模拟现实世界的现象,我们做出了一些看似合理的决定:我们用具有局部变量的计算对象来模拟具有局部状态的现实世界对象。我们将现实世界中的时间变化与计算机中的时间变化相对应。我们通过对模型对象的局部变量进行赋值来实现计算机中模型对象的状态变化。
是否有另一种方法?我们能否避免将计算机中的时间与建模世界中的时间相对应?我们必须使模型随时间变化以模拟变化世界中的现象吗?以数学函数的角度思考这个问题。我们可以将数量 x 随时间的变化行为描述为时间的函数 x(t)。如果我们一瞬间地专注于 x,我们会认为它是一个变化的数量。然而,如果我们专注于值的整个时间历史,我们并不强调变化-函数本身并不改变。
如果时间以离散步骤来衡量,那么我们可以将时间函数建模为(可能是无限的)序列。在本节中,我们将看到如何通过代表被建模系统的时间历史的序列来建模变化。为了实现这一点,我们引入了称为流的新数据结构。从抽象的角度来看,流只是一个序列。然而,我们会发现,将流的直接实现作为列表(如第 2.2.1 节中)并不能充分展现流处理的强大功能。作为替代方案,我们引入了延迟评估技术,这使我们能够将非常大(甚至是无限的)序列表示为流。
流处理使我们能够建模具有状态的系统,而无需使用赋值或可变数据。这具有重要的理论和实际意义,因为我们可以构建避免引入赋值固有缺陷的模型。另一方面,流框架也带来了自己的困难,关于哪种建模技术会导致更模块化和更易维护的系统的问题仍然是开放的。
列表效率低下
自第四课以来,我们一直使用列表来表示序列。但是列表表示法也有缺点。操作这些列表序列需要我们的程序在每个步骤中构造和复制数据结构(可能很大)。
让我们看看这个实际操作。这个过程是以我们熟悉和喜爱的迭代风格编写的:
(define (sum-primes a b)
(define (iter count accum)
(cond ((> count b) accum)
((prime? count) (iter (+ count 1) (+ count accum)))
(else (iter (+ count 1) accum))))
(iter a 0))
这个第二个过程利用了accumulate、filter和enumerate-interval。
(define (sum-primes a b)
(accumulate +
0
(filter prime? (enumerate-interval a b))))
在进行计算时,第一个程序只需要存储正在累积的总和。相比之下,第二个程序中的filter在enumerate-interval构造完整个区间的数字列表之前无法进行任何测试。filter生成另一个列表,然后传递给accumulate,最后被折叠成一个总和。
第一个程序不需要这样大的中间存储,我们可以将其视为逐步枚举间隔,每生成一个质数就将其加到总和中。
这是列表效率低下的另一个例子:
(car (cdr (filter prime?
(enumerate-interval 10000 1000000))))
尽管我们只想要第二个质数,但这段代码生成了一个巨大的整数列表和一个巨大的质数列表!
为什么要用流?
使用流,我们可以操纵序列而不会产生操纵列表序列的成本。使用流,我们可以兼得两全:我们可以优雅地构建程序作为序列操作,同时实现增量计算的效率。基本思想是只部分构造流,并将部分构造传递给消费流的程序。如果消费者尝试访问尚未构造的流的一部分,流将自动构造足够多的自身来生成所需的部分,从而保持整个流存在的幻觉。换句话说,尽管我们将编写程序,好像我们正在处理完整的序列,但我们的流实现设计为自动透明地将流的构造与其使用交错。
流是延迟的列表
流构造器和选择器
表面上,流只是具有不同名称的列表,用于操作它们的过程不同。它们有一个构造器 cons-stream,和两个选择器,stream-car 和 stream-cdr,满足以下约束:
-
(stream-car (cons-stream x y))返回x -
(stream-cdr (cons-stream x y))返回y
为了在使用流时构造流,我们将安排流的 cdr 在被 stream-cdr 过程访问时评估,而不是在流构造时评估。
作为数据抽象,流与列表相同。区别在于元素评估的时间。对于普通列表,car 和 cdr 在构造时都会被评估。对于流而言,cdr 在选择时才会被评估。
我们对流的实现将基于一个称为 delay 的特殊形式。评估 (delay [exp]) 不会评估表达式 [exp],而是返回一个所谓的延迟对象,我们可以将其视为在将来某个时间评估 [exp] 的“承诺”。作为 delay 的伴随,有一个名为 force 的过程,它以延迟对象作为参数执行评估 -- 实际上,强制延迟实现其承诺。我们将在下面看到如何实现 delay 和 force,但首先让我们使用它们来构建流。
cons-stream 是这样一个特殊形式,使得 (cons-stream [a] [b]) 等价于 (cons [a] (delay [b]))。
这意味着我们将使用对 car 和延迟的 cdr 进行成对构造。这些将是我们的 stream-car 和 stream-cdr 过程:
(define (stream-car stream) (car stream))
(define (stream-cdr stream) (force (cdr stream)))
注意 cons-stream 是一个特殊形式。如果不是的话,调用 (cons-stream a b) 将会评估 b,这意味着 b 将不会延迟。
the-empty-stream
有一个可区分的对象,the-empty-stream,它不能是任何 cons-stream 操作的结果,并且可以用谓词 stream-null? 来识别。
列表程序的流类比
我们可以制作和使用流,就像我们可以制作和使用列表一样,以表示按顺序排列的聚合数据。特别是,我们可以构建 list-ref、map、for-each 等的流类比。
stream-ref
(define (stream-ref s n)
(if (= n 0)
(stream-car s)
(stream-ref (stream-cdr s) (- n 1))))
如果我们定义 x 为
(define x (cons-stream 0 (cons-stream 1 (cons-stream 2 the-empty-stream))))
那么 (stream-ref x 0) 返回 0,(stream-ref x 2) 返回 2。(注意 n 从 0 开始计数)
stream-map
(define (stream-map proc s)
(if (stream-null? s)
the-empty-stream
(cons-stream (proc (stream-car s))
(stream-map proc (stream-cdr s)))))
如果 x 与上面相同,那么 (stream-map square x) 将返回一个包含 (0 1 4) 的流
stream-for-each
(define (stream-for-each proc s)
(if (stream-null? s)
'done
(begin (proc (stream-car s))
(stream-for-each proc (stream-cdr s)))))
stream-for-each 用于查看流很有用。以下可能有助于检查发生了什么:
(define (display-stream s)
(stream-for-each display-line s))
(define (display-line x)
(newline)
(display x))
使用流进行计算
让我们再次看一下我们之前看到的第二个素数计算,用流的术语重新表述:
(stream-car
(stream-cdr
(stream-filter prime?
(stream-enumerate-interval 10000 1000000))))
所以我们首先用参数 10,000 和 1,000,000 调用 stream-enumerate-interval。这将创建一个从 10,000 到 1,000,000 的数字流。
(define (stream-enumerate-interval low high)
(if (> low high)
the-empty-stream
(cons-stream
low
(stream-enumerate-interval (+ low 1) high))))
返回的结果是 (cons 10000 (delay (stream-enumerate-interval 10001 1000000))) 这是一个流,表示为一个对,其 car 是 10,000,cdr 是一个承诺,如果有必要,将枚举更多的区间。现在我们使用 stream-filter 进行过滤
(define (stream-filter pred stream)
(cond ((stream-null? stream) the-empty-stream)
((pred (stream-car stream))
(cons-stream (stream-car stream)
(stream-filter pred
(stream-cdr stream))))
(else (stream-filter pred (stream-cdr stream)))))
stream-filter 测试流的 stream-car(对偶的 car,即 10,000)。由于这不是素数,stream-filter 检查其输入流的 stream-cdr。对 stream-cdr 的调用强制评估延迟的 stream-enumerate-interval,现在返回
(cons 10001
(delay (stream-enumerate-interval 10002 1000000)))
stream-filter 现在查看这个流的 stream-car,10,001,看到这也不是素数,强制另一个 stream-cdr,依此类推,直到 stream-enumerate-interval 产生素数 10,007,然后根据其定义,stream-filter 返回
(cons-stream (stream-car stream)
(stream-filter pred (stream-cdr stream)))
就是
(cons 10007
(delay
(stream-filter
prime?
(cons 10008
(delay
(stream-enumerate-interval 10009
1000000))))))
现在将此结果传递给我们原始表达式中的 stream-cdr。这强制了延迟的 stream-filter,进而保持强制延迟的 stream-enumerate-interval,直到找到下一个素数,即 10,009。最后,传递给我们原始表达式中的 stream-car 的结果是
(cons 10009
(delay
(stream-filter
prime?
(cons 10010
(delay
(stream-enumerate-interval 10011
1000000))))))
Stream-car 返回 10,009,并且计算完成。只有测试了必要数量的整数以找到第二个素数,并且只枚举了必要的区间来提供素数筛选器。
实现 delay 和 force
尽管 delay 和 force 可能看起来像神秘的操作,但它们的实现实际上非常简单。delay 必须封装一个表达式,以便以后按需评估,我们可以简单地将表达式视为过程的主体来实现这一点。delay 可以是一个特殊形式,如下
(delay [exp])
是语法糖,等同于
(lambda () [exp])
force 简单地调用由 delay 生成的过程(无参数),因此我们可以将 force 实现为一个过程:
(define (force delayed-object)
(delayed-object))
再次注意 delay 作为特殊形式的重要性。如果不是,那么当我们调用 (delay b) 时,b 将在我们评估主体之前被评估。
这种实现足以使 delay 和 force 正常工作,但我们可以包含一个重要的优化。在许多应用程序中,我们最终会多次强制相同的延迟对象。这可能导致涉及流的递归程序严重低效。解决方案是构建延迟对象,以便第一次强制它们时,它们存储计算的值。后续的强制将简单地返回存储的值,而不重复计算。换句话说,我们将 delay 实现为一个特殊用途的记忆化过程。实现这一点的一种方法是使用以下过程,该过程以一个过程(无参数)作为参数,并返回该过程的记忆化版本。第一次运行记忆化过程时,它保存计算结果。在后续的评估中,它只是返回结果。
(define (memo-proc proc)
(let ((already-run? false) (result false))
(lambda ()
(if (not already-run?)
(begin (set! result (proc))
(set! already-run? true)
result)
result))))
Delay 然后被定义为(delay [exp])等同于
(memo-proc (lambda () [exp]))
而force保持不变
收获
在本节中,你学到了:
-
流是什么样的
-
流的一些有用应用
-
如何实现
delay和force
接下来呢?
让我们继续下一小节,学习无限列表!
无限流
介绍
我们已经看到如何支持操作流的幻觉,将其视为完整序列,而实际上我们只计算所需的流量。我们可以利用这种技术有效地将序列表示为流,即使序列非常长。但更重要的是,我们可以使用流来表示无限长的序列。例如,假设我们定义如下:
(define (integers-starting-from n)
(cons-stream n (integers-starting-from (+ n 1))))
(define integers (integers-starting-from 1))
然后integers表示所有正整数的流。更具体地说,integers的stream-car为 1,integers的stream-cdr是一个等价于(integers-starting-from 2)的承诺。
使用integers,我们可以定义其他无限流,例如不能被 7 整除的整数流:
(define (divisible? x y)
(= (remainder x y) 0))
(define no-sevens
(stream-filter (lambda (x) (not (divisible? x 7)))
integers))
然后,我们可以通过访问此流的元素来找到不能被 7 整除的整数:
-> (stream-ref no-sevens 100)
117
流程
到目前为止,我们一直在定义流的方式与我们定义列表的方式非常相似。现在我们将采取更“流式”的方法。
我们可以利用延迟评估来隐式定义流。例如,我们可以这样定义一个全为 1 的无限流:
(define ones (cons-stream 1 ones))
这与递归过程的定义方式非常相似:ones是一个car为1且cdr为评估ones的承诺的对。评估cdr再次给我们一个1和评估ones的承诺,依此类推。
我们还可以定义一个流程add-streams,它产生两个流的逐元素和:
(define (add-streams s1 s2)
(stream-map + s1 s2)
例如,(add-streams ones ones)将产生一个全为 2 的流。
我们可以重新定义然后隐式定义integers:
(define integers (cons-stream 1 (add-streams ones integers)))
这定义了integers为一个流,其stream-car为 1,其stream-cdr为ones和integers的和。因此,integers的第二个元素是integers的第一个元素加 1,或者 2;integers的第三个元素是integers的第二个元素加 1,或者 3;依此类推。这个定义之所以有效,是因为在任何时刻,已经生成了足够多的整数流,以便我们可以将其反馈到定义中,以生成下一个整数。
关于stream-map
请注意,在上面的示例中,我们使用两个流调用了stream-map。之前,我们只使用一个流调用了 stream-map:
-> (define x (cons-stream 1 (cons-stream 2 (cons-stram 3 the-empty-stream))))
-> (stream-map square x)
(1 #[stream with car 4])
你可以使用stream-map与任意数量的流,只要你提供的过程具有相应数量的参数:
-> (stream-map + x x)
(2 #[stream with car 4])
当然,stream-map的实际定义略有不同,但现在不用担心。
我们还可以以相同的方式定义斐波那契数列:
(define fibs
(cons-stream 0
(cons-stream 1
(add-streams (stream-cdr fibs) fibs))))
这个定义表示fibs是以 0 和 1 开头的流,使得流的其余部分可以通过将fibs与自身向右移动一个位置相加来生成。
我们还可以使用scale-stream定义另一个流操作。它接受两个参数——一个整数流和一个整数,并将流中的所有元素乘以该整数:
(define (scale-stream strm factor)
(stream-map (lambda (x) (* x factor)) strm))
现在我们可以这样定义所有 2 的幂的流:
(define doubles (cons-stream 1 (scale-stream doubles 2)))
现在我们可以以不同的、隐式的方式定义素数的无限流:
(define primes
(cons-stream 2
(stream-filter prime?
(integers-starting-from 3))))
这可能看起来相当简单——我们从第一个素数 2 开始,然后我们通过cons-stream将其余的素数与之连接起来。然而,prime?的定义方式使得这个问题变得有点微妙。
我们通过查看一个数是否被小于√(n)的任何一个素数(而不仅仅是任何整数!)整除来检查一个数是否是素数:
(define (prime? n)
(define (iter ps)
(cond ((> (square (stream-car ps)) n) #t)
((divisible? n (stream-car ps)) #f)
(else (iter (stream-cdr ps)))))
(iter primes))
这是一个递归定义(就像你在树中看到的那样!)因为primes是根据prime?谓词定义的,而prime?谓词本身使用了primes流。这个过程之所以有效是因为,在任何时刻,已经生成了足够多的primes流来测试我们需要检查的下一个数的素数性。也就是说,对于我们测试素数性的每个n,要么n不是素数(在这种情况下,已经生成了一个可以整除它的素数),要么n是素数(在这种情况下,已经生成了一个素数——即小于n的素数——它大于√(n))
要点
在本节中,您学到了:
-
无限流是什么!
-
我们可以用它们做一些很酷的东西
示例 - 使用流进行迭代
在第一单元中,我们编写了一个用于近似给定数字的平方根的过程——让我们称之为x。这个想法是通过反复应用改进猜测的过程来生成越来越好的x的平方根的猜测序列:
(define (sqrt-improve guess x)
(average guess (/ x guess)))
我们可以创建一个以 1 为初始猜测的无限流的猜测序列:
(define (sqrt-stream x)
(define guesses
(cons-stream 1.0 (stream-map (lambda (guess)
(sqrt-improve guess x))
guesses)))
guesses)
(sqrt-stream 2)的前几个元素将是:
1
1.5
1.4166666666666665
1.4142156862745097
1.4142135623746899
流的每个后续元素都会越来越接近 2 的平方根。
同样地,我们使用以下公式来近似π:

现在,让我们用一个无限流来计算π:
(define (pi-summands n)
(cons-stream (/ 1.0 n)
(stream-map - (pi-summands (+ n 2)))))
(define pi-stream
(scale-stream (partial-sums (pi-summands 1)) 4))
前几个元素如下所示:
4.
2.666666666666667
3.466666666666667
2.8952380952380956
3.3396825396825403
2.9760461760461765
3.2837384837384844
3.017071817071818
正如你所看到的,这些数字正在逼近π——在查看前八个元素后,我们知道π大约在 3.28 和 3.02 之间。
示例 - 交错流
追加流
假设你有两个流,你想将一个与另一个合并,类似于列表的append。用append,我们将一个列表的开头连接到另一个列表的结尾。等效的流定义将是这样的:
(define (stream-append s1 s2)
(if (stream-null? s1)
s2
(cons-stream (stream-car s1)
(stream-append (stream-cdr s1) s2))))
但等等!流可以是无限长的!如果我们调用(stream-append s1 s2)而s1是一个无限流,我们将永远无法访问s2的任何元素。
交错流
另一种方法是交错这两个流:
(define ones (cons-stream 1 ones)) ; (1 1 1 1 ...)
(define twos (cons-stream 2 twos)) ; (2 2 2 2 ...)
(define one-two (interleave ones twos)) ; (1 2 1 2 ...)
通过交错两个流,我们可以确保我们将使用来自两个流的元素。我们可以这样定义interleave:
(define (interleave s1 s2)
(if (stream-null? s1)
s2
(cons-stream (stream-car s1)
(interleave s2 (stream-cdr s1)))))
示例 - 无限对流
假设我们想要生成一个包含整数对[i, j]的无限流,其中 i≤j 且 i + j 是素数。如果int-pairs是所有整数对的流,我们的流是:
(stream-filter (lambda (pair)
(prime? (+ (car pair) (cadr pair))))
int-pairs)
现在我们只需要定义int-pairs。我们该如何做呢?让我们假设我们有两个等同于integers的流[S]和[T]。现在让我们想象一下[S]和[T]的整数对数组(或矩阵,如果你想这样考虑的话):

整数对的流是对角线上方的所有内容:

让我们称一般的整数对流为(pairs s t),并将其视为由三部分组成:对[S0, T0],第一行中其余的对,以及剩余的对。

这个分解中的第三部分(不在第一行的对)是由(stream-cdr s)和(stream-cdr t)形成的对(递归)。还要注意第二部分(第一行的其余部分)是:
(stream-map (lambda (x) (list (stream-car s) x))
(stream-cdr t))1
那么我们的整数对流就是:
(define (pairs s t)
(cons-stream (list (stream-car s) (stream-car t))
(combine (stream-map (lambda (x) (list (stream-car s) x))
(stream-cdr t))
(pairs (stream-cdr s) (stream-cdr t)))))
现在我们只需要使用某种combine函数将这些流组合在一起。我们知道追加不起作用,让我们使用interleave代替!我们的对流变成了:
(define (pairs s t)
(cons-stream (list (stream-car s) (stream-car t))
(interleave (stream-map (lambda (x) (list (stream-car s) x))
(stream-cdr t))
(pairs (stream-cdr s) (stream-cdr t)))))
作业 10
模板
你可以通过在终端中键入复制此作业��模板:
cp ~cs61as/autograder/templates/hw10.scm .
你也可以通过点击这里下载它。
练习 1
阅读SICP 3.5.1,然后回答以下问题:
-
(delay (+ 1 27))的值的类型是什么? -
(force (delay (+ 1 27)))的值的类型是什么?
练习 2
评估此表达式会产生错误:
(stream-cdr (stream-cdr (cons-stream 1 '(2 3))))
解释原因。
练习 3
考虑以下内容:
(define (enumerate-interval low high)
(if (> low high)
'()
(cons low (enumerate-interval (+ low 1) high)) ) )
(define (stream-enumerate-interval low high)
(if (> low high)
the-empty-stream
(cons-stream low (stream-enumerate-interval (+ low 1) high)) ) )
以下两个表达式之间有什么区别?
(delay (enumerate-interval 1 3))
(stream-enumerate-interval 1 3)
练习 4
数论中一个未解决的问题涉及以下算法,用于创建一系列正整数[mathjaxinline]s_1, s_2, \ldots[/mathjaxinline],其中[mathjaxinline]s_1[/mathjaxinline]是某个正整数,对于所有[mathjaxinline]n > 1[/mathjaxinline],
-
如果[mathjaxinline]s_n[/mathjaxinline]是奇数,则[mathjaxinline]s_{n+1} = 3s_n+1[/mathjaxinline];
-
如果[mathjaxinline]s_n[/mathjaxinline]是偶数,则[mathjaxinline]s_{n+1} = s_n \div 2[/mathjaxinline]。
无论选择什么起始值[mathjaxinline]s_1[/mathjaxinline],该序列(称为hailstone 序列)似乎总是以重复的值 1、4、2、1、4、2、1...结束。然而,目前尚不清楚是否总是这种情况。
-
编写一个过程
num-seq,给定一个正整数n作为参数,返回n的 hailstone 序列。例如,(num-seq 7)应返回表示序列 7, 22, 11, 34, 17, 52, 26, 13, 40, 20, 10, 5, 16, 8, 4, 2, 1, 4, 2, 1, ...的流。 -
编写一个过程
seq-length,给定由num-seq生成的流作为参数,返回出现在序列中直到第一个 1 的值的数量。例如,(seq-length (num-seq 7))应返回 17。你应该假设序列中的某处有一个 1。
练习 5
又到了作业时间——SICP!
完成以下内容:3.50, 3.51, 3.52,3.53, 3.54, 3.55, 3.56,3.64,3.66, 3.68。
练习 6
编写并测试两个操作非负真分数的函数。
第一个函数fract-stream将以两个非负整数的列表作为其参数,分子和分母,其中分子小于分母。它将返回一个表示分数十进制扩展的无限流的十进制数字。
第二个函数approximation将接受两个参数:一个分数流和一个非负整数 numdigits。它将返回一个包含十进制扩展的前 numdigits 位数的列表(而不是流)。
一些指导方针:
-
(fract-stream '(1 7))应返回代表小数的流 -
1/7 的展开式是 0.142857142857142857...
-
(stream-car (fract-stream '(1 7)))应返回1。 -
(stream-car (stream-cdr (stream-cdr (fract-stream '(1 7)))))应返回 -
2。 -
(approximation (fract-stream '(1 7)) 4)应返回(1 4 2 8)。 -
(approximation (fract-stream '(1 2)) 4)应返回(5 0 0 0)。
提交您的作业!
指南请参见此指南。它涵盖了基本的终端命令和作业提交。
如果您在提交作业时遇到任何问题,请不要犹豫向助教求助!
单元 4
11 - 元循环评估器
第 11 课简介
元循环评估器
你还记得第 6 课中的 Racket-1/Scheme-1 吗?现在是时候探索 Racket 和 Scheme 如何评估表达式了!
你可以通过在终端中键入以下内容来下载本课程的代码:
cp ~cs61as/lib/mceval.scm .
代码也可以在线查看这里
先决条件和期望
对 Racket-1/Scheme-1 如何工作有一个良好的理解将有助于本章。你还应该熟悉第 8 课中的评估环境模型。本课程涵盖的材料将与迄今为止涵盖的其他材料有很大不同,所以要做好准备!
阅读材料
这些是本课程的相关阅读材料:
什么是评估器?
到目前为止,我们已经学会了如何编写输出我们想要的过程。一旦我们定义了这些过程并在 Scheme 提示符中键入它们,我们就得到了值。但是你是否想过这些过程实际上是如何被评估和在 Scheme 中工作的?Scheme 如何知道表达式的含义?这就是评估器的作用。
一个评估器(或解释器)用于编程语言是一个过程,当应用于语言的表达式时,执行评估该表达式所需的操作。
等等,什么?评估器只是一个过程?
是的,就是这样。评估器只是另一个程序!
什么是元循环评估器?

我们为 Scheme 编写的评估器将作为一个 Scheme 程序实现。考虑到使用一个在 Scheme 中实现的评估器来评估 Scheme 程序可能看起来循环。然而,评估是一个过程,因此用 Scheme 来描述评估过程是合适的,毕竟,Scheme 是我们描述过程的工具。用相同语言编写的评估器来评估自身的语言被称为元循环。
元循环评估器本质上是在第 8 课中描述的评估环境模型的 Scheme 公式。回想一下,该模型有两个基本部分:
-
要评估一个组合(一个除了特殊形式之外的复合表达式),需要评估子表达式,然后将运算子子表达式的值应用于操作数子表达式的值。
-
要将一个复合过程应用于一组参数,需要在一个新环境中评估过程的主体。为了构建这个环境,需要通过一个框架扩展过程对象的环境部分,其中过程的形式参数绑定到过程应用的参数。
这两条规则描述了评估过程的本质,一个基本循环,在这个循环中,要在环境中评估的表达式被简化为要应用于参数的过程,然后又被简化为要在新环境中评估的新表达式,依此类推,直到我们到达符号,其值在环境中查找,以及原始过程,这些过程直接应用。这个评估循环将由评估器中两个关键过程 eval 和 apply 之间的互动体现出来。我们将很快详细介绍 eval 和 apply。
评估器的实现将依赖于定义要评估的表达式的语法的过程。我们将使用数据抽象使评估器独立于语言的表示。例如,我们不会选择将赋值表示为以符号 set! 开头的列表,而是使用抽象谓词 assignment? 来测试赋值,并使用抽象选择器 assignment-variable 和 assignment-value 来访问赋值的部分。我们将学习表达式和操作的实现,这些操作指定了过程和环境的表示。例如,make-procedure 构造复合过程,lookup-variable-value 访问变量的值,apply-primitive-procedure 将原始过程应用于给定的参数列表。
要点
在这个小节中,你学到了:
-
评估器的定义
-
元循环评估器的定义
接下来是什么?
现在是时候了解 Scheme 是如何实际工作的!令人兴奋!
求值
课程的其余部分可能包含一些概念,第一次学习时可能会感到困惑,所以请仔细重新阅读那些难以理解的句子!同时,要记住,元循环求值器的核心是抽象,所以如果你暂时不理解某个实现方式,很可能会在后面的部分中解释。
求值
(define (eval-1 exp)
(cond ((constant? exp) exp)
((symbol? exp) (eval exp)) ; use underlying Scheme's EVAL
((quote-exp? exp) (cadr exp))
((if-exp? exp)
(if (eval-1 (cadr exp))
(eval-1 (caddr exp))
(eval-1 (cadddr exp))))
((lambda-exp? exp) exp)
((pair? exp) (apply-1 (eval-1 (car exp)) ; eval the operator
(map eval-1 (cdr exp))))
(else (error "bad expr: " exp))))
这段代码看起来熟悉吗?应该是的;它是你在第 6 课学到的 Racket-1/Scheme-1 解释器的一部分!如果你看第 3 行,你会看到eval-1正在使用 Scheme 的eval过程。在第 6 课中,你不需要太担心细节,因为 Scheme 的eval过程处理了所有细节。但是 Scheme 的eval背后的细节是什么?
现在是时候看看mc-eval是如何编写的了。看一看,并将其与eval-1进行比较:
(define (mc-eval exp env)
(cond ((self-evaluating? exp) exp)
((variable? exp) (lookup-variable-value exp env))
((quoted? exp) (text-of-quotation exp))
((assignment? exp) (eval-assignment exp env))
((definition? exp) (eval-definition exp env))
((if? exp) (eval-if exp env))
((lambda? exp)
(make-procedure (lambda-parameters exp)
(lambda-body exp)
env))
((begin? exp)
(eval-sequence (begin-actions exp) env))
((cond? exp) (mc-eval (cond->if exp) env))
((application? exp)
(mc-apply (mc-eval (operator exp) env)
(list-of-values (operands exp) env)))
(else
(error "Unknown expression type -- EVAL" exp))))
mc-eval被定义为执行底层 Scheme 的eval工作,解释 Scheme 的语法规则,并将每个调用分解为适当的操作。如果你不理解,不要担心。我们将逐步解释这段代码。
mc-eval做什么?
过程mc-eval接受一个表达式和一个环境作为参数。它对表达式进行分类并指导其求值。为了保持过程的通用性,我们以抽象的方式表达对表达式类型的确定,不对各种表达式类型的具体表示做任何承诺。每种表达式类型都有一个用于测试的谓词,并选择其部分的抽象方法。
当mc-eval处理一个过程应用时,它使用list-of-values生成要应用过程的参数列表。过程list-of-values以组合的操作数作为参数。它评估每个操作数并返回相应值的列表:
(define (list-of-values exps env)
(if (no-operands? exps)
'()
(cons (mc-eval (first-operand exps) env)
(list-of-values (rest-operands exps) env))))
从左到右?从右到左? 给定一些操作数列表,list-of-values将递归构造一个嵌套的mc-eval调用的 cons 结构。注意,我们无法确定元循环求值器从左到右还是从右到左评估操作数的顺序。它的评估顺序继承自底层 Scheme:如果list-of-values中cons的参数从左到右评估,则list-of-values将从左到右评估操作数;如果cons中的参数从右到左评估,则list-of-values将从右到左评估操作数。
编写一个list-of-values的版本,无论 Scheme 底层的求值顺序如何,都从左到右评估操作数。同时编写一个从右到左评估操作数的list-of-values版本。
让我们逐行查看条件中的每个表达式的作用。
自求值表达式

对于自评估表达式,例如数字,mc-eval返回表达式本身。mc-eval必须在环境中查找变量以找到它们的值。
-
唯一的自评估项是数字和字符串:
(define (self-evaluating? exp) (cond ((number? exp) true) ((string? exp) true) (else false)))
请记住,单词不是字符串。字符串使用双引号(例如"Hello, world!")。
-
变量由符号表示:
(define (variable? exp) (symbol? exp))
特殊形式

特殊形式:句子和单词
对于引用的表达式,mc-eval返回被引用的表达式。
请记住,Scheme 解析器会自动将表达式'(some text here)转换为表达式对(quote (some text here))。
换句话说,引用的形式为(quote <text-of-quotation>):
(define (quoted? exp)
(tagged-list? exp 'quote))
(define (text-of-quotation exp) (cadr exp)) ;returns just the text as a list that will print to output
Quoted?是根据过程tagged-list?定义的,该过程识别以指定符号开头的列表:
(define (tagged-list? exp tag)
(if (pair? exp)
(eq? (car exp) tag)
false))
特殊形式:Lambda
lambda表达式必须通过将 lambda 表达式指定的参数和主体与评估环境打包在一起,转换为可应用的过程。
Lambda 表达式是以符号 lambda 开头的列表:
(define (lambda? exp) (tagged-list? exp 'lambda))
(define (lambda-parameters exp) (cadr exp))
(define (lambda-body exp) (cddr exp))
有一个用于 lambda 表达式的构造器,它被definition-value使用:
(define (make-lambda parameters body)
(cons 'lambda (cons parameters body)))
特殊形式:序列

-
Eval-sequence被apply用于评估过程体中的表达式序列。它还被eval用于评估begin表达式中的表达式序列。它接受一个表达式序列和一个环境作为参数,并按照它们出现的顺序评估表达式。返回的值是最终表达式的值。(define (eval-sequence exps env) (cond ((last-exp? exps) (mc-eval (first-exp exps) env)) (else (mc-eval (first-exp exps) env) (eval-sequence (rest-exps exps) env))))
-
Begin将一系列表达式打包成单个表达式。begin表达式需要按照它们出现的顺序评估其表达式序列。我们包括对begin表达式的语法操作,以从begin表达式中提取实际序列,以及返回序列中第一个表达式和其余表达式的选择器。(define (begin? exp) (tagged-list? exp 'begin)) (define (begin-actions exp) (cdr exp)) (define (last-exp? seq) (null? (cdr seq))) (define (first-exp seq) (car seq)) (define (rest-exps seq) (cdr seq))
有一个构造器sequence->exp(供cond->if使用),它将一个序列转换为单个表达式,必要时使用begin:
(define (sequence->exp seq)
(cond ((null? seq) seq)
((last-exp? seq) (first-exp seq))
(else (make-begin seq))))
(define (make-begin seq) (cons 'begin seq))
特殊形式:条件

-
Eval-if在给定环境中评估if表达式的谓词部分。如果结果为真,则 eval-if 评估结果,否则评估替代项:(define (eval-if exp env) (if (true? (mc-eval (if-predicate exp) env)) (mc-eval (if-consequent exp) env) (mc-eval (if-alternative exp) env)))
在eval-if中使用true?突出了实现语言和实现语言之间的连接问题。if-predicate在正在实现的语言中进行评估,因此产生该语言中的一个值。解释器谓词true?将该值转换为可以由实现语言中的 if 测试的值:真实性的元循环表示可能与基础 Scheme 的表示不同。
true?和false?定义如下:
(define (true? x)
(not (eq? x false)))
(define (false? x)
(eq? x false))
-
if表达式需要对其部分进行特殊处理,以便在谓词为真时评估结果,否则评估替代项。(define (if? exp) (tagged-list? exp 'if)) (define (if-predicate exp) (cadr exp)) (define (if-consequent exp) (caddr exp)) (define (if-alternative exp) (if (not (null? (cdddr exp))) (cadddr exp) 'false))
有一个用于if表达式的构造函数,供cond->if使用将cond表达式转换为if表达式:
(define (make-if predicate consequent alternative)
(list 'if predicate consequent alternative))
- 案例分析(
cond)被转换为一系列if表达式,然后进行评估。
例如,
(cond ((> x 0) x)
((= x 0) (display 'zero) 0)
(else (- x)))
可以表示为:
(if (> x 0)
x
(if (= x 0)
(begin (display 'zero)
0)
(- x)))
有提取 cond 表达式部分的语法过程,以及一个将cond表达式转换为if表达式的过程cond->if。案例分析以cond开始,并具有一系列谓词-动作子句。如果其谓词是符号else,则子句是else子句。
(define (cond? exp) (tagged-list? exp 'cond))
(define (cond-clauses exp) (cdr exp))
(define (cond-else-clause? clause)
(eq? (cond-predicate clause) 'else))
(define (cond-predicate clause) (car clause))
(define (cond-actions clause) (cdr clause))
(define (cond->if exp)
(expand-clauses (cond-clauses exp)))
(define (expand-clauses clauses)
(if (null? clauses)
'false ; no else clause
(let ((first (car clauses))
(rest (cdr clauses)))
(if (cond-else-clause? first)
(if (null? rest)
(sequence->exp (cond-actions first))
(error "ELSE clause isn't last -- COND->IF"
clauses))
(make-if (cond-predicate first)
(sequence->exp (cond-actions first))
(expand-clauses rest))))))
我们选择实现为语法转换的表达式(如cond)称为派生表达式。Let表达式也是派生表达式。
特殊形式:赋值和定义

对变量的赋值(或定义)必须递归调用eval来计算与变量关联的新值。必须修改环境以更改(或创建)变量的绑定。
以下过程处理对变量的赋值。它调用eval来找到要赋值的值,并将变量和结果值传递给set-variable-value!在指定环境中定义。
(define (eval-assignment exp env)
(set-variable-value! (assignment-variable exp)
(mc-eval (assignment-value exp) env)
env)
'ok)
变量的定义以类似的方式处理:
(define (eval-definition exp env)
(define-variable! (definition-variable exp)
(mc-eval (definition-value exp) env)
env)
'ok)
按照惯例,符号ok被返回为赋值或定义的值。
现在让我们看看赋值表达式是如何表示的。
赋值的形式为(set! <var> <value>):
(define (assignment? exp)
(tagged-list? exp 'set!))
(define (assignment-variable exp) (cadr exp))
(define (assignment-value exp) (caddr exp))
定义的形式为(define <var> <value>)或形式
(define (var parameter1 ... parametern)
body)
后一种形式(标准过程定义)可以重写为:
(define var
(lambda (parameter1 ... parametern)
body))
相应的语法过程如下:
(define (definition? exp)
(tagged-list? exp 'define))
(define (definition-variable exp)
(if (symbol? (cadr exp))
(cadr exp)
(caadr exp)))
(define (definition-value exp)
(if (symbol? (cadr exp))
(caddr exp)
(make-lambda (cdadr exp) ; formal parameters
(cddr exp)))) ; body
And 和 Or
回顾第 1 单元中特殊形式and和or的定义:
-
and:表达式从左到右进行评估。如果任何表达式评估为假,则返回
false;任何剩余的表达式都不会被评估。如果所有表达式评估为真值,则返回最后一个表达式的值。如果没有表达式,则返回 true。 -
or:表达式从左到右进行评估。如果任何表达式评估为真值,则返回该值;任何剩余的表达式都不会被评估。如果所有表达式评估为假,或者没有表达式,则返回
false。
通过定义适当的语法过程和评估过程eval-and和eval-or,将and和or安装为评估器的新特殊形式。或者,展示如何将and和or实现为派生表达式。
mc-eval 定义再次审视
让我们再次看看mc-eval的定义。现在你明白了吗?
(define (mc-eval exp env)
(cond ((self-evaluating? exp) exp)
((variable? exp) (lookup-variable-value exp env))
((quoted? exp) (text-of-quotation exp))
((assignment? exp) (eval-assignment exp env))
((definition? exp) (eval-definition exp env))
((if? exp) (eval-if exp env))
((lambda? exp)
(make-procedure (lambda-parameters exp)
(lambda-body exp)
env))
((begin? exp)
(eval-sequence (begin-actions exp) env))
((cond? exp) (mc-eval (cond->if exp) env))
((application? exp)
(mc-apply (mc-eval (operator exp) env)
(list-of-values (operands exp) env)))
(else
(error "Unknown expression type -- EVAL" exp))))
等等,等等,apply 是什么?我不知道那是什么!
我们将在下一小节中探讨这个问题。
以下哪些在其定义中使用了 mc-eval?可能有多个正确答案,因此请逐个检查每个答案。
要点
在本小节中,您了解了 Scheme 如何使用mc-eval和其他过程评估表达式。
接下来呢?
前往下一小节,了解 Scheme 如何应用评估的表达式!
应用
apply
Apply接受两个参数,一个过程和应该应用该过程的参数列表。Apply将过程分类为两种:它调用apply-primitive-procedure来应用原始过程;它通过顺序评估组成过程体的表达式来应用复合过程。用于评估复合过程体的环境是通过扩展过程携带的基本环境构造的,以包括一个框架,该框架将过程的参数绑定到应该应用过程的参数上。这是apply的定义:
(define (apply procedure arguments)
(cond ((primitive-procedure? procedure)
(apply-primitive-procedure procedure arguments))
((compound-procedure? procedure)
(eval-sequence
(procedure-body procedure)
(extend-environment
(procedure-parameters procedure)
arguments
(procedure-environment procedure))))
(else
(error
"Unknown procedure type -- APPLY" procedure))))
我们将逐一介绍定义中使用的步骤。
表示过程
要处理原始过程,我们假设我们可以使用以下过程:
-
(apply-primitive-procedure proc args)将给定的原始过程应用于列表'args'中的参数值,并返回应用的结果。
-
(primitive-procedure? proc)测试
proc是否是原始过程。
复合过程是使用构造函数make-procedure从参数、过程体和环境构造的:
(define (make-procedure parameters body env)
(list 'procedure parameters body env))
(define (compound-procedure? p)
(tagged-list? p 'procedure))
(define (procedure-parameters p) (cadr p))
(define (procedure-body p) (caddr p))
(define (procedure-environment p) (cadddr p))
原始过程
此时,或许很长时间以来,您可能想知道如何在 Scheme 中表示原始过程。实际上,表示原始过程没有正确的方式,只要apply可以使用primitive-procedure?和apply-primitive- procedure识别和应用它们即可。
创建 Scheme 的人决定将原始过程表示为以符号primitive开头并包含实现该原始过程的基础 Lisp 中的过程的列表。
(define (primitive-procedure? proc)
(tagged-list? proc 'primitive))
(define (primitive-implementation proc) (cadr proc))
(define primitive-procedures
(list (list 'car car)
(list 'cdr cdr)
(list 'cons cons)
(list 'null? null?)
<more primitives>
))
(define (primitive-procedure-names)
(map car
primitive-procedures))
(define (primitive-procedure-objects)
(map (lambda (proc) (list 'primitive (cadr proc)))
primitive-procedures))
要应用原始过程,我们只需将实现过程应用于参数,使用底层的 Lisp 系统:
(define (apply-primitive-procedure proc args)
(apply-in-underlying-scheme
(primitive-implementation proc) args))
环境操作
当然,评估器需要操作以操作环境。环境是什么?它是一系列框架,其中每个框架都是一个将变量与其对应值关联的绑定表。我们使用以下操作来操作环境:
-
(lookup-variable-value <var> <env>)返回绑定到符号
in the environment <env>的值,如果变量未绑定,则发出错误信号。</env> -
(extend-environment <variables> <values> <base-env>)返回一个新的环境,由一个新的框架组成,在该框架中,列表
中的符号绑定到列表 中的相应元素,其中封闭环境是环境。 -
(define-variable! <var> <value> <env>)向环境
中的第一个框架添加一个新绑定,将变量 与值<value>关联。</value> -
(set-variable-value! <var> <value> <env>)更改变量
在环境<env>中的绑定,使变量现在绑定到值<value>,或者如果变量未绑定,则发出错误信号。</value></env>
为了实现这些操作,我们将环境表示为框架列表。环境的封闭环境是列表的cdr。空环境就是空列表。
(define (enclosing-environment env) (cdr env))
(define (first-frame env) (car env))
(define the-empty-environment '())
环境的每个框架都表示为两个列表的对:绑定在该框架中的变量列表和关联值列表。
(define (make-frame variables values)
(cons variables values))
(define (frame-variables frame) (car frame))
(define (frame-values frame) (cdr frame))
(define (add-binding-to-frame! var val frame)
(set-car! frame (cons var (car frame)))
(set-cdr! frame (cons val (cdr frame))))
为了通过一个新的框架扩展环境,将变量与值关联起来,我们创建一个由变量列表和值列表组成的框架,并将其附加到环境中。如果变量的数量与值的数量不匹配,我们会发出错误信号。
(define (extend-environment vars vals base-env)
(if (= (length vars) (length vals))
(cons (make-frame vars vals) base-env)
(if (< (length vars) (length vals))
(error "Too many arguments supplied" vars vals)
(error "Too few arguments supplied" vars vals))))
要在环境中查找变量,我们扫描第一个框架中的变量列表。如果找到所需的变量,我们返回值列表中对应的元素。如果在当前框架中找不到变量,我们就搜索封闭环境,依此类推。如果到达空环境,我们会发出“未绑定变量”错误信号。
(define (lookup-variable-value var env)
(define (env-loop env)
(define (scan vars vals)
(cond ((null? vars)
(env-loop (enclosing-environment env)))
((eq? var (car vars))
(car vals))
(else (scan (cdr vars) (cdr vals)))))
(if (eq? env the-empty-environment)
(error "Unbound variable" var)
(let ((frame (first-frame env)))
(scan (frame-variables frame)
(frame-values frame)))))
(env-loop env))
要在指定的环境中将变量设置为新值,我们会像在lookup-variable-value中一样扫描变量,并在找到时更改相应的值。
(define (set-variable-value! var val env)
(define (env-loop env)
(define (scan vars vals)
(cond ((null? vars)
(env-loop (enclosing-environment env)))
((eq? var (car vars))
(set-car! vals val))
(else (scan (cdr vars) (cdr vals)))))
(if (eq? env the-empty-environment)
(error "Unbound variable -- SET!" var)
(let ((frame (first-frame env)))
(scan (frame-variables frame)
(frame-values frame)))))
(env-loop env))
要定义一个变量,我们在第一个框架中搜索变量的绑定,并在存在时更改绑定(就像在set-variable-value!中一样)。如果不存在这样的绑定,我们就在第一个框架中添加一个。
(define (define-variable! var val env)
(let ((frame (first-frame env)))
(define (scan vars vals)
(cond ((null? vars)
(add-binding-to-frame! var val frame))
((eq? var (car vars))
(set-car! vals val))
(else (scan (cdr vars) (cdr vals)))))
(scan (frame-variables frame)
(frame-values frame))))
重新审视apply
让我们再次看一下apply的定义。这次有没有更清晰的理解?
(define (apply procedure arguments)
(cond ((primitive-procedure? procedure)
(apply-primitive-procedure procedure arguments))
((compound-procedure? procedure)
(eval-sequence
(procedure-body procedure)
(extend-environment
(procedure-parameters procedure)
arguments
(procedure-environment procedure))))
(else
(error
"Unknown procedure type -- APPLY" procedure))))
总结
在本小节中,您学到了以下内容:
-
apply的定义方式 -
如何定义和应用原始过程
-
如何定义环境操作
接下来呢?
我们将学习评估器如何作为一个程序运行。
运行评估器
运行评估器
让我们看看 Scheme 如何运行评估器。到目前为止,我们已经学习了如何使用mc-eval和mc-apply评估 Scheme 表达式。那么评估器程序是如何运行的呢?
我们的评估器程序的作用是将所有表达式简化为原始过程的应用。因此,我们运行评估器所需的全部就是创建一个机制,使用底层 Scheme 系统进行原始过程的应用。
每个原始过程名称都必须有一个绑定,这样当mc-eval评估原始应用的操作数时,它将找到一个对象传递给mc-apply。因此,我们设置一个全局环境,将唯一对象与我们将要评估的表达式中可能出现的原始过程名称相关联(例如,我们将+绑定到具有相同名称的底层 Scheme 过程)。全局环境还包括true和false符号的绑定,以便它们可以用作要评估的表达式中的变量。
(define (setup-environment)
(let ((initial-env
(extend-environment (primitive-procedure-names)
(primitive-procedure-objects)
the-empty-environment)))
(define-variable! 'true true initial-env)
(define-variable! 'false false initial-env)
initial-env))
(define the-global-environment (setup-environment))
为了方便运行元循环评估器,我们提供了一个驱动循环,模拟底层 Scheme 系统的读取-评估-打印循环(或 REPL)。它打印一个提示,读取一个输入表达式,在全局环境中评估这个表达式,并打印结果。我们在每个打印的结果前加上一个输出提示,以区分表达式的值和可能打印的其他输出。
(define input-prompt ";;; M-Eval input:")
(define output-prompt ";;; M-Eval value:")
(define (driver-loop)
(prompt-for-input input-prompt)
(let ((input (read)))
(let ((output (mc-eval input the-global-environment)))
(announce-output output-prompt)
(user-print output)))
(driver-loop))
(define (prompt-for-input string)
(newline) (newline) (display string) (newline))
(define (announce-output string)
(newline) (display string) (newline))
我们使用一个特殊的打印过程user-print,以避免打印复合过程的环境部分,这可能是一个非常长的列表(甚至可能包含循环)。
(define (user-print object)
(if (compound-procedure? object)
(display (list 'compound-procedure
(procedure-parameters object)
(procedure-body object)
'(procedure-env>))
(display object)))
现在我们只需要初始化全局环境并启动驱动循环就可以运行评估器。以下是一个示例交互:
(define the-global-environment (setup-environment))
(driver-loop)
;;; M-Eval input:
(define (append x y)
(if (null? x)
y
(cons (car x)
(append (cdr x) y))))
;;; M-Eval value:
ok
;;; M-Eval input:
(append '(a b c) '(d e f))
;;; M-Eval value:
(a b c d e f)
等等,我还是不明白。我们如何用 Scheme 编写的评估器来评估 Scheme 代码?
这是因为 Scheme 足够强大,可以处理程序作为数据,并让我们构建既是分层又是循环的数据结构。我在下一节给你一个类比。
数据作为程序
要理解用 Scheme 编写的解释器解释 Scheme 表达式,可以将程序视为抽象机器的描述。例如,您可以将计算阶乘的程序视为:
(define (factorial n)
(if (= n 1)
1
(* (factorial (- n 1)) n)))
描述了一个包含递减、乘法和相等测试部分的机器,还有一个两位置开关和另一个阶乘机器。(阶乘机器是无限的,因为它内部包含另一个阶乘机器 -- 递归!)因此,这台机器看起来像这样:

像阶乘一样,评估器是一个非常特殊的机器,它以其他机器的描述作为输入,然后配置自身以模拟给定的机器。例如,如果我们给评估器阶乘的定义,评估器将模拟它并能够计算阶乘。

所以我们的评估器只是一个模仿所有其他机器的通用机器!
如果你想了解更多关于这些机器的信息,请询问第 5 单元。
要点
在这一小节中,你学会了评估器的工作原理。
接下来是什么?
去做你的家庭作业!你也应该开始进行第 4 个项目,你将学习 Python 编程语言。
Python 解释器
Python 简介
我们将学习 Python,这是 CS61A 使用的语言。你在 CS61A 的朋友们正在用 Python 写一个 Scheme 解释器。在 CS61AS 这里,你将为你的最后一个项目编写一个用 Scheme 编写的 Python 解释器。
打开 Python
要打开 Python,请进入终端并键入 "python"。将出现 ">>>" 提示符,这相当于 Scheme 的 "->"。
如你所学,Python 中的空格非常重要。对于 Python 来说,空格就像 Scheme 中的括号。
与 Python 玩耍
在解释器中尝试这些命令。这些命令大多数都来自于项目规范,加上了一些更多的示例。其中一些示例应该会出错。如果有您不希望的行为,请问!
你会如何让 Python 打印 "Hello World"?嗯,
>>> print "Hello World"
Hello World
就是这样了!(是的,真的)。正如你从这个简单的例子中注意到的那样,Python 不需要左括号来调用函数;你不需要在 'print' 前加上左括号。Python 区分大小写,所以 "PRINT" 是不会起作用的。另一个关键的区别是,Python 只支持中缀运算符,运算符位于其操作数之间:
>>> print 2 + 2
4
你实际上不需要 'print' 语句;解释器会自动评估在提示符下输入的任何内容,使用一个非常类似于元循环求值器中使用的读-求值-打印循环。例如:
>>> 2 + 2
4
以下内容的输出是什么?
>>> 3 + 1 - 5 * 1
>>> 3 + (1 - 5) * 1
>>> 10/2
>>> 10/0
>>> (5+1)
>>> (10)
赋值
Python 中的赋值与其他语言中的赋值类似。例如,如果你想要为名为 'x' 的变量提供一个值:
>>> x = 2
>>> print x
2
与 Scheme 不同,Python 不区分 DEFINE 和 SET!。如果变量 'x' 还不存在,则上述赋值将在全局环境中创建一个新变量 'x';否则,任何先前的 'x' 的值都将被覆盖。
尝试以下内容:
>>> num
>>> num = 3
>>> num
>>> num = num + 1
>>> num
>>> num = "Berkeley"
>>> num
布尔值
"如果我们使用 "=" 来赋值变量,那么如何检查相等性?"。Python(以及大多数其他语言)使用 "=="。
尝试以下内容:
>>> 5 == 1
>>> 5 == 5
>>> 5 = 5
>>> x = 10
>>> x == 5
>>> x == 10
>>> x == x
Python 支持布尔运算符 'and' 和 'or',其工作方式与对应的 Scheme 特殊形式完全相同:
>>> x = 3
>>> (x == 3) and (x == 4)
False
>>> True and 3 and 5
5
>>> True and 3 and False
False
>>> True or 3 or False
True
t 和 #f 的 Python 等效值分别为 True 和 False(大小写很重要)。
尝试以下内容:
>>> True and True and False
>>> True and True and True
>>> (1 == 0) and (42 == 42)
>>> (1 == 1) and (42 == (1 / 0))
>>> (1 == 0) and (42 == (1 / 0))
>>> 2 and 3 and 4
>>> (1 == 0) or (42 == (1 / 0))
>>> (1 == 1) or (42 == (1 / 0))
>>> True or 5
>>> False or 5
>>> 5 or True
>>> 10 or 5
>>> False or (1 == 0) or 5
>>> not True
>>> not False
>>> not (1==0)
>>> not 5
>>> not "world"
>>> not ""
列表
Python 有列表!(为什么不呢?)
>>> x = [1, 2, 3]
"x" 现在是一个存储着三个数字的变量。你可以猜到,Scheme 的类比是 "(list 1 2 3)"。Python 列表也可以是深层次的:
>>> x = [[1, 2, 3], 2, 3]
不幸的是,我们不能像在 Python 列表中一样进行 CAR 或 CDR。要访问列表的特定元素:
>>> x[1]
2
表示法 "x[1]" 返回列表的第二个元素(Python 使用基于零的计数)。同样,在这种情况下,"[" 字符可以被视为一个中缀运算符。
尝试以下内容:
>>> [0,1,3,-1,5]
>>> lst = [0,1,3,-1,5]
>>> lst
>>> 0 in lst
>>> 4 in lst
>>> 0 not in lst
>>> 4 not in lst
>>> newlst = ["hey","I am", "a list", "too", ["boo", 100]]
>>> newlst
>>> "hey" in newlst
>>> "am" in newlst
>>> newlst[0]
>>> newlst[0] == "hey"
>>> newlst[1]
>>> newlst[4]
>>> newlst[5]
块
If 条件语句
Python 的一个重要方面,源自其对可读代码的承诺,是其对缩进的使用。在大多数其他语言中,包括 Scheme,在这些语言中,缩进不是问题,因为这些语言忽略空格的数量,而是使用空格来界定符号、数字和单词。但是,在 Python 中,一行开头的空格数量是重要的。
>>> x = 2
>>> if x == 1:
... x = x + 1
... print x
(您将不得不在显示“...”提示后再次按 ENTER 键一次,以表示您已经完成了 'if'-语句。)Python 中的 'if'-语句与 Scheme 中的等效语句工作方式相同:如果 'if'-语句的条件满足,则评估体。请注意,我们使用了 '' 而不是 '=':由于 '=' 字符已用于赋值,因此我们使用 '' 来检查相等性。还请注意,体是缩进的:体中的所有语句都需要以相同的缩进开始。因此,以下内容将不起作用:
>>> x = 2
>>> if x > 1:
... x = x + 1
... print x
因为体中的第二个语句的缩进大于第一个语句。类似地,以下内容也将不起作用:
>>> x = 2
>>> if x > 1:
... x = x + 1
... print x
因为体中的第二个语句的缩进小于第一个语句。通常,只有在完成一组相关语句或块时,您才需要取消缩进。块中的所有语句都需要具有相同数量的空格缩进。例如,'if'-语句也可以有 'else'-子句,如果条件不满足,则评估该子句。
>>> x = 2
>>> if x > 1:
... x = x + 1
... print x
... else:
... x = x - 1
... print x
请注意,与 'if'-语句及其 'else'-子句对应的块中的行具有相同的缩进量,但块本身的缩进量不同(虽然它们不必如此!)。但是,'if'-语句和 'else'-子句需要具有相同的缩进量,因为它们属于同一语句。但是,所有不属于块或子块的语句都不应缩进。尝试在 Python 解释器提示符处尝试以下语句(在“>>>”之后缩进了两个空格):
>>> 2 + 3
缩进强制执行清晰的代码,但可能需要一段时间来习惯;需要记住的关键是,只有在开始新的语句块时才需要缩进。
尝试以下操作:
>>> if x == 3:
... print x + 1
... elif x < 4:
... print x + 2
... elif x > 5:
... print x + 3
... else:
... print x + 4
定义函数
Python 也有函数,它类似于 Scheme 的过程。以下定义了 'square' 函数:
>>> def square(x):
... return x * x
(再次,在显示“...”提示后,您将不得不再次按 ENTER 键一次,以表示您已经完成了过程体。)这种语法类似于 C 语言,其中函数的参数被括在括号中,并且紧跟在函数名称之后。要调用函数:
>>> square(3)
9
从这个意义上讲,左括号可以被视为一个中缀运算符,其中运算符位于其操作数之间。要了解为什么会这样,回想一下,在 Scheme 中,左括号可以被视为一个前缀运算符,它在后续参数上“调用”其第一个参数。同样,在 Python 中,左括号在下一个参数('3')上“调用”其第一个参数('square')。此外,如果 Python 过程需要返回值,我们必须在主体中显式添加一个'return'语句来返回答案;相比之下,在 Scheme 中,过程定义的最后一行总是返回的。这使我们能够区分返回值的 Python 函数和主要用于副作用的 Python 函数:
>>> def foo():
... print "Hello World"
尝试以下内容:
>>> def sum_of_squares(x,y):
... return square(x) + square(y)
>>> sum_of_squares(3,4)
>>> square(square(2))
循环
Python 有用于循环的结构。项目规范有更详细的解释,但试试以下代码:
while
“while”循环接受一个谓词,并将继续评估主体,直到谓词评估为 False。
>>> x = 3
>>> while x < 5:
... print x
... x = x + 1
>>> y = 1
>>> while y < 50:
... print y
... y = y*2
for
“for”循环接受一个列表(或任何类型的序列)并对序列的每个元素运行主体。这类似于您在第 9 课中学到的循环。
>>> for i in [1, 3, 5, 2, 4]:
... print i
>>> for wd in ["Twinkle","twinkle","little","stars"]:
... print wd
作业 11
练习 0。
一些热身问题来检查你的理解:
-
列出在元循环求值器中调用
mc-eval的所有过程。 -
列出在元循环求值器中调用
mc-apply的所有过程。 -
解释为什么
make-procedure不调用mc-eval。
作业 11 说明
一些学生抱怨本周的作业非常耗时。
因此,我们有些不情愿地将一些练习标记为可选;如果你真的时间紧迫,可以跳过这些。但如果你能完成所有练习,效果会更好!
可选练习后面有*。
模板
你可以通过在终端中输入以下内容来复制此作业的模板:
cp ~cs61as/autograder/templates/hw11.scm .
或者,你可以在这里下载。
练习 1。
Abelson & Sussman,练习4.3, 4.6, 4.7, 4.10,4.11*, 4.13,4.14,以及4.15。
练习 4。
Abelson & Sussman,练习4.1,4.2, 4.4, 和 4.5。
练习 2*。
修改元循环求值器以允许对过程的参数进行类型检查。这个功能应该如何工作呢?当定义一个新过程时,形式参数可以是一个符号,如常规情况,或者是一个包含两个元素的列表。在这种情况下,第二个元素是一个符号���即形式参数的名称。第一个元素是一个表达式,其值是一个谓词函数,该参数必须满足。如果参数有效,该函数应返回#t。例如,这里是一个具有经过类型检查的参数 num 和 list 的过程 foo:
> (define (foo (integer? num) ((lambda (x) (not (null? x))) lst))
(list-ref lst num))
> (foo 3 '(a b c d e))
d
> (foo 3.5 `(a b c d e))
Error: wrong argument type -- 3.5
> (foo 2 '())
Error: wrong argument type -- ()
在这个例子中,我们定义了一个名为foo的过程,它有两个形式参数,名为num和list。当调用foo时,求值器将检查第一个实际参数是否为整数,第二个实际参数是否不为空。其值为所需谓词函数的表达式应相对于foo的定义环境进行求值。(提示:考虑 extend-environment。)
更多挑战问题
如果你对本节感兴趣,这里还有一些可选练习。这些练习不计入学分。
- Abelson & Sussman,练习4.16 - 4.21。
提交你的作业!
有关说明,请参阅此指南。它涵盖了基本的终端命令和作业提交。
如果你在提交过程中遇到任何问题,请毫不犹豫地向助教求助!
项目 4 - Python 解释器
Schython
您已经看到如何在第 11 课中实现 Scheme 解释器(mceval.scm)。在这个项目中,您将帮助我们构建一个名为 Schython(Scheme + Python = Schython)的 Python 解释器。
要获取必要的项目文件和规范,请在您的解释器中键入以下内容:
cp -r ~cs61as/lib/schython/ .
您可以将.替换为您想要保存项目的任何目录。
项目文件
这里是schython/中包含的文件的详细信息:
| 文件名 | 目的 |
|---|---|
1.schython.text |
这是您的 Schython 项目规范。 |
2.start.scm |
这个文件将加载测试您代码所需的文件。确保它与您的其他 Schython 文件在同一个目录中。 |
3.obj.scm |
我们面向对象系统的代码。面向对象编程用于在parser.scm中创建和操作 line-object 类。请不要对这个文件进行更改。 |
4.parser.scm |
我们 Schython 解释器的解析器。这个文件将输入行分解为 Scheme 解释器可识别的 Python 字符。您应该编辑这个文件。 |
5.py-meta.scm |
这个文件负责评估我们的parser.scm中解析的 Python 代码。您应该编辑这个文件。 |
6.py-primitives.scm |
这个文件包含所有 Python 数据类型的 Scheme 表示。您应该编辑这个文件。 |
7.primitives.py |
包含一系列 Python 函数的文件。请不要对这个文件进行更改。 |
8.memoize.py |
用于第 9 题答案的文件。您应该编辑这个文件。 |
9.tests |
包含一些测试的目录,您可以运行这些测试来测试您的代码。这些测试案例取自schython.text中的示例。如何运行这些测试的说明可以在此目录中的README文件中找到。您的成绩将取决于您通过了多少个测试案例。 |
要加载项目,请在您的解释器中键入以下内容:
(load "start.scm")
评分
每个合作伙伴将解决九个问题。其中五个问题(问题 1、2、6、8 和 9)对两位合作伙伴都是共同的;其他问题(问题 3、4、5 和 7)应分别完成。
小组将提交一个完成的项目副本,每个问题只需一个答案。合作伙伴将在共同练习中获得相同的分数,而在单独问题中获得不同的分数。
在schython.text中会有一些需要合作的地方,合作伙伴们需要结合他们的工作。这是为了能够继续进行项目的下一部分。
如果您找不到合作伙伴和/或希望独自工作,请与助教联系。
更多关于 Python
Python 是一种现代且非常流行的语言,用于教授入门级的计算机科学课程,并且是 CS 61A 课程中使用的语言。我们将介绍 Python 语言的基础知识,以便在规范中编写 Schython 解释器。但是,如果你感兴趣(这对这个项目来说完全不是必需的),你可以查看Python 的文档以获取更全面的语言解析。
12 - 分析评估器和惰性评估器
第 12 课介绍
介绍
此时,你(原则上)知道如何在 Scheme 中构建一个 Scheme 解释器。现在我们看到如何使元循环求值器更加高效,以及改变元循环求值器如何改变语言的解释方式,以及这带来的好处。特别是,我们形成了两个新的求值器。第一个求值器将程序的语法分析(分析程序要做什么)与执行(实际执行程序要做的事情)分开,以提高效率。第二个求值器将解释器从应用序改为正则序。
先决条件和期望
你应该对第 11 课中的元循环求值器非常熟悉。本课程在 MCE 的思想和代码基础上进行了大量构建。
阅读材料
以下是本课程的相关阅读材料:
-
讲座笔记(跳过非确定性求值器。)
当你准备好了,继续下一节!
将分析与执行分开
分析评估器
要使用本节中的思想,请获取分析元循环评估器:
cp ~cs61as/lib/analyze.scm .
第 12 课中的元循环评估器实现很简单,但非常低效,因为表达式的语法分析与执行交织在一起。因此,如果一个程序被多次执行,其语法将被多次分析。让我们考虑一个例子。
假设我们已经定义了factorial函数如下:
(define (fact num)
(if (= num 0)
1
(* num (fact (- num 1)))))
当我们计算(fact 3)时会发生什么?
eval (fact 3)
self-evaluating? ==> #f
variable? ==> #f
quoted? ==> #f
assignment? definition?
if? ==> #f
lambda? ==> #f
begin? ==> #f
cond? ==> #f
application? ==> #t
eval fact
self-evaluating? ==> #f
variable? ==> #t
lookup-variable-value ==> <procedure fact>
list-of-values (3)
eval3 ==> 3
apply <procedure fact> (3)
eval (if (= num 0) ...)
self-evaluating? ==> #f
variable? ==> #f
quoted? ==> #f
assignment? ==> #f
definition? ==> #f
if? ==> #t
eval-if (if (= num 0) ...)
if-predicate ==> (= num 0)
eval (= num 0)
self-evaluating? ==> #f
...
if-alternative ==> (* num (fact (- num 1)))
eval (* num (fact (- num 1)))
self-evaluating? ==> #f
...
list-of-values (num (fact (- num 1)))
...
eval (fact (- num 1))
...
apply <procedure fact> (2)
eval (if (= num 0) ...)
评估器必须四次检查过程体,确定它是一个 if 表达式,提取其组成部分,并评估这些部分(这反过来又涉及决定每个部分是什么类型的表达式)。
这就是解释性语言比编译语言慢得多的原因之一:解释器一遍又一遍地进行程序的语法分析。编译器只进行一次分析,而编译后的程序只需执行依赖于变量实际值的计算部分。在本节中,我们将研究分析评估器,以了解如何防止程序语法的重复分析。
分离
eval接受两个参数,一个表达式和一个环境。其中,表达式参数在我们重新访问相同表达式时是相同的,而环境每次都会不同。例如,当我们计算(fact 3)时,我们在num的值为3的环境中评估fact的主体。该主体包括一个递归调用来计算(fact 2),在其中我们在num绑定为2的环境中评估相同的主体。
我们的计划是查看评估过程,找到那些仅依赖于exp而不依赖于env的部分,并仅执行一次。执行此工作的过程称为analyze。
analyze的结果是什么?它必须是某种可以与环境结合以返回值的东西。解决方案是analyze返回一个只接受env作为参数的过程,并完成其余的评估。
而不是
(eval exp env) ==> value
现在我们有
1\. (analyze exp) ==> exp-procedure
2\. (exp-procedure env) ==> value
检验你的理解
分析返回的过程接受什么类型的参数?
当我们再次评估相同的表达式时,我们只需重复第 2 步。我们所做的类似于记忆化,我们记住计算的结果以避免重复计算。不同之处在于,现在我们记住的是整体问题的部分解,而不是完整解的一部分。
我们可以通过以下方式复制原始eval的效果:
(define (eval exp env)
((analyze exp) env))
analyze
analyze的结构与原始eval类似:
(define (analyze exp)
(cond
((self-evaluating? exp)
(analyze-self-eval exp))
((variable? exp)
(analyze-var exp))
...
((foo? exp) (analyze-foo exp))
...))
区别在于接受表达式和环境作为参数的诸如eval-if之类的过程已被接受仅接受表达式作为参数的诸如analyze-if之类的过程所取代。这些分析过程如何工作?作为我们理解的中间步骤,这里有一个完全遵循eval-if结构并且不节省时间的analyze-if版本:
eval-if:
(define (eval-if exp env)
(if (true? (eval (if-predicate exp) env))
(eval (if-consequent exp) env)
(eval (if-alternative exp) env)))
analyze-if:
(define (analyze-if exp)
(lambda (env)
(if (true? (eval (if-predicate exp) env))
(eval (if-consequent exp) env)
(eval (if-alternative exp) env))))
这个版本的analyze-if返回一个以env为参数的过程,其主体与原始eval-if的主体完全相同。因此,如果我们这样做
((analyze-if some-if-expression) some-environment)
结果将与我们说
(eval-if some-if-expression some-environment)
在原始元循环评估器中。
但是我们希望改进这个analyze-if的第一个版本,因为它实际上并没有避免任何工作。每次调用analyze-if返回的过程时,它将执行原始eval-if执行的所有工作。
第一个版本的analyze-if包含三个对eval的调用。每个调用都对表达式进行分析,然后在给定环境中计算值。我们希望将这些eval调用分成两个独立的部分,并且仅在第一次执行时执行第一部分:
(define (analyze-if exp)
(let ((pproc (analyze (if-predicate exp)))
(cproc (analyze (if-consequent exp)))
(aproc (analyze (if-alternative exp))))
(lambda (env)
(if (true? (pproc env))
(cproc env)
(aproc env)))))
在这个最终版本中,analyze-if返回的过程不包含任何分析步骤。在调用该过程之前,所有组件都已经被分析过,因此不需要进一步分析。
效率上的最大收益来自对lambda表达式的处理方式。在原始元循环评估器中,为了清晰起见,我们省略了一些数据抽象,我们有
(define (eval-lambda exp env) (list ’procedure exp env))
评估器对lambda表达式实际上什么也不做,除了记住过程的文本和创建它的环境。但是在分析评估器中,我们分析过程的主体(使用analyze-sequence过程);存储为过程的表示不包括其文本!相反,评估器在元循环 Scheme 中表示一个过程为底层 Scheme 中的一个过程,以及形式参数和定义环境。
(务必阅读SICP 的第 4.1.7 节以了解所有的语法分析过程是如何实现的)。
级别混淆
分析评估器将表达式转换为
(if A B C)
转换为一个过程
(lambda (env)
(if (A-execution-procedure env)
(B-execution-procedure env)
(C-execution-procedure env)))
这可能看起来像是一种倒退;我们试图实现if,最终得到一个执行if的过程。这不是一个无限回归吗?
不,不是。执行过程中的if由底层 Scheme 处理,而不是由元循环 Scheme 处理。因此,没有回归;我们不为那个调用analyze-if。此外,底层 Scheme 中的if比在元 Scheme 中进行if的语法分析要快得多。
那又怎样?
表达式的语法分析是编译器的主要工作之一。在某种意义上,这个分析评估器就是一个编译器!它将 Scheme 编译成 Scheme,所以它不是一个非常有用的编译器,但是编译成其他东西,比如特���计算机的机器语言,其实并不难。
结构类似于这个的编译器被称为递归下降编译器。如今,在实践中,大多数编译器使用一种不同的技术(称为堆栈机),因为可以通过这种方式自动编写解析器。 (我之前提到过这是数据导向编程的一个例子。)但是,如果你手动编写解析器,最容易使用递归下降。
(在继续之前,请务必阅读 SICP 的4.1.7部分)。
一个例子
这是一个使用分析评估器评估阶乘的好例子。让我们考虑以下 Scheme 代码:
(define factorial
(lambda (n)
(if (= n 1)
1
(* (factorial (- n 1)) n))))
(factorial 2) ;; low argument, so that the example is not too long)))
这里有两个语句:一个定义和一个应用。
我们从定义开始,我们将在这里称之为d(其中d代表'(define (factorial n) ...)'):
(eval d env)
((analyze d) env)
((analyze-definition d) env)
analyze-definition将首先分析definition-value,然后创建一个执行过程,当执行时,将define变量名为分析的definition-value。
这一点至关重要。我们不只是将lambda分配给factorial,我们将分析的lambda分配给factorial。这将在以后提供性能优势。
因此,为了找出factorial的值,我们使用...当然是通过analyze-lambda来分析lambda(通过分析中的分派)。
analyze-lambda提供的好处真的是从一次分析主体开始,然后制作一个具有analyzed主体(一个 Scheme 过程)的过程抽象数据类型,而不是像旧的eval中那样简单的指令列表。
关键是,在调用我们的lambda时,我们不必处理解析。解析仅在创建lambda时完成。
让我们看看这个过程。
(注意:我将使用:=来表示存储:
var := value
这并不是真正的 Scheme,但我认为这比有一堆let语句更容易。)
(analyze-lambda '(lambda (n) ...)')
现在我们需要analyze主体,然后将其存储以供以后使用,这样我们就不会再次冗余地analyze主体。
analyzed-body := (analyze (lambda-body '(lambda (n) (if ...))'))
(analyze-if '(if (= n 1)
1
(* (factorial (- n 1)) n))')
analyze-if分析其所给的所有内容,存储它,然后使用这些存储的值创建一个新的执行过程。
if-pred := (analyze '(= n 1)')
; this is the execution procedure: (lambda (env)
; (execute-application (analyzed/= env)
if-true := (analyze '1')
; this is the execution procedure: (lambda (env) 1)
if-false := (analyze '(* (factorial (- n 1)) n)')
; this is too long to write out, but it's
; kind of like if-pred
;;this is the execution procedure we return:
;;let's call this execution procedure 'analyzed-fact-if'
(lambda (env)
(if (true? (if-pred env))
(if-true env)
(if-false env)))
现在我们知道了结果,让我们回到analyze-lambda。
analyzed-body := analyzed-fact-if
(analyze-lambda '(lambda (n) ...)')
=> (lambda (env) (make-procedure '(n) analyzed-body env'))
我们将最后一个表达式存储到factorial变量中,然后我们完成了定义factorial。请注意,我们只在分析阶段一次分析主体:在评估阶段我们永远不会analyze!这意味着在评估期间,每次调用此factorial函数时,我们知道其主体包含一个if语句,并且if语句检查n是否等于0(以及如果谓词为真或假时该怎么做)。
现在,继续评估阶乘。这就是你将看到所有神秘分析工作的价值所在。
(eval '(factorial 2) env') ; env has factorial definition
((analyze '(factorial 2)') env)
((analyze-application '(factorial 2)') env)
((lambda (env) (execute-application ...)) env)
((procedure-body {internal factorial value})
(extend-environment ...)) ; extend-environment is same as old eval
;; let's call the extended environment, env2
(analyzed-body env2) ; analyzed-body from definition above
((lambda (env)
(if (true? (if-pred env))
(if-true env)
(if-false env)))
env2)
(if (true? (if-pred env2)) ; (= n 0)
(if-true env2) ; 1
(if-false env2)) ; (* (factorial (- n 1)) n)
这里,n = 2 != 0,所以我们最终会执行(if-false env2)。if-false会对(factorial (- n 1))和n进行*的应用,但这些参数已经被分析过了(当我们进行analyze-lambda时)。所以我们评估已经分析过的(factorial (- n 1)),即:
(analyzed-factorial {result of calling analyzed (- n 1)})
(analyzed-body env3)
;env3 := env2 extended with n := (- {previous n} 1) = (- 2 1) = 1
(if (true? (if-pred env3)) ; (= n 0)
(if-true env3) ; 1
(if-false env3)) ; (* (factorial (- n 1)) n)
我们再次以相同的方式递归:
(if (true? (if-pred env4)) ; (= n 0)
(if-true env4) ; 1
(if-false env4)) ; (* (factorial (- n 1)) n)
这里,n实际上等于0,所以我们调用(if-true env4)。if-true忽略env4并返回数字1。然后,我们回到所有执行应用程序原语应用并将所有内容相乘。
然后我们得到... 2。
所以,我们完成了。
请注意,在评估阶段,我们从不检查语句的语法。语法已经被查看并分析过了。我们只是执行这些分析过的语句告诉我们要做的事情。想想当计算类似(factorial 100)这样的东西时,这里的效率提高了多少。
正常次序和应用次序
惰性评估器
要开始,请获取我们的惰性评估器版本:
cp ~cs61as/lib/lazy.scm .
现在我们已经将评估器表达为一个 Lisp 程序,我们可以通过简单修改评估器来尝试语言设计中的替代选择。实际上,新语言通常是通过首先编写一个将新语言嵌入到现有高级语言中的评估器来发明的。
例如,如果我们希望与 Lisp 社区的另一位成员讨论对 Lisp 的某个方面的拟议修改,我们可以提供一个体现了该变化的评估器。接收者然后可以尝试新的评估器,并将评论作为进一步的修改发送回来。高级别实现基础不仅使得测试和调试评估器更加容易;此外,嵌入还使设计者能够从底层语言中提取功能,就像我们的嵌入式 Lisp 评估器使用底层 Lisp 的原语和控制结构一样。设计者只有在以后(如果有的话)才需要费力地在低级语言或硬件中构建完整的实现。
在本节和下一节中,我们探讨了一些提供显著额外表达能力的 Scheme 变体。
正常和应用次序的回顾
在第 1 课中,我们开始讨论求值模型时,我们注意到 Scheme 是一种应用次序语言,即,当应用过程时,Scheme 过程的所有参数都会被求值。相比之下,正常次序语言会延迟求值过程参数,直到实际的参数值被需要。延迟求值过程参数直到最后可能的时刻(例如,直到它们被原始操作所需要)被称为惰性求值。
考虑以下过程
(define (try a b)
(if (= a 0) 1 b))
在 Scheme 中,评估 (try 0 (/ 1 0)) 会生成一个错误。使用惰性求值,不会出现错误。评估该表达式会返回 1,因为参数 (/ 1 0) 永远不会被求值。
利用惰性求值的一个例子是定义一个过程 unless
(define (unless condition usual-value exceptional-value)
(if condition
exceptional-value
usual-value))
它可用于表达式中,如下所示
(unless (= b 0)
(/ a b)
(begin (display "exception: returning 0")
0))
在应用次序语言中,这种做法行不通,因为通常值和异常值都会在调用 unless 之前被求值。惰性求值的一个优点是,一些过程(例如 unless)可以进行有用的计算,即使它们的一些参数的求值会产生错误或不会终止。
测试您的理解
考虑以下情况
> (define (double x) (+ x x))
double
> (double (+ 2 1))
6
在应用次序中,+ 会被调用多少次?
在正常次序中,+ 会被调用多少次?
严格与非严格
如果在进入过程体之前对参数进行求值,则称该过程在该参数上是非严格的。如果在进入过程体之前对参数进行求值,则称该过程在该参数上是严格的。在纯应用顺序语言中,所有过程在每个参数上都是严格的。在纯正常顺序语言中,所有复合过程在每个参数上都是非严格的,原始过程可以是严格的或非严格的。还有一些语言(参见SICP Exercise 4.31)允许程序员对他们定义的过程的严格性进行详细控制。
一个引人注目的例子是一个可以有用地变为非严格的过程是cons(或者,一般来说,几乎任何数据结构的构造函数)。即使元素的值未知,我们也可以进行有用的计算,将元素组合成数据结构并对生成的数据结构进行操作。例如,计算列表的长度而不知道列表中各个元素的值是完全有意义的。我们将在后面的课程中利用这个想法,将第 11 课的流实现为由非严格cons对形成的列表。
具有惰性计算的解释器
大思想
在本节中,我们将实现一个按正常顺序执行的语言,与 Scheme 相同,只是复合过程在每个参数上都是非严格的。原始过程仍然是严格的。 修改第 12 课的求值器,使其解释的语言以这种方式运行并不困难。几乎所有所需的更改都围绕过程应用。
(请记住,上面的选择只是选择!第 12 课中的元循环求值器运行得很好,但有时我们希望 Scheme 的行为有所不同。本节将讨论修改 MCE 代码,使我们解释的 Scheme 是按正常顺序执行的。)
基本思想是,在应用一个过程时,解释器必须确定哪些参数需要评估,哪些需要延迟。延迟的参数不会被评估;相反,它们被转换为称为惰性计算的对象。惰性计算必须包含在需要时产生参数值所需的信息,就好像在应用时已经评估了它一样。因此,惰性计算必须包含参数表达式和正在评估过程应用的环境。
在惰性计算中,强制执行一个惰性计算中的表达式称为强制。一般来说,只有在需要其值时,惰性计算才会被强制执行:当它被传递给将使用惰性计算的原始过程时;当它是条件语句的谓词的值时;以及当它是即将被应用为过程的操作符的值时。我们可以选择是否对惰性计算进行记忆化,就像我们在第 11 课中对延迟对象进行的那样。通过记忆化,第一次强制执行惰性计算时,它会存储计算出的值。后续的强制执行只需返回存储的值,而不重复计算。我们将使我们的解释器进行记忆化,因为这对许多应用来说更有效率。然而,这里有一些棘手的考虑。
修改求值器
惰性求值与第 12 课中的求值器之间的主要区别在于eval和apply中处理过程应用的方式。
eval的application?子句变为
((application? exp)
(apply (actual-value (operator exp) env)
(operands exp)
env))
这几乎与第 12 课中eval的application?子句相同。然而,对于惰性计算,我们调用apply与操作数表达式一起,而不是由评估它们产生的参数。由于如果参数要延迟,我们需要环境来构造惰性计算,因此我们也必须传递这个。我们仍然评估操作符,因为apply需要实际要应用的过程,以便根据其类型(原始 versus 复合)进行分派和应用。
每当我们需要表达式的实际值时,我们使用
(define (actual-value exp env)
(force-it (eval exp env)))
而不仅仅是eval,因此如果表达式的值是一个惰性计算,它将被强制执行。
修改apply
我们的新版本apply与 MCE 中的版本几乎相同。不同之处在于eval传入了未评估的操作数表达式:对于原始过程(严格的),我们在应用原始过程之前评估所有参数;对于复合过程(非严格的),我们在应用过程之前延迟所有参数。
(define (apply procedure arguments env)
(cond ((primitive-procedure? procedure)
(apply-primitive-procedure
procedure
(list-of-arg-values arguments env))) ; changed
((compound-procedure? procedure)
(eval-sequence
(procedure-body procedure)
(extend-environment
(procedure-parameters procedure)
(list-of-delayed-args arguments env) ; changed
(procedure-environment procedure))))
(else
(error
"Unknown procedure type -- APPLY" procedure))))
处理参数的过程与第 12 课中的list-of-values非常相似,只是list-of-delayed-args延迟参数而不是评估它们,而list-of-arg-values使用actual-value而不是eval:
(define (list-of-arg-values exps env)
(if (no-operands? exps)
'()
(cons (actual-value (first-operand exps) env)
(list-of-arg-values (rest-operands exps)
env))))
(define (list-of-delayed-args exps env)
(if (no-operands? exps)
'()
(cons (delay-it (first-operand exps) env)
(list-of-delayed-args (rest-operands exps)
env))))
处理if
我们必须更改求值器的另一个地方是在处理if时,我们必须使用actual-value而不是eval来获取谓词表达式的值,然后再测试它是true还是false:
(define (eval-if exp env)
(if (true? (actual-value (if-predicate exp) env))
(eval (if-consequent exp) env)
(eval (if-alternative exp) env)))
修改driver-loop
最后,我们必须更改driver-loop过程(read-eval-print循环)以使用actual-value而不是eval,以便如果延迟的值传播回read-eval-print循环,则在打印之前将其强制执行。我们还更改提示以指示这是惰性求值器:
(define input-prompt ";;; L-Eval input:")
(define output-prompt ";;; L-Eval value:")
(define (driver-loop)
(prompt-for-input input-prompt)
(let ((input (read)))
(let ((output
(actual-value input the-global-environment)))
(announce-output output-prompt)
(user-print output)))
(driver-loop))
测试它
进行这些更改后,我们可以启动求值器并对其进行测试。在关于正常顺序与应用顺序的部分讨论的try表达式成功评估表明解释器正在执行惰性求值:
(define the-global-environment (setup-environment))
(driver-loop)
;;; L-Eval input:
(define (try a b)
(if (= a 0) 1 b))
;;; L-Eval value:
ok
;;; L-Eval input:
(try 0 (/ 1 0))
;;; L-Eval value:
1
表示 Thunks
我们的求值器必须安排在将过程应用于参数时创建 thunk,并稍后强制这些 thunk。Thunk 必须将表达式与环境打包在一起,以便稍后可以生成参数。为了强制 thunk,我们只需从 thunk 中提取表达式和环境,并在环境中评估表达式。我们使用actual-value而不是eval,以便在表达式的值本身是 thunk 的情况下,我们将强制执行该值,依此类推,直到达到不是 thunk 的内容:
(define (force-it obj)
(if (thunk? obj)
(actual-value (thunk-exp obj) (thunk-env obj))
obj))
将表达式与环境打包的一种简单方法是创建一个包含表达式和环境的列表。因此,我们按照以下方式创建 thunk:
(define (delay-it exp env)
(list 'thunk exp env))
(define (thunk? obj)
(tagged-list? obj 'thunk))
(define (thunk-exp thunk) (cadr thunk))
(define (thunk-env thunk) (caddr thunk))
实际上,我们为我们的解释器想要的不完全是这样,而是已经被记忆的 thunk。当强制执行 thunk 时,我们将通过用其值替换存储的表达式并更改 thunk 标记将其转换为已评估的 thunk,以便可以识别为已经评估。
(define (evaluated-thunk? obj)
(tagged-list? obj 'evaluated-thunk))
(define (thunk-value evaluated-thunk)
(cadr evaluated-thunk))
(define (force-it obj)
(cond ((thunk? obj)
(let ((result (actual-value
(thunk-exp obj)
(thunk-env obj))))
(set-car! obj 'evaluated-thunk)
(set-car! (cdr obj) result) ; replace exp with its value
(set-cdr! (cdr obj) '()) ; forget unneeded env
result))
((evaluated-thunk? obj)
(thunk-value obj))
(else obj)))
请注意,相同的delay-it过程既适用于有记忆化,也适用于无记忆化。
惰性列表中的流
流再访
在第 11 课中,我们展示了如何将流实现为延迟列表。我们引入了特殊形式delay和cons-stream,这使我们能够构造一个“承诺”来计算流的cdr,而不实际履行该承诺直到以后。每当我们需要更多控制评估过程时,我们可以使用这种一般技术引入特殊形式,但这很笨拙。首先,特殊形式不像过程那样是一流对象,因此我们无法与高阶过程一起使用它。此外,我们被迫将流创建为一种类似但不完全相同于列表的新数据对象,这要求我们重新实现许多用于流的普通列表操作(map,append等)。
惰性求值器中的流
使用惰性求值,流(streams)和列表(lists)可以是相同的,因此不需要特殊形式或单独的列表和流操作。我们所需要做的就是安排cons是非严格的。实现这一点的一种方法是扩展惰性求值器以允许非严格的原语,并将cons实现为其中之一。更简单的方法是回顾第 4 课,根本没有必要将cons实现为原语。相反,我们可以将对偶表示为过程
(define (cons x y)
(lambda (m) (m x y)))
(define (car z)
(z (lambda (p q) p)))
(define (cdr z)
(z (lambda (p q) q)))
根据这些基本操作,列表操作的标准定义将适用于无限列表(流)以及有限列表,并且流操作可以实现为列表操作。以下是一些示例:
(define (list-ref items n)
(if (= n 0)
(car items)
(list-ref (cdr items) (- n 1))))
(define (map proc items)
(if (null? items)
'()
(cons (proc (car items))
(map proc (cdr items)))))
(define (scale-list items factor)
(map (lambda (x) (* x factor))
items))
(define (add-lists list1 list2)
(cond ((null? list1) list2)
((null? list2) list1)
(else (cons (+ (car list1) (car list2))
(add-lists (cdr list1) (cdr list2))))))
(define ones (cons 1 ones))
(define integers (cons 1 (add-lists ones integers)))
;;; L-Eval input:
(list-ref integers 17)
;;; L-Eval value:
18
请注意,这些惰性列表比第 11 课的流更懒惰:列表的car和cdr都是延迟的。实际上,即使访问惰性对的car或cdr也不需要强制列表元素的值。只有在真正需要时才会强制该值--例如,用作原语的参数,或作为答案打印。
作业 12
要获取作业文件,在教学账户上输入:cp ~cs61as/autograder/templates/hw12.scm .
练习 1
Abelson & Sussman,练习4.22 和 4.23。
练习 2
Abelson & Sussman,练习4.27 和 4.29。
练习 3
这个练习对于理解本课程的概念至关重要。
Abelson & Sussman,练习4.25, 4.26 和 4.28。
练习 4
这个练习不那么关键,但仍然涵盖了非常重要的概念。
Abelson & Sussman,练习4.30,4.32 和 4.33。
练习 5:专家级额外内容
如果你想的话可以做这个。这不计入学分。
Abelson & Sussman,练习4.31。这个练习不需要很高的智慧,但需要大量的工作和涉及许多细节的调试。另一方面,完成这个练习将教会你很多关于求值器的知识。
提交你的作业!
有关说明,请参阅此指南。它涵盖了基本的终端命令和作业提交。
如果在提交作业时遇到任何问题,请毫不犹豫地向助教求助!
Python - 简介,记忆化,惰性
迷你 Python 介绍
介绍
此时,您应该对 Scheme 和 Racket 中的编程原理非常熟悉了。现在是将这些知识转换为一种新语言的时候了!进入 Python。这比您项目 4 规范中的 Python 介绍要详细一些,但仍然相当基本。完成一个应该使另一个变得轻松。享受吧!
作业
作业提示散布在整个课程中,旨在在您学习的同时进行一些小练习。这是模板。本课程的作业部分将提供更多详细信息!
安装
我们将在本课程中使用 Python 3(具体来说是 3.5 版本)。这与 Python 2 不相等。如果您已经安装了 Python 3,则可以跳过以下内容。在您的终端启动 python 后,会显示您已安装的版本,请在那里检查。
Anaconda(推荐的安装方法)
Anaconda 是 Python 的一个发行版,它将 Python 与其他一些有用的库(如 NumPy)打包在一起。Anaconda 还使安装更多扩展变得更容易,并自动将 python 添加到计算机的路径变量中。
请从此链接安装 Python 3.5:https://www.continuum.io/downloads 并选择阅读 这个 以更加了解 conda 及其强大之处。
通过其他方法获取 Python 3.5 也可以,这只是一种推荐的方法。如果遇到任何问题,请咨询谷歌和 StackOverflow!
加载和运行 Python
如果您已正确安装 Python 3,则应该能够通过终端使用命令 python 启动它。您应该会看到三个大于号 >>>,表示 python 解释器正在接受输入!如果遇到问题,请检查是否已设置路径环境变量以指向您的安装位置(如果不确定是什么意思,请尝试谷歌!),并确保删除任何其他路径以指向较旧的 python 版本。如果您使用的是 mac,请参阅 此链接。
MyComputer ~ $ python
Python 3.5.1 |Anaconda 2.4.1 (64-bit)| (default, Jan 29 2016, 15:01:46) [MSC v.1900 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> exit()
MyComputer ~ $
如果您有一个文件想要在 python 中运行,可以在终端中输入“python”,然后是文件路径和“-i”标志,以加载文件并交互式运行 python。如果缺少 -i 标志,python 将加载文件,运行它,然后退出。
MyComputer ~ $ python python_hw.py -i
>>>
资源
不需要阅读!只是更多帮助和指导的参考:
准备好后,继续下一节!
基本数据类型
基础知识
作为经验丰富的程序员,我们将快速浏览基础知识,以便您可以深入了解。但在开始之前,请遵守一些规则:
-
Python 区分大小写
-
通过空格或制表符缩进用于构造 Python 代码的结构
-
空格和制表符不可互换,因此选择一种并坚持使用(建议使用空格)
-
如果您使用 Sublime,请在“视图”>“缩进”下检查“使用空格缩进”
-
如果您遇到错误,请在“视图”>“缩进”下尝试将所有缩进���换为空格,并仔细检查您的间距
-
-
括号可用于澄清评估顺序(就像数学中一样)
- (1 + 2) * 3
-
井号(#)将注释掉同一行后面的任何内容
数学和数字
数字是自我评估的(将返回它们自己)。可以对数字、保存数字值的变量以及数字返回值执行数值操作。以下是大多数内置 Python 数值操作的表格。随时将这些表达式直接输入 Python 解释器并检查结果。
| 操作 | 表达式 | 结果 |
|---|---|---|
| 加法 | 1 + 2 + 3 | 6 |
| 减法 | 7 - 1 | 6 |
| 乘法 | 2 * 3 | 6 |
| 除法(浮点数) | 5 / 2 | 2.5 |
| 除法(地板除) | 5 // 2 | 2 |
| 取模(余数) | 5 % 2 | 1 |
| 小于 | 5 < 7 | True |
| 大于 | 5 > 7 | False |
| 检查相等 | 5 == 5 | True |
| 小于或等于 | 5 <= 2 | False |
| 大于或等于 | 5 >= 2 | True |
布尔值
布尔值由True和False编码。布尔值再次是自我评估的(将返回它们自己)。以下操作返回布尔值,当与其他数据类型一起使用时,将考虑它们为真(任何不是False的东西都是真)。
| 操作 | 表达式 | 结果 |
|---|---|---|
| 真 | True |
True |
| 假 | False |
False |
| 非 | 非 True |
False |
| 与 | 1 and True |
True |
| 或 | False or not True |
False |
字符串
字符串是另一种自我评估的数据类型。它们构造为在匹配引号之间的字符序列(您可以使用单引号或双引号,但不能在字符串中混合使用它们)。
>>> "hello"
'hello'
>>> 'hello'
'hello'
开头和结尾引号内的字符不会被评估。因此,只要它们不匹配开放和关闭引号,您就可以在字符串中使用引号字符
>>> "hello my name is 'Sally'"
"hello my name is 'Sally'"
>>> 'hello my name is "Sally"'
'hello my name is "Sally"'
作业问题 1:淘气字符串
当您在字符串中不正确使用引号时返回的错误消息是什么?
提供一个示例并解释错误消息。
这里是一些对字符串进行有用操作和/或返回字符串的操作
| 操作 | 表达式 | 结果 | 注释 |
|---|---|---|---|
| 打印 | print(“hello”) | 打印到输出 | 也适用于数字 |
| 选择 | “hello”[0] | ‘h’ | 从零开始索引 |
| 选择 | “hello”[-1] | ‘o’ | 也可以是负数 |
| 切片 | “hello”[1:3] | ‘el’ | 包括开始;不包括结束 |
| 切片 | "hello"[1:] | 'ello' | 结束默认为字符串长度;但是第一个操作 |
| 切片 | "hello"[:-1] | 'hell' | 开始默认为零;但是最后一个操作 |
| 连接 | "hello" + " world" | 'hello world' | 不能与数字混合创建新字符串! |
| 转换 | str(1) | '1' | 用于数字和字符串的连接 |
| 重复 | "hello" * 3 | 'hellohellohello' | |
| 包含 | 'h' in "hello" | True |
|
| 获取长度 | len("hello") | 4 |
列表
列表和字符串相似!字符串是字符列表,但为了抽象化,我们区分了两者。使用字符串与列表时,请不要违反我们的数据抽象屏障,但是可以利用它们的相似之处来理解如何处理它们。 (一个重大区别是你不能设置字符串的元素,但是可以用列表!)
惊不惊喜... 列表是自我评估的! 列表通过在方括号之间枚举逗号分隔的元素来声明。 与字符串类似,使用索引访问列表值(索引从 0 开始)。
>>> test_list = ["this", "is", "a", "list", 1 , 2 , 3]
>>> test_list
['this', 'is', 'a', 'list', 1 , 2 , 3]
>>> test_list[3]
'list'
-
第 1 行:设置一个列表并将其赋给名为 test_list 的变量
-
第 2 行:检查变量 test_list 是什么
-
第 3 行:返回 ['this', 'is', 'a', 'list', 1 , 2 , 3](我们创建的列表!)
-
第 4 行:获取列表的第四个元素(索引 = 3)
-
第 5 行:'list' 被返回(第四个元素)
再次,这里是列表索引和操作的汇编!
对于下表,假设 x = ["this", "is", "a", "list"]
| 操作 | 表达式 | 结果 | 注释 |
|---|---|---|---|
| 打印 | print([1,2,3]) | 打印到输出 | 也适用于数字和字符串 |
| 选择 | x[0] | 'this' | 索引从零开始 |
| 选择 | x[-1] | 'list' | 也可以是负数 |
| 切片 | x[1:3] | ['is', 'a'] | 包括开始;不包括结束 |
| 切片 | x[1:] | ['is', 'a', 'list'] | 结束默认为字符串长度;但是第一个操作 |
| 切片 | x[:-1] | ['this', is', 'a'] | 开始默认为零;但是最后一个操作 |
| 连接 | [1, 2, 3] + [4, 5, 6] | [1, 2, 3, 4, 5, 6] |
| 连接 | >>> x = [1, 2, 3] >>> x += [4, 5, 6]
x | [1, 2, 3, 4, 5, 6] | |
| 重复 | ['Hi!'] * 4 | ['Hi!', 'Hi!', 'Hi!', 'Hi!'] | |
|---|---|---|---|
| 包含 | 3 in [1, 2, 3] | True |
| 迭代(控制部分中将更多介绍!) | for i in [1, 2, 3]: print(i) | 1 2
3 | |
| 获取长度 | len([1, 2, 3]) | 3 |
|---|
作业问题 2:水果和蔬菜
x = ["apple", "banana", "carrot"]写一行代码,执行后返回 "apples bananas and carrots"。
变量和定义
变量
要定义一个变量,请使用等号符号。(要检查相等性,使用双等号。)变量可以在过程定义内外定义。
>>> x = 1 #set x to be 1
>>> x
1
>>> x == 1 #check if x equals 1
True
定义过程
要定义一个过程,请使用“def”。在 Python 中,缩进(行首的空格)和冒号是结构化 Python 代码的分隔符。因此,我们将使用缩进来指示过程的主体。完成后,如果你直接在解释器中输入,则需要一个与 def 行匹配缩进的空行来关闭定义块。
>>> def func(x):
... x = x * 2
... return x + 1
...
>>> func(1)
3
-
(0 space indentation) 函数头部分配函数名称和参数
-
(3 space indentation) 函数的主体将 x 值加倍并返回(x 的两倍)+ 1
-
(0 space indentation) 空行用于结束定义块
-
使用参数 x 为 1 调用 func
-
返回 3
注意我们如何使用 return 语句。return 会停止过程并将输出传回以显示。没有 return 语句的行不会传播到过程内部之外。一个恰当的类比是:return 语句类似于你大声说出来的话,而非 return 语句类似于你说话之前的思维。
如果你的主体是单个表达式,你可以在一行中编写过程定义。你仍然需要空行
>>> def func(x): return (x * 2) + 1
...
>>> func(2)
5
-
(0 space indentation) 函数头部分配函数名称和参数以及单个表达式主体
-
(0 space indentation) 空行用于结束定义语句
-
使用参数 x 为 1 调用 func
-
返回 3
控制措施
If、Elif、Else
条件语句使用 if、elif 和 else 语句形成。if 语句由谓词和一个在谓词满足时执行的主体组成。Elif 是“else if”的缩写,用于第一个 if 语句之外的任何其他条件。elif 语句的构造与 if 语句类似。else 语句跟随所有 if 和 elif 语句,当之前的所有条件语句都不满足时触发。
If、elif 和 else 使用缩进和冒号适当地阻止代码。完成后,您需要一个空行,与第一行具有相同的缩进,以关闭条件语句,当您直接输入到解释器时。
>>> if False:
... 3
... elif True:
... 4
... else:
... 5
...
4
-
第 1 行:(0 个空格缩进)if 条件
-
第 2 行:(3 个空格缩进)if 主体
-
第 3 行:(0 个空格缩进)else if 条件
-
第 4 行:(3 个空格缩进)else if 主体
-
第 5 行:(0 个空格缩进)else
-
第 6 行:(3 个空格缩进)else 主体
-
第 7 行:(0 个空格缩进)空行关闭 if 块并调用评估
-
第 8 行:返回 4(跳过 if 情况,触发 elif 情况,永远不会到达 else 情况)
请注意,没有使用返回语句。这是因为条件语句在过程定义之外。在函数体内,您期望“return 3”而不是“3”等等,如果这是期望的返回值。
作业问题 3:Fizz Buzz
编写一个程序,打印从 1 到 n 的整数(n 是程序的参数)。但是对于三的倍数,打印“Fizz”而不是数字,对于五的倍数,打印“Buzz”。对于既是三的倍数又是五的倍数的数字,打印“FizzBuzz”。
循环和范围
在 python 中,支持循环。循环多次执行一个代码块或一行代码。循环对于希望通过一个序列或重复一个操作的情况非常有用---使用循环进行迭代,而不是递归。for循环控制迭代次数以对应要迭代的序列的条目。while循环通过谓词控制迭代次数。
在循环中可以调用某些控制语句来停止并跳出循环,或者跳到下一个迭代。break执行前者,continue执行后者。如果需要示例,请查阅在线资源。
While 循环
While 循环包含一个谓词,在每次迭代开始之前检查。如果谓词不满足,则 while 循环停止。while 块使用冒号和缩进来指示哪一行是头部,哪些是主体
>>> x = 0
>>> while x < 3:
... print("repeat")
... x += 1
...
hello
hello
hello
>>> x #check what x is
3
-
第 1 行:设置一个变量 x 等于零
-
第 2 行:(0 个空格)带有 x 小于 3 的条件的 While 头部
-
第 3 行:(3 个空格)While 主体行调用打印
-
第 4 行:(3 个空格)While 主体行将 x 增加 1(x += 1 与 x = x + 1 相同)
-
第 5 行:(0 个空格)空行关闭 While 块并评估该块
-
第 6 行:当 x = 0 时打印 hello。
-
第 7 行:当 x = 1 时打印 hello。
-
第 8 行:当 x = 2 时打印 hello。
-
第 9 行:检查 x 的值。
-
第 10 行:返回 x 的值为 3(这不是小于 3)
作业问题 4:白雪公主和七个小矮人
编写一个名为
snow_white的程序,它接受两个数字作为参数,第一个是num_chants,第二个是max_sing。该程序:
- 交替打印"heigh"和"ho"。
- 每唱完
num_chants次"heigh"或者"ho"后,打印"its off to work we go"。- 在唱了"it's off to work we go"
max_sing次后停止打印。例子:每唱完
5次交替出现的"hi"和"ho"之间打印"it's off to work we go",最多2次。>>> snow_white(5, 2) heigh ho heigh ho heigh it's off to work we go ho heigh ho heigh ho it's off to work we go使用 while 循环(可能还有控制语句)来实现此行为。
作业问题 5:将第一个奇数推后(来自 CS10)
写一个名为
push_first_odd_back的函数,它以列表作为参数。该函数应该将第一个奇数放到输入列表的末尾。不要返回新列表 - 实际上,这个函数不应该返回任何东西,它只应该修改输入列表。(提示:使用 while 循环)
对于循环
For 循环包含一个变量和一个序列(稍后详细介绍)。每次迭代时,变量的值都会更改为序列中的下一个值。与其他多行块一样,for 循环由冒号和缩进限定,并在空行处完成。在 for 循环的主体中,您可以访问被迭代的变量的值。
range函数创建一个数字序列,然后可以在for循环中用于控制。Range 接受一个开始、结束和增量,以创建一个包含开始和增量条目的序列,但不包括结束。如果没有提供,range 将默认开始为零,增量为一。现在,只在for循环的上下文中使用range,在后面的课程中,我们将深入探讨range的工作原理。
提示:如果你想要 x 次迭代并且实际上不打算使用迭代变量,可以使用range(x)。
>>> for i in range(2): #same as do two times
... print "hello"
...
hello
hello
>>> for i in range(3): #i is 0 then 1 then 2
... print i
...
0
1
2
>>> for i in range(2, 6, 2): #start at 2, stop before 6, skip 2
... print(i)
...
2
4
你也可以在 range 的位置使用字符串或列表作为要迭代的序列。
>>> sum = 0
>>> for number in [1,5,8]: #iterating over a list
... sum += number
...
>>> sum
14
>>> longer_string = ""
>>> for letter in "apple": #iterating over string
... longer_string += letter * 3
...
>>> longer_string
'aaappppppllleee'
作业问题 6:猫和狗
编写一个程序,如果字符串中的"cat"和"dog"出现的次数相同,则返回 True。
cat_dog('catdog') → True cat_dog('catcat') → False cat_dog('1cat1cadodog') → True
字典和记忆化
字典
字典与列表非常相似,但不使用索引来引用值,而是使用键。

作为提醒,这是如何声明和访问列表值的方式:
>>> list_var = [0, 1, 2, 3]
>>> list_var[0]
0
>>> list_var[1]
1
现在,将其与字典的结构进行对比。
创建字典:(键必须是不可变的,即字符串、数字、元组,但不能是列表!)
>>> empty_dict = {}
>>> full_dict = {"January": 31, "February":28, "March": 31}
访问字典:
>>> empty_dict["April"] #should error because there is no "April" key in this dictionary
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'April'
>>> full_dict["January"] #should return the value associated with "January" key
31
添加和更改字典键值对:
>>> empty_dict["April"] = 30 #adding a new key,value pair
>>> empty_dict
{'April': 30}
>>> full_dict["February"] = 29 #changing an existing key's value
>>> full_dict #note that there is no order to the entries of the dictionary
{'March': 31, 'February': 29, 'January': 31}
有用的字典操作:
>>> len(full_dict)
3
>>> print ("dictionary as string: " + str(full_dict)) #str returns a printable string representation
dictionary as string: {'January': 31, 'March': 31, 'February': 29}
>>> full_dict.get("April", default=False) #returns default if key is not in dictionary
False
>>> full_dict.has_key("January")
True
>>> "January" in full_dict #same as has_key operation
True
>>> full_dict.update(empty_dict) #adds all of empty_dict's key,values into full_dict
>>> full_dict
{'January': 31, 'March': 31, 'February': 29, 'April': 30}
遍历字典的键
>>> all_months = ""
>>> total_days = 0
>>> for key in full_dict:
... all_months += key
... all_months += " "
... total_days += full_dict[key]
...
>>> total_days
121
>>> all_months
'January March February April '
遍历字典的值
>>> total_days = 0
>>> for val in full_dict.values():
... total_days += val
...
>>> total_days
121
删除
>>> del empty_dict['April']; # remove entry with key 'Name'
>>> empty_dict.clear(); # remove all entries in dict
>>> del empty_dict ; # delete entire dictionary
作业问题 7:字符频率
编写一个函数
char_freq(),它接受一个字符串并构建其中包含的字符的频率列表。将频率列表表示为一个 Python 字典,其中每个字母作为一个键,存储该字母出现的次数。尝试使用类似
char_freq("abbabcbdbabdbdbabababcbcbab")的东西。作业问题 8.1:凯撒密码
编写一个函数
rotate_letters(),它接受一个数字并创建一个由该数字偏移的小写字母的新映射。将新映射作为字典返回,原始字母映射到移位后的字母。例如,rotate_letters(2)将映射'a'->'c','b'->'d','c'->'e'等。作业问题 8.2:凯撒密码
编写一个函数
decode_cipher(),它接受一个字母映射的字典和一个密码字符串(仅包含小写字母)。返回当每个字符被字典中的映射替换时创建的解码字符串。例如,decode_cipher(rotate_letters(2), "abc")应返回"cde"。使用此函数解码“jbj fpurzr vf terng”,假设字母已经移位了 13 个位置。
记忆化
现在你已经掌握了学习一个名为记忆化的新主题的所有工具!记忆化是在计算事物时存储答案(特别是计算昂贵的答案)的行为,以便如果需要重复该计算,你已经有了一个记忆化的答案。记忆化通常用于改进慢递归过程的效率,该过程进行重复计算。
考虑生成第 n 个斐波那契数的斐波那契函数。递归地,该过程的蛮力定义如下:
def fib(n):
return n if n < 2 else fib(n-2) + fib(n-1)
如果 n 足够大,你将等待很长时间才能得到 fib 的返回值。考虑 n 为 5 的情况。我们将不得不计算 fib(5-2)和单独计算 fib(5-1)。但在计算 fib(5-1)时,我们将不得不重新计算 fib(5-2)。按照这种逻辑,你可以看到我们将得到许多不必要的重复计算。
查看调用 fib(6)生成的递归树。你能发现所有重叠的计算吗?

为了减少我们的低效性,我们应该在完成计算后缓存或存储计算。然后,在进行任何计算之前,我们只需检查我们的缓存是否已经完成了该计算。我们的缓存可以用字典创建!键将对应于参数值,值将对应于计算的结果。
fib_cache = {}
def fib(n):
if n in fib_cache:
return fib_cache[n]
else:
fib_cache[n] = n if n < 2 else fib(n-2) + fib(n-1)
return fib_cache[n]
这有点混乱,因为缓存存在于函数之外。或者,您可以封装缓存和函数,使其在每次调用包装函数时都进行记忆化。缓存将在每次调用memo_fib()时重置,但至少在一个调用内会发生记忆化。在项目 4 中,您将编写一个更好的记忆化例程,它不那么混乱,但在调用之间仍然进行了记忆化。
def memo_fib(n):
fib_cache = {}
def fib(n):
if n in fib_cache:
return fib_cache[n]
else:
fib_cache[n] = n if n < 2 else fib(n-2) + fib(n-1)
return fib_cache[n]
return fib(n)
作业问题 9: 记忆化的阶乘乘积
以类似于
memo_fib的方式编写一个记忆化的累积阶乘乘积过程。您必须使用递归。累积阶乘 5 的结果 = 5! * 4! * 3! * 2! * 1!
惰性评估和生成器 TA DAH,你完成了!
惰性评估
对不起,这是一个话题的小跳跃。你从第 12 课跳过了这部分,所以我们在这里做一个快速介绍。
惰性评估是正常顺序评估的实现,与应用顺序评估相对。回顾一下,在第 1 课中,我们开始讨论评估模型时,我们注意到 Scheme 是一种应用顺序语言,即当应用过程时,Scheme 过程的所有参数都会被评估。相比之下,正常顺序语言延迟评估过程参数,直到实际参数值被需要。延迟评估过程参数直到最后可能的时刻(例如,直到它们被原始操作需要时)被称为惰性评估。
Python 类似于 Scheme。当你定义一个过程并用参数调用它时。所有参数在主体评估之前都会被评估。
考虑该过程
def try(a, b):
if a == 0:
return True
else:
return b
评估try(0, 1/0)将触发除零错误,因为参数都会首先被评估。
在惰性评估中,不会发生错误。评估表达式将返回 1,因为参数1/0永远不会被评估,因为它从未在原始过程中使用,也没有返回。
if在直接使用时是一个惰性过程。如果你尝试:
$ python
>>> if 0 == 0:
... True
... else:
... 1/0
...
True
看到 True 被返回,因为我们永远不需要评估 1/0,所以我们永远不会出错!所有这些都是可能的,因为if在评估过程中被处理为一个特殊形式(想想第 11 课的 mc-eval)。为了在每个过程调用中获得这种行为,我们必须改变 Python 中 eval 和 apply 的工作方式。我们只有在返回或原始使用时才立即评估过程应用的参数,而不是在将其传递给 apply 之前立即评估参数。
如果你对在解释器中实现惰性评估有更深入的了解感兴趣,请阅读原始第 12 课的内容。
如果你想尝试在 Python 中使用惰性评估包装器的实现,请查看这个网站上的 lazy.py 模块。
范围和生成器
我们一直在for循环中使用range(),但我们并没有想过它是如何工作的。Range 是一个惰性的不可变序列。在幕后,通过 range 创建的序列中的元素直到需要它们时才被创建。不相信我吗?尝试print(range(4)和print([0, 1, 2, 3])。
我们可以通过生成器的使用创建类似于range()的序列。在 Python 中,生成器是通过计算并根据需要yield下一个值来创建序列的函数。它们类似于 Scheme 中的流,是一种惰性序列,而不是急切枚举的列表。
生成器是可迭代的,所以你可以在 for 循环中使用它们,就像我们使用 range()一样。你也可以在任何生成器过程上调用next(generator)来获取后续元素。然而,生成器不能被多次迭代。一旦你用完了序列,它就消失了。如果你尝试在用完的生成器上调用next(),你会收到一个StopIteration错误消息。
想一想为什么我们想要生成器。为什么不总是使用列表?
yield是我们创建生成器而不是函数的过程。你会使用yield而不是return。现在举个例子:
def gen_to(n):
for i in range(n+1):
yield i
现在尝试使用 for 循环打印每个元素:
gen_to_7 = gen_to(7)
for i in gen_to(7):
print(i)
现在尝试调用next:
next(gen_to_7)
啊哈!StopIteration错误!
现在是一个无限生成器!尝试print和通过调用next来遍历。
def gen_forever():
i = -1
while true:
i += 1
yield i
作业问题 10:成长的烦恼(指数增长)
编写一个名为
gen_exp()的生成器,接受一个数字 n,并从 n 开始生成(永远)n 的指数 n 的指数 n。例如,
gen_exp(2)的前几个元素应该是 2,(2²),((2²)^ 2),(((2²)^ 2) ^ 2)
完成啦!
更多酷炫的东西!如果你喜欢学习 Python,你应该深入了解。
-
导入模块像数学模块...太棒了。查看此链接以获取 Python 3.5 捆绑的模块库的广泛目录。
-
科学数学内容,已经随 Anaconda 发行版安装
Python 作业
截止日期为 2016 年 4 月 28 日晚上 11:59(太平洋时间)
(但你可能应该在项目 4 之前/期间完成这个)
模板
在此处下载链接。
自动评分程序
抱歉!目前还没有自动评分程序。尽量测试你的作业,你将会被宽容地评分。为了努力而给你 A。如果时间允许,将会有自动评分程序。
练习
作业问题 1: 淘气字符串
当你在字符串内部不正确使用引号时返回的错误消息是什么?
提供一个例子并解释错误信息。
作业问题 2: 水果和蔬菜
x = ["apple", "banana", "carrot"]
写一行代码,当执行时返回“apples bananas and carrots”。
作业问题 3: Fizz Buzz
编写一个程序,打印从 1 到 n(n 是该过程的参数)的整数。但对于三的倍数,打印“Fizz”而不是数字,对于五的倍数,打印“Buzz”。对于既是三的倍数又是五的倍数的数字,打印“FizzBuzz”。
作业问题 4: 白雪公主和七个小矮人
编写一个名为snow_white的程序,它接受两个数字作为参数,第一个是num_chants,第二个是max_sing。
该程序:1. 交替打印“heigh”“ho” 2. 在“heigh”或“ho”的num_chants后打印“我们去工作” 3. 在打印“it's off to work we go” max_sing次后停止打印
例子:应该在每5次交替他和他之间打印"我们去工作"。
snow_white(5, 2)
heigh
ho
heigh
ho
heigh
it's off to work we go
ho
heigh
ho
heigh
ho
it's off to work we go
使用 while 循环(可能还有控制语句)来实现这种行为。
作业问题 5: 将第一个奇数推回(取自 CS10)
编写一个名为push_first_odd_back的函数,它接受一个列表作为参数。该函数应该将第一个奇数放在输入列表的末尾。不要返回一个新列表 - 实际上,这个函数不应该返回任何东西,它只应该修改输入列表。(提示:使用 while 循环)
作业问题 6: 猫和狗
编写一个程序,在给定的字符串中,如果字符串"cat"和"dog"出现的次数相同,则返回 True。
cat_dog('catdog') → True
cat_dog('catcat') → False
cat_dog('1cat1cadodog') → True
作业问题 7: 字符频率
编写一个名为char_freq()的函数,它接受一个字符串并构建其中包含的字符的频率列表。将频率列表表示为一个 Python 字典,其中每个字母作为一个键,存储该字母出现的次数。
尝试使用类似char_freq("abbabcbdbabdbdbabababcbcbab")的东西。
作业问题 8.1: 凯撒密码
编写一个名为rotate_letters()的函数,它接受一个数字并创建一个新的映射,将小写字母偏移该数字。将新的���射作为字典返回,原始字母映射到移位后的字母。例如,rotate_letters(2)将映射'a'->'c','b'->'d','c'->'e'等等。
作业问题 8.2: 凯撒密码
编写一个函数decode_cipher(),它接受一个字母映射的字典和一个密码字符串(仅包含小写字母)。返回通过字典中的映射替换每个字符而创建的解码字符串。例如,decode_cipher(rotate_letters(2), "abc")应返回"cde"。
使用这个函数解码"jbj fpurzr vf terng",假设字母已经被移动了 13 位。
作业问题 9: 记忆化阶乘
以类似于memo_fib的方式编写一个记忆化累积阶乘过程。你必须使用递归。
累积阶乘 5 = 5! * 4! * 3! * 2! * 1!
作业问题 10: 成长的痛苦(指数增长)
编写一个生成器gen_exp(),它接受一个数字 n,并生成(永远)从 n 开始的 n 的 n 的 n 的指数。例如,gen_exp(2)的前几个元素应该是 2,(2²),((2²)^ 2),(((2²)^ 2) ^ 2)
提交你的作业!
请将作业提交为 python_hw 而不是 hw12。请在 2016 年 4 月 1 日之后(而不是之前)提交!
查看此指南获取说明。它涵盖了基本的终端命令和作业提交。
如果你在提交作业时遇到任何问题,请不要犹豫向助教求助!
13 - 逻辑编程
第 13 课介绍
介绍
本周的重要概念是逻辑编程或声明式编程。
这是我们远离以硬件术语表达计算的最大一步。当我们发现流时,我们看到如何以与评估顺序无关的方式表达算法。现在我们将描述一种没有(可见)算法的计算方式!
我们正在使用 A&S 在 Scheme 中实现的逻辑编程语言。因此,表示法类似于 Scheme,即充满了列表。标准逻辑语言如 Prolog 有不同的表示法,但思想是相同的。
先决条件
这节课遵循的范式与你之前见过的任何内容都非常不同。因此,没有先决条件!
阅读材料
大部分这节课内容取自这些笔记和SICP 4.4.1-4.4.3 节。
逻辑编程
逻辑编程在提供接口给数据库进行信息检索方面表现出色。我们将在本章中使用的查询语言是为了以这种方式使用而设计的。
我们所做的一切只是断言事实:
> (load "~cs61as/lib/query.scm")
> (query)
;;; Query input:
(assert! (Brian likes potstickers))
并询问关于事实的问题:
;;; Query input:
(?who likes potstickers)
;;; Query results:
(BRIAN LIKES POTSTICKERS)
尽管断言和查询采用列表形式,看起来有点像 Scheme 程序,但实际上不是!这里没有函数对参数的应用;一个断言只是数据。
尽管出于各种原因,传统上将动词(关系)放在第一位,但这仍然是真实的:
(assert! (likes Brian potstickers))
以后我们将遵循这个惯例,但这样做会更容易陷入认为有一个名为likes的函数的陷阱。继续阅读以了解我们如何在这种特殊的语言中编程!
什么是逻辑编程
什么是逻辑编程?
在本课程开始时,我们强调计算机科学处理命令式(如何)知识,而数学处理声明式(是什么)知识。事实上,编程语言要求程序员以一种表明解决特定问题的逐步方法的形式表达知识。另一方面,高级语言作为语言实现的一部分,提供了大量的方法论知识,使用户不必担心指定计算将如何进行的许多细节。
大多数编程语言,包括 Lisp,在计算数学函数的值时都是有组织的。面向表达式的语言(如 Lisp,Fortran 和 Algol)利用了一个“双关语”,即描述函数值的表达式也可以被解释为计算该值的手段。因此,大多数编程语言都倾向于单向计算(具有明确定义的输入和输出)。然而,也有一些根本不同的编程语言放松了这种偏见。逻辑编程通过将关系式编程视觉与一种称为统一化的强大符号模式匹配相结合来扩展这一思想。
当这种方法奏效时,编写程序可以是一种非常强大的方式。部分原因在于一个“是什么”事实可以用来解决许多不同的问题,这些问题可能具有不同的“如何”组成部分。
一个示例数据库
在我们深入讨论逻辑编程的具体内容之前,我们需要一个数据库来进行操作。您可以使用以下命令加载此数据库:
> (load "~cs61as/lib/query.scm")
> (initialize-data-base microshaft-data-base)
> (query-driver-loop)
Microshaft 的人员数据库包含有关公司人员的断言。以下是有关住宅计算机巫师 Ben Bitdiddle 的信息:
(address (Bitdiddle Ben) (Slumerville (Ridge Road) 10))
(job (Bitdiddle Ben) (computer wizard))
(salary (Bitdiddle Ben) 60000)
每个断言都是一个列表(在本例中是一个三元组),其元素本身可以是列表。
作为公司的住宅巫师,Ben 负责公司的计算机部门,并监督两名程序员和一名技术员。以下是关于他们的信息:
(address (Hacker Alyssa P) (Cambridge (Mass Ave) 78))
(job (Hacker Alyssa P) (computer programmer))
(salary (Hacker Alyssa P) 40000)
(supervisor (Hacker Alyssa P) (Bitdiddle Ben))
(address (Fect Cy D) (Cambridge (Ames Street) 3))
(job (Fect Cy D) (computer programmer))
(salary (Fect Cy D) 35000)
(supervisor (Fect Cy D) (Bitdiddle Ben))
(address (Tweakit Lem E) (Boston (Bay State Road) 22))
(job (Tweakit Lem E) (computer technician))
(salary (Tweakit Lem E) 25000)
(supervisor (Tweakit Lem E) (Bitdiddle Ben))
还有一个受到 Alyssa 监督的程序员实习生:
(address (Reasoner Louis) (Slumerville (Pine Tree Road) 80))
(job (Reasoner Louis) (computer programmer trainee))
(salary (Reasoner Louis) 30000)
(supervisor (Reasoner Louis) (Hacker Alyssa P))
所有这些人都在计算机部门工作,其工作描述中的第一项为计算机。
Ben 是一名高级员工。他的主管是公司的大佬本人:
(supervisor (Bitdiddle Ben) (Warbucks Oliver))
(address (Warbucks Oliver) (Swellesley (Top Heap Road)))
(job (Warbucks Oliver) (administration big wheel))
(salary (Warbucks Oliver) 150000)
除了由 Ben 监督的计算机部门外,公司还有一个会计部门,由一名首席会计师和他的助手组成:
(address (Scrooge Eben) (Weston (Shady Lane) 10))
(job (Scrooge Eben) (accounting chief accountant))
(salary (Scrooge Eben) 75000)
(supervisor (Scrooge Eben) (Warbucks Oliver))
(address (Cratchet Robert) (Allston (N Harvard Street) 16))
(job (Cratchet Robert) (accounting scrivener))
(salary (Cratchet Robert) 18000)
(supervisor (Cratchet Robert) (Scrooge Eben))
还有一个为大佬工作的秘书:
(address (Aull DeWitt) (Slumerville (Onion Square) 5))
(job (Aull DeWitt) (administration secretary))
(salary (Aull DeWitt) 25000)
(supervisor (Aull DeWitt) (Warbucks Oliver))
数据库还包含有关哪些工作可以由从事其他种类工作的人员完成的断言。例如,计算机巫师可以完成计算机程序员和计算机技术员的工作:
(can-do-job (computer wizard) (computer programmer))
(can-do-job (computer wizard) (computer technician))
一名计算机程序员可以代替一名实习生:
(can-do-job (computer programmer)
(computer programmer trainee))
此外,众所周知,
(can-do-job (administration secretary)
(administration big wheel))
简单查询
查询语言允许用户通过对系统提示的查询来从数据库中检索信息。例如,要找到所有计算机程序员,可以说
;;; Query input:
(job ?x (computer programmer))
系统将以以下项目回应:
;;; Query results:
(job (Hacker Alyssa P) (computer programmer))
(job (Fect Cy D) (computer programmer))
输入查询指定我们正在寻找与某种模式匹配的数据库条目。在这个例子中,模式指定由三个项目组成的条目,其中第一个是文字符号 job,第二个可以是任何东西,第三个是文字列表(computer programmer)。匹配列表中第二个项目的“任何东西”由模式变量?x指定。模式变量的一般形式是一个符号,被视为变量的名称,前面加上一个问号。我们将在下面看到为什么指定模式变量的名称比只是在模式中放入?来代表“任何东西”更有用。系统通过显示与指定模式匹配的数据库中的所有条目来响应简单查询。
一个模式可以有多个变量。例如,查询
(address ?x ?y)
将列出所有员工的地址。
一个模式可以没有变量,这种情况下查询只是确定该模式是否是数据库中的一个条目。如果是,将会有一个匹配;如果不是,将没有匹配。
同一个模式变量可以在查询中出现多次,指定相同的“任何东西”必须出现在每个位置。这就是为什么变量有名称。例如,
(supervisor ?x ?x)
找到所有自我监督的人(尽管在我们的示例数据库中没有这样的断言)。
查询
(job ?x (computer ?type))
匹配所有第三个项目是一个两元素列表且第一个项目是 computer 的工作条目:
(job (Bitdiddle Ben) (computer wizard))
(job (Hacker Alyssa P) (computer programmer))
(job (Fect Cy D) (computer programmer))
(job (Tweakit Lem E) (computer technician))
这个相同的模式不匹配
(job (Reasoner Louis) (computer programmer trainee))
因为条目中的第三个项目是一个包含三个元素的列表,而模式的第三个项目指定应该有两个元素。如果我们想要更改模式,使第三个项目可以是以 computer 开头的任何列表,我们可以指定
(job ?x (computer . ?type))
例如,
(computer . ?type)
匹配数据
(computer programmer trainee)
以?type为列表(programmer trainee)。它也匹配数据
(computer programmer)
以?type为列表(programmer),并匹配数据
(computer)
以?type为空列表()。
我们可以描述查询语言对简单查询的处理如下:
-
系统找到查询模式中所有满足模式的变量赋值 -- 也就是说,所有变量的值集合,使得如果将模式变量实例化(替换为)这些值,结果就在数据库中。
-
系统通过列出满足它的变量赋值的查询模式的所有实例来响应查询。
请注意,如果模式没有变量,则查询将简化为确定该模式是否在数据库中。如果是,则空赋值,即不为变量分配任何值,将满足该数据基的模式。
断言和查询:第 1 部分
在数据库中添加一些关于你喜欢的事物的断言。这应该看起来非常类似于
(assert! (likes brian potstickers))
接下来,编写一个查询,返回所有你喜欢的事物。它应该返回你刚刚添加的所有断言。
断言和查询:第 2 部分
在数据库中再添加一些关于你的项目伙伴喜欢的事物的断言。编写另一个查询,返回他/她喜欢的所有事物。
断言和查询:第 3 部分
最后,编写一个查询,返回数据库中任何人喜欢的所有事物。
简单查询
给出从数据库中检索以下信息的简单查询:
-
所有由 Ben Bitdiddle 监督的人;
-
会计部门所有人的姓名和工作;
-
住在 Slumerville 的所有人的姓名和地址。
记住,要加载示例数据库并运行查询系统,请在解释器中键入以下命令:
(load "~cs61as/lib/query.scm")
(initialize-data-base microshaft-data-base)
(query-driver-loop)
复合查询
简单查询构成了查询语言的基本操作。为了形成复合操作,查询语言提供了组合的手段。使查询语言成为逻辑编程语言的一点是,组合手段反映了形成逻辑表达式时使用的组合手段:与、或和非。(这里的与、或和非不是 Lisp 的原语,而是内置于查询语言中的操作。)
我们可以如下使用与来找到所有计算机程序员的地址:
(and (job ?person (computer programmer))
(address ?person ?where))
结果输出为
(and (job (Hacker Alyssa P) (computer programmer))
(address (Hacker Alyssa P) (Cambridge (Mass Ave) 78)))
(and (job (Fect Cy D) (computer programmer))
(address (Fect Cy D) (Cambridge (Ames Street) 3)))
一般来说,
(and <query1> <query2> ... <queryn>)
对于同时满足<query1> <query2> ... <queryn>的模式变量值集合都满足
至于简单查询,系统通过找到满足查询的所有模式变量赋值,然后显示具有这些值的实例化来处理复合查询。
另一种构建复合查询的方法是通过或。例如,
(or (supervisor ?x (Bitdiddle Ben))
(supervisor ?x (Hacker Alyssa P)))
将找到所有由 Ben Bitdiddle 或 Alyssa P. Hacker 监督的员工:
(or (supervisor (Hacker Alyssa P) (Bitdiddle Ben))
(supervisor (Hacker Alyssa P) (Hacker Alyssa P)))
(or (supervisor (Fect Cy D) (Bitdiddle Ben))
(supervisor (Fect Cy D) (Hacker Alyssa P)))
(or (supervisor (Tweakit Lem E) (Bitdiddle Ben))
(supervisor (Tweakit Lem E) (Hacker Alyssa P)))
(or (supervisor (Reasoner Louis) (Bitdiddle Ben))
(supervisor (Reasoner Louis) (Hacker Alyssa P)))
一般来说,
(or <query1> <query2> ... <queryn> )
对于所有满足至少一个<query1> <query2> ... <queryn>的模式变量值集合都满足。
复合查询也可以用not形成。例如,
(and (supervisor ?x (Bitdiddle Ben))
(not (job ?x (computer programmer))))
找到所有由 Ben Bitdiddle 监督的不是计算机程序员的人。一般来说,
(not <query1>)
对于所有不满足<query1>的模式变量赋值都满足。
最终的组合形式称为lisp-value。当lisp-value是模式的第一个元素时,它指定下一个元素是一个应用于其余(实例化的)元素作为参数的 Lisp 谓词。一般来说,
(lisp-value <predicate> <arg1> ... <argn>)
将满足模式变量的赋值,其中应用于实例化的<arg1> ... <argn>的<predicate>为真。例如,要找到所有薪水大于$30,000 的人,我们可以写
(and (salary ?person ?amount)
(lisp-value > ?amount 30000))
规则
只要我们告诉系统孤立的事实,我们就无法得到非常有趣的回复。但我们也可以告诉它规则,让它从一个事实推断出另一个事实。例如,如果我们有很多事实,比如:
(mother Eve Cain)
那么我们可以建立一个关于祖母关系的规则:
(assert! (rule (grandmother ?elder ?younger)
(and (mother ?elder ?mom)
(mother ?mom ?younger) ))))
规则说第一部分(结论)为真如果我们可以找到变量的值,使得第二部分(条件)为真。
再次,抵制尝试进行函数的组合的诱惑!
(assert! (rule (grandmother ?elder ?younger) ;; WRONG!!!!
(mother ?elder (mother ?younger)) ))
Mother不是一个函数,你不能像这个错误的例子尝试的那样询问某人的母亲。相反,就像上面的正确版本一样,你必须建立一个变量(?mom),它有一个满足我们需要的两个母亲关系的值。
在这种语言中,单词assert!,rule,and,or和not具有特殊含义。其他一切都只是可以成为断言或规则的词。
分析家谱
让我们尝试写一些规则!以下数据库(参见创世记 4)通过该数据库追溯了亚当的后裔的家谱,经由该迦音:
(son Adam Cain)
(son Cain Enoch)
(son Enoch Irad)
(son Irad Mehujael)
(son Mehujael Methushael)
(son Methushael Lamech)
(wife Lamech Ada)
(son Ada Jabal)
(son Ada Jubal)
制定规则,如“如果 S 是 F 的儿子,F 是 G 的儿子,那么 S 是 G 的孙子”和“如果 W 是 M 的妻子,S 是 W 的儿子,那么 S 是 M 的儿子”(在圣经时代比今天更真实)将使查询系统能够找到该因的孙子;拉麦的儿子;麦土沙的孙子。
更多规则
这是一个稍微复杂��规则:
(rule (lives-near ?person-1 ?person-2)
(and (address ?person-1 (?town . ?rest-1))
(address ?person-2 (?town . ?rest-2))
(not (same ?person-1 ?person-2))))
它指定如果两个人住在同一个城镇,那么他们住在附近。最后的not子句防止规则说所有人都住在自己附近。same关系由非常简单的规则定义:
(rule (same ?x ?x))
拼车时间
通过给出查询
(lives-near ?person (Hacker Alyssa P))
艾莉莎·P·哈克能够找到住在她附近的人,与他们一起上班。另一方面,当她试图通过查询找到所有住在附近的人的配对时
(lives-near ?person-1 ?person-2)
她注意到每对住在附近的人都被列出两次;例如,
(lives-near (Hacker Alyssa P) (Fect Cy D))
(lives-near (Fect Cy D) (Hacker Alyssa P))
为什么会发生这种情况?有没有办法找到一个住在附近的人的列表,其中每对只出现一次?解释一下。(不要写代码!)
逻辑作为程序
我们可以将规则视为一种逻辑蕴涵:如果对模式变量的值的分配满足主体,那么它满足结论。因此,我们可以认为查询语言具有根据规则执行逻辑推理的能力。例如,考虑append操作。Append可以由以下两条规则表征:
-
对于任何列表
y,空列表和y连接形成y。 -
对于任何
u、v、y和z,如果v和y连接形成z,那么(cons u v)和y连接形成(cons u z)。
要在我们的查询语言中表达这一点,我们为一个关系定义了两条规则
(append x y z)
我们可以解释为“x 和 y 连接形成 z”:
(assert! (rule (append () ?y ?y)))
(assert! (rule (append (?u . ?v) ?y (?u . ?z))
(append ?v ?y ?z)))
第一条规则没有主体,这意味着结论对于任何?y的值都成立。请注意第二条规则如何使用点尾符号来命名列表的 car 和 cdr。
遵循这两条规则,我们可以制定计算两个列表追加的查询:
;;; Query input:
(append (a b) (c d) ?what)
;;; Query results:
(append (a b) (c d) (a b c d))
更引人注目的是,我们可以使用相同的规则来询问“哪个列表,当追加到(a b)时,会产生(a b c d)?”这样做如下:
;;; Query input:
(append (a b) ?what (a b c d))
;;; Query results:
(append (a b) (c d) (a b c d))
逻辑编程中的新事物是我们可以反向运行一个“函数”!我们可以告诉它答案,然后得到问题。但真正的魔力是...
;;; Query input:
(append ?this ?that (a b c d))
;;; Query results:
(append () (a b c d) (a b c d))
(append (a) (b c d) (a b c d))
(append (a b) (c d) (a b c d))
(append (a b c) (d) (a b c d))
(append (a b c d) () (a b c d))
我们还可以询问所有追加形成(a b c d)的列表对!我们可以使用逻辑编程来计算相同问题的多个答案!不知何故,它找到了使我们的查询成立的所有可能值的组合。
追加程序是如何工作的?将其与 Scheme 的append进行比较:
(define (append a b)
(if (null? a)
b
(cons (car a) (append (cdr a) b)) ))
像 Scheme 程序一样,逻辑程序有两种情况:有一个基本情况,其中第一个参数为空。在这种情况下,组合列表与第二个追加列表相同。还有一个递归情况,在这种情况下,我们将第一个追加列表分为其 car 和 cdr。我们将给定的问题简化为关于将(cdr a)追加到b的问题。逻辑程序在形式上不同,但它表达的是同样的意思。
(就像在祖母的例子中,我们不得不给母亲一个名字而不是使用函数调用一样,在这里我们必须给(car a)一个名字--我们称之为?u。)
警告
查询系统似乎展示了相当多的智能,使用规则推断上述查询的答案。实际上,正如我们将在下一节中看到的那样,系统在解开规则时遵循一个明确定义的算法。不幸的是,尽管系统在追加案例中表现出色,但在更复杂的情况下,一般方法可能会失效。
在追加案例中使用的“向后工作”的魔法并不总是奏效。让我们看看下面的例子,它颠倒了一个列表。
(assert! (rule (reverse (?a . ?x) ?y)
(and (reverse ?x ?z)
(append ?z (?a) ?y) )))
(assert! (reverse () ()))
这对于(reverse (a b c) ?what)有效,但���过来不行;它会陷入无限循环。我们也可以编写一个仅向后工作的版本:
(assert! (rule (backward (?a . ?x) ?y)
(and (append ?z (?a) ?y)
(backward ?x ?z) )))
(assert! (backward () ()))
但编写一个双向工作的程序要困难得多。即使在我们说话的同时,逻辑编程爱好者也在尝试推动这个想法的极限,但现在,你仍然需要了解一些关于下面的算法才能确信你的逻辑程序不会循环。
最后一对
定义规则来实现SICP 练习 2.17的last-pair操作,该操作返回包含非空列表的最后一个元素的列表。在类似(last-pair (3) ?x)、(last-pair (1 2 3) ?x)和(last-pair (2 ?x) (3))的查询上检查你的规则。你的规则在类似(last-pair ?x (3))的查询上是否正确?
要点
以下是这一小节的一些要点:
-
在逻辑编程中,我们断言事实并提出问题。
-
一个断言由一个列表表示。
-
我们使用查询语言从数据库中检索信息。
-
规则允许从一个事实推断出另一个事实。
-
我们可以使用逻辑编程编写诸如
append之类的程序!
接下来是什么?
前往下一小节,了解查询系统的工作原理!
查询系统的工作原理
介绍
在本节中,我们将给出一个概述,解释了系统的一般结构,而不涉及底层实现细节。在描述解释器的实现之后,我们将能够理解一些其限制以及查询语言的逻辑操作与数学逻辑操作不同的微妙方式。
显然,查询评估器必须执行某种搜索才能将查询与数据库中的事实和规则进行匹配。实现这一点的一种方式是将查询系统实现为非确定性程序(您不必担心这种方式)。另一种可能性是借助流来管理搜索。我们的实现遵循了第二种方法。
查询系统围绕两个称为模式匹配和统一的核心操作组织。我们首先描述模式匹配,并解释了如何通过此操作以及以帧流的形式组织信息,实现简单和复合查询。
接下来我们讨论统一,这是模式匹配的一种泛化,需要实现规则。最后,我们通过一个类似于eval为元圈求值器分类表达式的过程,展示了整个查询解释器是如何组合在一起的。
模式匹配
模式匹配器是一个测试某个数据是否符合指定模式的程序。例如,数据列表((a b) c (a b))与模式(?x c ?x)匹配,其中模式变量?x绑定到(a b)。相同的数据列表与模式(?x ?y ?z)匹配,其中?x和?z都绑定到(a b),而?y绑定到c。它还与模式((?x ?y) c (?x ?y))匹配,其中?x绑定到a,?y绑定到b。但是,它与模式(?x a ?y)不匹配,因为该模式指定了第二个元素为符号a的列表。
查询系统使用的模式匹配器接受模式(例如,(?x c ?x)))、数据(例如,((a b) c (a b)))以及指定各种模式变量绑定的框架作为输入。它检查数据是否与模式匹配,且与框架中已有的绑定一致。如果匹配成功,则返回给定的框架,其中可能还包含由匹配确定的任何绑定。否则,表示匹配失败。
例如,使用模式(?x ?y ?x)来匹配数据(a b a),给定一个空框架将返回一个指定?x绑定到a和?y绑定到b的框架。尝试使用相同的模式、相同的数据以及一个指定?y绑定到a的框架将失败。尝试使用相同的模式、相同的数据以及一个框架,其中?y绑定到b而?x未绑定将返回给定框架,并增加?x绑定到a的绑定。
模式匹配器是处理不涉及规则的简单查询所需的全部机制。例如,处理查询
(job ?x (computer programmer))
我们扫描数据库中的所有断言,并选择与初始空帧相匹配的那些。对于每个匹配,我们使用匹配返回的帧来为?x实例化模式。
帧流
通过使用流来组织模式与帧的匹配测试。给定一个单个帧,匹配过程逐个遍历数据库条目。对于每个数据库条目,匹配器生成一个指示匹配失败的特殊符号或帧的扩展。所有数据库条目的结果被收集到一个流中,通过过滤器传递以筛选掉失败。结果是一个通过与数据库中某个断言匹配扩展给定帧的所有帧的流。
在我们的系统中,一个查询接受一个帧流输入,并为流中的每个帧执行上述匹配操作,如下图所示。也就是说,对于输入流中的每个帧,查询通过与数据库中断言的匹配生成一个新的流,其中包含该帧的所有扩展。然后将所有这些流组合在一起形成一个巨大的流,其中包含输入流中每个帧的所有可能扩展。这个流就是查询的输出。

要回答一个简单的查询,我们使用一个由单个空帧组成的输入流进行查询。生成的输出流包含所有对空帧的扩展(即,对我们查询的所有答案)。然后使用这些帧的流来生成原始查询模式的副本流,其中变量由每个帧中的值实例化,这最终被打印出来。
复合查询
当我们处理复合查询时,流式帧实现的真正优雅之处显而易见。处理复合查询利用了我们的匹配器要求匹配与指定帧一致的能力。例如,处理两个查询的“and”,如
(and (can-do-job ?x (computer programmer trainee))
(job ?person ?x))
(非正式地说,“找出所有能胜任计算机程序员实习生工作的人”),我们首先找出所有与该模式匹配的条目
(can-do-job ?x (computer programmer trainee))
这会产生一个帧流,每个帧都包含?x的绑定。然后对流中的每个帧,我们找出所有与之匹配的条目
(job ?person ?x)
以与?x的给定绑定一致的方式。每个这样的匹配将产生一个包含?x和?person绑定的帧。两个查询的“and”可以被视为两个组件查询的串联组合,如下图所示。通过第一个查询过滤器的帧被第二个查询进一步过滤和扩展。

两个查询的和组合是通过连续操作帧流产生的。
下图显示了计算两个查询的或的类似方法,作为两个组成查询的并行组合。帧流的输入分别由每个查询扩展。然后,将两个结果流合并以产生最终的输出流。

两个查询的或组合是通过并行操作和合并结果来产生的帧流。
即使从高层描述中,复合查询的处理可能会很慢也是显而易见的。例如,由于查询可能为每个输入框架产生多个输出框架,并且每个“和”中的查询都从上一个查询获取其输入框架,因此一个“和”查询在最坏的情况下可能必须执行指数数量的匹配查询。虽然处理仅简单查询的系统非常实用,但处理复杂查询极其困难。
从框架流的观点来看,某些查询的否定充当一个过滤器,以移除可以满足查询的所有框架。例如,给定模式
(not (job ?x (computer programmer)))
我们尝试为输入流中的每个框架生成满足(job ?x (computer programmer))的扩展框架。我们从输入流中移除存在这种扩展的所有框架。结果是一个由仅那些绑定?x不满足(job ?x (computer programmer))的框架组成的流。例如,在处理查询时
(and (supervisor ?x ?y)
(not (job ?x (computer programmer))))
第一个子句将生成绑定了?x和?y的框架。然后,否定子句将通过删除所有?x绑定满足?x是计算机程序员的限制的框架来过滤这些框架。
lisp-value特殊形式被实现为框架流上的类似过滤器。我们使用流中的每个框架来实例化模式中的任何变量,然后应用 Lisp 谓词。我们从输入流中移除谓词失败的所有框架。
统一
为了处理查询语言中的规则,我们必须能够找到结论与给定查询模式相匹配的规则。规则的结论类似于断言,但它们可以包含变量,因此我们将需要模式匹配的一般化 -- 称为统一 -- 在其中,“模式”和“数据”都可以包含变量。
统一器接受两个模式,每个模式包含常量和变量,并确定是否可以为变量分配值使得两个模式相等。如果可以,则返回包含这些绑定的帧。例如,统一(?x a ?y)和(?y ?z a)将指定一个帧,其中?x, ?y和?z必须都绑定到a。另一方面,统一(?x ?y a)和(?x b ?y)将失败,因为没有值可以使这两个模式相等(对于模式的第二个元素相等,?y必须为b;然而,对于第三个元素相等,?y必须为a)。查询系统中使用的统一器,就像模式匹配器一样,接受一个帧作为输入,并执行与该帧一致的统一。
统一算法是查询系统中技术难度最大的部分。对于复杂的模式,执行统一可能看起来需要推理。例如,要统一(?x ?x)和((a ?y c) (a b ?z)),算法必须推断出?x应该是(a b c),?y应该是b,?z应该是c。我们可以将这个过程视为在模式组件之间解方程组。一般来说,这些是同时方程,可能需要大量的操作才能解决。例如,将(?x ?x)和((a ?y c) (a b ?z))统一可以看作是指定同时方程
?x = (a ?y c)
?x = (a b ?z)
这些方程意味着
(a ?y c) = (a b ?z)
这反过来意味着
a = a, ?y = b, c = ?z,
因此
?x = (a b c)
在成功的模式匹配中,所有模式变量都变为绑定,并且它们绑定到的值只包含常量。迄今为止我们看到的所有统一的例子也是如此。然而,一般来说,成功的统一可能不会完全确定变量的值;有些变量可能仍然未绑定,而其他变量可能绑定到包含变量的值。
考虑对(?x a)和((b ?y) ?z)进行统一。我们可以推断出?x = (b ?y)和a = ?z,但我们无法进一步解出?x或?y。统一并不失败,因为可以通过给?x和?y赋值来使这两个模式相等。由于这种匹配并不限制?y可以取的值,因此不会将?y的绑定放入结果帧中。但是,这种匹配确实限制了?x的值。无论?y有什么值,?x都必须是(b ?y)。因此,将?x绑定到模式(b ?y)中。如果以后确定了?y的值并将其添加到帧中(通过需要与此帧一致的模式匹配或统一),则先前绑定的?x将引用此值。
应用规则
统一是从规则中推断出推理的查询系统组成部分的关键。为了看到这是如何完成的,请考虑处理涉及应用规则的查询,例如
(lives-near ?x (Hacker Alyssa P))
要处理此查询,我们首先使用上面描述的普通模式匹配过程来查看数据库中是否有与此模式匹配的断言。(在这种情况下不会有任何匹配,因为我们的数据库中没有关于谁住在谁附近的直接断言。)下一步是尝试将查询模式与每个规则的结论统一。我们发现模式与规则的结论统一。
(rule (lives-near ?person-1 ?person-2)
(and (address ?person-1 (?town . ?rest-1))
(address ?person-2 (?town . ?rest-2))
(not (same ?person-1 ?person-2))))
导致一个指定?person-2绑定为(Hacker Alyssa P)并且?x应该绑定为(具有与)?person-1相同值的帧。现在,相对于这个帧,我们评估规则体给出的复合查询。成功匹配将通过为?person-1提供绑定来扩展此帧,因此为?x提供一个值,我们可以用它来实例化原始查询模式。
一般来说,查询评估器在尝试在指定了一些模式变量绑定的帧中建立查询模式时使用以下方法应用规则:
-
将查询与规则的结论统一,如果成功,形成原始帧的扩展。
-
相对于扩展帧,评估由规则体形成的查询。
注意这与 Lisp 的 eval/apply 评估器中应用过程的方法有多么相似:
-
将过程的参数绑定到其参数以形成扩展原始过程环境的帧。
-
相对于扩展环境,评估由过程体形成的表达式。
两个评估器之间的相似性应该不足为奇。正如过程定义是 Lisp 中的抽象手段一样,规则定义是查询语言中的抽象手段。在每种情况下,我们通过创建适当的绑定并相对于这些绑定评估规则或过程体来展开抽象。
简单查询
我们在本节前面看到了如何在没有规则的情况下评估简单查询。现在我们已经看到了如何应用规则,我们可以描述如何通过同时使用规则和断言来评估简单查询。
给定查询模式和一系列帧,我们为输入流中的每个帧生成两个流:
-
通过将模式与数据库中的所有断言(使用模式匹配器)进行匹配获得的一系列扩展帧。
-
通过应用所有可能的规则(使用统一器)获得的一系列扩展帧。
追加这两个流产生一个流,其中包含给定模式可以满足的所有方式,与原始帧一致。这些流(每个输入流中的一个帧)现在都被组合成一个大流,因此包含了原始输入流中任何帧可以扩展以与给定模式匹配的所有方式。
查询评估器和驱动循环
尽管底层匹配操作复杂,但系统的组织方式类似于任何语言的评估器。协调匹配操作的过程称为qeval,它的作用类似于 Lisp 的 eval 过程。qeval的输入是一个查询和一组帧的流。其输出是一组帧的流,对应于成功匹配查询模式的帧,这些帧扩展了输入流中的某个帧。像eval一样,qeval对不同类型的表达式(查询)进行分类,并为每种类型调度到适当的过程。每个特殊形式(and、or、not和lisp-value)都有一个过程,还有一个用于简单查询的过程。
驱动循环类似于本章其他评估器的driver-loop过程,它从终端读取查询。对于每个查询,它使用查询和由单个空帧组成的流调用qeval。这将产生所有可能匹配(所有空帧的所有可能扩展)的流。对于结果流中的每个帧,它使用在帧中找到的变量的值实例化原始查询。然后打印这些实例化查询的流。
驱动程序还检查特殊命令assert!,该命令表示输入不是查询,而是要添加到数据库的断言或规则。例如,
(assert! (job (Bitdiddle Ben) (computer wizard)))
(assert! (rule (wheel ?person)
(and (supervisor ?middle-manager ?person)
(supervisor ?x ?middle-manager))))
一个例子
这里有一个部分追踪的示例:
;;; Query input:
(assert! (rule (append () ?y ?y)))
;;; Query input:
(assert! (rule (append (?u . ?v) ?y (?u . ?z))
(append ?v ?y ?z)))
;;; Query input:
(append ?a ?b (aa bb))
(unify-match (append ?a ?b (aa bb)) ; MATCH ORIGINAL QUERY
(append () ?1y ?1y) ; AGAINST BASE CASE RULE
()) ; WITH NO CONSTRAINTS
RETURNS: ((?1y . (aa bb)) (?b . ?1y) (?a . ()))
PRINTS: (append () (aa bb) (aa bb))
由于基本情况规则没有主体,一旦我们匹配成功,就可以打印成功的结果。(在打印之前,我们必须在环境中查找变量,以便打印的内容不包含变量。)
现在我们将原始查询与另一条规则的结论统一起来:
(unify-match (append ?a ?b (aa bb)) ; MATCH ORIGINAL QUERY
(append (?2u . ?2v) ?2y (?2u . ?2z)) ; AGAINST RECURSIVE RULE
()) ; WITH NO CONSTRAINTS
RETURNS: ((?2z . (bb)) (?2u . aa) (?b . ?2y) (?a . (?2u . ?2v)))
[call it F1]
这一步成功了,但我们还没有准备好打印任何东西,因为现在我们必须将该规则的主体作为一个新的查询。请注意缩进,以指示这次调用 unify-match 是在待处理规则内部进行的。
(unify-match (append ?2v ?2y ?2z) ; MATCH BODY OF RECURSIVE RULE
(append () ?3y ?3y) ; AGAINST BASE CASE RULE
F1) ; WITH CONSTRAINTS FROM F1
RETURNS: ((?3y . (bb)) (?2y . ?3y) (?2v . ()) [plus F1])
PRINTS: (append (aa) (bb) (aa bb))
(unify-match (append ?2v ?2y ?2z) ; MATCH SAME BODY
(append (?4u . ?4v) ?4y (?4u . ?4z)) ; AGAINST RECURSIVE RULE
F1) ; WITH F1 CONSTRAINTS
RETURNS: ((?4z . ()) (?4u . bb) (?2y . ?4y) (?2v . (?4u . ?4v))
[plus F1]) [call it F2]
(unify-match (append ?4v ?4y ?4z) ; MATCH BODY FROM NEWFOUND MATCH
(append () ?5y ?5y) ; AGAINST BASE CASE RULE
F2) ; WITH NEWFOUND CONSTRAINTS
RETURNS: ((?5y . ()) (?4y . ?5y) (?4v . ()) [plus F2])
PRINTS: (append (aa bb) () (aa bb))
(unify-match (append ?4v ?4y ?4z) ; MATCH SAME BODY
(append (?6u . ?6v) ?6y (?6u . ?6z)) ; AGAINST RECURSIVE RULE
F2) ; SAME CONSTRAINTS
RETURNS: () ; BUT THIS FAILS
要点
以下是本小节的几个要点:
-
简单查询使用模式匹配器处理。
-
要处理复合查询,模式匹配器需要检查匹配是否与指定的帧一致。
-
规则使用统一处理。
接下来是什么?
在下一小节中,我们将讨论逻辑编程与数理逻辑之间的关系。
逻辑编程是否是数学逻辑
逻辑编程是否是数学逻辑?
查询语言中使用的组合方法一开始可能看起来与数学逻辑的操作和,或和非完全相同,而查询语言规则的应用实际上是通过一种合法的推理方法完成的。尽管如此,将查询语言与数学逻辑等同起来并不真正有效,因为查询语言提供了一种解释逻辑语句的控制结构。我们通常可以利用这种控制结构。例如,要查找所有程序员的监督者,我们可以以两种逻辑等价形式之一来制定查询:
(and (job ?x (computer programmer))
(supervisor ?x ?y))
或者
(and (supervisor ?x ?y)
(job ?x (computer programmer)))
如果一家公司的主管比程序员多得多(通常情况下),最好使用第一种形式而不是第二种形式,因为必须对每个由第一个 and 子句产生的中间结果(帧)进行扫描。
逻辑编程的目标是为程序员提供将计算问题分解为两个单独问题的技术:“什么”应该计算以及“如何”计算。这是通过选择数学逻辑语句的子集来实现的,这些子集足够强大,可以描述任何人可能想要计算的东西,但足够弱以具有可控的过程性解释。这里的意图是,一方面,在逻辑编程语言中指定的程序应该是一种可以由计算机执行的有效程序。控制(“如何”计算)通过使用语言的评估顺序来实现。我们应该能够安排子句的顺序和每个子句中子目标的顺序,以便计算按照被认为是有效和高效的顺序进行。与此同时,我们应该能够将计算的结果(“什么”计算)视为逻辑法则的简单结果。
我们的查询语言可以被视为数学逻辑的可过程解释子集。一个断言代表一个简单的事实(一个原子命题)。规则表示规则结论在规则体成立的情况下成立的蕴含关系。规则具有自然的过程性解释:要建立规则的结论,就要建立规则的体。因此,规则指定了计算。然而,由于规则也可以被视为数学逻辑的陈述,我们可以通过断言在数学逻辑中完全工作来证明逻辑程序实现的任何“推理”都是合理的。
无限循环
逻辑程序的过程性解释的一个结果是,可能构建出解决某些问题的无望低效程序。当系统陷入无限循环进行推断时,效率低下的极端情况发生。作为一个简单的例子,假设我们正在建立一个包括著名婚姻的数据库,其中包括
(assert! (married Minnie Mickey))
如果我们现在问
(married Mickey ?who)
我们将得不到任何响应,因为系统不知道如果 A 与 B 结婚,那么 B 也与 A 结婚。因此,我们断言这个规则。
(assert! (rule (married ?x ?y)
(married ?y ?x)))
再次查询
(married Mickey ?who)
不幸的是,这将使系统陷入无限循环,如下所示:
-
系统发现适用于结婚规则;也就是说,规则结论
(married ?x ?y)成功地与查询模式(married Mickey ?who)统一,产生一个框架,其中?x绑定到 Mickey,?y绑定到?who。因此,解释器继续在这个框架中评估规则主体(married ?y ?x)-- 实际上,处理查询(married ?who Mickey)。 -
一个答案直接出现在数据库中作为断言:
(married Minnie Mickey). -
结婚规则也适用,因此解释器再次评估规则主体,这次等效于
(married Mickey ?who)。
系统现在陷入无限循环。实际上,系统是否会在陷入循环之前找到简单答案(married Minnie Mickey)取决于关于系统检查数据库中项目顺序的实现细节。这是循环可能发生的种类的一个非常简单的例子。相关规则的集合可能导致更难以预料的循环,循环的出现可能取决于and子句中的顺序或关于系统处理查询的顺序的低级细节。
not存在的问题
查询系统中的另一个怪癖涉及not。考虑前面介绍的 Microshaft 数据库,考虑以下两个查询:
(and (supervisor ?x ?y)
(not (job ?x (computer programmer))))
(and (not (job ?x (computer programmer)))
(supervisor ?x ?y))
这两个查询不会产生相同的结果。第一个查询首先找到与数据库中匹配(supervisor ?x ?y)的所有条目,然后通过删除?x的值满足(job ?x (computer programmer))的框架来过滤结果框架。第二个查询首先通过过滤传入的框架来删除可以满足(job ?x (computer programmer))的框架。由于唯一的传入框架为空,它检查数据库是否有任何满足(job ?x (computer programmer))的模式。由于通常存在这种形式的条目,not子句过滤掉空框架并返回一个空的框架流。因此,整个复合查询返回一个空的框架流。
麻烦的是,我们对not的实现实际上是作为变量的值的过滤器。如果在处理not子句时,存在一些变量仍然未绑定(就像上面的示例中的?x一样),系统将产生意料之外的结果。类似的问题也会出现在使用lisp-value时——如果它的一些参数未绑定,Lisp 谓词无法工作。
查询语言中的not与数学逻辑中的not有一种更严重的区别。在逻辑中,我们解释语句“not P”表示 P 不是真的。然而,在查询系统中,“not P”意味着从数据库的知识中无法推导出 P。例如,给定 Microshaft 数据库,系统会愉快地推导出各种not语句,比如 Ben Bitdiddle 不是棒球迷,外面不下雨,以及 2 + 2 不等于 4.78。换句话说,逻辑编程语言中的not反映了所谓的封闭世界假设,即所有相关信息都已包含在数据库中。
要点
在这个小节中,你学到了:
-
逻辑编程是数学逻辑的一个可过程解释的子集。
-
存在一些陷阱:循环和
not。
实现查询系统
实现查询系统
如果你对这个查询系统内部工作原理感兴趣,请查看SICP Section 4.4.4。虽然这些内容非常有趣,但你不需要了解这门课程中查询系统的实现。
作业 13
对于涉及编写查询或规则的所有问题,请测试您的解决方案。
运行查询系统并加载示例数据:
> (load "~cs61as/lib/query.scm")
> (initialize-data-base microshaft-data-base)
> (query-driver-loop)
您现在处于查询系统的解释器中。
要添加一个断言:
(assert! (foo bar))
要添加一条规则:
(assert! (rule (foo) (bar)))
其他任何内容都是一个查询。
练习 1
Abelson & Sussman,练习4.56,4.57, 4.58和4.65。
练习 2:专家级别的额外练习
如果你想的话可以这样做。这不计入学分。
在本节的前面,我们描述了允许推断出单向反向关系的规则,即,
;;; Query input:
(forward-reverse (a b c) ?what)
;;; Query results:
(FORWARD-REVERSE (A B C) (C B A))
;;; Query input:
(forward-reverse ?what (a b c))
;;; Query results:
... infinite loop
或者
;;; Query input:
(backward-reverse ?what (a b c))
;;; Query results:
(BACKWARD-REVERSE (C B A) (A B C))
;;; Query input:
(backward-reverse (a b c) ?what)
;;; Query results:
... infinite loop
定义规则,使得可以推断出双向的反向关系,从而产生以下对话:
;;; Query input:
(reverse ?what (a b c))
;;; Query results:
(REVERSE (C B A) (A B C))
;;; Query input:
(reverse (a b c) ?what)
;;; Query results:
(REVERSE (A B C) (C B A))
提交您的作业!
查看此指南获取说明。它涵盖了基本的终端命令和作业提交。
如果你在提交作业时遇到任何问题,请毋需犹豫地向助教求助!
14 - 并发和 MapReduce
第 14 课简介
简介
在这节课中,我们将讨论并发的基础知识。
先决条件
你应该了解赋值和可变数据。
阅读材料
并发简介
在第 3 单元中,我们看到了具有局部状态的计算对象作为建模工具的强大功能。但是这种能力是有代价的。
通过引入赋值,我们被迫将时间引入到我们的计算模型中。在引入赋值之前,我们所有的程序都是无时无刻的,即任何具有值的表达式总是具有相同的值。相比之下,回想一下在 SICP 3.1.1 节开头介绍的模拟从银行账户中提取现金并返回余额的示例:
> (withdraw 25)
75
> (withdraw 25)
50
这里对同一表达式的连续求值会产生不同的值。这种行为源于赋值语句(在本例中是对变量balance的赋值)的执行,它界定了值改变的时间点。求值表达式的结果不仅取决于表达式本身,还取决于求值是在这些时间点之前还是之后发生的。以具有局部状态的计算对象构建模型迫使我们面对时间作为编程中的一个基本概念。
我们可以进一步构建计算模型以匹配我们对物理世界的感知。世界中的对象不是按顺序逐个更改的。相反,我们将它们视为同时行动——一次全部。因此,通常将系统建模为同时执行的计算过程的集合是很自然的。就像我们可以通过组织具有单独局部状态的对象来使程序模块化一样,通常适当将计算模型分成单独且并发地演变的部分。
除了使程序更加模块化外,并发计算还可以提供比顺序计算更快的速度优势。顺序计算机一次只执行一个操作,因此执行任务所需的时间与执行的总操作数量成正比。然而,如果可以将问题分解为相对独立且仅需要偶尔通信的部分,那么可能可以将部分分配给单独的计算处理器,从而产生与可用处理器数量成正比的速度优势。
不幸的是,在并发存在的情况下,赋值引入的复杂性变得更加棘手。并发执行的事实,无论是因为世界是并行运行的还是因为我们的计算机是,并且在我们对时间的理解中引入了额外的复杂性。
并行性
入门
要使用本节中的想法,您需要我们的并发库。从实验室计算机(或通过 SSH)中,将以下内容键入您的 Scheme 解释器:
(load "~cs61as/lib/concurrency.scm")
概述
在涉及任何形式的并行性时,我们通常认为的许多事情在普通编程中变得棘手。这些情况包括:
-
多处理器(硬件)共享数据
-
软件多线程(模拟并行性)
-
操作系统输入/输出设备处理程序
这在 CS 162(操作系统)中有更详细的介绍。
为什么并行性很困难
要简单地看出问题所在,想想 Scheme 表达式
(set! x (+ x 1))
正如您将在 61C 中更详细地了解的那样,Scheme 将这转换为一系列指令发送到您的计算机。细节取决于特定的计算机模型,但会类似于这样:
lw $8, x ; Put the value of x into processor register number 8.
addi $8, $8, 1 ; Take the value of register 8, add 1 to it, and put
; the new value back into register 8.
sw $8, x ; Set the value in register 8 as the value of x.
您不必理解这里代码的细节(您将在 61C 中学习),但您应该知道正在发生什么。
(寄存器是计算机放置值的地方,以便它可以对其进行操作。因此,计算机通常不能立即将 1 添加到 x - 它必须首先将 x 的值放入寄存器,然后才能将 1 添加到其中。)
通常我们期望这些指令序列会产生期望的效果。如果这些指令之前 x 的值为 100,那么之后应该是 101。
但想象一下,这三条指令序列可能会被中间发生的其他事件打断。具体来说,假设还有其他人也试图将 1 添加到 x 的值。现在我们可能会有这样的序列:
my process value of x other process
---------- ---------- -------------
$8 = ?? x = 100 $9 = ??
lw $8, x
$8 = 100 x = 100 $9 = ??
addi $8, $8, 1
$8 = 101 x = 100 $9 = ??
lw $9, x
$8 = 101 x = 100 $9 = 100
addi $9, $9, 1
$8 = 101 x = 100 $9 = 101
sw $9, x
$8 = 101 x = 101 $9 = 101
sw $8, x
$8 = 101 x = 101 $9 = 101
x 的最终值将是 101,而不是正确的 102。
我们需要解决这个问题的一般思路是关键部分,这意味着一系列不得中断的指令。从加载开始到存储结束的三条指令是一个关键部分。
实际上,我们不必说这些指令不能被打断;我们必须强制执行的唯一条件是它们不能被使用变量 x 的另一个进程打断。如果另一个进程同时想要将 1 添加到 y,那没问题。因此,我们希望能够说出类似于
reserve x
lw $8, x
addi $8, 1
sw $8, x
release x
抽象级别
计算机实际上并没有像reserve和release这样的指令,但我们会看到它们提供了类似的机制。典型的编程环境包括三个抽象级别的并发控制机制:
SICP name What's protected Provided by
--------- ---------------- -----------
serializer high level abstraction programming language
(procedure, object, ...)
mutex critical section operating system
test-and-set! one atomic hardware
state transition
在 SICP 中,串行器和互斥体是抽象数据类型。有一个使用互斥体实现的构造函数make-serializer,以及一个使用test-and-set!实现的构造函数make-mutex,后者是一个(在我们的情况下是模拟的)硬件指令。
我们将在接下来的章节中讨论串行器和互斥体。
序列化器
什么是序列化器
我们引入了一个称为序列化器的抽象。这是一个接受另一个过程(称为proc)作为其参数的过程。序列化器返回一个新的过程(称为protected-proc)。当调用时,protected-proc调用proc,但只有当同一个序列化器没有被另一个受保护的过程使用时才会调用。proc可以有任意数量的参数,而protected-proc将接受相同的参数并返回相同的值。
可能会有许多不同的序列化器同时运行,但每个序列化器不能同时执行两个任务。所以如果我们说
(define x-protector (make-serializer))
(define y-protector (make-serializer))
(parallel-execute (x-protector (lambda () (set! x (+ x 1))))
(y-protector (lambda () (set! y (+ y 1)))))
那么两个任务可以同时运行;它们的机器指令如何交错并不重要。
但如果我们说
(parallel-execute (x-protector (lambda () (set! x (+ x 1))))
(x-protector (lambda () (set! x (+ x 1)))))
那么,因为我们在两个任务中使用相同的序列化器,序列化器将确保它们不会在时间上重叠。
我们引入了一个新的基本过程,parallel-execute。它接受任意数量的参数,每个参数都是一个没有参数的过程,并且并行而不是按顺序调用它们。(这不是 Scheme 的标准部分,而是教科书本节的扩展。)
你可能会想到所有那些(lambda ()...)符号的必要性。由于序列化器不是一个特殊形式,它不能接受表达式作为参数。相反,我们必须给它一个可以调用的过程。
让我们看一下这段代码是如何工作的示例:
(define x-protector (make-serializer))
(define protected-increment-x (x-protector (lambda () (set! x (+ x 1)))))
> x
100
> (protected-increment-x)
> x
101
实现序列化器
序列化器是一个高级抽象。我们如何让它工作?这是一个错误的尝试来实现序列化器:
(define (make-serializer)
(let ((in-use? #f))
(lambda (proc)
(define (protected-proc . args)
(if in-use?
(begin
(wait-a-while) ; Never mind how to do that.
(apply protected-proc args)) ; Try again.
(begin
(set! in-use? #t) ; Don't let anyone else in.
(apply proc args) ; Call the original procedure.
(set! in-use? #f)))) ; Finished, let others in again.
protected-proc)))
这有点复杂,所以要集中精力关注重要部分。特别是不要在乎并行性的调度方面--我们如何要求这个过程在尝试再次使用序列化器之前等待一段时间。也不要在乎关于apply的东西,这只是为了我们可以序列化具有任意数量参数的过程。
需要关注的部分是这个:
(if in-use?
....... ; wait and try again
(begin (set! in-use #t) ; Don't let anyone else in.
(apply proc args) ; Call the original procedure.
(set! in-use #f))) ; Finished, let others in again.
这段代码的意图是首先检查序列化器是否已经在使用。如果没有,我们通过将in-use设置为 true 来声明序列化器,完成我们的工作,然后释放序列化器。
问题在于这一系列事件受到与我们试图保护的过程相同的并行性问题的影响!如果我们检查in-use的值,发现它是 false,而正好在那时另一个进程悄悄地抓住了序列化器怎么办?为了使这个工作起作用,我们必须有另一个序列化器来保护这一个,第三个序列化器来保护第二个,依此类推。
没有简单的方法可以通过竞争进程内的巧妙编程技巧来避免这个问题。 我们需要在提供并行性的底层机制上获得帮助:硬件和/或操作系统。该底层必须提供保证原子操作,我们可以测试in-use的旧值并将其更改为新值,而没有其他进程介入的可能性。(事实证明,有一个非常棘手的软件算法可以生成保证原子测试和设置,但实际上,几乎总是有硬件支持并行性。如果你想看到软件解决方案,请在维基百科中查找“Peterson's algorithm”。)
教科书假定存在一个名为test-and-set!的过程,具有原子性的保证。尽管在第 312 页上有一个伪实现,但该过程实际上不起作用,原因与我的make-serializer的伪实现相同。你需要想象的是,test-and-set!是计算机硬件中的一条单指令,类似于我们开始讨论的 Load Word 指令等。(这是一个现实的假设;现代计算机确实提供了一些这样的硬件机制,正是我们现在讨论的原因。)
要理解如何正确实现串行器,你首先需要了解并理解互斥锁(在两个部分中)。
编程考虑
编程考虑
即使使用了串行器,编写成功处理并发的程序也并不容易。事实上,今天广泛使用的所有操作系统在这个领域都存在错误;例如,Unix 系统预计每个月或两个月会因并发错误而崩溃。
为了使讨论具体化,让我们考虑一个为全球数千名同时用户提供服务的航空公司预订系统。以下是可能出错的事情:
-
错误的结果。 最糟糕的问题是同一个座位被两个不同的人预订。就像给 x 加 1 的情况一样,预订系统必须首先找到一个空座位,然后将该座位标记为已占用。读取然后修改数据库的这个顺序必须受到保护。
-
低效率。 确保正确结果的一个非常简单的方法是使用单个串行器保护整个预订数据库,以便一次只有一个人可以发出请求。但这是一个不可接受的解决方案;成千上万的人正在等待预订座位,大多数人并非为同一航班。
-
死锁。 假设有人想去一个没有直达航班的城市。我们必须确保在同一天可以预订到 A 航班的座位和连接航班 B 的座位,然后再承诺任何预订。这可能意味着我们需要同时使用两个串行器,一个用于每个航班。假设我们说类似于
(serializer-A (serializer-B (lambda () ...))))
与此同时,还有人说
(serializer-B (serializer-A (lambda () ...))))
时间可能会安排得当,我们得到串行器 A,另一个人得到串行器 B,然后我们每个人都被困在等待对方(永远!)。
- 不公平。 这并非在每种情况下都是问题,但有时您希望避免解决死锁问题的解决方案总是优先考虑某个进程。如果高优先级进程贪婪,低优先级进程可能永远无法轮到共享数据。
并发程序的正确行为
根据您正在编写的特定程序,正确行为的定义可能有所不同。通常情况下,如果并发程序产生与进程按某种顺序顺序运行时相同的结果,则说该并发程序显示正确行为。这一要求有两个重要方面。
首先,它不要求进程实际按顺序运行,而只要求产生与它们按顺序运行时相同的结果。
其次,由于我们只要求结果与某个顺序的结果相同,所以并发程序可能会产生多个可能的“正确”结果。
互斥体
什么是互斥体?
互斥体是支持两个操作的对象--可以获取互斥体,也可以释放互斥体。一旦获取了互斥体,那么在释放互斥体之前,该互斥体上的任何其他获取操作都不能继续进行。
互斥体是一个可变对象(这里我们将使用一个一元列表,称为单元格),可以保存值 true 或 false。当值为 false 时,互斥体可用于获取。当值为 true 时,互斥体不可用,任何试图获取互斥体的进程都必须等待。
我们的互斥体构造函数make-mutex首先将单元格内容初始化为 false。要获取互斥体,我们测试单元格。如果互斥体可用,我们将单元格内容设置为 true 并继续。否则,我们在循环中等待,一遍又一遍地尝试获取,直到发现互斥体可用为止。要释放互斥体,我们将单元格内容设置为 false。
(define (make-mutex)
(let ((cell (list false)))
(define (the-mutex m)
(cond ((eq? m 'acquire)
(if (test-and-set! cell)
(the-mutex 'acquire))) ; retry
((eq? m 'release) (clear! cell))))
the-mutex))
(define (clear! cell)
(set-car! cell false))
Test-and-set!测试单元格并返回测试结果。此外,如果测试为 false,test-and-set!在返回 false 之前将单元格内容设置为 true。这个过程是 Scheme 的原语。它的实现需要硬件支持。
使用互斥体
该书在串行化器和原子硬件功能之间使用了一个中间抽象级别,称为互斥体。互斥体和串行化器之间有什么区别?串行化器作为一个抽象,提供了一个受保护的操作,而无需程序员考虑保护的机制。互斥体暴露了事件的顺序。就像之前的不正确实现所说的那样
(set! in-use #t)
(apply proc args)
(set! in-use #f)
正确版本使用类似的顺序
(mutex ’acquire)
(apply proc args)
(mutex ’release)
顺便说一句,在这些部分的所有版本中都有另一个错误;我们通过忽略返回值的问题简化了讨论。我们希望由 protected-proc 返回的值与原始 proc 返回的值相同,即使对 proc 的调用不是最后一步。因此,正确的实现是
(mutex ’acquire)
(let ((result (apply proc args)))
(mutex ’release)
result)
就像书中第 311 页的实现一样。
使用互斥体实现串行化器
现在我们了解了互斥体以及如何使用它们,相对来说实现串行化器就相对简单了。我们的实现如下。确保你理解它是如何工作的以及为什么!
(define (make-serializer)
(let ((mutex (make-mutex)))
(lambda (p)
(define (serialized-p . args)
(mutex 'acquire)
(let ((val (apply p args)))
(mutex 'release)
val))
serialized-p)))
Mapreduce
Mapreduce 简介
在本节中,我们将重新访问第 2 单元的高阶函数(map和accumulate)并将其与并行性结合起来,从而使我们能够高效地处理大量数据。
Mapreduce 背景
Google 的工程师们注意到他们的大部分计算可以分解为对数据的某个函数的map,然后是之后的accumulate(也称为reduce,因此得名)。结果是一个名为mapreduce的库过程,它接受两个函数作为参数;一个充当mapper,另一个充当reducer。它接受大量数据,将它们分成较小的部分,将mapper应用于较小的数据,并使用reducer组合结果。Mapreduce 处理与并行性有关的一切,我们只需提供这两个函数。
尽管这可能看起来像mapreduce正在做的事情,但这不是mapreduce:
(define (mapreduce mapper reducer base-case data)
(accumulate reducer base-case (map mapper data)))
为什么不是 mapreduce 呢?因为它没有处理数据的划分,应用映射器并行性以及在减少之前对它们进行排序。不过,它做对的是,想要使用 mapreduce 的人只需要传递四个参数:mapper, reducer, base-case和我们要处理的data给mapreduce函数。
如果您感兴趣,这里是由提出 mapreduce 的 Google 员工撰写的一篇论文。这不是必需的,但它非常易读和有趣。旧的讲义也有很好的解释,如果您觉得不理解 mapreduce,应该阅读这些内容。
Mapreduce 的分解

-
将
mapper映射到较小的数据(并行完成)。这涉及选择我们要处理的输入部分并为每个结果“附加”一个键 -
根据它们的键将结果排序到“桶”中
-
每个“桶”都传递给一个
reducer来累积值
我们将在后续部分更详细地查看这些步骤。
在本课程中,我们将使用搜索文本文件并查找单词频率的示例。
这将作为我们的测试文件:
>(define song1 '( ((please please me) i saw her standing there)
((please please me) misery)
((please please me) please please me)))
>(define song2 '( ((with the beatles) it wont be long)
((with the beatles) all ive got to do)
((with the beatles) all my loving)))
>(define song3 '( ((a hard days night) a hard days night)
((a hard days night) i should have known better)
((a hard days night) if i fell)))
>(define all-songs (append song1 song2 song3))
( ((please please me) i saw her standing there)
((please please me) misery)
((please please me) please please me)
((with the beatles) it wont be long)
((with the beatles) all i have got to do)
((with the beatles) all my loving)
((a hard days night) a hard days night)
((a hard days night) i should have known better)
((a hard days night) if i fell) )
请注意,每一行都带有它们的标题。我们建议将此标签打开在某个地方,以便您知道我们的输入是什么样子的。
您可以在此处获取我们的 mapreduce 实现的数据和其他函数这里。请注意,此实现不涉及并行性。
映射器

(map mapper data)
-
输入:(较小的)键-值对
-
输出:键-值对列表
映射器是一个接受数据(作为键值对)并返回键值对列表的函数。键值对列表与我们在第 9 课中玩过的关联列表(也称为 a-lists)相同。键用于跟踪数据的来源;这对于并行化很重要。请注意,输入的键不一定与映射器输出的键相同。这将是我们的键值对的 ADT:
(define make-kv-pair cons)
(define kv-key car)
(define kv-value cdr)
如果我们查看样本输入的第一项,结果如下:
>(kv-key '((please please me) i saw her standing there))
(please please me)
>(kv-value '((please please me) i saw her standing there))
(i saw her standing there)
为什么我们输出键值对列表而不是单个键值对?以下是一些原因:
-
无键值:有时我们的数据中可能没有我们感兴趣的键。例如,想象一种情况,你想要统计一个单词中元音字母的数量,然后遇到了单词'fly'。在这种情况下,我们将返回空列表。
-
多个键值:有时我们的数据对应于我们想要生成的 2 个或更多个键。这适用于我们的歌词示例(如下所示)
扩展示例:单词计数
这是我们示例中映射器的定义。
>(define (mapper input-kv-pair)
(map (lambda (wd) (make-kv-pair wd 1)) (kv-value input-kv-pair)))
>(mapper '((please please me) i saw her standing there))
((i . 1) (saw . 1) (her . 1) (standing . 1) (there . 1))
>(mapper '((please please me) please please me))
((please . 1) (please . 1) (me . 1))
我们的映射器做什么?它接受一个键值对(其键是歌曲标题,其值是歌曲中的一行)。对于行中的每个单词,将该单词用作新键,并将其与值 1 配对。请注意,即使单词在行中出现两次,比如'(please please me)',它也会输出'(please . 1)'两次而不是'(please . 2)'。这没问题,因为这里我们只是从 1 开始计数。我们稍后将它们相加。
按桶排序

在我们实际进入减少器之前,有一个中间步骤对键进行排序,并将相同的键分组在一起。幸运的是,我们可以利用抽象化,并使用函数**按桶排序**将它们排序到'桶'中。具有相同键的键值对被分组到同一个'桶'下。这是调用上一步映射器的结果:
>(map mapper all-songs)
( ((i . 1) (saw . 1) (her . 1) (standing . 1) (there . 1))
((misery . 1))
((please . 1) (please . 1) (me . 1))
((it . 1) (wont . 1) (be . 1) (long . 1))
((all . 1) (i . 1) (have . 1) (got . 1) (to . 1) (do . 1))
((all . 1) (my . 1) (loving . 1))
((a . 1) (hard . 1) (days . 1) (night . 1))
((i . 1) (should . 1) (have . 1) (known . 1) (better . 1))
((if . 1) (i . 1) (fell . 1)) )
调用按桶排序的结果如下:
>(sort-into-buckets (map mapper all-songs))
'( ((i . 1) (i . 1) (i . 1) (i . 1))
((saw . 1))
((her . 1))
((standing . 1))
. . .
((all . 1) (all . 1))
((have . 1) (have . 1))
. . .
((if . 1))
((fell . 1)) )
为了保持简洁,结果的某些部分被省略了。请注意这里的键和值是如何组织的。结果是一个桶列表,其中一个桶是具有相同键的键值对列表。((i . 1) (i . 1) (i . 1) (i . 1))是一个桶的示例,其中每个键值对具有键'i'。
减少器

-
输入:两个“值”
-
输出:一个值
减少器接受两个值(没有键),并输出一个单个值。
扩展示例:单词计数
这是我们减少器的定义
(define (reducer num other-num)
(+ num other-num))
减少一个桶
请注意,我们的减少器接受两个值,而我们之前步骤的结果(按桶排序)是一个桶列表(其中一个桶是一个键值对列表)。让我们看看如何使用我们的减少器将其减少到单个键值对。让我们使用我们的第一个键值对列表:
((i . 1) (i . 1) (i . 1) (i. 1) (i . 1))
> (accumulate reducer 0 (map kv-value '((i . 1) (i . 1) (i . 1) (i . 1) (i . 1))))
这简化为:
> (accumulate reducer 0 '(1 1 1 1 1))
5
在调用 accumulate 之前,我们必须通过使用 map 从 kv- 对列表中获取值。请注意,调用 accumulate 的结果是与键相关联的单个值(在本例中为 'i' 的 5)。因为我们最终的结果需要是一个 kv- 对,所以最终我们必须返回 (i . 5)。表达式变为:
(make-kv-pair (kv-key '(i . 1))
(accumulate reducer 0 (map kv-value '((i . 1) (i . 1)
(i . 1) (i . 1) (i . 1)))))
我们可以将上面的表达式推广到除了 '((i . 1) (i . 1) (i . 1) (i . 1) (i . 1))' 之外的任何其他“桶”。
(define (reduce-bucket reducer base-value bucket)
(make-kv-pair (kv-key (car bucket))
(accumulate reducer base-value (map kv-value bucket))))
减少一个桶的列表
上面的 reduce-bucket 过程减少一个桶。我们从上一步的结果 (sort-into-buckets (map mapper data)) 是一个桶的列表。要减少一个桶的列表,我们可以再次使用 map。我们将定义函数 group-reduce 来实现这一点。
(define (groupreduce reducer base-case buckets)
(map (lambda (bucket) (reduce-bucket reducer base-case bucket))
buckets))
如果我们使用到目前为止的内容,它会计算为以下内容:
>(groupreduce reducer 0 (sort-into-buckets (map mapper all-songs)))
( (i . 4) (saw . 1) (her . 1)
. . .
(misery . 1) (please . 2) (me . 1)
. . .
(all . 2) (have . 2))
为了简洁起见,某些值被省略。最终的结果再次是一个 kv- 对的列表:键是单词,值是这些单词在我们的数据中出现的次数。我们刚刚使用 mapreduce 构建了数据中单词计数!
这是我们的 mapreduce 的最终(手势模糊,近似)定义:
(define (mapreduce mapper reducer base-case data)
(groupreduce reducer base-case (sort-into-buckets (map mapper data))))
为什么不是实际的 mapreduce?实际的方法将涉及并行地映射和减少我们的 kv- 对,并且我们必须考虑并发问题。上面的定义捕捉了我们关注的主要部分。
练习 Mapreduce
编写一个 mapreduce 函数就是定义您的映射器和减少器。我们有一系列不同的场景,希望您定义相应的映射器、减少器,并最终调用 mapreduce。
记住:
-
您的映射器的输入是一个键值对,输出是一个键值对的列表。
-
您的减少器的输入是两个值,输出是一个值
链接 Mapreduce
如前所述,由于 mapreduce 的输入是一个键值对列表,输出也是一个键值对列表,因此可以将 mapreduce 链接在一起。它看起来像这样:(mapreduce some-mapper some-reducer some-base-case (mapreduce another-mapper another-reducer another-base-case actual-input))。请注意,第一个 mapreduce 的键和值可能与第二个 mapreduce 的完全不同。
最常见的单词
让我们写另一个 mapreduce 函数(我们还没有链接)。这次,我们的输入的键是 'words',值是数字,表示它们在文档中出现的次数。我们希望输出是一个仅有一个键值对的列表,就像输入一样,我们的键是一个单词,值是一个数字,表示遇到的最高数字。
注意:我们的解决方案并不理想,有点牵强。它没有充分利用 mapreduce 提供的并行性。
>(define x (list (make-kv-pair her 1) (make-kv-pair i 4) (make-kv-pair saw 1))
>(most-frequent x) ; i appears the most
((i . 4))
现在我们开始链接
我们上面的函数是有效的,如果我们传递键为单词,值为数字的对。在现实生活中,我们可能无法直接访问每个单词的单词计数;我们必须从原始文档中处理它。
编写函数real-most-frequent,接受一个键值对列表,其中键是文件名,而值是来自该文件的行(就像我们所有歌曲示例一样)。我们的输出再次是一个单一键值对列表。你可能想要重用我们在本课程中已经定义的任何函数。
>(real-most-frequent all-songs)
((i . 4))
读者不包含 MapReduce 练习。如果你想获得更多练习,Lesson 14 讨论中有 MapReduce 问题。
作业 14
要处理下一个作业问题中的思想,你应该首先
(load "~cs61as/lib/concurrency.scm")
练习 1
练习[3.38](http://mitpress.mit.edu/sicp/full- text/book/book-Z-H-23.html#%_thm_3.38),[3.39, 3.40, 3.41, 3.42](http://mitpress.mit.edu/sicp/full- text/book/book-Z-H-23.html#%_thm_3.39),[3.44](http://mitpress.mit.edu/sicp /full-text/book/book-Z-H-23.html#%_thm_3.44),[3.46, 3.48](http://mitpress.mit.edu/sicp/full- text/book/book-Z-H-23.html#%_thm_3.46),由阿贝尔森和苏斯曼
练习 2:链接 Mapreduce
编写函数real-most-frequent,接受一个键值对列表,其中键是文件名,值是来自该文件的行(就像我们的 all-songs 示例)。我们的输出再次是一个单一键值对列表。你可能想要重用我们在课程中定义的任何函数。
>(real-most-frequent all-songs)
((i . 4))
练习 3:使用流进行 Mapreduce
我们当前的 mapreduce 是使用列表的。现实生活中的 mapreduce 处理的是非常大的数据集:大到列表无法容纳。解决这个问题的一种方法是使用流而不是列表来进行 mapreduce。你可以在这里找到我们的适用于流的 mapreduce 版本这里或者在"~cs61as/lib/mapreduce/streammapreduce.scm"中找到
有什么改变?我们的 map、sort-into-buckets 和 filter 现在可以与流一起工作。作为用户,你需要提供什么?就像之前做过的那样,mapper 和 reducer。mapper 和 reducer 发生了什么变化?它们没有。mapper 和 reducer 的行为不会改变。你可以加载文件并尝试(mapreduce mapper reducer 0 all-songs),其中 mapper 和 reducer 是你在课程中定义的。它们会以相同的方式工作。唯一的区别是,如果 all-songs 很大,我们之前的版本会崩溃,而我们的流版本仍然能够处理它
练习 4:你想成为最强的吗?
你可以访问所有 744 只宝可梦数据的流这里和"~cs61as/lib/mapreduce/pokemon_data"。streammapreduce.scm应该会自动加载它,并将变量"data"定义为你的输入。键是宝可梦的国家编号,值是一个区域编号、名称、名称(是的,它出现两次),以及它们拥有的类型列表。例如,第一个元素是(1 1 bulbasaur bulbasaur grass poison),所以它的国家编号是 1,区域编号是 1,名称是 bulbasaur,类型是'grass'和'poison'。宝可梦可以有 1 或 2 种类型。这里有一个只有一种类型的例子:(4 4 charmander charmander fire)。你可以在加载文件后在解释器中输入(ss data)来查看输入。
定义 mapper、reducer 和基本情况,使得调用(mapreduce mapper reducer base-case data)会返回一个键值对列表,其中键是不同类型,值表示数据集中该类型的宝可梦出现的次数。最终结果应该产生以下结果(任意顺序):
((grass . 86) (dragon . 39) (normal . 99) (flying . 93) (poison . 59) (ice . 35) (fire . 58) (ghost . 37) (psychic . 77) (electric . 47) (water . 124) (fairy . 35) (bug . 70) (steel . 42) (ground . 62) (rock . 54) (fighting . 45) (dark . 44))
提交您的作业!
有关说明,请参阅此指南。它涵盖了基本的终端命令和作业提交。
如果您在提交作业时遇到任何问题,请不要犹豫向助教求助!


如果指数是 2 的幂,这个方法很有效。如果我们使用规则
,我们也可以利用连续平方来计算一般的指数。
浙公网安备 33010602011771号