程序员修炼之道——第四章 注重实效的偏执

注重实效的偏执

  1. 按合约设计: 以后再总结
  2. 死程序不会说谎: 如果出问题,快速的让你的程序死掉
  3. 断言式编程: 开着断言
  4. 何时使用异常:
  5. 怎样配平资源: 要有始有终

按合约设计

与计算机系统打交道很困难。与人打交道更困难。但作为一个族类,我们花费在弄清楚人们交往的问题上的时间更长。在过去几千年中我们得出一些解决办法可应用于编写软件。确保坦率地最佳方案之一就是合约
合约既规定你地权利与责任,也归定了你地工作时数和你必须遵循地行为准则。作为回报,公司付给你薪水和其他津贴。双方都履行其义务,每个人都从中受益。
或许你有一份雇佣合约,规定了你地工作时数和你必须遵循地行为准则。作为回报,公司付给你薪水和其他津贴。双方都履行其义务,每个人都从中受益。

DBC

Bertrand Meyer 为Eiffel语言发展了按合约设计地概念。这是一种简单而强大地技术,它关注地是用文档记载(并约定)软件模块地权利于责任,以确保程序正确性。什么是正确的程序?不多不少,做它声明要做的事情的程序。用文档记载这样的声明,并进行校验,是按合约设计(简称DBC)的核心所在。
软件系统中的每一个函数和方法都会做某件事情。在开始做某事之前,例程对世界的状态可能有某种期望,并且也可能有能力陈述系统结束时的状态。
前条件(precondition)。为了调用例程,并须为真的条件;例程的需求。在其前条件被违反时,例程决不应被调用,传递号数据是调用者的责任。
后条件(postcondition)。例程保证会做的事情,例程完成时世界的状态。例程有后条件这一事实意味着它会结束:不允许有无限循环。
类不变项(class invariant)。类确保从调用者的视角来看,该条件总是为真。在例程的内部处理过程中,不变项不一定会保持,但在例程退出、控制返回到调用者时,不变项必须为真(注意,类不能给出无限制的对参与不变项的任何数据成员的写访问)。

DBC与常量参数
后条件常常要使用传入方法的参数来校验正确的行为。但如果允许例程改变传入的参数,你就有可能规避合约。Eiffel不允许这样的事发生,但Java允许。 这里,我们使用Java关键字final指示我们的意图:参数在方法内不应被改变。这并非十分安全——子类有把参数重新声明为非final的自由。另外,你可以使用iContract语法variable@pre获取变量在进入方法时的初始值。

死程序不说谎

你是否注意到,有时别人在你自己意识到之前就能觉察到你的事情出了问题。别人的代码也一样。如果我们的某个程序开始出错,如果我们的某个程序开始出错,有时库例程会最先抓住它。一个“迷途的”指针也许已经致使我们用无意义的内容覆写了某个文件句柄。对read的下一次调用将会抓住它。或许缓冲区越界已经把我们要用于检测分配多少内存的计数器变成了垃圾。也许我们对malloc的调用将会失败 数百万条之前的某个逻辑错误意味着某个case语句的选择开关不再是预期的1、2或3.我们将会命中default情况(这是为什么每个case/switch)语句都需要有default子句的原因之一——我们想要知道何时发生了“”
我们很容易掉进“它不能发生”这样的一种心理状态。我们中的大多数人编写代码都不检查文件是否能成功关闭,或者某个跟踪语句是否按照我们的预期写出。而如果所有的事情都能如我们所愿,我们很可能不需要那么做——这些代码在任何正常的条件都不会失败。但我们是在防卫性地编程,我们在程序的其他部分中查找破坏堆栈的“淘气指针”,我们在检查确实加载了共享库的正确版本。
所有的错误都能为你提供信息。你可以让自己相信错误不可能发生,并选择忽略它。但与此相反,注重实效的程序员告诉自己,如果有一个错误,就说明非常、非常糟糕的事情已经发生了。

要崩溃,不要破坏(trash)

