1.1.8 函数作为黑箱抽象
sqrt是我们用一组手工定义的函数实现计算过程的第一个例子。请注意,sqrt_iter的声明是递归的,也就是说,该函数的定义基于它自身。基于一个函数自身来定义它的想法可能令人感到不安,这种“循环”定义有意义的理由不清晰:它是否完全地刻画了一个能由计算机实现的计算过程呢?我们将在1.2节更深入地讨论这一问题。现在我们先看看sqrt实例表现的另外一些重要情况。
可以看到,平方根的计算问题可以自然地分解为若干子问题:怎么才能说一个猜测足够好,怎样去改进一个猜测等。这些工作中的每一个都通过一个独立函数完成。整个sqrt程序可以看作一簇函数(如图1.2所示),它们直接反映了从原问题到子问题的分解。
图1.2 sqrt程序的函数分解
这一分解策略的重要性,不仅在于把一个问题分解成几个部分。当然,我们总可以拿来一个大程序,把它切分成几个部分——最前面10行,随后10行,再后面10行等。其实,这里的关键是,分解得到的每个函数完成了一件可以清晰说明的工作,这使它们可以被用作定义其他函数的模块。例如,当我们基于square定义函数is_good_enough时,就是把square看作“黑箱”。我们暂时不关心这个函数如何得到结果,而是只注意它能计算平方值的事实。如何计算平方的细节被隐去不提,推迟到以后再考虑。确实如此,如果只看is_good_enough函数,与其说square是函数,不如说它是一个函数的抽象,即所谓函数抽象。在这一抽象层次上,任何计算平方的函数都同样可以使用。
这样,如果只考虑返回值,下面这两个求数值平方的函数并无差别。两者都以一个数值作为参数,产生这个数的平方作为函数值[22]。
由此可见,一个函数应该能隐藏一些细节。这使该函数的使用者有可能不必自己写这些函数,而是从其他程序员那里作为黑箱接受它。在使用一个函数时,用户应该不需要知道它是如何实现的。
局部名
函数的用户不必关心的实现细节之一,就是实现者为函数所选用的形式参数的名字,也就是说,下面两个函数应该是不可区分的:
这一原则(函数的意义不依赖于其作者为形式参数选用的名字)从表面看是自明的,但其影响却很深远。最直接的影响是,函数的形式参数名必须局部于有关的函数体。例如,我们在前面平方根函数的is_good_enough声明里用到名字square:
is_good_enough作者的意图是确定第一个参数的平方是否位于第二个参数的给定误差的范围内。可以看到,is_good_enough的作者用名字guess表示其第一个参数,用x表示第二个参数,而square的实际参数就是guess。如果square的作者也用x(例中确实如此)表示参数,可以想到,is_good_enough里的x必须与square里的x不同。运行函数square绝不应该影响is_good_enough里用的那个x的值,因为在square完成计算后,is_good_enough可能还需要x的值。
如果参数不是局部于它们所在的函数体,那么square里的参数x就会与is_good_enough里的参数x相互干扰。如果这样,is_good_enough的行为方式就依赖我们所用的square的具体版本,square也就不是我们希望的黑箱了。
函数的形式参数在函数声明里扮演着非常特殊的角色,形式参数的具体名字其实并不重要,这样的名字称为是约束的。因此我们说,函数声明约束了它的所有形式参数。如果在一个函数声明里把某个约束名统一换名,该函数声明的意义不变[23]。如果一个名字不是约束的,我们就说它是自由的。一个名字的声明被约束的那一集语句称为这个名字的作用域。在一个函数声明里,被声明为函数形式参数的约束名都以该函数的体为作用域。
在上面is_good_enough的声明里,guess和x是约束的名字,而abs和square是自由的。要保证is_good_enough的意义与我们对名字guess和x的选择无关,只需要求这些名字都与abs和square不同(如果我们把guess重命名为abs,就会因为捕获了名字abs而引进一个错误,因为这样做把一个原来自由的名字变成约束的了)。is_good_enough的意义当然与其中自由名字的选择有关,这个意义显然依赖于(这个声明之外的)一些事实:名字abs引用一个函数,该函数能求出数值的绝对值。如果我们把is_good_enough声明里的abs换成math_cos(基本余弦函数),它计算的就是另一个不同的函数了。
内部声明和块结构
到现在为止,我们只分离出了一种可用的名字:函数的形式参数是其函数体里的局部名字。这个平方根程序还表现了另一种情况,我们也会希望能控制其中名字的使用。目前这个程序由几个相互独立的函数组成:
问题是,在这个程序里,只有一个函数对sqrt的用户是重要的,那就是这里的sqrt。其他函数(sqrt_iter、is_good_enough和improve)只会干扰用户的思维,因为在需要与平方根程序一起使用的其他程序里,它们再不能声明另一个名为is_good_enough的函数作为程序的一部分了,因为在sqrt里用了这个名字。当许多分别工作的程序员一起构造大型系统时,这一问题就会变得非常严重。举例说,在构造一个大型数值函数库时,许多数值函数都需要计算一系列近似值,因此它们都可能需要名字为is_good_enough和improve的函数作为辅助函数。由于这些情况,我们自然会希望把子函数也局部化,把它们隐藏到sqrt里面,使sqrt可以与其他同样采用逐步逼近方法的函数共存,即使它们中每一个都有自己的is_good_enough函数。为使这件事成为可能,我们允许在一个函数声明里包含一些局部于这个函数的内部声明。例如,在解决平方根问题时,我们可以写:
任意一对匹配的花括号表示一个块,块内部的声明局部于这个块。这种嵌套的声明结构称为块结构,这是最简单的名字包装问题的正确处理方法。实际上,这里还潜藏着一个很好的情况:除了可以内部化辅助函数的声明,我们还可能简化它们。因为x在sqrt的声明中是受约束的,函数is_good_enough、improve和sqrt_iter也都声明在sqrt内部,也就是在x的定义域里。这样,明确地把x在这些函数之间传来传去就没有必要了。我们可以让x作为这些内部声明里的自由变量,如下所示。这样,在外围的sqrt被调用时,x由其实际参数得到自己的值。这种做法称为词法作用域[24]。
我们将广泛使用块结构,借助它把大程序分解为一些容易掌握的片段[25]。块结构的思想源自程序设计语言Algol 60,这种结构也出现在各种最新的程序设计语言里,是帮助我们组织大程序的结构的一种重要工具。