|
提示30 |
|
|
You Can’t Write Perfect Software |
|
这刺痛了你?不应该。把它视为生活的公理,接受它,拥抱它,庆祝它。因为完美的软件不存在。在计算技术简短的历史中,没有一个人曾经写出过一个完美的软件。你也不大可能成为第一个。除非你把这作为事实接受下来,否则你最终会把时间和精力浪费在追逐不可能实现的梦想上。
那么,给定了这个让人压抑的现实,注重实效的程序员怎样把它转变为有利条件?这正是这一章的话题。
每个人都知道只有他们自己是地球上的好司机。所有其他的人都等在那里要对他们不利,这些人乱冲停车标志、在车道之间摇来摆去、不作出转向指示、打电话、看报纸、总而言之就是不符合我们的标准。于是我们防卫性地开车。我们在麻烦发生之前小心谨慎、预判意外之事、从不让自己陷入无法解救自己的境地。
编码的相似性相当明显。我们不断地与他人的代码接合——可能不符合我们的高标准的代码——并处理可能有效、也可能无效的输入。所以我们被教导说,要防卫性地编码。如果有任何疑问,我们就会验证给予我们的所有信息。我们使用断言检测坏数据。我们检查一致性,在数据库的列上施加约束,而且通常对自己感到相当满意。
但注重实效的程序员会更进一步。他们连自己也不信任。知道没有人能编写完美的代码,包括自己,所以注重实效的程序员针对自己的错误进行防卫性的编码。我们将在“按合约设计(Design by Contract)”中描述第一种防卫措施:客户与供应者必须就权利与责任达成共识。
在“死程序不说谎”中,我们想要确保在找出bug的过程中,不会造成任何破坏。所以我们设法经常检查各种事项,并在程序出问题时终止程序。
“断言式编程”描述了一种沿途进行检查的轻松方法——编写主动校验你的假定的代码。
与其他任何技术一样,异常如果没有得到适当使用,造成的危害可能比带来的好处更多。我们将在“何时使用异常”中讨论各种相关问题。
随着你的程序变得更为动态,你会发现自己在用系统资源玩杂耍——内存、文件、设备,等等。在“怎样配平资源(How to Balance Resources)”中,我们将提出一些方法,确保你不会让其中任何一个球掉落下来。
不完美的系统、荒谬的时间标度、可笑的工具、还有不可能实现的需求——在这样一个世界上,让我们安全“驾驶”。
当每个人都确实要对你不利时,偏执就是一个好主意。
——Woody Allen
21 按合约设计
没有什么比常识和坦率更让人感到惊讶。
——拉尔夫•沃尔多•爱默生,《散文集》
与计算机系统打交道很困难。与人打交道更困难。但作为一个族类,我们花费在弄清楚人们交往的问题上的时间更长。在过去几千年中我们得出的一些解决办法也可应用于编写软件。确保坦率的最佳方案之一就是合约。
合约既规定你的权利与责任,也规定对方的权利与责任。此外,还有关于任何一方没有遵守合约的后果的约定。
或许你有一份雇用合约,规定了你的工作时数和你必须遵循的行为准则。作为回报,公司付给你薪水和其他津贴。双方都履行其义务,每个人都从中受益。
全世界都——正式地或非正式地——采用这种理念帮助人们交往。我们能否采用同样的概念帮助软件模块进行交互?答案是肯定的。
DBC
Bertrand Meyer[Mey97b]为Eiffel语言发展了按合约设计的概念[25]。这是一种简单而强大的技术,它关注的是用文档记载(并约定)软件模块的权利与责任,以确保程序正确性。什么是正确的程序?不多不少,做它声明要做的事情的程序。用文档记载这样的声明,并进行校验,是按合约设计(简称DBC)的核心所在。
软件系统中的每一个函数和方法都会做某件事情。在开始做某事之前,例程对世界的状态可能有某种期望,并且也可能有能力陈述系统结束时的状态。Meyer这样描述这些期望和陈述:
l 前条件(precondition)。为了调用例程,必须为真的条件;例程的需求。在其前条件被违反时,例程决不应被调用。传递好数据是调用者的责任(见115页的方框)。
l 后条件(postcondition)。例程保证会做的事情,例程完成时世界的状态。例程有后条件这一事实意味着它会结束:不允许有无限循环。
l 类不变项(class invariant)。类确保从调用者的视角来看,该条件总是为真。在例程的内部处理过程中,不变项不一定会保持,但在例程退出、控制返回到调用者时,不变项必须为真(注意,类不能给出无限制的对参与不变项的任何数据成员的写访问)。
让我们来看一个例程的合约,它把数据值插入惟一、有序的列表中。在iContract(用于Java的预处理器,可从[URL 17]获取)中,你可以这样指定:
/**
* @invariant forall Node n in elements() |
* n.prev() != null
* implies
* n.value().compare To(n.prev().value()) > 0
*/
public class dbc_list {
/**
* @pre contains(aNode) == false
* @post contains(aNode) == true
*/
public void insertNode(final Node aNode) {
// ...
这里我们所说的是,这个列表中的节点必须以升序排列。当你插入新节点时,它不能是已经存在的,我们还保证,在你插入某个节点后,你将能够找到它。
你用目标编程语言(或许还有某些扩展)编写这些前条件、后条件以及不变项。例如,除了普通的Java构造体,iContract还提供了谓词逻辑操作符——forall、exists、还有implies。你的断言可以查询方法能够访问的任何对象的状态,但要确保查询没有任何副作用(参见124页)。
|
DBC与常量参数 后条件常常要使用传入方法的参数来校验正确的行为。但如果允许例程改变传入的参数,你就有可能规避合约。Eiffel不允许这样的事情发生,但Java却允许。这里,我们使用Java关键字final指示我们的意图:参数在方法内不应被改变。这并非十分安全——子类有把参数重新声明为非final的自由。另外,你可以使用iContract语法variable@pre获取变量在进入方法时的初始值。 |
这样,例程与任何潜在的调用者之间的合约可解读为:
如果调用者满足了例程的所有前条件,例程应该保证在其完成时、所有后条件和不变项将为真。
如果任何一方没有履行合约的条款,(先前约定的)某种补偿措施就会启用——例如,引发异常或是终止程序。不管发生什么,不要误以为没能履行合约是bug。它不是某种决不应该发生的事情,这也就是为什么前条件不应被用于完成像用户输入验证这样的任务的原因。
|
提示31 |
|
|
Design with Contracts |
|
在“正交性”(34页)中,我们建议编写“羞怯”的代码。这里,强调的重点是在“懒惰”的代码上:对在开始之前接受的东西要严格,而允诺返回的东西要尽可能少。记住,如果你的合约表明你将接受任何东西,并允诺返回整个世界,那你就有大量代码要写了!
继承和多态是面向对象语言的基石,是合约可以真正闪耀的领域。假定你正在使用继承创建“是一种(is-a-kind-of)”关系,即一个类是另外一个类的“一种”。你或许会想要坚持Liskov替换原则(Lis88):
子类必须要能通过基类的接口使用,而使用者无须知道其区别。
换句话说,你想要确保你创建的新子类型确实是基类型的“一种”——它支持同样的方法,这些方法有同样的含义。我们可以通过合约来做到这一点。要让合约自动应用于将来的每个子类,我们只须在基类中规定合约一次。子类可以(可选地)接受范围更广的输入,或是作出更强的保证。但它所接受的和所保证的至少与其父类一样多。
例如,考虑Java基类java.awt.Component。你可以把AWT或Swing中的任何可视组件当作Component,而不用知道实际的子类是按钮、画布、菜单,还是别的什么。每个个别的组件都可以提供额外的、特殊的功能,但它必须至少提供Component定义的基本能力。但并没有什么能阻止你创建Component的一个子类型,提供名称正确、但所做事情却不正确的方法。你可以很容易地创建不进行绘制的paint方法,或是不设置字体的setFont方法。AWT没有用于抓住你没有履行合约的事实的合约。
没有合约,编译器所能做的只是确保子类符合特定的方法型构(signature)。但如果我们适当设定基类合约,我们现在就能够确保将来任何子类都无法改变我们的方法的含义。例如,你可能想要这样为setFont建立合约,确保你设置的字体就是你得到的字体:
/**
* @pre f != null
* @post getFont() == f
*/
public void setFont(final Font f) {
// ...