尽早检测问题的好处之一是你可以更早崩溃。而有许多时候,让你的程序崩溃是你的最佳选择。其他的办法可以是继续执行,把坏数据写到某个极其重要的数据库或是命令洗衣机进入其第二十次连续的转动周期。
Java语言和库已经采用了这一哲学,当意料之外的某件事情在runtime系统发生时,它会抛出RuntimeException, 如果没有被捕获,这个异常就会渗透到程序的顶部,致使其中止,并显示栈踪迹。
你可以在别的语言中做相同的事情。如果没有异常机制,或是你的库不抛出异常,那么就确保你自己对错误进行了处理。在C语言中,对于这一目的,宏可能非常有用:
显然,有时简单地退出运行中的程序并不合适,你申请的资源可能没有释放,或者你可能要写出日志消息,清理打开的事务,或与其他进程交互。我们在“何时使用异常”中讨论的技术在此能对你有帮助,但是基本的原则是一样的——当你的代码发现,某件事时不可能发生的事已经发生时,你的程序就不再有存活能力。从此开始,它所做的所有事情都会变得可疑,所以要尽快终止它,死程序带来的危害通常比有疾患的程序要小的多。

断言式编程

每一个程序员似乎都必须在其职业生涯的早期记住一段曼特罗,它是计算技术的基本原则、是我们学着应用于需求、设计、代码、注释——也就是我们所做的每一件事情——的核心信仰,那就是:
这绝不会发生
我们不要这样自欺欺人,特别是在编码时
如果它不可能发生,用断言确保它不会发生
无论何时你发现自己在思考“但那当然不可能发生“。增加代码检查它,最容易的办法是使用断言。
传给断言的条件不应该有副作用。记住断言可能会在编译时被关闭——决不要把必须执行的代码放在assert中
不要用断言替代真正的错误处理,断言检查的是决不应该发生的事情:你不会想编写有判断逻辑的断言。
而且,提供给你的assert宏会在断言失败时调用exit,并不会意味着你编写的版本就应该这么做。如果你需要释放资源,就让断言生成异常、longjump到某个退出点,或是调用错误处理器。确保你在终止前的几毫秒内执行的代码不依赖最初触发断言失败的信息。

让断言开着

在你把程序交付使用时关闭断言就像你曾经成功过,就不用保护网去走钢丝。那样做有极大的价值,但却难以获得人身保险。
即使你确实有性能问题,也只关闭那些真的有很大影响的断言。

何时使用异常

在死程序不说谎中我们提出,检查每一个可能的错误——特别时在意料之外的错误——时一种良好的实践。但是,在实践中这可能会把我们引向相当丑陋的代码。你的正常逻辑左后可能被错误逻辑完全遮蔽,如果你赞成”例程必须有个单个return语句“的编程学派,情况就更是如此了。我们见过看上去像这样的代码:
在这里插入图片描述
改种写法就会很清楚
在这里插入图片描述

什么是异常情况

关于异常的问题之一是知道何时使用它们。我们相信,异常很少应作为程序的正常流程的一部分使用;异常应保留给意外事件。假定某个未被抓住的异样会终止你的程序,问问你自己:”如果我移走所有的异常处理器,这些代码是否仍在运行?“如果答案是”否“,那么异常也许就正在被用在非异常的情形中。
例如,如果你的代码试图打开一个文件进行读取,而该文件并不存在,应该引发异常吗?
我们的回答是:**“这取决于实际情况。”**如果文件应该在那里,那么引发异常就有了正当理由。某件意外之事发生了——你期望其存在的文件好像消失了。另一方面。如果你不清楚该文件是否应该存在,那么你找不到它看起来就不是异常情况,错误返回就是合适的。
让我们看一看第一种情况的一个例子。下面代码打开文件/etc/password,这个文件在所有Unix系统上都应该存在, 如果它失败了,它会把FileNotFoundException 传给它的调用者。

