1.2 功能的抽象

为了能解决大型软件项目,必须要将其拆分成更小的部分。其中一种可以将问题拆分成较小部分的方法是:将其分解为一组可以相互协作的功能。这一方法被称为功能抽象(functional abstraction),或者叫作过程抽象(procedural abstraction)。

让我们通过一个简单的例子来理解编写函数其实是一个抽象的实例。假设你要写一个需要计算某个值的平方根的程序,你知道应该怎么做吗?你是否知道求平方根的具体算法并不重要,因为Python数学模块(math)中提供了平方根函数。

import math
...
answer = math.sqrt(x)

你可以很有信心地使用sqrt函数来求平方根,因为你知道它会做什么,即使你可能并不知道它是如何完成计算的。在这里,你只关注了sqrt函数的某些方面(做什么),而忽略了另一些细节(如何完成)。这就是抽象。

分离一个组件的能够做什么和它会怎么去完成这一任务的关系,是一种特别强大的抽象形式。如果我们将一个函数想象成一个服务,那么使用这个函数的程序就可以被称为服务的客户端(client),并且这个函数被实际执行的代码就可以被称为服务的实现(implement)。在客户端工作的程序员只需要知道这个函数的功能即可,并不需要知道该函数工作的任何过程细节。因此对于客户端使用者来说,这个函数就像一个能执行所需操作的神奇的黑盒子。类似地,这个函数的实现者也不需要关心应该如何使用该函数,可以自由地专注于这个函数应该如何完成其任务的各种细节,而不用在意实际调用函数的位置和原因。

为了实现这种清晰的分离,客户端使用者和实现者必须对要完成的功能达成一致,也就是说,他们必须对客户端代码和具体实现之间的接口有共同的理解。这个接口就像是一种将函数的两个不同角度的视图分开的抽象屏障。图1.1所示为用图形化的方式呈现Python字符串分割方法(或字符串模块中的等效的分割函数)。图里显示了这个方法/函数需要一个字符串作为必需参数,以及另一个字符串作为可选参数,最后返回一个字符串。使用这个split方法/函数的客户端使用者并不需要关心它的工作方式(也就是框内的内容),只需要知道应该如何使用它。因此,我们需要的是仔细描述函数将做什么,而不必描述函数将会如何完成它的工作。这种描述被称为规范(specification)。

图1.1 split功能的黑箱接口示意图

很明显,描述函数的调用方式是规范的一个重要部分。也就是说,我们需要知道函数的名称、需要什么参数以及函数返回的内容。这些信息也可以被称为函数的签名(signature)。除了签名,规范还需要精确描述函数的功能。我们需要知道调用函数所提供的参数与结果是如何相关的。这种关联信息,很多时候是以非正式的格式完成。例如,假设你正在编写Python数学模块(math)中的平方根函数。让我们来看看下面这个函数的规范。

def sqrt(x):
    """Computes the square root of x"""

这并不是一个很好的函数规范。这种非正式格式描述的问题在于它们往往并不完整,且含糊不清。要知道,一个良好的规范应当可以使客户端使用者和实现者(即使他们是同一个人)仅仅依靠函数的规范,就能够完成各自的任务。这也是抽象过程如此有用的原因。如果这个函数计算x的平方根,但没有返回结果怎么办?纯理论上来说,这也是符合规范的;但这样的话,这个方法对客户端使用者来说没有任何用处。同样,sqrt(16)可以返回-4吗?如果函数的实现只适用于浮点数,但客户端使用了整数作为参数调用该函数,应该怎么办?如果导致了程序崩溃,那是谁的错?如果客户端使用负数作为参数调用此函数会发生什么?它会返回一个负数,还是会直接崩溃?如果客户端使用字符串作为参数调用此函数会发生什么?可见,这种简单、非正式格式的描述,并没有告诉我们如何理解这个函数。

