错误、异常与自定义异常

程序员对于异常(Exception)这个词应该都不陌生,尤其现在Exception基本上是OOP编程语言的标配。于我而言,这个词既熟悉又陌生,熟悉是因为听过了很多遍、似乎也有大量使用;陌生是因为很少真正思考过到底什么是异常,以及如何使用异常。本文记录我对如何使用异常、自定义异常的一些看法,不一定正确,还请多多指教。
本文地址:http://bsyjek.com/xybaby/p/11645885.html

什么是异常

异常是错误处理的一种手段:

exception handling is an error-handling mechanism

上述定义中的error是广义的error,任何代码逻辑、操作系统、计算机硬件上的非预期的行为都是error。并不是Java语言中与Exception对立的Error(Java中,Error和Exception是有区别的,简而言之,Error理论上不应该被捕获处理,参见Differences between Exception and Error),也不是golang中与panic对立的error。

在编程语言中,对于error的分类,大致可以分为Syntax errors、Semantic errors、Logical errors,如果从error被发现的时机来看,又可以分为Compile time errors、Runtime errors。

结合实际的编程语言,以及wiki上的描述:

Exception handling is the process of responding to the occurrence, during computation, of exceptions – anomalous or exceptional conditions requiring special processing – often disrupting the normal flow of program execution.

可以看出,一般来说,Exception对应的是Runtime error,比如下面的代码

FileReader f = new FileReader("exception.txt"); //Runtime Error

如果文件不存在,就会抛出异常,但只有当程序运行到这一行代码的时候才知道文件是否存在。

需要注意的是,异常并不是错误处理的唯一手段,另一种广为使用的方式是error codeerror code是一种更为古老的错误处理手段,下一章节将会就error code与exception的优劣介绍。

什么时候使用异常

下面用两个例子来阐释什么时候使用异常。

初探异常

第一个例子来自StackExchange When and how should I use exceptions? .
题主需要通过爬取一些网页,如http://www.abevigoda.com/来判断Abe Vigoda(教父扮演者)是否还在世。代码如下:

def get_abe_status(url):
    # download the page
    page = download_page(url)

    # get all mentions of Abe Vigoda
    hits = page.find_all_mentions("Abe Vigoda")

    # parse the first hit for his status
    status = parse_abe_status(hits[0])

    # he's either alive or dead
    return status == "alive"

def parse_abe_status(s):
    '''Param s: a string of the form "Abe Vigoda is something" and returns the "something" part'''
    return s[13:]
    

简而言之,就是下载网页内容,提取所有包含"Abe Vigoda"的句子,解析第一个句子来判断"Abe Vigoda"是否尚在人世。

上述的代码可能会出现几个问题:

  • download_page由于各种原因失败,默认抛出IOError
  • 由于url错误,或者网页内容修改,hits可能为空
  • 如果hits[0]不再是"Abe Vigoda is something" 这种格式,那么parse_abe_status返回的既不是alive,也不是dead,与预期(代码注释)不相符

首先,对于第一个问题,download_page可能抛出IOError,根据函数签名,函数的调用者可以预期该函数是需要读取网页,那么抛出IOError是可以接受的。

而对于第二个问题 -- hits可能为空,题主有两个解决方案。

使用error code

在这里,就是return None

def get_abe_status(url):
    # download the page
    page = download_page(url)

    # get all mentions of Abe Vigoda
    hits = page.find_all_mentions("Abe Vigoda")

    if not hits:
        return None

    # parse the first hit for his status
    status = parse_abe_status(hits[0])

    # he's either alive or dead
    return status == "alive"

显然,这里是通过error code(None)来告诉调用者错误的发生,上一章节也提到 error code是除了Exception handling之外的另一种广泛使用的error handling 手段。

那么error code相比Exception有哪些优缺点呢?
首先是优点:

  • 没有引入新的概念,仅仅是普通的函数调用
  • 易于理解,不会打乱当前的执行流

相比Exception,其缺点包括:

  • 代码时刻都需要检查返回值,而调用者很容易遗漏某些检查,这就可能隐藏、推迟更严重问题的暴露

  • 缺乏错误发生的上下文信息
  • 有的时候一个函数根本没有返回值(比如构造函数),这个时候就得依赖全局的error flag(errno)

比如Linux环境下,linux open返回-1来表示发生了错误,但具体是什么原因,就得额外去查看errno

回到上述代码,从函数实现的功能来说,check_abe_is_alive应该是比get_abe_status更恰当、更有表达力的名字。对于这个函数的调用者,预期返回值应该是一个bool值,很难理解为什么要返回一个None。而且Python作为动态类型语言放大了这个问题,调用很可能对返回值进行conditional execution,如if check_abe_is_alive(url):, 在这里None也被当成是False来使用,出现严重逻辑错误。

