我们在上一章体验了 Racket 语言当中十分有趣的big-bang表达式,但在尝试设计一个世界的时候,其实会发现状态本身可能并不简单。交通灯只是一个枚举、座位号只是一个区间,那么像一个包含经验值、金币数等多方面的玩家数据呢?它该是什么。
本章会学习如何设计复合数据定义,以表示由两个或多个自然连接的值组成的信息,以及如何将这些数据用作 HtDW 问题中的世界状态。
学习目标
- 能够识别应表示为复合数据的领域信息
- 能够读写
define-struct定义 - 能够设计接受和/或返回复合数据的函数
- 能够设计使用复合世界状态的世界程序
define-struct
在之前,我们总是操作一些原子类型或者是稍显复杂的枚举、区间等,但对于复杂情况,比如表示x和y坐标,一个人的姓和名,我们该怎么做?
Racket 有一个能创建复合类型的表达式,即define-struct。以二维坐标为例,我们可以写出:(define-struct pos (x y))。这里pos是结构名,而后面的(x y)一类都是字段名。
尝试在一个空白的程序中运行这段代码,你会发现没有任何输出,这是因为define-struct和define一样,都是定义用的代码,不会实际产出什么的东西。也可以将pos理解为我们创造的复合类型,里面包含一个x值和一个y值。
我们总需要以某种方式得到一个pos类型的东西,可以为它写一个
约定俗成的是,当你写出了pos这种复合类型,你同时得到了make-pos表达式,你可以往里面传两个值(对应x和y),来获得一个pos类型的值:(define P1 (make-pos 3 6))。
这时候P1就是一个x=3且y=6的pos类型值了。那么我们又该如何获取它的x和y呢?
1 | (pos-x P1) |
运行后会发现交互区出现了3和6,也就是说不仅仅是make-pos,pos-x和pos-y也出现了。
接着就是和之前各种带?的表达式一样,我们也可以通过pos?表达式来判断后面的值是不是pos类型,比如:
1 | (pos? P1) ; true |
相关术语
- 结构名 (Structure Name):当你定义一个复合类型时,类型的名字
- 字段名 (Field Name):当你定义一个复合类型时,类型里面包含了哪些值
- 构造器 (Constructor):用于获取一个指定的复合类型,同时需要填满字段,通过
make-结构名表达式。 - 选择器 (Selector):用于从给定复合类型获取里面的一个字段,通过
结构名-字段名表达式。 - 谓词 (Predicate):用于判断一个表达式/值是否符合给定的复合类型,通过
结构名?表达式。
ps: 谓词这个翻译在这不太好
比如(define-struct pos (x y)):
make-pos是它的构造器表达式,接受两个字段值。pos-x和pos-y分别获取一个pos类型值里面的x和y值,接受一个pos类型值。pos?判断后面传入的一个值是否是pos类型的,返回布尔值。
Compound Data Definitions
本节会像之前第一次学到数据定义一样,细致地介绍如何定义一个复合类型。在此之前,下载来自edX的 compound-starter.rkt 文件。