在这里插入图片描述
但是,第二种情况可能涉及打开用户在命令行上指定的文件,这里引发异常没有正当理由,代码看起来也不同。
在这里插入图片描述
这一FileInputStream调用有可能生成异常。这个例程会把它传递出去,但是,这个异常只在真正异常的情形下才生成;只是试图打开不存在的文件将生成传统的错误返回。
将异常用于异常的问题
我们为何要提出这种使用异常的途径?嗯,异常表示即时的、非局部的控制转移——这是一种级联的goto。那些把异常用作其正常处理的一部分的程序,将遭受到经典的意大利面条式代码的所有可读性和可维护性问题的折磨。这些程序破坏了封装:通过异常处理,例程和它们的调用者被更紧密地耦合在一起。

错误处理器是另一种选择

错误处理器是检测到错误时调用的例程。你可以登记一个例程处理特定范畴的错误。处理器会在其中一种错误发生时被调用,或者用于替代异常,或者于异常一起使用。
考虑一个客户——服务器的应用的实现,它使用了Java的Remote Method Invocation (RMI)设施,因为RMI的实现方式,每个对远地调用都必须准备处理RemoteException,增加代码处理这些异常可能会变得让人厌烦,并且意味着我们难以编写既能与本地例程也能与远地例程一起工作的代码。一种绕开这一问题的可能方法是把你的远地对象包装在非远地的类中,这个类随即实现一个错误处理器接口,允许客户代码登记一个在检查到远地异常时调用的例程。

怎样配平资源

”我把你带进这个世界“我的父亲会说:”我也可以把你赶出去。那没有我影响。我要再造另一个你“
只要再编程,我们都要管理资源:内存、事务、线程、文件、定时器——所有数量有限的事物。大多数时候。资源使用遵循一种可预测的模式:你分配资源、使用它,然后解除其分配。
要有始有终
在大多数情况下这条提示都很容易应用。它只是意味着,分配某项资源的例程或对象应该负责解除该资源的分配。让我们通过一个糟糕的代码例子看一看该提示的应用方式——这是一个打开文件、从中读取消费者信息、更新某个字段、然后写回结果的应用。我们除去了其中的错误处理代码。以让例子更清晰。

在这里插入图片描述
初看上去,例程updateCustomer相当好。它似乎实现了我们所需要的逻辑——读取记录,更新余额,写回记录。但是这样的整洁掩盖了一个重大的问题,**例程readCustomer和writeCustomer紧密地耦合在一起——它们共享全局变量CFile readCustomer 打开文件,并把文件指针存储在cFile中,而writeCustomer使用所存储地指针在其结束时关闭文件。**这个全局变量甚至没有出现在updateCustomer例程中。
这是为什么不好?让我们考虑一下,不走运地维护程序员被告知发生了变化——余额只应在新地值不为负时更新,他进入源码改动updateCustomer:
在这里插入图片描述
在测试时一切似乎都很好。但是,当代码投入实际工作,若干小时后他就会崩溃了,抱怨说打开的文件太多。因为writeCustomer在有些情形下不会调用,文件也就不会被关闭。
这个问题的一个非常糟糕的解决方案是在updateCustomer中对特殊情况进行处理:
在这里插入图片描述
这可以修正问题——不管新的余额是多少,文件现在都会被关闭——但这样的修正意味着三个例程都会通过cFile耦合在一起,我们在掉进陷阱,如果我们继续沿着这一方向前进,事情就会开始迅速变糟。
要有始有终这一提示告诉我们,分配资源的例程也应该释放它。通过稍稍重构的代码,我们可以在此应用该提示。
在这里插入图片描述
现在updateCustomer 例程承担了关闭该文件的所有责任。它打开文件并(有始有终)在退出前关闭它。例程配平了对文件的使用:打开和关闭在同一地方,而且显然每一次打开都有对应的关闭。重构还移除了丑陋的全局变量。

嵌套的分配

对于一次需要不只一个资源的例程,可以对资源分配的基本模式进行扩展。有两个另外的建议:

  1. 以与资源分配的次序相反的次序解除资源的分配,这样,如果一个资源含有两一个资源的引用,你就不会在成资源被遗忘
  2. 在代码的不同地方分配同一组资源时,总是以相同的次序分配它们。这将降低繁盛死锁的可能性。不管我们在使用的是何种资源——事务、内存、文件、线程、窗口——基本的模式都适用:
  3. 无论是谁分配的资源,它都应该负责解除该资源的分配。但是,在有些语言中,我们可以进一步的发展这个概念。

