作为黑箱抽象的过程
通过最大正方形的扩展示例,讲解如何把复杂问题分解为子问题、函数作为黑箱抽象的思想、内部定义与变量作用域。
过程即抽象
到目前为止,我们定义过的函数都是各自完成单一计算的(例如 square、fib 和 factorial)。你也可以把不同的函数组合起来,创造出复杂得多的函数,让每个函数负责原问题的一个子问题。本节将以这样一个函数为例,探讨”函数即抽象”的思想。
扩展示例:最大正方形
Charlie 有大量块状(不是排状)巧克力,他想把这些巧克力块摆成尽可能大的正方形阵列,好在朋友们面前炫耀!假设 Charlie 有 13 块巧克力,那么能摆出的最大正方形是 3x3 = 9(如左图所示),还剩下 4 块。
Charlie 想知道:“给我一定数量的巧克力块,我的正方形边长最大能是多少?“我们可以把这个问题表示为一个函数 (largest-square total guess)。largest-square 接受两个实参:total 表示 Charlie 有多少块巧克力(在上面的例子中,total 是 13);guess 表示你对最大边长的初始猜测。这个函数将输出你的巧克力正方形能有的最大边长(这个例子中是 3)。我们将把这个函数拆分成若干子问题,并在本节剩余部分把它们拼装起来。
最大正方形:概览
有一点看起来可能有点奇怪,那就是 guess 这个实参似乎是多余的。你完全可以只用一个 total 实参写出同样功能的函数。我们加入 guess 是为了给这个问题增加一点额外的复杂度。可以把 guess 看作 Charlie 对边长上限的估计。在 13 块巧克力的原始例子中,假设 Charlie 猜测最大边长是 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),会发生什么?
选项
- 报错。—— 求值
largest-square的过程体时,我们需要所有辅助过程都已定义!(正确答案) - 返回 3。—— 求值
largest-square的过程体时,我们需要所有辅助过程都已定义!
“等等等等,你刚定义了一个函数,但它调用了其他还没定义的函数!我们还没有定义 good-enough? 或 improve-guess!”
没错,里面用到的函数定义还不完整,但请注意,我们(程序员)能看懂这个函数在做什么!我们已经把”寻找最大正方形”的问题分解成了若干小问题,比如”够接近了吗?“和”改进我们的猜测”。我们本可以按别的方式拆分代码,比如每 3 行或每 5 行一块,但那样每个子问题就没有明确的任务了。把代码拆成连贯的、可辨识的任务,这一点至关重要。
这个重要思想我们在末尾还会再次探讨,但首先让我们把定义补完整。
最大正方形:子问题
是时候做必要的功课,让这个函数真正跑起来了!
(define (largest-square total guess)
(if (good-enough? total guess)
guess
(largest-square total (next-guess guess))))
问题:
我们要定义函数 good-enough?,它接受两个输入:total(你拥有的巧克力块总数)和 guess(你当前的猜测)。当下一个整数所需的块数超过 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))
________________________)
选项
(< total (square (next-guess guess)))—— 没错!(< total guess)—— 我们总得以某种方式把下一个猜测考虑进来。(< total (next-guess guess))—— 我们需要对下一个猜测取平方。
接下来我们定义函数 next-guess,它接受你当前的猜测,返回下一个要尝试的数:
(next-guess 1) ;; 应返回 2
(next-guess 3) ;; 应返回 4
选择用什么代码填入空白:
(define (next-guess guess)
________________________)
选项
guess—— 我们的过程总得对guess做点什么吧。(+ guess 1)—— 很好!(- guess 1)—— 我们的过程应该增大guess。
函数即抽象
从正方形巧克力的例子中,我们能学到什么呢?回想一下,当我们第一次只定义 largest-square 时,我们就能理解这个过程在做什么,而不必真的知道 good-enough? 或 next-guess 是怎么实现的。我们可以认为这些函数对我们而言是抽象出来的:我们知道它会输出什么,但不在乎它是怎么实现的。只要它们行为正确,我们就满意了!
这个道理在生活中同样适用。打开电视时,我们从来不会想”哦,电视能看是因为我们让电子穿过屏幕,电子在电磁场引导下让我们看到图像!“。我们通常想的是”我按这个按钮,就能看电影了”。我们不需要知道电视怎么工作就能使用它;它的实现细节对我们而言是抽象掉的。
内部定义
我们已经定义了一个相对复杂、依赖其他过程的过程。现在看看能不能改进代码的组织方式!
请注意,我们对 good-enough? 和 next-guess 的定义非常专属于 largest-square 这个问题,很难找到别的函数会用到它们。而且,当 Charlie 想求最大正方形时,他会调用 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) 时会发生什么?
选项
- 4 —— 再想想!
- 5 —— 再想想!
- 报错。—— 由于
next-guess只定义在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))))
前面我们说过,good-enough? 和 next-guess 只定义在 largest-square 函数内部。既然这些函数在 largest-square 里面了,我们就可以把其他冗余的部分也去掉。注意,next-guess 和 good-enough? 接受的 total 和 guess 与传进 largest-square 的是同一组值。把两个辅助函数中冗余的实参去掉后,得到:
(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))))
如何搞清楚一个函数能访问什么、不能访问什么?我们将在 Unit 3 中用大量篇幅讨论这个问题。当一个函数定义在另一个函数内部时,里面的函数可以访问外层函数的变量和形参。因为 next-guess 定义在 largest-square 内部,所以 next-guess 可以访问 largest-square 的形参 total 和 guess。
如果你觉得助记口诀有用,可以把外层函数想象成家长,内层函数想象成婴儿。家长可以把东西借给婴儿(比如手机),但婴儿不会让家长拿走他的玩具。