【随笔·技术】重构错误处理程式

原网页


有人研究过,程式中可能会有高达90%的比率在管理与处理错误,Bob大叔在《Clean Code》中谈到「许多程式码[1]完全由错误处理所主宰」,90%的比率是真的在管理与处理错误的逻辑吗?还是只是如Bob大叔说的,根本就是散乱的错误处理程式码?商务逻辑相关的程式码需要重构,对于错误处理程式码的重构,我们也有许多需要学习的地方。

错误处理就是一件事

在重构或程式码可读性的概念中有个共同特性,就是函式(方法)应该只做一件事,避免函式中的程式码陷入逻辑泥块(Logical clump)。在没有例外处理的语言中,透过回传错误码,让函式客户端可以检查执行结果,确认后续是要进行正常或错误处理流程,如果客户端必须呼叫多个函式来完成一项任务,检查错误码、正常与多个错误处理流程夹杂的情况下,容易使得客户端程式码变得混乱。

例外[2]处理机制可以在错误发生的时候抛出例外,让错误处理能推到想要的边界进行处理。以Java来说,客户端可以在try区块中处理正常流程,在catch区块处理呼叫各函式时可能抛出的例外,让原先纠缠在错误处理流程中的正常流程清楚地呈现出来,try区块中的流程亦可抽取为函式独立地做一件事,那么目前的try-catch就能专心地做错误处理这件事,如同Bob大叔说的「函式应该只做一件事,而错误处理就是一件事」。

有时例外处理流程会形成一种模式,例如涉及资源建立、使用与关闭的操作若会抛出例外,为了有限资源在各种错误发生时都能确实释放,不免要撰写类似的try-catch-finally流程,在具有受检例外[3]的Java中更是难以避免这类情况,像是JDBC的处理流程就是如此,此时可以采用样版回呼(Template callback)模式,适当地让资源相关操作从错误处理流程中独立出来,Spring的JdbcTemplate就是这类实现,因为这类资源建立、关闭的操作模式太频繁出现,JDK7就提出了try-with-resources语法来解决这类需求,确实地让资源建立、使用与关闭的操作与错误处理分离,若进一步地结合JDK8的Lambda语法,还可让资源的使用从建立与关闭中分离。例如设计一个open方法,就可以专心在FileInputStream的使用,让开启档案的意图显而易见:

open(fileName, fileInputStream -> {
    // 操作FileInputStream实例
});

多个捕捉做相同处理时的重构

如果多种例外捕捉后,做的都是相同的错误处理,像是日志,或者是将程式库的例外封装为自定义例外等,错误处理的程式码必然就出现重复,自然就会呈现需要重构的讯号。因为多种例外做的都是相同的事,可将有继承关系的例外处理程式码,合并在父类别的捕捉区块中,但不建议使用catch-all的方式,例如使用ExceptionThrowable来捕捉所有例外,因为对于其他不相关的例外,这是一种隐藏错误的做法。

然而在合并有继承关系的例外处理程式码之后,仍会发现没有继承关系的例外处理程式码出现重复,Bob大叔在《Clean Code》中提出的作法是包裹呼叫的API,确保它在捕捉各种例外后,能转换为(自定义的)共同例外型态,如此客户端就只需要捕捉一种例外,因而可让客户端程式码大幅简化,如果使用的是第三方API,也可以同时降低了对它的依赖。

如果多种例外在捕捉之后,做完相同处理就将原例外重新抛出,可以参考guava-libraries的作法,你可以使用catch-all的方式捕捉各种型态的例外,做完相同错误处理之后,使用Throwables.propagateIfInstanceOf以指定的例外型态重新抛出(通常是受检例外),或者是使用Throwables.propagate,将原例外以RuntimeException包裹后重新抛出,既消除了重复的错误处理程式码,又避免了隐藏错误。

虽然实际上,Throwables.propagateIfInstanceOf只是将型态判断与转型的逻辑封装并予以重用,但对客户端程式码的简化确实有所帮助,不过,这种方式对于错误处理时进行例外型态转换,或者是不重新抛出的情况并不适用,guava-libraries的〈ThrowablesExplained〉文中也解释了其他一些不适用的场合。JDK7中,对于多个捕捉做同一件事的情况,提出了Multi-catch语法,算是为这问题提出了较好的解决方案。

多个捕捉做不同处理时的重构

如果多种例外捕捉后,分别进行不同的错误处理,此时得检视多种例外是由单一方法抛出,或多个方法操作而分别抛出不同例外,最常见的情况是一个try区块进行了数个会抛出例外的操作,然后底下连续多个catch区块逐一针对不同例外作处理。实际上每个会抛出例外的方法发生错误时,理由应该是各不相同的,应试着让这些方法各有一个try-catch区块,让每个方法的错误处理流程各自显露出来。

一旦你根据不同方法引发的例外,将一个try搭配多个catch的程式码,分解为数个try-catch区块之后,应当立即想到「错误处理就是一件事」,而两个以上的try-catch时,无论那些try-catch是形成巢状或者是瀑布式流程,都意谓着你的程式码做了两件以上的事,重构的方式之一,就是每个try-catch重构至独立的方法之中,让每个方法都只会出一个try陈述。

当发现一个方法中会出现多个try-catch时,而每个try-catch都做类似模式(但细节不同)的转换或错误处理时,如果你接触过函数式的错误处理风格,例如我先前专栏〈函数式风格错误处理〉中谈过的OptionEitherTry等概念,就有可能进行Monad风格的错误处理,我在专栏〈神秘的Monad不神秘〉中谈到OptionalflatMap可连续处理null与物件值转换的问题,实际上,Mario Fusco在〈Monadic Java〉中就以类似风格,设计了Validation等类别,可以用Monad风格对使用者进行如下的程式码验证与验证失败讯息之收集,而又不会迷失在瀑布式的ValidationException捕捉程式码之中:

Validation<List<String>, Person> validation = success(person)
    .failList()
    .flatMap(Validator::validAge)
    .flatMap(Validator::validName);

重构是看待错误处理的一个角度

既然程式中可能会有高达90%的比率在管理与处理错误,我们真的该认真且从不同角度去看待,像是受检或非受检例外的运用、例外应捕捉或抛出、避免隐藏错误、换个典范风格思考错误处理的可能性等,都该有所思考,我的专栏〈Shit Happens!该抓还是该丢?〉、〈避免隐藏错误的防御性设计〉与〈函数式风格错误处理〉都曾做过一些探讨。

从重构角度出发来看待错误处理程式码,你会发现Martin Fowler的《Refactoring》中揭露的重构原则,对待错误处理程式码也是适用的,错误处理之所以重要,就在于它是处理不对的事情,本身必须正确,然而就如Bob大叔说的「如果它糢糊了原本程式码的逻辑,那就不对了」


  1. 代码

  2. 异常

  3. Checked Exception

推荐阅读更多精彩内容