这可能听起来像是挑刺。因为通常每个人都“理解”平方根功能该做些什么。所以,如果我们有任何疑问,都可以通过查看该函数的源代码或通过实际使用来证明我们的假设(例如尝试计算sqrt(-1)并查看会发生什么)。但是,做这些事情就会打破客户端使用者和实现者之间的抽象隔离。而强制客户端程序员去理解函数的细节实现,也就意味着他或她必须去厘清这些代码的所有细节并进行处理,从而失去抽象的优势。另一方面,如果客户端程序员只是依赖于代码实际执行的结果(通过尝试它),他或她就有可能做出一些实现者无法预期的假设。例如,实现者发现了计算平方根的更好方法,因此修改了代码实现,那么客户端使用者对某些“边缘”行为的假设可能就不再正确。但如果保持抽象隔离,客户端代码和实现代码都可以随意修改,因为抽象隔离能够确保程序继续正常运行。这种理想的情况被称为实现独立性(implementation independence)。

希望下面这个令人刻骨铭心的反例,可以让你深刻地认识到在大型编程时,组件的精确规范是多么重要。美国航空航天局1999年的火星气候轨道飞行任务,由于假设与实现不匹配而造成了巨大的损失。原因很简单,只是因为一个模块需要使用英制单位来获得信息,但被假设为可以用公制单位。在大多数情况下,仔细定义的规范是绝对必要的。因为只要规范没有被明确地说明或者被严格地遵守,意想不到的灾难就会出现。

所以很显然,我们需要一种比非正式描述更好的东西来更好地表述规范。函数规范通常包含先验条件和后置条件。先验条件是关于调用函数时计算状态的假设。后置条件则是关于函数完成后的真实情况的陈述。下面就是sqrt函数包含先验条件和后置条件的示例规范:

def sqrt(x):
    """Computes the square root of x.
    pre: x is an int or a float and x >= 0
    post: returns the non-negative square root of x"""

先验条件用来陈述实现中所做的任意假设,尤其是关于函数参数的假设。为了完整地描述,它一般会使用这个参数的正式名称(在这个例子里是x)来描述参数。后置条件就需要描述函数实现代码中使用输入参数完成了什么。前后条件加在一起,对函数的描述就成为客户端与实现之间的一种契约。如果客户端使用者保证在调用函数时满足先验条件,那么实现者就保证在函数结束时也将满足后置条件。因此,这种使用先验条件和后置条件来描述系统中的模块的方式也被称为契约式设计(design by contract)。

先验条件和后置条件是程序断言(assertion)的一种特定的文档类型。断言,是一段关于计算状态的声明,并且在程序中的特定点处,这个计算状态为真。在函数执行之前,先验条件必须为真,并且后置条件也必须为真。稍后,我们将会看到程序在其他地方非常有价值的文档化断言。

如果你读得足够仔细,你可能会觉得上面sqrt函数的示例中的后置条件有点不对劲,因为它描述了这个函数应该做什么。严格来说,断言不应该说明函数的作用,而是应该说明程序中给定的点,现在什么是真的。因此,将后置条件表示为post:RETVAL ==√x可能更加正确,其中RETVAL用来表示函数的返回值。尽管严格来说,这样描述不太准确,但大多数程序员都倾向于像我们这个例子中一样,提供不太正式的后置条件。鉴于这种非正式风格更受欢迎,而且信息量也没有变少,我们在后面将继续使用这种“返回这个、那个和其他”的形式来表述后置条件。当然,如果你坚持应该严格使用完美的断言的话,可以做一些必要的改变。

现在,我们可以发现一个关于规范中先验条件和后置条件的重要观点:规范的重点在于它提供了对函数或其他组件的简洁和精确的描述。如果规范都模糊不清,或者比实际中实现的代码更长、更复杂的话,我们什么都得不到。数学符号往往是简洁而精确的,所以它们通常在规范中非常有用。实际上,一些软件工程方法采用完全正式的数学符号来描述所有系统组件,即形式化方法(formal method),这样可以允许用数学的方式陈述和证明程序的属性,提高了开发过程的精确性。在最好的情况下,这样也可以证明程序的正确性,也就是程序的代码忠实地实现了它的规范。然而,使用这种方法需要相当强大的数学功底,既然它还没有在整个行业中通用,那么我们目前将继续使用这种不太正式的规范,只在需要、合适且有用的时候使用众所周知的数学和编程符号。

