上一章讲述了系统设计中的函数设计,但在函数设计之外,数据结构也是很重要的一部分。
学习目标
- 能够运用《如何设计数据定义》(HtDD)方法为原子数据设计数据定义
- 能够识别应表示为简单原子数据、区间、枚举、分项和混合数据分项的问题领域信息
- 能够运用”数据驱动模板”方法为操作原子数据的函数生成模板
- 能够运用《如何设计函数》(HtDF)方法设计操作原子数据的函数
cond Expressions
cond 表达式可以在当
在此之前,下载来自edX的 cond-starter.rkt 文件。

让我们来过一遍这段代码在干什么:
- 开始定义了三个不同的
rectangle,分别命名为I1、I2、I3 - 中间的三个
check-expect先跳过,只是用来测试函数的 - 最后有个名为
aspect-ratio的函数,接受一个Image- 如果
img的高大于img的宽,那么返回"tall" - 否则,判断
img的高是否等于img的宽,如果是,返回"square"- 否则返回
"wide"
- 否则返回
- 如果
从函数的判断逻辑和返回值能看出来:这个函数是用来判断传入的图像是宽、是高、还是宽高相等(正方形),故它if表达式来模拟出三个判断,这不太好。
接下来,本节会介绍cond表达式,它与if的不同点在于,
快捷注释
对于大段代码,可以在它之前一行写#;来整个注释
比如:
1 | #; ; 之后整个 define 表达式就失效了 |
我们注释这个函数后,再写个新的,并尝试用一下新的cond表达式:
1 | (define (aspect-ratio img) |
这里可能会发现一个漏洞,如果cond里面所有的分支都没有满足,需要一个保底值,而这个保底触发条件就是else,所以可以尝试理解以下的改写:
1 | (define (aspect-ratio img) |
当然,这个表达式的基本要求就是:
[Q A]中的Q始终为Boolean类型,因为它是用来判断能不能返回A的。- 最后一个分支的
Q可以是else。
接下来让我们来解读一个完整的cond表达式,看看它是怎么运行的:
1 | (cond [(> 1 2) "bigger"] |
首先,如果一个cond表达式里面一个[Q A]都没有,肯定会报错,可以试试。
然后,针对每一个分支,思路是:将Q和A都从表达式化简成值,比如第一行就可以变成[false "bigger"]得到:
1 | (cond [false "bigger"] |
将带有false的分支删掉,得到:
1 | (cond [(= 1 2) "equal"] |
然后剩下的第一行可以变成[false "equal"]:
1 | (cond [false "equal"] |
再次将带有false的分支删掉,得到:
1 | (cond [(< 1 2) "smaller"]) |
最后一行的Q为true:
1 | (cond [true "smaller"]) |
最终得到:"smaller"。
总结一下,cond表达式的解读顺序是:
- 所有的内部的函数调用、表达式都化简
- 将所有的
Q逐个变成确认的true/false - 将
cond表达式变成一个准确答案
Data Definitions
在编程中,我们时常
在此之前,下载来自edX的 next-color-starter.rkt 文件。
假如你需要写一个程序,模拟交通灯的交替:

看到这段代码可能会很困惑,除了next-color这个函数名看起来有点意思,这函数里面好像就只有0 1 2的返回值?这些测试又在干什么?
这里就要涉及到我们将实际问题抽象成所写代码的过程了。
ps: 我将 Problem Domain 暂且译成实际问题,而不是问题域,有兴趣可以自行搜索
从题目能看出,我们需要
红灯是
这里我们可以在代码中用0这个数来代表红灯,回到程序,我们就会合理猜测1或许是黄灯,2是绿灯。这就是

在这个程序的最开始,我们发现了它的
1 | ;; TLColor is one of: |
之后也写了这个数据类型的解释,表示这个数据类型与实际信息的关系:
1 | ;; interp. 0 means red, 1 yellow, 2 green |
同时函数名和它的传参(fn-for-tlcolor c)也意味着这个函数接受TLColor这个类型。
然后就发现下面的函数签名变成了TLColor -> TLColor,而非Natural,这意味着只有0 1 2这三个数是合乎条件的,其他数都不在内。
总之,数据定义描述了:
- 如何赋予数据一个新的类型
- 如何让数据代表一些信息
- 如何将数据解释为信息
同时,数据定义也让函数的:
- 接受参数的范围受限(从
Natural意义不明的自然数,变为自定义明确的TLColor) - 返回值受限(同上)
- 帮助我们更清晰地写出测试
数据定义的出现让我们不再让信息未经加工就充斥于我们的程序之中,给我们造成混乱。
Atomic Non-Distinct
在本节开始之前,下载来自edX的 city-name-starter.rkt 文件。

在这道题中,我们需要为城市的名字这一
假设有城市:Vancouver和Boston,这些都是信息,而且都是Vancouver可以拆成V-A-N-C-O-U-V-E-R多个字符,但它就不是城市的名字了。
在定义这种信息的数据时,我们可以先写一个;; CityName is String。借由这个类型注释,我们知道Vancouver的数据表示为"Vancouver",而Boston则表示为"Boston"。
之后写一行解释,;; interp. the name of a city,说明CityName作为一种数据定义,代表的是城市的名字。
然后就可以写下这两个城市名字的常量了:
1 | (define CN1 "Boston") |
以及它的函数模板,标注该函数基于Atomic Non-Distinct规则,相关类型为String:
1 | #; |
完整的原子数据定义如下:
1 | ;; CityName is String |
HtDF With Non-Primitive Data
书接上回,我们写了名为CityName的数据定义,也就是说CityName成为了一个
该函数接受一个城市的名字,判断该城市是否为最好的城市。
我们在上一节程序的最底部写一行;; Functions:注释之后,定下函数的签名和目的:
1 | ;; CityName -> Boolean |
之后写一个桩函数,顺便起好函数名,比如说best?,接受的参数就是cn,随便给个返回值:
1 | (define (best? cn) false) ; stub |
然后就是测试,考虑到我们的返回值类型是Boolean,所以我们的测试一定要至少覆盖到一个false的结果和一个true的结果:
1 | (check-expect (best? "Boston") false) |
运行后测试不通过。还记得上一节CityName这个类型的相关模板吗?将它复制进来,改名,并写个注释:
1 | ; took template from CityName |
结合测试的情况,我们开始写函数体,通过城市的名字判断这个城市是不是最好的,我们需要一个if表达式:
1 | (define (best? cn) |
运行后测试通过。
HtDF X Structure of Data Orthogonality
上一节,我们体会到对于一个自定义的数据类型,我们是如何为它写函数的。本节领略一下系统设计这方面,因为设计方法本身其实也是很有说法的:
起初我们学了一些String、Number,围绕这些写了double、yell、area、image-area、tall等函数。
现在我们又接触了遵循 HtDD 设计的非基本类型,比如说原子类型的CityName以及之后会讲到的,TLColor。围绕它们写了best?、next-color函数。
目前没涉及到的非基本类型还有一些,除了枚举之外,还有distinct、interval和itemization。
这两大种类型还能互相结合,变成Compound类型,还有list、tree之类。
即使之后学到的数据类型越来越多,HtDF recipe 依然适用,它保证涉及到得到数据形式都是

数据类型和数据结构都是本章重点探讨的话题,故之后将会在数据上花更多时间。
Interval
在本节开始之前,下载来自edX的 seat-num-starter.rkt 文件。

在一个矩形的剧院里管理售票,为此设计一个数据定义来表示一排中座位号,一排有32个位置。
回忆起atomic相关的内容,座位号确实可以代指一个Integer类型,但它又不仅仅是个简单的Integer,而是由范围的。
我们可以用Integer[0, 10]是指一个包含0和10的正整数区间,Number[0, 10)则是指不包含10的数,这里开闭区间的表示和数学一样。
接下来开始写这道题的数据定义:;; SeatNum is Integer[1, 32],当然考虑到自然数的概念,写;; SeatNum is Natural[1, 32]也是可以的,这里使用Natural类型。
下一行就是解释这个区间,同时注明范围:;; interp. seat number in a row, 1 and 32 are aisle seats。
然后为这个区间写一些常量作为例子:
1 | (define SN1 1) ; aisle |
以及SeatNum相关函数的模板,并标注该函数基于Atomic Non-Distinct规则,相关类型为Natural[1, 32]:
1 | (define (fn-for-seat-num sn) |
完整的Interval原子数据定义如下:
1 | ;; SeatNum is Integer[1, 32] |
Enumeration
在本节开始之前,下载来自edX的 letter-grade-starter.rkt 文件。

这道题让我们设计一个数据类型来代表学生的成绩 (A, B, C),red、yellow、green。
枚举的数据定义不太一样,我们先给成绩起个名字,同时声明它是三个字母中的一个:
1 | ;; LetterGrade is one of: |
由于枚举中的各个项通常是字符串,它的解释可以;; interp. the letter grade in a course。
在之前,为了代表三个项,我们可能需要用数字0、1、2等代替,一一去写每个数代表的意思,但现在可以通过枚举和里面的字符串来简化解释。
之后对应这个枚举写三个常量作为例子:
1 | (define LG1 "A") |
Wait,最开始的类型定义很清晰地指出了LetterGrade和三个的等级之间的关系,那这些例子完全是重复的,所以我们完全可以这么写:<examples are redundant for enumerations>
为这个枚举类型写一个模板函数,注明它使用的模板规则。枚举的模板规则比较复杂,enumeration类型需要在模板内写一个cond表达式来覆盖所有可能的枚举值,以及在规则内需要写上多少个 cases 以及对应的每个 case。
在这里三个项不是以前遇到的atomic non-distinct这种原子数据类型,而是三个确切原子类型的值,所以需要写atomic distinct。
对于每个atomic distinct,我们要根据它的类型去完善cond表达式里面的Q,比如说这里的String,在Q的位置就得填:(string=? lg "A")。之后A的位置就是对应 case 的处理了。
按照以往,模板函数的返回值应该是诸如(... s)一类,但在这里,A应该填(...)就行了
以下是枚举类型的完整数据定义:
1 | ;; LetterGrade is one of: |
补充
由于 edX 有很多参考难以说完,这里列举一些其他的:
Atomic Non-Distinct, 返回均为(... x):
Number->(number? x)String->(string? x)Boolean->(boolean? x)Image->(image? x)- interval like
Number[0, 10)->(and (<= 0 x) (< x 10))
Atomic Distinct Value, 返回均为(...):
"red"->(string=? x "red")false->(false? x)empty->(empty? x)
Enumeration和下一节Itemization都需要在模板函数体内体现cond表达式
Itemization
在本节开始之前,下载来自edX的 countdown-starter.rkt 文件。

我们先站在枚举的视角来看这个问题,会发现它和上一道题一样,把情况分为三类:
- 还没开始
- 倒数10秒
- Happy New Year!
但仔细一想不太对,第一和第三个状态可以轻松表示,但第二个这个倒数十秒似乎不简单,我们暂且用区间来表示。
这就是本节介绍的
ps: 奇怪的翻译
和枚举一样,我们来写它的数据定义:
1 | ;; CountDown is one of: |
但不同于枚举,几个项可以被一行简单解释,分项的解释需要针对于每一项:
1 | ;; interp. |
也因为这一点,它的例子不能省略:
1 | (define CD1 false) |
以及它的函数模板:
1 | #; |
类型安全
也许会疑惑为什么(and (number? c) (<= 1 c) (<= c 10))需要有个(number? c),做三个与判断,这是因为每个Q都需要将你传入值的类型判断出来,这样就能分情况处理。
而且,最关键的是这个and的后面两个参数都是只有数字才能做的判断,不能把其他类型放进去比大小,所以应该在第一个参数这写一个数字类型判断,防止程序出问题。
HtDF with Interval
包括本节,之后两节将会通过写函数的方式巩固对Interval、Enumeration和Itemization的理解。
在本节开始之前,下载来自edX的 aisle-starter.rkt 文件。

这道题让我们设计一个用于代表SeatNum的数据定义,如果给定的座位号在过道上,就返回true。让我们直接从代码文件的;; Functions:一栏开始写函数:
回忆 HtDF recipe,我们需要先写出函数的签名:SeatNum -> Boolean,因为是从座位号这个自定义类型得到一个布尔值,之后再解释:produce true is the given seat number is on the aisle。
桩函数也就能写出来了:(define (aisle? an) false) ; stub
从代码最开始的数据定义能看出来SeatNum是一个Natural[1, 32],它特别是闭区间,其中1和32都是属于过道的座位。所以我们最好写出三个测试,分别是1、中间的某个位置和32:
1 | (check-expect (aisle? 1) true) |
之后让我们从代码文件上方给出的有关SeatNum类型的函数模板,声明;<use template from SeatNum>,改名为aisle?,得到:
1 | (define (aisle? sn) |
ps: 注意把桩函数注释掉
然后开始实现函数体。我们可以从题目思考下,首先是不是只要座位号为1或者32就说明它在过道上,也就是说我们需要判断座位号是否是这两个数中的一个。这里可以想出以下可能方案:
可以通过判断sn是1或32来返回true和false:
1 | (define (aisle? sn) |
可以将1、32和其他情况视为cond表达式的三个 cases:
1 | (define (aisle? sn) |
这是最简单的,因为我们知道or表达式的返回值就是布尔:
1 | (define (aisle? sn) |
函数部分代码如下:
1 | ;; Functions: |
HtDF with Enumeration
在本节开始之前,下载来自edX的 bump-up-starter.rkt 文件。

先过一眼题目:设计一个与LetterGrade数据类型相关的函数,接受一个LetterGrade,得到它的下一个更高等级,比如C变B、B变A,当然A是不变的。
由此可知函数签名:LetterGrade -> LetterGrade,解释为produce next highest letter grade (no change for A)。
为此写桩函数:(define (bump-up lg) "A") ; stub。
考虑到代码文件上方的LetterGrade数据类型是一个枚举,我们在写测试的时候需要照顾到所有的情况,故我们需要写三个测试:
1 | (check-expect (bump-up "A") "A") |
之后声明;<use template from LetterGrade>使用上面写的LetterGrade模板函数,将它复制过来,并改名为bump-up,由于函数做的事情很简单,顺手就可以把函数体写了:
1 | (define (bump-up lg) |
测试运行通过,函数部分代码如下:
1 | ;; Functions: |
HtDF with Itemization
在本节开始之前,下载来自edX的 countdown-to-display-starter.rkt 文件,并在代码文件上方引入(require 2htdp/image)

题目下方的数据定义在之前介绍分项的时候写过了,我们要做的是:设计一个函数,接受Countdown类型,得到能显示当前状态的图像。
由此可知函数签名:Countdown -> Image,解释为produce nice image of current state of countdown。
为此写桩函数:(define (countdown-to-image c) (square 0 "solid" "white")) ; stub。
考虑到代码文件上方的Countdown数据类型是分项,我们在写测试的时候需要照顾到所有的情况,故我们需要写三个测试,后面的图像表达可以自定义:
1 | (check-expect (countdown-to-image false) (square 0 "solid" "white")) |
text 表达式
这里出现了一个属于图像库的新表达式,强烈建议在了解所有新表达式的时候先去官方文档查询用法。
这里样例内的text表达式需要传入一个String、一个Number和一个String,它们分别对应:
- 你要显示的文字
- 字号大小
- 文字颜色
之后声明;<use template from Countdown>>使用上面写的Countdown>模板函数,将它复制过来,并改名为countdown-to-image,由于函数做的事情很简单,顺手就可以把函数体写了:
1 | (define (countdown-to-image c) |
测试运行通过,函数部分代码如下:
1 | ;; Functions: |
Structure of Information Flows Through
回忆一下,在本章开始时,有一张讲述数据结构与关系的图:

从刚刚学的三个新的数据类型能看出来它不仅在简单组合原子数据类型,更是在阐述一个十分完整的数据定义过程:从信息到数据,再到模板和测试。
在程序设计中,了解信息本身的结构是至关重要的部分,因为如果数据定义变得越来越复杂,一个巧妙的结构设计就能让整个程序都变得简洁有效。
Practice Problems
这一章的 Recommended Problems:
- HtDD P2 - Demolish
- HtDD P3 - Rocket Descent
ps: 我觉得难度在于背模板,而不是写代码本身
HtDD P2 - Demolish 题解
预计耗时:20 min / 中等
这道题有两个部分,分别是数据定义和函数设计。让我们先来看数据定义部分:你需要定义一个BuildingStatus类型来将温哥华的建筑物分为"new"、"old"和"heritage"。
结合之前学的类型,我们可以使用枚举,并写上这个类型的意思。当然,由于它是枚举类型,例子是不需要的:
1 | ;; BuildingStatus is one of: |
因为它是枚举,所以在设计函数模板时需要用cond表达式来囊括所有情况,同时将每个 case 的Q完善:
1 | (define (fn-for-building-status bs) |
以及模板的规则,因为枚举的三个都是String,而String属于Atomic Distinct:
1 | ;; Template rules used: |
数据定义部分代码:
1 | ;; BuildingStatus is one of: |
数据定义部分结束,接下来是函数设计:判断传入的城市状态是否为"old",如果是就应被拆除,否则不拆,函数名为demolish。
从题目能理解出它的传入值类型就是刚刚的BuildingStatus,而返回值类型是Boolean。同时函数的目的就是:produce true if a building should be torn down。
考虑到该枚举类型存在三种情况,我们需要写三个check-expect,除了"old"情况,返回值都是false:
1 | (check-expect (demolish "new") false) |
定义它的桩函数:;(define (demolish bs) true) ; stub,并且声明将会用BuildingStatus的函数模板:;<use template from BuildingStatus>
接下来将数据定义部分的函数模板复制下来,将函数名改为demolish,并且根据测试来完善函数体:
1 | (define (demolish bs) |
函数设计代码如下:
1 | ;; BuildingStatus -> Boolean |
HtDD P3 - Rocket Descent 题解
预计耗时:25 min / 困难
这道题也是相同的两个部分。让我们先来看数据定义部分:你需要定义一个RocketDescent类型来记录火箭的轨迹。火箭是从第100公里从地球降落,到达地面视为降落完成。
由于我们需要记录轨迹本身,即Number(0, 100],和降落完成状态"done"。以此考虑使用分项:
1 | ;; RocketDescent is one of: |
ps: Number[0, 100] 也是可以的
ps2: 教授这里将降落完成状态视为false,我觉得怪怪的,用了"done"
因为这是分项,我们需要解释每一个情况,以及例子:
1 | ;; interp. |
分项的函数模板也是用cond表达式列举情况,对于第一个情况的Q可以认为是:在判断传入类型是Number的前提下,判断它是否在(0, 100]之间:(and (number? rd) (< 0 rd) (<= rd 100))。第二个Q就可以使用else简写:
1 | (define (fn-for-rocket-descent rd) |
声明模板规则使用时需注意Interval类型是Atomic Non-distinct的:
1 | ;; Template rules used: |
数据定义部分结束,接下来是函数设计:将火箭降落状态变成可读的字符串,在降落中输出剩余距离,在降落时输出"The rocket has landed!",函数名是rocket-descent-to-msg。
从题目能理解出它的传入值类型就是刚刚的RocketDescent,而返回值类型是String。同时函数的目的就是:produce a broadcast message of rocket's descent distance。
考虑到该分项类型存在两种情况,且里面有Interval,故会有多个check-expect:
1 | (check-expect (rocket-descent-to-msg 100) "Altitude is 100 kms.") |
定义它的桩函数:;(define (rocket-descent-to-msg rd) "") ; stub,并且声明将会用RocketDescent的函数模板:;<use template from RocketDescent>
接下来将数据定义部分的函数模板复制下来,将函数名改为rocket-descent-to-msg,并且根据测试来完善函数体。对于剩余距离本身这个数字部分rd,我们需要额外使用(number->string rd)来将其从Number类型变成String。对于其他字符串部分,可以用string-append拼接:
1 | (define (rocket-descent-to-msg rd) |
函数设计代码如下:
1 | ;; RocketDescent -> String |