在上一章,我们体验了 Racket 的语言设计,也简单介绍并使用了函数 (Function),但实际上这也是这类语言 —— 类 Lisp/Scheme 语言的精髓之一。

本章会详细讲解 Racket 语言中的函数,以及如何将自己的大脑改造成函数

学习目标

  • 能够运用《如何设计函数》(HtDF)方法设计处理原始数据的函数
  • 能够阅读完整的函数设计并识别其不同组成部分
  • 能够评估各元素在清晰度、简洁性及彼此一致性方面的表现
  • 能够评估整个设计对给定问题的解决效果
以下内容涉及到的edX链接均不保证可访问性

HtDF Recipe

在本节开始之前,下载来自edX的 double-starter.rkt 文件,将其放在 DrRacket 内:

double-starter.rkt
double-starter.rkt

HtDF (How to Design Functions) Recipe 告诉我们如何设计出一个好的函数,如何将大事化小,逐个解决。也就是说,在学习函数的开始,我们会将 Recipe 用作学步车,之后熟练运用的时候可以不逐步遵循。

两个版本的HtDF有显著差异,但我会尽力讲解

先让我们看看刚刚的问题:遵循要求设计一个函数,接受一个数字,得到该数字的两倍。

第一步,写出它的签名 (Signiture)目的 (Purpose),和一个 (Stub)

1
2
3
4
;; Number -> Number  ; 这是函数的签名,即传入什么类型,得到什么类型
;; produce 2 times the given number ; 这是函数的目的,即它需要做到什么事情

;(define (double n) 0) ; 这是个符合函数签名的桩,占位置用

多个参数的签名

如果签名的参数涉及多个,请写;; Image Image -> Boolean,用空格,而不要用,一类东西隔开。

桩是指函数的定义,其包含一个可用的函数名、符合要求的参数数量以及一个是正确返回类型的值。

1
2
(define         (double     n)                0)
↑ 定义表达式 ↑ 函数名 ↑ 正确的参数数量 ↑ 正确的返回类型的值

ps: 如果你学过其他语言,需要注意桩这个奇怪概念,以及 double 是函数名而非类型

格式规范

函数的签名和目的均一行表述完毕,且开头为;; 的注释,以便与后面的代码区分。

1
2
;; Number -> Number
;; produce 2 times the given number

对于桩,前面只有; ,是因为之后会把注释删掉以便使用。

1
;(define (double n) 0) 

;; 用于表述一些帮助文本,在之后不会被删掉;; 是临时注释。

截至目前,我们接触到的基本类型有:NumberStringImageBoolean

第二步,我们引入check-expect表达式,这个表达式可以传入两个别的表达式,用于判断它们是否相等。相比于我们学过的=运算符来说,它不仅仅是简单的布尔或if判断,更属于测试 (Testing) 的一部分。

当我们写出如下代码并运行:

1
2
3
(define (double n) 0)
(check-expect (double 3) 6)
(check-expect (double 4.2) 8.4)

会有一个窗口出现,告诉你这两个测试(即刚刚我们写的两行check-expect)都失败了。这一步被称为写样例(Example/Sample)

第三步,我们开始设计这个函数了,先写出它的模板 (Template)

1
2
(define (double n)  ; 这就是模板,虽然和之前的桩一样达不到目的
(... n)) ; 这只是个占位置的东西,...和n之间需要空格来保证运行通过

第四步,开始写函数的函数体 (Body)。就和数学函数一样,输入和结果之间需要给定运算过程,而这个过程就是函数体。开始思考我们该如何让上面写的两个check-expect成立呢?

  • (check-expect (double 3) 6) 就是 (check-expect (double 3) (* 2 3))
  • (check-expect (double 4.2) 8.4) 就是 (check-expect (double 4.2) (* 2 4.2))

之后,我们知晓这个函数应当让n变成它的两倍,即(* 2 n),就可以把它作为函数体填进去了。

1
2
(define (double n)
(* 2 n))

重复声明

当定义了一个新的函数或是常量之类,赋予其名时,被称为声明 (Declaration)。在整个程序运行中,我们需要注意这一点,如果你的程序出现了两个相同名字的函数、常量,有可能会出现错误。比如以下代码:

1
2
3
4
5
(define (double n) 0)
(check-expect (double 3) 6)
(check-expect (double 4.2) 8.4)
(define (double n) ; 错误!double 函数已经定义过了
(* 2 n))