这道题要求我们设计一个数据结构,用于记录曲棍球员的姓和名,也就是说这两个值应该被绑在一块,形成一个复合类型。
首先,我们来尝试走一遍流程,先是类型定义本身:(define-struct player (fn ln))。
和普通数据定义一样,我们也要写注释声明Player:;; Player is (make-player String String),这样我们既注明了它的构造器,也明确了fn和ln的类型是String。
那么我们该如何解释它呢?我们应该从player、fn和ln这三个名字开始,因为对于其他人来说他们可能并不知道这三个名字是什么意思:
1 | ;; interp. (make-player fn ln) is a hockey player with |
然后就是一些例子,可以随便起一些名字:
1 | (define P1 (make-player "Bobby" "Orr")) |
接下来就是函数模板,既然它是个复合类型,那么获取它的两个字段值也是它模板函数需要用到的部分:
1 | (define (fn-for-player p) |
但它的模板规则就是带两个字段的复合类型:
1 | ;; Template rules used: |
完整定义如下:
1 | (define-struct player (fn ln)) |
Practice Problems
- Compound P1 - Movie
- Compound P3 - Student
Compound P1 - Movie 题解
预计耗时:25 min / 中等
这道题有两个部分,分别是数据定义和函数设计。让我们先来看数据定义部分:你需要定义一个movie类型来记录一部电影的名字、预算和发布时间。
既然是复合类型,我们就需要使用define-struct表达式,后面跟三个不同类型的名字(title budget year),同时标注这些类型是一个字符串和两个自然数,并给出解释:
1 | (define-struct movie (title budget year)) |
借助构造器make-movie写三个例子:
1 | (define M1 (make-movie "Titanic" 200000000 1997)) |
因为它是带有三个字段的复合类型,我们写的模板函数也要照顾到这三个字段,通过movie-字段名来展开三个字段:
1 | (define (fn-for-movie m) |
以及比较好写的复合类型模板规则:
1 | ;; Template rules used: |
函数定义部分代码:
1 | (define-struct movie (title budget year)) |
数据定义部分结束,接下来是函数设计:判断传入的两个Movie的发布时间哪个更新,返回其名字。
从题目能理解出它的传入值类型是两个Movie,而返回值类型是String。同时函数的目的就是:determine which of two given movies was released most recently。
可以随意写一些测试,记得引用一开始的M1、M2和M3:
1 | (check-expect (chronological-movie M1 M2) "Avatar") |
定义它的桩函数:; (define (chronological-movie m1 m2) "") ; stub。由于这里的实现与函数模板差别大,我们就不再写use template from之类的东西,而是现写新模板:函数名就是fn-for-movie,传入参数m1和m2,之后需要写上这俩所有的字段名:
1 | (define (fn-for-movie m1 m2) |
最后实现函数,函数名和桩函数一样。对于函数体部分,我们只需要使用if表达式判断两个movie-year的大小,最后看情况返回对应movie的title就行:
1 | (define (chronological-movie m1 m2) |
函数设计代码如下:
1 | ;; Movie Movie -> String |
Compound P3 - Student 题解
预计耗时:25 min / 中等
这道题也是相同的两个部分。让我们先来看数据定义部分:你需要定义一个Student,记录学生的名字、年级(从1到12)和是否存在过敏(为了准备出游的午餐)。
ps: 由于是否过敏这一项是布尔类型的,所以它会带问号
类型名就是student,带有三个字段名(name grade allergies?),定义为Student is (make-student String Natural[1, 12] Boolean),解释是interp. a student with a name, in grade 1-12, and true if they have allergies。之后写一些例子:
1 | (define-struct student (name grade allergies?)) |
同样,它的函数模板也是用需要标明所有传入的字段,以及它的函数模板规则:
1 | (define (fn-for-student s) |
数据定义部分结束,接下来是函数设计:考虑到六年级及以下学生的过敏是很危险的,我们需要判断学生是否属于这种情况并添加到特殊列表中(这个列表只是用来起名字的,不需要实际实现)。
从题目能理解出它的传入值类型是Student,而返回值类型是Boolean。同时函数的目的就是: produce true if the given student is at or below grade 6 and has allergies。
考虑到函数存在两个判断,我们需要多写一些测试:
1 | (check-expect (add-name? S1) true) |
定义它的桩函数:;(define (add-name? s) true) ; stub,并且声明将会用Student的函数模板:;<use template from Student>
接下来将数据定义部分的函数模板复制下来,将函数名改为add-name?,并且根据测试来完善函数体。对于六年级及以下和是否有过敏,很显著的是我们需要使用and来进行与的判断:
1 | (define (add-name? s) |
ps: 如果用了if也是可以的
函数设计代码如下:
1 | ;; Student -> Boolean |
HtDW With Compound Data
本节我们将基于复合类型设计一个世界。在之前,我们设计过带有一只猫的世界,现在我们将从一头牛开始。这头牛走出栅栏,过一会又转身回来。你会发现这个行为和猫不同 —— 猫只有一个轴上的坐标变化,而牛似乎还会涉及到方向。
下载来自edX的 cowabunga-starter.rkt 文件。

以下是图中两头牛的素材:


这道题很长,简单来说就是有头牛碰到屏幕边缘就来回走,用户也可以按下空格键来手动改变方向。按照上一章的步骤,我们先开始分析:
- 常量:
- 窗口的长宽是基本的,不会变
- 牛在往返走,故它的
y坐标也不会变 - 牛本身的图像(左和右)和空白背景不会变
- 变量:
- 牛的
x坐标在来回变化 - 牛每次碰壁需要转向,转向本身只会改变牛的图像,而它的速度被改变了(方向上)
- 牛的
big-bang选项:on-tick和on-draw是最基本的on-key用来响应用户按下空格键的行为

分析明白后,我们可以从edX的 cowabunga-v0.rkt 文件开始,这个代码文件是本节项目的第一个,帮我们写了注释和常量定义。
那我们就从数据定义开始。由于牛的变化的量是它的x坐标和速度,它需要一个复合类型把它们装起来:(define-struct cow (x dx)),定义为:Cow is (make-cow Natural[0, WIDTH], Integer)。
它的解释需要更加详尽:
1 | ;; interp. (make-cow x dx) is a cow with x coordinate x and x velocity dx |
之后就是它的例子,考虑到世界全程这头牛要么向左走要么向右走,我们就写这两种情况的例子:
1 | (define C1 (make-cow 10 3)) ; at 10, moving left -> right |
以及它的函数模板和模板规则:
1 | #; |
最后能得到edX的 cowabunga-v1.rkt 文件,数据定义部分代码如下:
1 | (define-struct cow (x dx)) |
为了效率,我们接下来直接从edX的 cowabunga-v2.rkt 文件开始,这里帮我们写好了big-bang相关的表达式和桩函数。
先从next-cow函数开始,阅读它的目的能发现它需要让牛的x坐标增长,并根据是否在边缘来改变速度的方向。
它的测试需要考虑到三种情况:远离边缘时、到达边缘时、试图越过边缘时。
1 | (check-expect (next-cow (make-cow 20 3)) (make-cow (+ 20 3) 3)) ;away from edges |
然后就是桩函数:;(define (next-cow c) c) ;stub以及来自cow的函数模板,将它复制过来并改名。考虑到我们需要处理三种情况,可以使用cond表达式,在Q和A中都写上cow的两个字段:
1 | (define (next-cow c) |
ps: 括号恐惧症犯了
这里就是cond的三种情况,分别处理:
- 碰到屏幕右边
- 碰到屏幕左边
- 正常移动
第一种情况,我们需要判断下一刻牛会不会撞上右边缘,这个逻辑该怎么写呢?如果牛的当前坐标和牛当前的速度相加发现能达到WIDTH,是不是意味着牛下一秒会撞到右边缘。之后的处理方法就是将牛安全的放在边缘,并让当前的速度方向取反:
1 | [(> (+ (cow-x c) (cow-dx c)) WIDTH) |
第二种情况和第一种就很像了,就是牛向左走的碰到左边缘的时候。这时候由于速度已经是负数了,那么我们直接将牛的x坐标和速度相加,判断它是否达到0以下就行了,同时将牛放到0的位置并以方向相反的速度移动:
1 | [(< (+ (cow-x c) (cow-dx c)) 0) |
第三种情况就很简单了,就是牛在屏幕中晃悠:
1 | [else |
最后的函数体代码如下:
1 | (define (next-cow c) |
让我们从edX的 cowabunga-v3.rkt 文件开始,这里已经做好了next-cow。这次我们开始写剩下的部分。
先是render-cow,先写它的测试:
1 | (check-expect (render-cow (make-cow 99 3)) |
复合数据测试
在测试复合数据时,尽可能让每个字段多变一变,这样能测试到更多东西。
以及桩函数:;(define (render-cow c) MTS) ;stub。从cow的函数模板复制下来,改名:
1 | ; took template from Cow |
抛开显示哪头牛不谈,我们先用place-image显示牛本身:
1 | (define (render-cow c) |
接下来就是封装的魅力了。对于显示哪头牛我们当然可以通过直接在place-image后面写个if判断方向正不正来返回对应的牛。但我们要遵循单一职责原则 —— 在这里可以解释为一个函数不要干超过它预设职责的事情。
对于选择牛图像这件事,我们可以把它放在另一个函数里面,称作choose-image,传入一个cow。
先写上函数的签名Cat -> Image,目的produce RCOW or LCOW depending on direction cow is going,以及特有的!!!。
它的测试需要包括向左走和向右走牛的图像选择:
1 | (check-expect (choose-image (make-cow 10 3)) RCOW) |
之后从cow的模板函数复制过来,然后使用if表达式判断传入cow的dx是否为正,如果是那么就返回RCOW,否则就是LCOW:
1 | (define (choose-image c) |
最后的键盘响应就不细说了:
1 | ;; Cow KeyEvent-> Cow |
回过头来,我们做了choose-image的封装,那么(place-image ... (cow-x c) CTR-Y MTS)这个地方的...就可以变成(choose-image c)了。
如果不出意外,运行测试会成功,然后在交互区向main函数随便传个cow就行,比如:(main (make-cow 10 5)),回车后就能看到效果了。
Practice Problems
- Compound P9 - Water Balloons
Compound P9 - Water Balloons 题解
预计耗时:90 min / 困难
这道题看似复杂,实际上就是一个图像从左移动到右,同时在旋转。
那么经过分析,我们可以发现以下常量:
- 有关窗口的宽高、空白场景
- 有关气球本身的图像,线速度和角速度,以及它不变的
y坐标
1 |
|
然后是数据定义,我们将这个世界的状态定为BalloonState,含有x和a两个字段,分别意为x坐标和角度:
1 | (define-struct bs (x a)) |
它的函数模板和规则如下:
1 | #; |
然后就是函数设计部分,这一部分首先需要考虑的就是整体的main函数和里面的big-bang表达式。我们将on-tick的函数命名为next-bs、to-draw的函数命名为render-bs、以及on-key的函数命名为reset-bs。
main函数本身就是BalloonState -> BalloonState的持续运行的函数,用于实现动画。它的起始状态应为(main (make-bs 0 0)):
1 | ;; BalloonState -> BalloonState |
之后就是每个函数的处理了。对于next-bs函数,我们知道它的签名也是BalloonState -> BalloonState,目的是调整下一次气球的线速度和角速度值。只需要让当前bs-x与线速度相加,bs-a与角速度相减(加减与旋转方向有关)就能处理:
1 | ;; BalloonState -> BalloonState |
这次的渲染函数render-bs需要注意,我们会使用rotate表达式来渲染旋转过的图像,但总体上还是place-image的。
但我们刚刚的实现 —— (- 12 ANGULAR-SPEED) —— 没有考虑小于0或者大于360的情况,也就是说角度会超出可用范围(这里是这样的)。我们需要使用某种方法来让这个值始终在360以内。
这时候就需要使用modulo表达式来取余。会发现比如361,如果使其对360取余,那么值就是1。如果是720,那就是0。我们可以通过这种方法来让值始终可用:
1 | ;; BalloonState -> Image |
按空格键重置状态就很好写了,和之前都差不多:
1 | ;; BalloonState KeyEvent -> BalloonState |