作为黑箱抽象的过程

通过最大正方形的扩展示例,讲解如何把复杂问题分解为子问题、函数作为黑箱抽象的思想、内部定义与变量作用域。

过程即抽象

到目前为止,我们定义过的函数都是各自完成单一计算的(例如 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:

guessleftovernext guess
213-4 = 92+1 = 3
313-9 = 43+1 = 4
413-16 = -34+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。

如果你觉得助记口诀有用,可以把外层函数想象成家长,内层函数想象成婴儿。家长可以把东西借给婴儿(比如手机),但婴儿不会让家长拿走他的玩具。

玩手机的婴儿 婴儿玩具