7 错误处理
错误处理很重要,但如果因为错误处理搞乱了代码,那它就是错误的代码
7.1 使用异常而非返回码
遇到错误时,最好抛一个异常,这样调用代码会整洁,其逻辑不会被错误处理搞乱。
7.2 先写try-catch-finally
异常的妙处之一是,他们在程序中定义了范围。执行try-catch-finally语句中的try部分的代码时,你是在表明可随时取消执行,并在catch语句中接续。
某种意义上,try代码块就像是事务。catch代码块将程序维持在一种持续状态,无论try中发生什么均如此。
所以在编写可能抛出异常的代码时,最好先写出try-catch-finally语句,这样能帮助你定义该代码的用户应该期待什么,无论try代码块中执行的代码出什么错都一样。
7.3 使用未检异常
已检异常和未检异常:
- 未检异常(编译器要求必须处置的异常):
正确的程序在运行中,很容易出现的、情理可容的异常状况。除了Exception中的RuntimeException及RuntimeException的子类以外,其他的Exception类及其子类(例如:IOException和ClassNotFoundException)都属于可查异常。这种异常的特点是Java编译器会检查它,也就是说,当程序中可能出现这类异常,要么用try-catch语句捕获它,要么用throws子句声明抛出它,否则编译不会通过。- 已检异常(编译器不要求强制处置的异常):
包括运行时异常(RuntimeException与其子类)和错误(Error)。RuntimeException表示编译器不会检查程序是否对RuntimeException作了处理,在程序中不必捕获RuntimException类型的异常,也不必在方法体声明抛出RuntimeException类。RuntimeException发生的时候,表示程序中出现了编程错误,所以应该找出错误修改程序,而不是去捕获RuntimeException。
使用已检异常的代价就是违反开放/闭合原则。如果在方法中排除已检异常,而catch语句在3个层级之上,你就得在catch语句和抛出异常处之间的每个地方签名中声明该异常。(这意味着,对软件中较低层级的修改,都会波及较高层的签名)
7.4 给出异常发生的环境说明
你抛出的每个异常,都应该提供足够的环境说明,以便判断错误的来源和位置。
在Java中,你可以从任何异常中得到栈踪迹,然而,栈踪迹无法告诉你改失败操作的初衷。
应该创建信息充分的错误信息,并和异常一起传递出去,在消息中,应包括失败的操作和失败类型。
7.5 依调用者需要定义异常类
对异常分类有很多方式。在定义异常类时,最重要的考虑应该是它们如何被捕获。
可以依据来源分类:是来自组件还是其他地方?也可以依据其类型分类:是设备错误、网络错误还是编程错误?
对于代码的某个特定区域,单一异常类通常可行。伴随异常发送出来的信息能够区分不同错误。
如果你想要捕获某个异常,并且放过其他异常,就使用不同的异常类。
7.6 定义常规流程
你可以打包外部API以抛出自己的异常,在代码的顶端定义一个处理器来应对任何失败了的运算,也可以对特例场景使用特例模式。
特例模式:创建一个类或配置一个对象,用来处理特例,客户代码就不用应对异常行为了,异常行为被封装到特例对象中。
7.7 别返回null值
要讨论错误,就要考虑到那些容易因其错误的做法。
第一项就是返回null值,返回null值,基本上就是在给自己增加工作量,也是在给调用者添乱。只要有一处没有检查null值,应用程序就会失控。
7.8 别传递null值
在方法中返回null值是糟糕的做法,将null值传递给其他方法就更糟糕了。除非API要求你向它传递null值,否者就要尽可能避免传递null值。
在大多数编程语言中,没有良好的方法能应对由调用者意外传入的null值。事已至此,恰当的做法就是禁止传入null值。这样在编码的时候就会时时记住参数列表中的null值意味着出问题,从而大量避免这种无心之失。
7.9 小结
整洁代码是可读的,但也要强固。可读与强固并不冲突,如果将错误处理隔离看待,独立与主要逻辑之外,就能写出强固而整洁的代码。做到这一步,我们就能单独处理它也可能级大地提升代码的可维护性。
8 边界
8.1 使用第三方代码
在接口提供者和使用者之间,存在与生俱来的矛盾。
第三方程序包和框架提供者追求普适性,这样就能在多种环境中工作,从而吸引广泛的用户。而使用者则想要得到集中满足特定需求的接口。
这种矛盾会导致系统边界上出现问题。
边界上的接口是隐藏的。他能随来自应用程序其他部分的极小的影响而变动。
tips:建议不要将Map(或在边界上的其他接口)在系统中传递。如果你实用类似Map这样的边界接口,就把它保留在类或近亲类中。避免从公共API中返回边界接口,或将边界接口作为参数传递给公共API。
8.2 浏览和学习的边界
第三方代码帮助我们在更少时间内发布更丰富的功能。
设想我们对第三方代码库的使用方法不清楚,我们可能会花一两天时间去阅读文档,决定如何使用。然后,编写使用第三方代码的代码,看看是否如我们所愿,之后会陷入长时间的调试,找出代码的缺陷…
学习第三方代码很难,整个第三方代码也很难,同时做这两件事更是难上加难。我们不应该在生产代码中试验新东西,而是编写测试来遍览和理解第三方代码—学习性测试。
8.3 学习log4j
上面提到学习性测试,例如我们想使用Apache log4j包来替代自定义的日志代码,无须多久就可以写上第一个测试用例,在运行时,会发现需要使用Appender,慢慢发现还要有ConsoleAppender还有Appender输出流,不断实践的整个过程会让你从如何写一个测试用例到明白日志打印的机制,最后你会明白如何初始化一个简单的控制台日志器,也能把这些知识封装到自己的日志类中,好将应用程序的其他部分与log4j的边界接口隔离开来。
8.4 学习性测试的好处不只是免费
- 学习性测试毫无成本。无论如何我们都得学习要使用的API,而编写测试则是获得这些知识的容易而不会影响其他工作的途径。学习性测试是一种精确试验,帮助我们增进对API的理解。
- 学习性测试不光免费,还在投资上有正向的回报。
- 学习性测试确保第三方程序包按照我们想要的方式工作。
- 无论是否需要通过学习性测试来学习,都要有一些列与生产代码中调用方式一致的输出测试来支持整洁的边界。如果不使用这些边界测试来减轻迁移的劳力,我们可能就会超出应有时限,长久地绑在旧版本上面。
8.5 使用尚不存在的代码
还有另一种边界,那种将一直和位置分隔开的边界。在代码中总有许多地方是我们的知识未及之处。有时,边界那边就是未知的。有时,我们并不往边界那边看过去。
8.6 整洁的边界
-
边界上会发生有趣的事。改动是其中之一。如果有良好的软件设计,则无需巨大投入和重写即可进行修改。在使用我们控制不了的代码时,必须加倍小心保护投资,确保未来的修改不至于代价太大。
-
边界上的代码需要清晰的分割和定义了期望的测试。应该避免我们的代码过多地了解第三方代码中的特定信息。依靠你能控制的东西,好过依靠你控制不了的东西,免得日后受它控制。
-
我们通过代码中少数几处引用第三方边界接口的位置来管理第三方边界。可以像我们对待Map那样包装他们,也可以使用ADAPTER模式将我们的接口转换为第三方提供的接口。采用这两种方式,代码都能更好地与我们沟通,如果在边界两边推动内部一致的用法,当第三方代码有改动时,修改点就会更少。
9 单元测试
**对于大多数人来说,单元测试使用来确保程序“可运行”的用过即仍的短代码。**我们辛勤地编写类和方法,再弄出一些特殊代码来测试他们。通常这些代码会是一种简单的驱动式程序,让我们能够手工与自己编写的程序交互。
但是在争先恐后将测试加入规程中时,许多程序员遗漏了一些关于编写好测试的更细微但却重要的要点。
9.1 TDD三定律
第一定律:在编写不能通过的单元测试前,不可编写生产代码。
第二定律:只可编写刚好无法通过的单元测试,不能编译也不算通过。
第三定律:只可编写刚好足以通过当前失败测试的生产代码。
这三条定律将你限制在大概三十秒的一个循环中。测试与生产代码一起编写,测试只比生产代码早写几秒。
但这样写程序,我们每天会编写数十个测试,每个月就是数百个,每年可能就是数千个,测试将会覆盖所有生产代码。测试代码量足以匹敌生产代码量,导致令人生畏的管理问题。
9.2 保持测试整洁
脏测试等同于没测试。
测试必须随生产代码的演进而修改。测试越脏,就越难修改。测试代码越缠结,你就越有可能花更多时间塞进新测试,而不是编写新的生产代码。修改生产代码后,就测试就会失败,而测试代码中乱七八糟的东西将阻碍测试再次通过。于是,测试变得就像是不断翻番的债务。
测试代码和生产代码一样重要。
测试代码不是二等公民,它需要被思考、被设计和被照料,它该像生产代码一般保持整洁。
测试带来一切好处。
如果测试不能保持整洁,你就会失去他们,没有了测试,你就会失去保证生产代码可扩展的一切要素。
如果测试不干净,你改动自己代码的能力就会有所限制,而你也会开始失去改进代码的结构的能力。测试越脏,代码就会变得越脏。最终失去了测试,代码就开始腐坏。
9.3 整洁的测试
整洁的测试有3要素:可读性、可读性和可读性。
在单元测试中,可读性甚至比在生产代码中还重要。
明确,简洁,并有足够的表达力是保证可读性的重要因素。
在测试中你要以尽可能少的文字表达大量的内容。
9.3.1 面向特定领域的测试语言
打造一套包装这些API的函数和工具代码,可以更方便地编写测试,写出来的测试也更便于阅读。测试语言可以帮助程序员编写自己的测试,也可以帮助后来者阅读测试。
9.3.2 双重标准
测试API中的代码与生产代码相比,的确有一套不同的工程标准。测试代码应当简单、精悍、足具表达力,但他应该与生产代码一般有效。
双重标准:有些事你大概永远不会在生产环境中做,而在测试环境中做完全没有问题。通常这关乎内存或CPU效率的问题,不过却永远不会与整洁有关。
9.4 每个测试一个断言
有的流派认为,**JUnit中每个测试函数都应该有且只有一个断言语句。**这条规则看似过于苛刻,但其有“可快速方便地理解”的好处。
单个断言是一个好准则,通常会创建支持这条准则的特定领域测试语言,一般而言单个测试中的断言数量应该最小化,但并不表示不能再单个测试中放入多于一个的断言。
9.4.1 每个测试一个概念
**每个测试函数中只测试一个概念。**不应该要超长的测试函数,测试完这个又测试那个。
例如下列测试应该拆解为3个单独测试,因为它测试了3件不同的事。如果把3件事混到一起,读者就不得不猜想每段代码出现的理由,以及那段代码要测试什么。
public void testAddMonths() {
SerialDate d1 = SerialDate.createInstance(30,4,2022);
SerialDate d2 = SerialDate.addMonths(1,d1);
assertEquals(30, d2.getDayOfMonth());
assertEquals(6,d2.getMonth());
assertEquals(2022, d2.getYYYY());
SerialDate d3 = SerialDate.addMonths(2,d1);
assertEquals(31, d3.getDayOfMonth());
assertEquals(7,d3.getMonth());
assertEquals(2022, d3.getYYYY());
SerialDate d4 = SerialDate.addMonths(1, SerialDate.addMonth(1, d1));
assertEquals(30, d4.getDayOfMonth());
assertEquals(7,d4.getMonth());
assertEquals(2022, d4.getYYYY());
}
9.5 F.I.R.S.T
整洁的测试还应该准寻一下5条规则:
-
快速(First)。测试应该能够快速运行。测试运行缓慢,你就不会想要频繁地运行它。如果你不频繁运行测试,就不能尽早发现问题,也无法轻易修正,从而也不能轻而易举地清理代码。最终,代码就会腐坏。
-
独立(Independent)。测试应该相互独立。某个测试不应为下一个测试设定条件。你应该可以单独运行每个测试,以及任何顺序运行测试。当测试互相依赖时,头一个测试没通过就会导致一连串的测试失败,使问题诊断变得困难,隐藏了下级错误。
-
可重复(Repeatable)。测试应当可以在任何环境中重复通过。你应该能够在生产环境、质检环境中运行测试,也能够在无网络状态使用笔记本电脑运行测试。如果测试不能在任意环境中重复,你就总会有个解释其失败的接口。当环境条件不具备时,你也会无法运行测试。
-
自足验证(Self-Validating)。测试应该有布尔值输出。无论是通过或失败,你都不应该通过查看日志文件来确认测试是否通过。你不应该手工比对两个不同的文本文件来确认测试是否通过。如果测试不能自足验证,对失败的判断就会变得以来主观,而运行测试也需要更长的手工操作时间。
-
及时(Timely)。测试应及时编写。单元测试应该恰好在使其通过的生产代码之前编写。如果在编写生产代码之后编写测试,你就会发现生产代码难以测试。你可能会因为某些生产代码本身难以测试而不去设计可测试的代码。
9.6 小结
对于项目的健康度,测试和生产代码同等重要。或许测试更为重要,因为它保证和增强了生产代码的可扩展性、可维护性和可复用性。所以保持测试整洁,可以让测试具有表达力并短小精悍。发明作为面向特定领域语言的测试API,帮组自己编写测试。
如果坐视测试腐坏,那么代码也会跟着腐坏。