返回None也体现了error code的缺点:延迟问题的暴露,且丢失了错误发生的上下文。比如一个函数应该返回一个Object,结果返回了一个None,那么在使用这个返回值的某个属性的时候才会出trace,但使用这个返回值的地方可能与这个返回值创建的地方已经隔了十万八千里。没有让真正的、原始的错误在发生的时候就立刻暴露,bug查起来也不方便。

抛出异常

class NotFoundError(Exception):
    """Throw this when something can't be found on a page."""

def get_abe_status(url):
    # download the page
    page = download_page(url)

    # get all mentions of Abe Vigoda
    hits = page.find_all_mentions("Abe Vigoda")

    try:
        hits[0]
    except IndexError:
        raise NotFoundError("No mentions found.")

    # parse the first hit for his status
    status = parse_abe_status(hits[0])

    if status not in ['alive', 'dead']:
        raise SomeTypeOfError("Status is an unexpected value.")

    # he's either alive or dead
    return status == "alive"

注意上面的代码同时也包含了第三个问题的解决方案,即确保statusalive或者dead二者之一。不过我们重点关注对hits为空的处理。有两点值得注意:

  1. 抛出的是自定义异常NotFoundError,而不是IndexError。这是一个明智的选择,因为hits为空是一个实现细节,调用者很难想象为啥要抛出IndexError。关于自定义异常,后面还有专门的章节讨论。
  2. 通过尝试捕获IndexError来判断hits为空,这个是不太推荐的做法,因为这里明显可以通过if not hits来判断hits是否为空

关于用条件判断(if) 还是 try-catch, 在Best practices for exceptions中是这样描述的

Use exception handling if the event doesn't occur very often, that is, if the event is truly exceptional and indicates an error (such as an unexpected end-of-file). When you use exception handling, less code is executed in normal conditions.

Check for error conditions in code if the event happens routinely and could be considered part of normal execution. When you check for common error conditions, less code is executed because you avoid exceptions.

if 还是 try-catch,其实暗示了关于异常本身一个有争议的点:那就是exception是否应该充当流程控制的手段,wiki上总结说不同的语言有不同的偏好。不过,个人认为,如果能用if,就不要使用try-catch,exception仅仅使用在真正的异常情况。

再探异常

第二个例子来自stackoverflow When to throw an exception ,题主的习惯是针对任何非预期的情况都定义、抛出异常,如UserNameNotValidException, PasswordNotCorrectException, 但团队成员不建议这样做,因此题主发帖寻求关于异常使用的建议。

我想这是一个我们都可能遇到的问题,捕获并处理异常相对简单,但什么时候我们应该抛出异常呢,该抛出标准异常还是自定义异常呢?我们先看看StackOverflow上的回答

高票答案1:

My personal guideline is: an exception is thrown when a fundamental assumption of the current code block is found to be false.
答主举了一个Java代码的例子:判断一个类是不是List<>的子类,那么理论上不应该抛出异常,而是返回Bool值。但是这个函数是有假设的,那就是输入应该是一个类,如果输入是null,那么就违背了假设,就应该抛出异常。

高票答案2:

Because they're things that will happen normally. Exceptions are not control flow mechanisms. Users often get passwords wrong, it's not an exceptional case. Exceptions should be a truly rare thing, UserHasDiedAtKeyboard type situations.
答主直接回答题主的问题,强调异常应该是在极少数(预期之外)情况下发生的错误才应该使用,异常不应该是流程控制的手段

高票答案3:

My little guidelines are heavily influenced by the great book "Code complete":

  • Use exceptions to notify about things that should not be ignored.
  • Don't use exceptions if the error can be handled locally
  • Make sure the exceptions are at the same level of abstraction as the rest of your routine.
  • Exceptions should be reserved for what's truly exceptional.
    答主参考《代码大全》认为仅仅在出现了当前层次的代码无法处理、也不能忽略的错误时,就应该抛出异常。而且异常应该仅仅用于真正的异常情况。

高票答案4:

One rule of thumb is to use exceptions in the case of something you couldn't normally predict. Examples are database connectivity, missing file on disk, etc.
异常应该仅仅由于意料之外、不可控的情况,如数据连接,磁盘文件读取失败的情况

高票答案5:

Herb Sutter in his book with Andrei Alexandrescu, C++ Coding Standards: throw an exception if, and only if

  • a precondition is not met (which typically makes one of the following impossible) or
  • the alternative would fail to meet a post-condition or
  • the alternative would fail to maintain an invariant.

从上述回答可以看出,如果违背了程序(routine)的基本假设(assumption、prediction、setup、pre-condition)h或者约束(post-condition、invariant),且当前层次的代码无法恰当处理的时候就应该抛出异常。

现代软件的开发模式,比如分层、module、component、third party library使得有更多的地方需要使用异常,因为被调用者没有足够的信息来判断应该如何处理异常情况。比如一个网络链接库,如果连接不上目标地址,其应对策略取决于库的使用者,是重试还是换一个url。对于库函数,抛出异常就是最好的选择。

自定义异常

在上一章节中我们已经看到了自定义异常(NotFoundError)的例子.

