2.26. 抛出错误

函数可以通过抛出异常来向其调用者发出问题信号。关键字是 raise

def square_root(x):
    if x < 0:
        raise ValueError("square_root expects a non-negative number")
    return x ** 0.5

调用 square_root(-1) 会在 raise 那一行停下,跳出函数,并在调用者中寻找匹配的 except。如果没有任何调用者捕获它,脚本就会带着回溯信息结束。

2.26.1. 为什么要抛出异常而不是返回一个哨兵值

报告“无效输入”有两种方式:

# signal with a sentinel
def square_root_or_none(x):
    if x < 0:
        return None
    return x ** 0.5

# raise an exception
def square_root(x):
    if x < 0:
        raise ValueError("...")
    return x ** 0.5

异常这种形式通常更好:

  • 调用者必须有意识地处理错误情况 —— 要么用 try,要么让异常向外传播。哨兵值容易被遗忘,也容易被误当作正常结果。

  • 错误消息会随异常一同传递;而哨兵值的做法必须把诊断信息附加到别处。

  • 对于未处理的异常,默认行为是带着回溯信息的明显崩溃,且回溯会指向出问题的那次调用。而默默返回 None 之后会变成隐蔽的 bug。

只有当“未找到”是一种常规的、非异常的结果时才使用哨兵值 —— dict.get() 在键缺失时返回 None,正是因为查找有时本就预期会落空。

2.26.2. 自定义异常类

要抛出一个调用者可能希望与内置错误区分开来的问题,请定义一个 Exception 的子类:

class ConfigError(Exception):
    pass

def load_config(path):
    try:
        f = open(path)
    except OSError as e:
        raise ConfigError("missing config file: " + path)

try:
    load_config("settings.json")
except ConfigError as e:
    print("startup failed:", e)

空的 class 体没有问题 —— 重要的是名称本身,因为调用者是按类来捕获的。如果某个调用者可能希望在一个块中捕获整个一族错误,就把相关的错误归到一个共同的基类下。

2.26.2.1. 重新抛出

except 块内部使用一个裸 raise 会重新抛出当前异常,使其传播到下一个处理程序:

try:
    do_work()
except Exception as e:
    log(e)
    raise        # let it keep going

当一个函数想要观察某个错误(记录它、计数、撤销一次部分更改)而又不真正处理它时,这正是恰当的写法。

2.26.3. 何时捕获、何时传播

一条有用的经验法则:

  • 在能够有意义地恢复的层级捕获异常 —— 替换为默认值、重试、跳过无效输入。

  • 当除了崩溃之外没有任何有用的事可做时,或者当上一层才知道如何恢复时,就让它向外传播。

调用栈中间的某个函数若吞掉每一个错误并悄悄返回,会让故障无法追踪。请优先让异常一路传播,直到抵达真正有应对方案的代码。