运行代码之后,交互区出现Both tests passed!,就说明我们通过测试了。


总之,写一个函数的步骤大概是:

  • 定下函数的签名和目的,为此写个桩,给函数占个位置,在测试的时候至少可以运行
  • 写几个check-expect表达式用来当这个函数的样例
  • 完善函数的模板
  • 写函数体
  • 通过测试

Simple Practice & When Tests are Incorrect

ps: 由于 edX 的这门课需要付费才能查看 Graded Assignments,本人只能根据题目要求来写了

这一节我们会面临第一个 HtDF 问题,即写一个真正有意义的函数:设计一个函数,传入一个单词,得到它的复数形式,假设给任何单词末尾加个s就够了。

根据上一节提到的步骤,我们可以先确认函数签名、目的和桩。签名当然要望文生义一点比较好,而目的也很明确(如题),那开始写桩:

1
(define (pluralize word) "s")

之后为这个桩函数写一些测试,即check-expect表达式,代入我们预期的结果:

1
2
3
(check-expect (pluralize "apple") "apples")
(check-expect (pluralize "orange") "oranges")
(check-expect (pluralize "banana") "bananas")

这三个测试肯定是不会通过的,因为我们还没有实现,先写出模板,确认我们的函数是围绕word做一些运算的:

1
2
(define (pluralize word)
(... word))

然后就是最重要的部分 —— 实现,我们需要知道怎么给单词后面加s:单词是个字符串"s"当然也是个字符串,那么这就是个简单的字符串拼接的问题,所以我们可以使用string-append表达式:

1
2
(define (pluralize word)
(string-append word "s"))

最后如果能通过测试,这个函数就完美地写完了。

再试试