程序员应该首先熟悉编程语言提供的标准异常类,需要的时候尽量选择最合适的标准异常类。如果标准异常类不能恰如其分的表达异常的原因时,就应该考虑自定义异常类,尤其是对于独立开发、使用的第三方库。

自定义异常有以下优点:

  • 类名暗示错误,可读性强, 这也是标准库、第三方库也有很多异常类的原因
  • 方便业务逻辑捕获处理某些特定的异常
  • 可方便添加额外信息

    For example, the FileNotFoundException provides the FileName property.

Why user defined exception classes are preferred/important in java?中也有类似的描述

To add more specific Exception types so you don't need to rely on parsing the exception message which could change over time.
You can handle different Exceptions differently with different catch blocks.

一般来说,应该创建框架对应的特定异常类,框架里面所有的异常类都应该从这个类继承,比如pymongo

class PyMongoError(Exception):
    """Base class for all PyMongo exceptions."""


class ProtocolError(PyMongoError):
    """Raised for failures related to the wire protocol."""


class ConnectionFailure(PyMongoError):
    """Raised when a connection to the database cannot be made or is lost."""

异常使用建议

在知道什么时候使用异常之后,接下来讨论如何使用好异常。

下面提到的实践建议,力求与语言无关,内容参考了9 Best Practices to Handle Exceptions in JavaBest practices for exceptions

Exception应该包含两个阶段,这两个阶段都值得我们注意:

  • Exception initialization:通过raise(throw)抛出一个异常对象,该对象包含了错误发生的上下文环境
  • Exception handling,通过try - catch(expect) 来处理异常,通常也会通过finally(ensure)来处理一下无论异常是否发生都会执行的逻辑,以达到异常安全,比如资源的释放。

try-catch-finally
try-catch-finally代码块就像事务,无论是否有异常发生,finally语句都将程序维护在一种可持续,可预期的状态,比如上面提到的资源释放。不过为了防止忘掉finally的调用,一般来说编程语言也会提供更友好的机制来达到这个目的。比如C++的RAII,python的with statement,Java的try-with-resource

如果可以,尽量避免使用异常
前面提到,exception应该用在真正的异常情况,而且exception也会带来流程的跳转。因此,如果可以,应该尽量避免使用异常。``Specail case Pattern```就是这样的一种设计模式,即创建一个类或者配置一个对象,用来处理特殊情况,避免抛出异常或者检查返回值,尤其适合用来避免return null。

自定义异常, 应该有简明扼要的文档
前面也提到,对于第三方库,最好先有一个于库的意图相匹配的异常基类,然后写好文档。

exception raise
对于抛出异常的函数,需要写好文档,说清楚在什么样的情况下会抛出什么样的异常;而且要在异常类体系中选择恰到好处的异常类,Prefer Specific Exceptions

clean code vs exception
《clean code》建议第三方库的使用者对第三方库可能抛出的异常进行封装:一是因为对这些异常的处理手段一般是相同的;二是可以让业务逻辑于第三方库解耦合。

In fact, wrapping third-party APIs is a best practice. When you wrap a third-party API, you minimize your dependencies upon it

exception handling
捕获异常的时候,要从最具体的异常类开始捕获,最后才是最宽泛的异常类,比如python的Exception

In catch blocks, always order exceptions from the most derived to the least derived

程序员应该认真对待异常,在项目中看到过诸多这样的python代码:

try:
    # sth 
except Exception:
    pass

第一个问题是直接捕获了最宽泛的类Exception;其次并没有对异常做任何处理,掩耳盗铃,当然,实际中也可能是打印了一条谁也不会在乎的log。

如果我们调用了一个接口,而这个接口可能抛出异常,那么应该用当前已有的知识去尽力处理这个异常,如果当前层次实在无法处理,那么也应该有某种机制来通知上一层的调用者。checked exception肯定是比函数文档更安全、合适的方法,不过诸多编程语言都没有checked exception机制,而且《clean code》也不推荐使用checked exception,因为其违背了开放关闭原则,但是也没有提出更好的办法。

Wrap the Exception Without Consuming It
有的时候抛出自定义的异常可能会比标准异常更有表达力,比如读取配置文件的时候 ConfigError(can not find config file)IoError更合适,又比如前面例子中的NotFoundError

不过,重要的是要保留原始的trace stack,而不是让re-raise的stack。比如以下Java代码:

public void wrapException(String input) throws MyBusinessException {
    try {
        // do something may raise NumberFormatException
    } catch (NumberFormatException e) {
        throw new MyBusinessException("A message that describes the error.", e);
    }
}

python2中是下面的写法

def bar():
    try:
        foo()
    except ZeroDivisionError as e:
        # we wrap it to our self-defined exception
        import sys
        raise MyCustomException, MyCustomException(e), sys.exc_info()[2]

references

posted @ 2019-10-10 09:44 xybaby 阅读(...) 评论(...) 编辑 收藏