对象与异常

分配与解除分配的对称让人想起类的构造器与析构器。类代表某个资源,构造器给予你该资源类型的特定对象,而析构器将其从你的作用域中移除。
如果你是在用面对对象语言编程,你可能会发现把资源封装在类中很有用。每次你需要特定的资源类型时,你就实例化这个类的一个对象。当对象出作用域或是被垃圾收集器回收时,对象的析构器就会解除包装资源的分配。

配平与异常

支持异常的语言可能会使用解除资源的分配很棘手。如果有异常被抛出,你怎样保证在发生异常之前分配的所有资源都得到清理?答案在一定程度上取决于语言。
在C++异常机制下配平资源
C++支持try…catch 异常机制。遗憾的是,这意味着在退出某个捕捉异常、并随即将其重新抛出的例程时,总是至少有两条可能的路径。
在这里插入图片描述
注意我们创建的节点是在两个地方释放的——一次是在例程正常的退出路径上,一次是在异常处理器中。这显然违反了DRY原则,可能会发生维护问题。
但是我们可以对C++的语义加以利用。局部对象在从包含它们的块中退出时会被自动销毁。这给了我们一些选择。如果情况允许,我们可以把“n”从指针改变为栈上实际的Node对象:
在这里插入图片描述
在这里,不管是否抛出异常,我们都依靠C++自动处理Node对象的析构。如果不可能不使用指针,可以通过在另一个类中包装资源获得同样的效果。
在这里插入图片描述
现在包装类NodeResource确保了在其对象被销毁时,相应的节点也会被销毁。为了方便起见,包装提供了解除引用操作符——> ,这样它的使用者可以直接访问所包含的Node对象中的字段。

在Java中配平资源

与C++不同,Java实现的是自动对象析构的一种“懒惰”形式。未被引用的对象被认为是垃圾收集的候选者,如果垃圾收集器回收它们,它们的finalize方法就会被调用。尽管这为开发者提供了便利,他们不再须要为大多数内存泄漏承受指责,但同时也使得实现C++方式的资源清理变得很困难,幸运的是,Java语言的设计者考虑周详地增加了一种语言特性进行补偿:finall子句 当try 块含有finally子句时,如果try块中有任何语言被执行,该子句中的代码就保证会被执行。是否有异常抛出没有影响(即或try块中的代码执行了return语句)——finally子句中的代码都将会运行这意味着我们可以通过这样的代码配平我们的资源使用:
在这里插入图片描述

当你无法配平资源时

有的基本的资源分配模式并不合适。这通常会出现在使用动态数据结构的程序中,一个例程将分配一块内存区,并把它链接进某个更大的数据结构中,这块内存可能会在那里呆上一段时间。
这里的诀窍是为内存分配设立一个语义不变项,你须要决定谁为某个聚集数据结构中的数据负责。当你解除顶层结构的分配时会发生什么?你有三个主要选择:

  1. 顶层结构还负责释放它包含的任何子结构。这些结构随即递归地删除它们包含的数据,等等。
  2. 只是解除顶层结构的分配。它指向的任何结构都会被遗弃
  3. 如果顶层结构含有任何子结构,它就拒绝自身的分配。

这里的选择取决于每个数据结构自身的情形。但是,对于每个结构,你都须明确做出选择,并始终如一地实现你的选择。在像C这样的过程语言中实现其中的任何选择都可能会成问题:数据结构自身不是主动的,在这样的情形下,我们的偏好是为每个重要结构编写一个模块,为该结构提供分配和解除分配设施。

检查配平

因为注重实效的程序员谁也不信任,包括我们自己,所以我们觉得,构建代码、对资源确实得到了适当释放进行实际检查,这总是一个好主意。对于大多数应用,这通常意味着为每种资源类型编写包装,并使用这些包装追踪所有的分配和解除分配在你的代码中的特定地方,程序逻辑将要求资源处在特定的状态中:使用包装对此进行检查。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值