实现一个函数:传入一个单词(是问候语,如"hello", "Bye"一类),得到它后面加了!的单词。(如”"hello!"

注意

我们在编写函数的时候,有时候会出错,无论是语法还是逻辑,最终导致测试不通过。这里我们不仅仅需要思考是不是函数写错了,同时也要确保你的测试本身是没有问题的。比如,在验证一个求正方形面积的函数:

1
2
(check-expect (area 3) 3)  ; ??? 为什么边长为3的正方形面积为3,不是9吗
(check-expect (area 3) 9) ; 正确的测试

Varying Recipe Order

在初学设计函数的时候,困惑是常有的:

  • 前面的部分看起来既麻烦又啰嗦,但实际上,熟练后的函数设计过程是近乎无感的。
  • 函数体是一个函数最重要的部分,决定了函数本身的功能。
  • 不确定函数签名时候,思考这个函数需要什么、得到什么,先把测试样例写出来,再回头写签名。

下载来自edX的 image-area-starter.rkt 文件,将其放在 DrRacket 内:

image-area-starter.rkt
image-area-starter.rkt

这道题要求我们设计一个函数:接受一个图像并得到图像的面积,也就是说我们需要将这个图像的宽高相乘得到一个值。

至此,我们已经得到一个签名;; Image -> Number,且函数的目的也明确了,即;; produce image's width * height (area)

这俩明确后,就可以写桩函数了,我们可以为这个函数和参数起一个有意义的名字:(define (image-area img) 0)

然后就是函数的测试,我们需要为它写个样例:

1
(check-expect (image-area (rectangle 2 3 "solid" "red")) (* 2 3))

之后运行测试,显然是不通过的。

ps: 如果遇到了 rectangle: this function is not defined,请在程序开头加上 (require 2htdp/image) 以引入图像库

经过最初的思考,我们需要围绕传入的图像做运算,即这个函数是围绕img在做事情的,所以这是它的模板

1
2
(define (image-area img)
(... img))

函数的所有前期构思都结束了,接下来就是最重要的函数体编写,在此之前可以把上面写过的桩函数和模板都注释掉,写一个完整可用的函数:

1
2
(define (image-area img)
(* (image-width img) (image-height img)))

最后,运行测试通过,完整代码如下:

1
2
3
4
5
6
7
8
9
10
;; Image -> Natural
;; produce image's width * height (area)
(check-expect (image-area (rectangle 2 3 "solid" "red")) (* 2 3))
; (define (image-area img) 0) ; stub

;(define (image-area img) ; template
; (... img))

(define (image-area img)
(* (image-width img) (image-height img)))

类型

在本节之前,所有提到的数字可能都被Number代指,但到了后期,这一表述将会变得不再准确,比如小数/浮点数 (Float)

有关 Racket 语言有关数字的类型

Racket 语言的数字类型其实分为很多种,刚刚写的函数签名,更确切地来说,应当是:;; Image -> Natural,即返回值类型是个自然数

当然,可以仔细翻阅 Racket 文档,也会发现在 Racket 中,NaturalExact-Nonnegative-Integer是同义词。

Poorly Formed Problems

在这一节,我们会遇到表述没有那么清楚的题目。在之后的函数设计中,明确知道函数的需求本身已经将函数设计完成大半了,但真正的难点是需求自身的不清晰。

下载来自edX的 tall-starter.rkt 文件,将其放在 DrRacket 内:

tall-starter.rkt
tall-starter.rkt

在这道题中,我们需要先确认函数的签名:传入一个图像,得到这个图像高不高。一开始乍一眼可能觉得它的返回值类型是Number一类的,但实际上是个Boolean,即;; Image -> Boolean

同时明确函数的目的,判断图片高不高也是不够细节的,应当是:;; produce true if the image is tall。在明确函数的目的时,需要细节到函数的返回值类型。

确保引入(require 2htdp/image),接下来为它写个桩函数和测试样例:

1
2
3
(check-expect (tall? (rectangle 2 3 "solid" "red")) true)

(define (tall? img) false)

问号结尾的函数名

如果题目涉及到诸如判断…,得到一个Boolean之类的,很有可能需要在函数名末尾加一个?来表示它是需要做出判断并直接给出一个 Yes or No 的答案。

考虑样例多样性

在写测试时,有时需要考虑在尽可能多的方面去检测函数的健壮性和安全性。

比如这个判断图像高不高的函数,如果传入一个圆或三角形什么的,是否需要特殊处理?

假设这个函数只需要这一个测试样例,完成它的模板:

1
2
(define (tall? img)
(... img))

在实现函数体的时候,我们需要先思考如何判断图像高不高 —— 图像的高大于它的宽 —— 需要一个if表达式判断:

1
2
3
4
(define (tall? img)
(if (> (image-height img) (image-width img))
true
false))

最后运行,测试通过。

测试覆盖率

取决于你的 DrRacket 个性化设置,你会发现代码运行后,false变成了黑底黄字。

这是因为 Racket 发现:运行了程序中所有的check-expect表达式后,有一部分程序始终没有跑过,这部分在 DrRacket 中会被特别标注。

测试覆盖率在大型项目中尤为重要,它决定了整个项目的安全、健壮和可维护性。

在这套课程中,请务必按照最高覆盖率去写,比如:一个函数需要传2个图像,判断第一个图像是否比第二个图像大,这俩图像需要用它们的宽高,总共就会有3*3=9check-expect

所以我们可以为这个函数再写一个测试,使其可以覆盖到false部分:

1
(check-expect (tall? (rectangle 3 2 "solid" "red")) false)

之后,或许还有一个点我们忽略了,由于题目表述模糊,我们并不知道宽高相等是否意味着图像是高的,我们应当在写样例的时候确认尽可能多的预期行为和结果 —— 假设这个情况不算高:

1
(check-expect (tall? (rectangle 3 3 "solid" "red")) false)

再次运行,测试通过,同时我们也实现了很不错的测试覆盖率。

明确所有模糊

题目当中的模糊请使用注释来明确。

Criteria

最后,本节会给出函数的评分标准,主要从四个方面:

  • Commit Ready / 提交前检查
    • 代码简洁
    • 所有测试代码不应被注释,桩和模板应被注释
    • 在编辑器里做得草稿最后应被删除
  • Design Completeness / 设计步骤完整
    • 所有 HtDF recipe 提到的步骤应该完整地呈现
  • Internal Quality / 高质量
    • 代码里的每一个设计部分需要即整洁又正确,顺序对应
    • 函数名望文生义
    • 所有测试应通过
    • 测试能做到对程序的全覆盖,不漏分支
  • Problem Satisfied / 满足题目要求
    • 函数设计应遵循题目
    • 如果题目有部分模糊,请准确识别出并在设计过程中将其确定

Practice Problems

这一章的 Recommended Problems:

HtDF P2 - Less Than 5 题解

预计耗时:10 min / 简单

这道题需要我们设计一个函数:接受一个字符串,判断它的长度是否小于5,根据 HtDF recipe 作答,需要完整注释。

按照步骤,我们先思考函数的

  • 签名:String -> Boolean,这里的坑点是返回值类型应为Boolean
  • 目的:produce true if length of s is less than 5,时刻注意需要将目的确切到要返回什么上。

之后再写一些样例,需要覆盖到函数尽可能多的返回可能性,比如:

1
2
3
(check-expect (less-than-5? "helloWorld") false)
(check-expect (less-than-5? "hello") false)
(check-expect (less-than-5? "hell") true)

写一个桩函数:(define (less-than-5? s) true),再将其变为模板:

1
2
(define (less-than-5? s)
(... s))

最后就可以构思函数体了,由于涉及字符串的长度,我们需要string-length表达式;同时又需要做判断,故也需要使用<

1
2
(define (less-than-5? s)
(< (string-length s) 5))

通过测试,完成题目,答案如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
;; String -> Boolean
;; produce true if length of s is less than 5
(check-expect (less-than-5? "") true)
(check-expect (less-than-5? "five") true)
(check-expect (less-than-5? "12345") false)
(check-expect (less-than-5? "eighty") false)

;(define (less-than-5? s) ;stub
; true)

;(define (less-than-5? s) ;template
; (... s))

(define (less-than-5? s)
(< (string-length s) 5))

HtDF P3 - Boxify 题解

预计耗时:15 min / 中等

这道题让我们设计一个函数:传入一个图像,得到一个被矩形包住的图像。更细节地说:通过创建一个outline rectangle,让它的尺寸刚刚好能够overlay住原图像,比原图宽高各大2个像素

由题目描述可知函数名定为boxify

  • 签名:Image -> Image
  • 目的:puts a box around given image. Box is 2 pixels wider and taller than given image.

ps: 目的描述很难遵循标准答案,此处直接贴上来了

之后为他写样例,这部分在这道题挺难的,算是快把函数体都写出来了:

1
2
3
4
5
6
7
8
9
(check-expect (boxify (ellipse 60 30 "solid" "red")
(overlay (ellipse 60 30 "solid" "red")
(rectangle 62 32 "outline" "black"))))
(check-expect (boxify (circle 10 "solid" "red"))
(overlay (rectangle 22 22 "outline" "black")
(circle 10 "solid" "red")))
(check-expect (boxify (star 40 "solid" "gray"))
(overlay (rectangle 67 64 "outline" "black")
(star 40 "solid" "gray")))

写个桩函数:(define (boxify i) (circle 2 "solid" "green"))

桩函数的返回值该怎么写

写任何只要符合返回值类型的值/表达式就行,比如:

  • 如果是Boolean,写truefalse都是可以的。
  • 如果是Number,随便写个数字都行。
  • 如果是Image,捏一个图像就可以。

然后将其变为模板:

1
2
(define (boxify i) 
(... i))

从刚刚写的样例就能想到函数体该怎么写了:

1
2
3
4
5
6
(define (boxify i)
(overlay (rectangle (+ (image-width i) 2)
(+ (image-height i) 2)
"outline"
"black")
i))

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
;; Image -> Image
;; Puts a box around given image. Box is 2 pixels wider and taller than given image.
;; NOTE: A solution that follows the recipe but makes the box the same width and height
;; is also good. It just doesn't look quite as nice.
(check-expect (boxify (circle 10 "solid" "red"))
(overlay (rectangle 22 22 "outline" "black")
(circle 10 "solid" "red")))
(check-expect (boxify (star 40 "solid" "gray"))
(overlay (rectangle 67 64 "outline" "black")
(star 40 "solid" "gray")))

;(define (boxify i) (circle 2 "solid" "green"))

#;
(define (boxify i)
(... i))

(define (boxify i)
(overlay (rectangle (+ (image-width i) 2)
(+ (image-height i) 2)
"outline"
"black")
i))

HtDF P6 - Double Error 题解

预计耗时:7 min / 简单

这道题是道找 Bug 题,也就是说我们需要找出题目里这部分代码的问题,并以最小幅度将其改对。观察代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
;; Number -> Number
;; doubles n
(check-expect (double 0) 0)
(check-expect (double 4) 8)
(check-expect (double 3.3) (* 2 3.3))
(check-expect (double -1) -2)


#;
(define (double n) 0) ; stub

(define (double n)
(* (2 n)))

经过十分细致的逐行检查后,也许会发现最后一行(* (2 n))这里,里面的2 n不应当被套一层括号。