另一个重要的观点就是:在代码中放置规范。在Python里,开发人员有两种方法可以将注释放入代码中:常规注释(用前导的#符号表示)和文档字符串(模块顶部、紧跟在函数名或类名之后的字符串表达式)。文档字符串将和它们附着的各种对象一起被打包,从而方便之后在运行时随时查看。正是由于文档字符串也被同时用于实现Python内部帮助文档,以及被PyDoc标准文档模块用来自动创建API文档,因此它可以成为一个特别好的媒介来定义规范。一般来说,对客户端程序员有用的信息,应当包含在文档字符串中;而仅供函数实现者使用的信息,应该使用内部注释。

契约式设计的基本思想是:如果在调用函数时满足函数的先验条件,则在函数结尾的后置条件也必须满足。如果不能够满足先验条件,则万事皆休。这就产生了一个有趣的问题:当不能满足先验条件时,这个方法应该做些什么?从规范的角度来看,在这种情况下这个方法做什么都无所谓,可以说是“松了一口气”。但如果你是实现者,你可能会想忽略掉不满足的先验条件。这样做的话,有可能会意味着执行这个函数会导致程序立即崩溃;也有可能代码虽然能够继续运行,但会产生一些无意义的结果。不论是哪一种结果,其实都不太好。

一个更好的应对方法是:采用防御性编程实践。因为有未满足的先验条件,说明程序里存在错误,所以你应该能够检测到这种错误并处理它,而不是无动于衷地忽略这种情况。但是这个应对方法应该怎么实现呢?例如,我们可以让它输出错误信息。因此在sqrt函数里就可能会有下面这样的代码:

def sqrt(x):
    ...
    if x < 0:
       print 'Error: can't take the square root of a negative'
    else:
       ...

输出错误消息的弊端是调用程序无法知道出现了什么问题。例如,这个错误消息可能只会出现在程序生成的报告里,甚至可能会被忽视掉。在实际项目中,很可能会在图形程序里调用通用库,如sqrt函数,在这样的情况下,这个错误消息就根本不会出现在任何地方。

在大多数的情况下,方法被设计成向外输出消息的形式并不太合适(除非这个函数的规范里定义了需要输出某些东西)。更理想的状况是:函数能以某种方式表示发生了错误,并且能够让客户端程序决定如何处理这个问题。对于某些程序,遇到错误的正确响应可能会终止程序并输出错误消息;而在其他情况下,程序也许能够从错误中恢复并继续运行。这样的选择结果应该只能由客户端做出。

一个方法可以通过多种方式发出错误信号,例如返回一个超出范围的结果。下面就是这样的一个例子:

def sqrt(x):
    ...
    if x < 0:
       return -1
    ...

既然sqrt函数的规范明确表示返回值不能为负,那么值-1就可以用来指示错误。客户端代码可以凭借它返回的结果来确定是否正常。另一种方法是,使用一个全局(global,程序的所有部分都能访问)变量来记录错误。客户端代码在每次操作后,都会去检查这个全局变量的值,来确认是否存在错误。

当然,用这种特殊的检测方法来“检查错误”有一个问题:客户端程序可能由于决策结构而不断地去检查错误以致变得混乱。例如客户端的代码逻辑可能会变成下面这样:

x = someOperation()
if x is not OK:
    fix x
y = anotherOperation(x)
if y is not OK:
    abort
z = yetAnotherOperation(y)
if z is not OK:
    z = SOME_DEFAULT_VALUE

可以看出,每次操作之后的错误检查,已经多到模糊了原始算法的意图的地步。

大多数现代编程语言都包含异常处理(exception handling)机制,它为程序运行过程中出现的错误提供了一种优雅的处理方法。异常处理背后的基本思想是程序错误不会直接导致“崩溃”,而是将程序的控制权转移到一个被称为异常处理程序(exception handler)的特殊部分。这个异常处理程序特别有用的是:让客户端程序不必显式地去检查是否发生了错误。实际上,客户端只需要说:“如果出现任何错误,这里是我想要执行的代码。”然后,在运行时系统会确保在发生错误时调用适当的异常处理程序。

在Python里,运行时的错误会生成异常对象(exception object)。程序使用try语句来捕获和处理这些错误。例如,取负数的平方根会导致Python生成ValueError(值错误异常)——这是一个Python的通用Exception(异常)类的子类。如果客户端程序没有处理此异常,程序将会终止。下面就是交互时发生的情况:

>>>sqrt(-1)
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
ValueError: math domain error
>>>

当然,程序里也可以“捕获”try语句中引起的异常:

>>> try:
...    sqrt(-1)
... except ValueError:
...    print "Ooops, sorry."
...
Ooops, sorry.
>>>

当执行到try下方缩进的这条语句的时候,如果发生错误,Python会查看错误是否与任一except子句中列出的类型匹配。如果有,就执行第一个匹配的except语句块。但是,如果没有匹配到任何一个except子句,则程序将停止并显示错误消息。

要利用异常处理来验证先验条件,我们只需要在判断决策中验证先验条件,并且生成适当的异常对象。这称为引发异常(raising an exception),由Python里的raise语句完成。raise语句非常简单:raise <expr>。其中<expr>是生成包含有关错误信息的异常对象的表达式。当执行raise语句时,它会让Python解释器中断当前操作并将控制权转移到异常处理程序。如果找不到合适的处理程序,则程序终止。

Python库中的sqrt函数会通过这样的检查来确保其参数为非负数,并且参数具有正确的类型(int或float)。因此,sqrt函数的代码可以包含下面这些检查语句:

def sqrt(x):
    if x < 0:
        raise ValueError('math domain error')
    if type(x) not in (type(1), type(1L), type(1.0)):
        raise TypeError('number expected')
    # compute square root here

请注意,用这些条件检查是不需要else语句的。因为当raise语句执行时,它有效地终止了该函数,所以只有满足先验条件时才会执行“计算平方根”的部分。

通常情况下,检测到先验条件违规时引发何种类型的异常并不重要。重要的是,错误应该被尽可能早地诊断出来。Python提供了一个将断言直接嵌入代码的语句——assert(断言)。它输入一个布尔表达式(Boolean expression),当表达式计算结果不为True时,就会引发AssertionError异常。使用assert语句使得强制执行先验条件变得特别容易。

def sqrt(x):
    assert x >= 0 and type(x) in (type(1), type(1l), type(1.0))
    ...

正如你所见,assert语句是一种将断言直接插入代码的非常简便的方法。它非常有效地将先验条件(和其他断言)的文档转换为额外的测试,有助于确保程序依照规范正常运行。

这种防御性编程有个潜在的缺点:它增加了程序执行的额外开销。因为每次调用函数的时候,都会额外消耗几个CPU周期来检查先验条件。然而,鉴于现代处理器的速度不断提高以及错误程序的潜在危险不断增加,这通常是值得付出的代价。另外,assert语句还有一个好处:可以在需要的时候关闭断言检查。在命令行执行Python的时候,加上-O开关就可以让解释器跳过所有断言的检查。也就是说,我们可以在程序测试时使用断言,然后在认为系统能够正常工作并投入到生产环境的时候将其关闭。

当然,在测试过程中检查断言,而在生产系统中将其关闭,就好像在有安全网的时候,在距离地面大约3米的地方练习一个特技,然后在刮风的日子里、没有安全网的情况下,在离地面大约30米的地方表演这个特技。在测试期间捕获错误非常重要,但在系统使用时继续捕获它们更为重要。因此,我们的建议是随时随地地使用断言并随时保持打开断言检查。

你可能已经学会了一种非常流行的程序设计方法——自上而下的设计(top-down design)。自上而下的设计本质上是通过功能抽象来将应用程序的大问题分解为更小、更易于管理的组件。例如,假设你正在开发一个帮助你老师进行评分的程序。你的老师希望这个程序能够输入一组考试成绩,并且输出一份总结学生表现的报告。具体而言,程序输出的报告里应该包含以下有关的统计信息。

高分,这是数据集中的最大数字。

低分,这是数据集中的最小数字。

平均值,这是数据集的“平均”分数。它通常被表示为,并且由下面这个公式计算得到:

即所有的得分(xi表示第i个得分)之和除以统计的分数个数(n)。

标准差,这是衡量分数分布情况的指标。标准偏差s可以由下面这个公式算出:

在这个公式中,是平均值,xi表示第i个数据值,n是数据值的数量。这个公式看起来很复杂,但计算起来并不难。表达式是单个元素与平均值的“偏差”的平方。分数的分子也就是所有数据值的偏差(平方之后)的和。

在刚开始编写这个程序的时候,你可以开发一个包含以下功能的简单算法:

Get scores from the user
Calculate the minimum score
Calculate the maximum score
Calculate the average (mean) score
Calculate the standard deviation

假设你正在与朋友合作开发此程序,你可以将这个算法划分为多个部分,并且每个部分都能够与程序里的其他部分协作。然而,在开始真正的工作之前,你需要一个更完整的设计来确保每个人开发的部件都能够组合在一起,并且解决整个问题。通过自上而下的设计,你可以把算法中的每一行当作一个单独的函数来编写。这个设计也将会包含每个方法的规范。

一个显而易见的解决方案是:把考试成绩存储在列表中,然后把这个列表作为参数,传递到各个方法里去。使用这种解决方案的话,可以参考下面的设计示例:

# stats.py
def get_scores():
    """Get scores interactively from the user
    post: returns a list of numbers obtained from user"""
def min_value(nums):
    """ find the minimum
    pre: nums is a list of numbers and len(nums) > 0
    post: returns smallest number in nums"""
def max_value(nums):
    """ find the maximum
    pre: nums is a list of numbers and len(nums) > 0
    post: returns largest number in nums"""
def average(nums):
    """ calculate the mean
    pre: nums is a list of numbers and len(nums) > 0
    post: returns the mean (a float) of the values in nums"""
def std_deviation(nums):
    """calculate the standard deviation
    pre: nums is a list of numbers and len(nums) > 1
    post: returns the standard deviation (a float) of the values
          in nums"""

有了这些方法的规范,你和你的朋友就应该能够轻松地分配这些方法并且很快地完成这个程序。让我们实现其中一个方法,看看它应该是什么样的。如std_deviation方法的实现示例:

def std_deviation(nums):
    xbar = average(nums)
    sum = 0.0
    for num in nums:
        sum += (xbar - num)**2
    return math.sqrt(sum / (len(nums) - 1))

可以看到,这段代码的运行依赖于average函数。由于我们已经定义了这个方法,因此可以放心地在这里直接使用它,从而避免了重复工作。这里还使用了简化了的+=(加法赋值)运算符;这是求和的简写方式。也就是说x +=y语句和x=x+y语句会产生相同的结果。

程序的其余部分将留给你来完成。如你所见,在这个程序里自上而下的设计和方法的规范齐头并进。所以,在设计确定了必要的功能时,规范保证了正式且确定的设计。因此,程序的各个部分可以被单独处理。你肯定能轻而易举地完成这个程序。

为了使规范有效,规范必须同时说明客户端和实现者的期望。任何在客户端可见的影响都应在后置条件中被描述出来。例如,假设std_deviation函数是下面这样实现的:

def std_deviation(nums):
    # This is bad code. Don't use it.
    xbar = average(nums)
    n = len(nums)
    sum = 0.0
    while nums != []:
        num = nums.pop()
        sum += (xbar - num)**2
    return math.sqrt(sum / (n - 1))

这段代码中使用了Python列表的pop方法。对nums.pop()的调用将会返回列表中的最后一个数字,并从列表中删除该项。之后循环继续,直到处理完列表中的所有元素。这个版本的std_deviation能够返回正确的值,因此它似乎符合先验条件和后置条件指定的契约。但是,作为参数传递的列表对象nums是可变的,而且对列表的任何修改都将对客户端可见。所以,这段代码的使用者会非常惊讶,因为他们会发现调用std_deviation (examScores)会导致examScores中的所有值被删除!

这类函数调用和程序其他部分之间的相互影响被称为副作用(side effects)。在这个例子里,删除examScores中的元素是调用std_deviation函数的副作用。一般来说,应当避免方法中的副作用,但完全不准修改又禁止得太严格了。有些方法就是需要产生副作用,列表类中的pop方法就是一个很好的例子。它被用来获取一个值,然后就像副作用一样,从列表中删除这个元素。因此,有一个至关重要的事情需要注意,方法中的任何副作用都应在其后置条件中指出。由于std_deviation的后置条件并没有包含会修改nums的任何内容,因此这个代码实现隐性地违反了契约。方法的可见效果应该只是在后置条件中描述的那些。

顺便说一下,输出消息或将信息放在文件中也是副作用的例子。正如上面提到的,方法通常不应该输出任何东西,除非这是它声明的功能中的一部分,这也是一个(可能)未注明副作用的特殊情况。