原文链接:https://realpython.com/python-raise-exception/

by Leodanis Pozo Ramos Jun 19, 2023

目录

  • 在Python中处理异常
  • 在Python中引发异常:raise语句
  • 选择引发的异常:内置vs自定义
  • 引发内置的异常
  • 编写并引发自定义的异常
  • 决定何时引发异常
  • 引发异常的实操
  • 条件性地引发异常
  • 重新引发先前的异常
  • 使用from子句链接异常
  • 学习引发异常的最佳做法
  • 引发异常和assert语句
  • 引发异常组
  • 结语

在你的Python学习过程中,你会遇到一些情况,需要向代码中的问题发出信号。例如,可能文件不存在,网络或数据库连接失败,或者你的代码收到了无效的输入。解决这些问题的常见方法是引发异常,通知用户发生了错误。这就是Python中的raise语句的用途。

学习raise语句可以帮助你有效处理代码中的错误和异常情况。这样,你可以开发出更健壮的程序和更高质量的代码。

在本教程中,你将学习以下内容

  • 使用raise语句在Python中引发异常
  • 在代码中决定应该引发哪些异常以及何时引发异常
  • 探索在Python中引发异常的常见用例
  • 在Python代码中应用引发异常的最佳做法

为了充分理解本教程,你应该了解Python的基础知识,包括variablesdata typesconditionalexceptionclasses等内容。

在Python中处理异常

Exceptions在Python中扮演着重要的角色。它们允许你处理代码中的errors(错误)exceptional situations(异常情况)。那么什么是异常呢?异常表示一个错误或指示出现了问题。一些编程语言(如CGo)鼓励返回错误代码并进行check。相反,Python鼓励你抛出异常并进行handle

**注意:**在Python中,并不是所有的异常都是错误。内置的 StopIteration 异常就是一个很好的例子。Python在内部使用这个异常来终止iterators上的迭代。Python异常中表示错误的,都有Error后缀跟在它们名字后面。

Python还有一个专门的异常类别用来向程序员发出warnings。当你需要在程序中警示用户某种条件时,警告就派上用场了。然而,这种条件可能不足以引发异常并终止程序运行。一个常见的警告的例子是DeprecationWarning

当程序中出现了问题,Python会自动引发异常。比如,看看当你试图访问一个list对象中不存在的索引时会发生什么:

>>> colors = [
...     "red",
...     "orange",
...     "yellow",
...     "green",
...     "blue",
...     "indigo",
...     "violet"
... ]

>>> colors[10]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: list index out of range

在这个例子中,你的colors列表没有10这个索引。它的索引是从06,包含了你的7种颜色。所以,如果你试图获取索引为10的元素,就会得到一个IndexError异常告诉你目标索引超出了范围。

注意:在上面的例子中,Python自动引发了异常,这只适用于内置异常。而你,作为一个程序员,可以选择使用内置异常或定制化异常,正如你在之后的选择引发的异常:内置vs自定义章节会学到的。

每次引发异常都有一个traceback,也称作stack trace, stack traceback或者backtrace,等等其他名字。一个traceback是包含追溯到当前异常的一系列调用和操作的报告。

在Python中,大部分情况下traceback开头是Traceback (most recent call last)。然后你就会在紧随而来的错误信息里看到真正的调用栈和异常名。

**注意:**自从Python 3.9引入了新的PEG parser以来,一直在不断努力改进 tracebacks中的error messages,使其更有帮助和具体。这一努力持续取得新的成果,Python 3.12中引入了even better error messages

异常会导致你的程序终止,除非你使用tryexcept代码块处理它们:

>>> try:
...     colors[10]
... except IndexError:
...     print("Your list doesn't have that index :-(")
...
Your list doesn't have that index :-(

处理一个异常的第一步是预测可能发生的异常。如果你不这么做,就不能处理异常,程序也会崩溃。那样的话,Python会打印出异常追溯然后你就可以研究怎么修复问题。有时,你为了探索什么异常会被引发必须让程序运行失败。

在上面的案例中,你事先就知道从一个列表中获取大于它索引范围的元素会引发IndexError异常。所以,你对于捕获、处理这个特定的异常有所准备。try代码块负责捕获异常。except子句指定了你预测的异常,并且except代码块允许你采取相应的措施。整个过程被称作exception handling(异常捕获)

如果你的代码在一个函数内部引发了异常而不处理,那么异常将传播到函数被调用的地方。如果在调用处也不处理,就会继续传播直达主程序。如果主程序那也不处理,程序就会终止并有一个异常回溯。

Python里到处都是异常。几乎每个standard library里的模块都会使用它们(Exceptions)。Python在很多情况下都会引发异常。Python文档声明了这点:

Exceptions are a means of breaking out of the normal flow of control of a code block in order to handle errors or other exceptional conditions. An exception is raised at the point where the error is detected; it may be handled by the surrounding code block or by any code block that directly or indirectly invoked the code block where the error occurred. (Source)

总结,当程序执行过程中遇到错误时,Python自动引发异常。Python也允许你根据自身需要使用raise关键字引发异常。这个关键字允许你以更加可控的方式处理你程序中的错误。

在Python中引发异常:raise语句

在Python里,你可以引发内置的或自定义的异常。当你引发一个异常,结果就跟Python(自动)引发的是一样的。你得到了一个异常追溯,并且你的程序会崩溃除非你按时处理异常。

**注意:**在Python术语里,异常被raised,而其他编程语言例如C++Java,异常被thrown

想要自己去引发异常,你需要使用raise语句,使用如下语法:

raise [expression [from another_expression]]

一个不含参数的raise关键字会重新引发当前激活的异常。注意你只能在含有已激活异常的except代码块里单独使用raise。否则你就会得到一个RuntimeError异常:

>>> raise
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
RuntimeError: No active exception to reraise

在这个例子中,你在一个没有激活异常的上下文环境里。因此,Python不能重新引发(reraise)一个先前的异常。改为引发一个RuntimeError异常。

在你捕获了一个异常之后,如果需要进行某些操作,单独使用raise就很有用,因为接下来你会想重新引发原始的异常。你将在重新引发一个先前的异常章节学习raise的使用案例。

raise语法中的expression对象必须返回一个继承自BaseException的类的实例,它(BaseException)是所有内置异常的基类。也支持返回异常类本身,这样的话Python会为你自动实例化该类。

注意expression可以是Python里任何返回异常类/实例的expression。例如,你用来raise的参数可以是返回一个异常的自定义函数:

>>> def exception_factory(exception, message):
...     return exception(message)
...

>>> raise exception_factory(ValueError, "invalid value")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid value

这里的exception_factory()函数接受一个异常类和一个错误信息作为参数。然后这个函数将输入异常实例化并把错误信息作为该异常的参数。最后,它把异常实例返回给调用者。正如上面的示例,你可以使用这个函数作为raise关键字的一个参数。

from子句也是raise语法中的可选项。它允许你将异常链接在一起。如果你提供了from子句,那么another_expression必须是另一个异常类或实例。你将在使用from子句链接异常章节学到。

这是raise的另一个例子。这次,你创建了一个Exception类的全新实例:

>>> raise Exception("an error occurred")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
Exception: an error occurred

仅供举例来说,你引发了一个Exception的实例,但是引发这种泛型的异常事实上不是最好的方式。你在之后将会学到,用户自定义的异常应该继承自这个(Exception)类,虽然它们也可以继承自其他内置异常啦。

**注意:**在Python里,一般用小写字母做异常的错误信息的开头。并且结尾也没有句号。

为了说明,看看下列例子:

>>> 42 / 0
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

>>> [][0]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: list index out of range

在第一个例子中,你因为试图用42除以0,就得到了一个ZerorDivisionError异常。注意默认错误信息用小写字母开头并且结尾没有句号。

在第二个例子中,你试图从空列表中访问0索引,Python引发了TypeError(疑似作者笔误)异常。在这个例子中,错误信息也遵循同样的模式。

即使这种方式不是一个明确的惯例,你也应该考虑在自己的代码库中沿用这种模式以保持一致性。

Exception这样的异常类的构造器接受多个positional参数或是参数的元组:

>>> raise Exception("an error occurred", "unexpected value", 42)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
Exception: ('an error occurred', 'unexpected value', 42)

你在实例化异常时一般就使用一个参数:一个提供了恰当错误信息的字符串。然而,你也可以在实例化时提供多个参数。在这个例子中,你给Exception类的构造器提供了额外的参数。这些参数让你能给开发者提供更多关于错误如何被引发和应该怎么修复的信息。

.args属性使你能直接获取传给Exception构造器的所有参数:

>>> error = Exception("an error occurred", "unexpected value", 42)
>>> error.args
('an error occurred', 'unexpected value', 42)
>>> error.args[1]
'unexpected value'

在这个例子中,你使用.args属性获取到了参数。这个属性保存着一个元组以便你能通过索引访问特定的参数。

现在你学习了Python中关于引发异常的基础知识,是时候前进啦。在自己引发异常时,一个至关重要的点就是,你得决定在特定时刻怎样的异常才是最恰当的。

选择引发的异常:内置vs自定义

当你的代码中需要手动引发异常时,决定需要引发的异常是重要的一步。通常来说,你需要引发的异常得清晰传达出正在处理的问题。在Python里,你有两种不同的方式引发异常:

  • 内置异常:这些异常是Python内置的。你不用导入就可以在代码里直接使用它们。

  • 用户定义的异常:自定义异常是你在没有内置异常满足需求时创建的。对于一个项目,你通常会把它们(自定义异常)放在专门的模块里。

在接下来的章节,你会找到一些关于在代码中引发何种异常的指导思想。

引发内置的异常

Python有着丰富的内置异常集合,按照类 hierarchy 进行组织,BaseException在顶部。BaseException最频繁使用的子类就是Exception

Exception类是Python异常处理框架的基础。它是你能在Python里找到的大部分内置异常的基类。也是你在自定义异常时通常会使用类。

Python有超过60种built-in exceptions。你也许在日常写代码时已经见过下面这些具体的异常了:

Exception ClassDescription
ImportErrorAppears when an import statement has trouble loading a module
ModuleNotFoundErrorHappens when import can’t locate a given module
NameErrorAppears when a global or local name isn’t defined
AttributeErrorHappens when an attribute reference or assignment fails
IndexErrorOccurs when an indexing operation on a sequence uses an out-of-range index
KeyErrorOccurs when a key is missing in a dictionary
ZeroDivisionErrorAppears when the second operand in a division or modulo operation is 0
TypeErrorHappens when an operation, function, or method operates on an object of inappropriate type
ValueErrorOccurs when an operation, function, or method receives the right type of argument but the wrong value

这个表只是Python内置异常中的一小部分。当使用raise语句时,你可以用这些以及其他所有内置异常。

在大多数情况下,你都能为自己的特定使用情境找到合适的内置异常。如果你找到了,使用内置异常就比自定义的要好。比如,你正在编写一个计算列表内所有值的平方的函数,你想确保输入对象是列表或元组:

>>> def squared(numbers):
...     if not isinstance(numbers, list | tuple):
...         raise TypeError(
...             f"list or tuple expected, got '{type(numbers).__name__}'"
...         )
...     return [number**2 for number in numbers]
...

>>> squared([1, 2, 3, 4, 5])
[1, 4, 9, 16, 25]

>>> squared({1, 2, 3, 4, 5})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in squared
TypeError: list or tuple expected, got 'set'

squared()里,你使用一个 conditional 语句来检查输入对象是否是列表或元组。如果不是,就引发一个TypeError异常。这是一个相当好的选择因为你想确保输入的类别是正确的。如果类别都错了,TypeError是很合理的响应。

编写并引发自定义的异常

如果你找不到一个内置异常从语义上满足你的需求,你就可以自定义一个。为了做到这点,你必须继承另一异常类,通常是Exception。例如,你正在编写一个gradebook app并且需要计算学生的平均分。

你想确保所有分数都在0100之间。为了应对这个场景,你可以创建一个叫做GradeValueError的自定义异常。如果分数不在这个范围,就引发异常,说明这分数无效:

# grades.py

class GradeValueError(Exception):
    pass

def calculate_average_grade(grades):
    total = 0
    count = 0
    for grade in grades:
        if grade < 0 or grade > 100:
            raise GradeValueError(
                "grade values must be between 0 and 100 inclusive"
            )
        total += grade
        count += 1
    return round(total / count, 2)

在这个例子中,你先是通过继承Exception创建了一个自定义异常。你不需要给自定义异常添加新功能,所以你用了pass语句在类主体中占位。这个新的异常是你的分数项目中特有的。注意异常名是如何有助于传达底层问题的。

**注意:**在Python里,自定义异常时使用pass语句作为类主体是常见的方式。这是因为类名对于自定义异常通常是最重要的。

可能有的时候你也希望给自定义异常添加一些新特性,可惜这部分内容不在本教程范围内。

calculate_average_grade()函数内部,你使用for loop 来迭代输入的分数列表。然后你检查当前分数是否在0100外。如果是这样,你就实例化并引发自定义异常GradeValueError

你的函数以这种方式起作用:

>>> from grades import calculate_average_grade

>>> calculate_average_grade([98, 95, 100])
97.67

>>> calculate_average_grade([98, 87, 110])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 6, in calculate_average_grade
GradeValueError: grade values must be between 0 and 100 inclusive

当有无效分数时,你的函数引发GradeValueError,这是这个项目特有的,针对实际错误展示了清晰的信息。

有些开发者会提出异议,在这个案例里,可以直接用内置的ValueError而不是自定义的异常,他们可能也是对的。通常来说,你会在希望点出项目特有的错误或异常情景时去自定义异常。

如果你希望使用自定义异常,记住 naming conventions 也适用于它们。进一步说,你需要给自定义异常加上Error后缀来代表错误,不加就说明不是错误。如果你自定义了警告,你就用Warning后缀。所有这些命名惯例都能使别的开发者更明确你的意图。

决定何时引发异常

在有效地使用raise语句过程中,另一个重要步骤是决定何时引发异常。你得决定引发异常还是解决异常哪个才是最好的方式。通常来说,在这些情况下你就该引发异常:

  • 发出错误和异常场景的信号:最常见的使用raise语句的场景是在错误或异常场景发生时发出信号。当你的代码中出现了错误或异常场景,你可以用引发异常作为响应。
  • 在做完额外处理后重新引发异常:一个常见的使用raise语句的场景是在做完一些操作后重新引发一个激活状态的异常。一个好的例子是你需要先记录错误然后才能引发异常。

有的编程语言,比如C和Go,鼓励你从函数和方法中返回错误码。然后你就可以用条件语句检查这些错误码,相应地来处理底层错误。

比如,Go程序员很熟悉这种结构:

func SomeFunc(arg int) error {
    result, err := DoSomething(arg)
    if err != nil {
        log.Print(err)
        return err
    }
    return nil
}

在这里,DoSomething()一定得返回一个结果和一个错误。这个条件语句会检查错误并采取相应举动。

相反,Python鼓励你使用异常来处理错误和异常场景。然后你就必须用try...except结构来处理错误。这种方式在Python代码里相当常见。标准库里和Python自身就有很多例子。

一个(和上面代码)等效的Python函数长下面这样:

def some_func(arg):
    try:
        do_something(arg)
    except Exception as error:
        logging.error(error)
        raise

some_func()中,你使用try...except代码块来捕获在do_something或任何其他该函数调用的代码会引发的异常。error对象代表案例中的目标异常。然后你就可以记录错误并且重新引发激活状态的异常,也就是error

注意:那种鼓励使用错误码和条件判断的方式被称作look before you leap (LBYL)。相反,鼓励引发并处理异常的方式被称作easier to ask forgiveness than permission (EAFP)。想了解更多,看这里LBYL vs EAFP: Preventing or Handling Errors in Python

译者注:LBYL:三思而后行;EAFP:先斩后奏。

在实操中,你会在错误或异常情景发生时尽快引发异常。另一方面,何时捕获并处理引发的异常又取决于你的特定场景。有时,在异常最先发生的地方捕获它比较合理。这样,你就有足够的上下文信息来正确地处理异常并修复它而不至于终止整个程序。

然而,如果你在写一个库,你一般不会有足够的上下文信息来处理异常,所以你就让调用者自己去处理异常。

既然现在你已经明白何时引发异常了,是时候上手开始引发异常了。

引发异常的实操

在Python里,引发异常是一种处理错误和异常情景的常见方式。因此,你在一些场景下会使用raise语句。

例如,你使用raise语句来引发异常作为特定情况下的响应或者在执行某些额外处理后重新引发激活状态的异常。你也会使用from子句来链接异常以便任何人从此处或上层代码debug时能获取更多上下文信息。

在深入讨论之前,你将学习少许异常内部结构的知识。异常类有一个.args属性,是一个元组,里面包含了你提供给构造器的所有参数:

>>> error = Exception("an error occurred")

>>> error.args
('an error occurred',)
>>> error.args[0]
'an error occurred'

.args属性让你能动态地访问在实例化时传给构造器的所有参数。在你引发自己的异常时,这会很有帮助。

异常类还有两个方法:

  1. .with_traceback() 允许你给异常提供一个新的追溯,返回值是更新后的异常对象。

  2. .add_note()允许你在一次异常追溯中包含one or more notes。你可以通过检视给定异常的.__notes__特殊属性来访问笔记列表。

这两个方法都让你能够在一个异常中提供额外信息,这能帮助用你库的人debug他们自己的代码。

最后,异常也有一堆特殊的,或者说双下划线的属性例如.__notes__。两个最常用的是.__traceback__.__cause__

**注意:**特殊属性一般也被称为双下划线(dunder)属性。dunder这个词是double underscores的缩写。

译者注:即魔法方法/属性。

.__traceback__属性保存着附加在激活异常上的追溯对象:

>>> try:
...     result = 42 / 0
... except Exception as error:
...     tb = error.__traceback__
...
>>> tb
<traceback object at 0x102b150c0>

在这个例子里,你访问了.__traceback__属性,它保存着一个异常追溯对象。你可以使用.with_traceback()方法和这样的追溯对象来定制一个给定的异常。

Python在异常发生时会自动创建一个追溯对象。然后就附加在异常的.__traceback__属性上,这(属性)是可写的。你可以创建一个异常然后使用.with_traceback()方法提供你自己的追溯。

.__cause__属性保存着你在链接异常时传给from的那个异常类的表达式。你在学习from章节时会了解更多。

现在既然你已经知道了异常内部构建的基础,是时候继续学习引发异常了。

条件性地引发异常

一种常见做法是在遇到特定场景时使用raise语句引发异常。这些场景通常和可能的错误、异常情景相关。

比如,你想写一个判断给定数是否为质数的函数。输入的数肯定得是个整数。还应该大于等于2。下面是一个通过引发异常处理这些情景的代码实现:

>>> from math import sqrt

>>> def is_prime(number):
...     if not isinstance(number, int):
...         raise TypeError(
...             f"integer number expected, got {type(number).__name__}"
...         )
...     if number < 2:
...         raise ValueError(f"integer above 1 expected, got {number}")
...     for candidate in range(2, int(sqrt(number)) + 1):
...         if number % candidate == 0:
...             return False
...     return True
...

这个函数检查了输入的数是否是int的实例,如果不是就引发TypeError。然后检查了输入的数是否小于2,如果是就引发ValueError。请注意,这两个if语句都会检查那些如果passed silently就会触发错误或特定场景的情况。

译者注:Python之禅里有一句:Errors should never pass silently. 错误不应该默默传递/通过。

然后这个函数就会在2到这个numbersquare root(平方根)之间迭代。在循环内,条件语句会检查当前数能否被区间内别的数整除。如果是,就返回False因为不是质数。否则,就返回True表明是质数。

最后,尤其要注意,在你做任何计算之前就引发这两类异常。通常认为最好的做法就是像上面这个函数一样早早地引发异常。

重新引发先前的异常

你可以使用不传任何参数就使用raise语句来重新引发你代码里的上次异常。典型做法是当你需要在错误发生时记录错误,就会想到这么使用raise

>>> import logging

>>> try:
...     result = 42 / 0
... except Exception as error:
...     logging.error(error)
...     raise
...
ERROR:root:division by zero
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero

在这个例子中,你使用Exception来捕获任何try代码块中的异常。如果任何异常发生了,你就可以把实际发生的错误使用标准库里的logging模块记录下来,然后重新用单独的raise语句引发激活状态的异常。

**注意:**在Python里,像上面这个例子这样捕获一个泛型的Exception一般被认为很糟糕。你永远应该努力去针对性地捕获异常。这样,你才能预防隐藏的未知错误。

然而,在这个raise的特定案例里,你可能想在except子句里指定一个宽泛、通用的异常以便捕获到多种不同错误,记录,然后重新引发原始异常以便上层代码处理。

注意如果你用raise时把激活状态异常的引用作为参数传入,效果是相似的:

>>> try:
...     result = 42 / 0
... except Exception as error:
...     logging.error(error)
...     raise error
...
ERROR:root:division by zero
Traceback (most recent call last):
  File "<stdin>", line 5, in <module>
  File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero

如果你把当前错误作为raise的参数,追溯里就多了一块。追溯里的第二行告诉你代码第5行在重新引发一个异常。这是你手动引发的异常。追溯第三行告诉你原始异常的位置,即代码第2行。

另一种常见的重新引发异常的情景是你想把一个异常封装成另一种,或者说捕获一个异常然后转换成另一种。为了说明这点,就比如你正在写一个数学库,你有一些外部数学库作为依赖项。每个外部库都有它自己的异常,这样就会让你和用你库的人晕头转向。

在这种情况下,你可以捕获这些库的异常,封装成自定义的异常,然后引发。比如,下面这个函数捕获了一个ZeroDivisionError然后封装成自定义的MathLibraryError

>>> class MathLibraryError(Exception):
...     pass
...

>>> def divide(a, b):
...     try:
...         return a / b
...     except ZeroDivisionError as error:
...         raise MathLibraryError(error)
...

>>> divide(1, 0)
Traceback (most recent call last):
  File "<stdin>", line 3, in divide
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in divide
MathLibraryError: division by zero

在这个例子中,你捕获了一个具体的异常,ZeroDivisionError,然后封装成你自己的异常,MathLibraryError。这个技巧在以下情况很有用:

  • **抽象化外部异常:**当你在写一个和多个外部组件有交互的库时,你可能想抽象外部异常并引发自定义的异常这样你的用户就不会依赖前者。正如上面这个例子所示。

  • 统一处理方式:当你的多种异常类型都采取相同的处理方式时,也许把这些异常全部捕获并引发同一种自定义的异常更合理,然后按计划处理。这么做可以简化你的异常处理逻辑。

  • 增强捕获异常的上下文信息:当你处理一个一开始没有足够上下文或行为信息的异常时,你可以添加这些特征并重新手动引发异常。

上面这些关于重新引发异常的案例都很好,并且能使你在代码生涯中处理异常时更方便。然而使用from子句改变异常通常是一种更好的选择。

例如,如果你在上面的案例中用from None语法,就可以抑制ZeroDivisionError然后仅仅得到MathLibraryError的信息。在接下来的章节里,你会学习from子句的运作方式。

使用from子句链接异常

raise语句有一个可选的from子句。这个子句允许你在传参给from后把第二个异常链接到引发的异常上。注意如果你用了from子句,那么它的参数必须是一个返回异常类/实例的表达式。你通常会在except代码块中使用from来把引发的异常链接到激活的异常上。

如果from的参数是一个异常类的实例,那么Python就会把它附加给引发的异常的.__cause__属性上。如果它是一个异常类,那么Python就先实例化这个类,再附加给.__cause__属性。

from的效果是你会得到两个异常的追溯信息:

>>> try:
...     result = 42 / 0
... except Exception as error:
...     raise ValueError("operation not allowed") from error
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ValueError: operation not allowed

在这个例子中,你在except子句中使用Exception来捕获try代码块的任何异常。然后你从具体的异常中引发ValueError,在上面的例子中,是从ZeroDivisionError中引发。

from子句把两个异常链接了起来,为用户的debugg提供了完整的上下文信息。注意Python是如何将第一个异常呈现为第二个异常的直接原因。这样,你能更好地追溯错误然后修复它。

这种技巧在你处理一个可能引发多种异常的代码时相当便利。考虑下面这个divide()函数:

>>> def divide(x, y):
...     for arg in (x, y):
...         if not isinstance(arg, int | float):
...             raise TypeError(
...                 f"number expected, got {type(arg).__name__}"
...             )
...     if y == 0:
...         raise ValueError("denominator can't be zero")
...     return x / y
...

这个函数在输入参数不是一个数时引发TypeError。类似地,又在y参数等于0时引发ValueError因为不能除以0。

接下来看看from子句起到的帮助:

>>> try:
...     divide(42, 0)
... except Exception as error:
...     raise ValueError("invalid argument") from error
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 6, in divide
ValueError: denominator can't be zero

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ValueError: invalid argument

>>> try:
...     divide("One", 42)
... except Exception as error:
...     raise ValueError("invalid argument") from error
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 4, in divide
TypeError: number expected, got str

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ValueError: invalid argument

在第一个例子中,追溯显示了在divide()的第二个参数为0时会发生的ValueError异常。这个追溯帮助你在代码里追踪实际的错误。在第二个例子中,追溯直接让你看到使用错误参数类型引发的TypeError异常。

请注意,如果你不用from子句,Python也会同时引发两个异常,只是输出有点不同:

>>> try:
...     divide(42, 0)
... except Exception as error:
...     raise ValueError("invalid argument")
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 6, in divide
ValueError: denominator can't be zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ValueError: invalid argument

译者注:

有from: The above exception was the direct cause of the following exception:

没from: During handling of the above exception, another exception occurred:

现在追溯就不会点明第一种异常是第二种异常的根源了。

另一种使用from子句的方式是把None作为参数。使用from None可以在原始异常的追溯不是必须或不能提供信息时抑制或隐藏它。你也可以用这种语法在引发自定义异常时抑制内置异常的追溯。

为了说明from None的用法,试想你在编写一个调用外部REST API的包。你决定用requests库来访问API。然而,你不想暴露这个库提供的异常。相反,你希望自定义异常。

**注意:**为了让下面这段代码运行起来,你得先在Python environment 里用 pip 或类似的工具装好requests库。

这是如何实现该行为的:

>>> import requests

>>> class APIError(Exception):
...     pass
...

>>> def call_external_api(url):
...     try:
...         response = requests.get(url)
...         response.raise_for_status()
...         data = response.json()
...     except requests.RequestException as error:
...         raise APIError(f"{error}") from None
...     return data
...

>>> call_external_api("https://api.github.com/events")
[
    {
        'id': '29376567903',
        'type': 'PushEvent',
    ...
]

call_external_api()函数接收一个URL作为参数然后向它发出GET请求。如果在API请求过程中发生了错误,就引发你自定义的APIError异常。from None子句会把原始异常的追溯隐藏起来,替换为你的。

为了检查这个函数是如何工作的,设想你在提供API endpoint时犯了个拼写错误:

>>> call_external_api("https://api.github.com/event")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 7, in call_external_api
__main__.APIError: 404 Client Error: Not Found for url:
  https://api.github.com/event

在这个例子中,你拼错了目标URL,。这个错误引发了一个异常,由于在except子句里用了宽泛的 RequestException 异常,Python可以自动捕捉到它。注意你是如何在追溯中移植原始异常的。相反,你只得到了APIError异常。

from None结构在你想提供一个自定义的追溯和错误信息并移植原始异常时很有用。在原始异常信息对使用者没啥用并且你希望提供更多有用信息时(from None)就派上了用场。

学习引发异常的最佳做法

当在Python里引发异常时,你可以遵循一些让你的代码生涯更愉悦的做法和建议。这里有一些总结:

  • **较宽泛的异常而言,更倾向于使用具体的异常:**你应该引发能满足需求的最具体的异常。这种做法能帮助你定位和修复问题。
  • **报错的信息量要高,避免没有任何信息的异常:**你应该给所有的异常都写出描述性强、清晰的错误信息。这种做法能给debug提供上下文环境。
  • 较自定义异常而言,更倾向于使用内置异常:在为代码中的每一类错误编写你自己的异常之前,应该试着找到适当的内置异常。这个做法可以确保于Python生态的其余部分保持一致性。大多数有经验的Python开发者都很熟悉内置异常,所以(如果你多用内置异常)他们就能很容易地了解并上手你的代码。
  • **避免引发AssertionError异常:**你在代码里应该避免引发AssertionError。因为这个异常是专门给assert语句用的,对其他上下文环境不适用。
  • **尽快引发异常:**你应该早早地在代码里检查可能的错误和异常情景。这种做法确保了你的代码不会因为推迟错误检查带来一些额外步骤,从而使代码变得更加高效。这种做法符合fail-fast的设计思想。
  • **在你的代码文档里解释引发的异常:**你应该清晰地列出并解释一段代码可能引发的所有异常。这种做法能帮助其他开发者明白他们可能遇到的异常以及怎么适当地处理这些异常。

为了说明上述建议中的部分,考虑下面这个例子,你引发了一个宽泛的异常:

>>> age = -10

>>> if age < 0:
...     raise Exception("invalid age")
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
Exception: invalid age

在这个例子中,你使用了Exception类来点明一个和输入值强相关的问题。在这个例子中,使用一个像ValueError这样的更具体的异常要合适得多。改进报错信息也会带来一点帮助:

>>> if age < 0:
...     raise ValueError("age must not be negative")
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
ValueError: age must not be negative

在这个例子中,正是异常的名字帮助你获悉了实际的错误。此外,报错信息也很精准,很有帮助。重申,不管你在用内置还是自定义异常,报错的信息量都要高。

通常来说,异常里的报错信息应该简明扼要地描述出哪儿出了问题。报错信息应该足够具体,以便其他开发者可以识别、诊断、debug错误。然而,它也不该显示过多你代码的内部细节,因为这可能导致安全漏洞。

在任何情况下,记住这些只是建议,而不是严格的规则。你会遇到某些希望引发并处理宽泛异常的情景。

最后,如果你在给其他开发者写一个库,一定要把你的函数、方法引发的异常写进文档。你应该列出你的代码可能引发的异常,简短地描述每种异常的意思以及调用者怎么在他们的代码中处理异常。

引发异常和assert语句

raise语句并不是你在Python里能引发异常的唯一方式。也可以用assert语句。然而,assert语句的目的不同,它也只能引发一种错误——AssertionError

assert语句是Python里的一个 debuggingtesting 工具。允许你写出被称作 assertionssanity checks 。你可以用这些检查来验证你代码里特定的假设是否保持真值。如果你的任何一个断言变成了非真值,那么assert语句就会引发一个AssertionError异常。收到这个异常就说明你代码里有bug。

**注意:**为了深入了解如何在你的代码里写断言,看这篇文章: Python’s assert: Debug and Test Your Code Like a Pro

你不应该用assert语句去处理用户的输入或是其他种类的输入错误。为什么?因为断言能够,也很可能会在生产环境中被禁用。这么一来,它们的最佳使用方式就是在开发时作为debug和测试的工具。

在Python里,assert语句有着如下语法:

assert expression[, assertion_message]

在这个结构里,expression可以是任何有效的你需要检查 truthiness 的Python expression 或对象。如果expression为非真,那么这个语句就引发一个AssertionError。这个assertion_message参数是可选的,但是鼓励使用,因为能在debug和测试时增加更多上下文信息。它能提供一个该语句捕获到的问题的报错信息。

这里有一个案例,向你展示了怎么一个写有着描述性错误信息的assert语句:

>>> age = -10

>>> assert age >= 0, f"expected age to be at least 0, got {age}"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError: expected age to be at least 0, got -10

这个断言里的错误信息清晰地传达了是什么导致条件判断失败。在这个例子中,条件判断失败了,所以assert语句引发了一个AssertionError作为响应。再次,你应该用别的什么而不是assert语句来检查输入,因为生产环境里断言会被禁用,这样输入就会跳过验证。

另一个很重要的事是,你的代码里不应该显式地用raise语句引发AssertionError。这个异常只该在你开发过程中测试、debug代码时作为断言失败的结果而出现。

引发异常组

如果你在用Python 3.11 或更高版本,那么你可以选择使用新的 ExceptionGroup 类和相关联的except*语法。这种新的Python特性在你需要并行处理多个错误时很有用。例如,你可能会在一个asynchronous 程序具有多个并行的、可能同时失败的任务时求助于它们(异常组)。但通常来说,你不会经常用上ExceptionGroup

有别于常见的错误信息,ExceptionGroup构造器接收一个额外的由非空的异常列表组成的参数。这里有一个简化例子展示了如何引发异常组以及它的追溯长什么样:

>>> raise ExceptionGroup(
...     "several errors",
...     [
...         ValueError("invalid value"),
...         TypeError("invalid type"),
...         KeyError("missing key"),
...     ],
... )
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  | ExceptionGroup: several errors (3 sub-exceptions)
  +-+---------------- 1 ----------------
    | ValueError: invalid value
    +---------------- 2 ----------------
    | TypeError: invalid type
    +---------------- 3 ----------------
    | KeyError: 'missing key'
    +------------------------------------

正如你在Python里引发的任何其他异常那样,你引发了一个ExceptionGroup。然而,异常组的追溯和常规异常的追溯相当不同。你会获取到异常组的信息和组内异常的信息。

一旦你把多个异常封装成一个异常组,就可以使用except*语法捕获到它们,像下面这样:

>>> try:
...     raise ExceptionGroup(
...         "several errors",
...         [
...             ValueError("invalid value"),
...             TypeError("invalid type"),
...             KeyError("missing key"),
...         ],
...     )
... except* ValueError:
...     print("Handling ValueError")
... except* TypeError:
...     print("Handling TypeError")
... except* KeyError:
...     print("Handling KeyError")
...
Handling ValueError
Handling TypeError
Handling KeyError

注意这个结构与多个的捕获不同异常except子句或是单个的捕获多个异常的except子句表现都不同。在后一种情况里,代码将捕获到第一个出现的异常。而有了新的语法,你的代码就会引发所有异常,当然也能全部捕获到。

最终,当你引发ExceptionGroup,Python会把它视作普通的异常因为它就是Exception的子类。例如,如果你把except子句里的星号去掉了,Python就捕获不到列表里的任何一个异常了:

>>> try:
...     raise ExceptionGroup(
...         "several errors",
...         [
...             ValueError("invalid value"),
...             TypeError("invalid type"),
...             KeyError("missing key"),
...         ],
...     )
... except ValueError:
...     print("Handling ValueError")
... except TypeError:
...     print("Handling TypeError")
... except KeyError:
...     print("Handling KeyError")
...
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 2, in <module>
  | ExceptionGroup: several errors (3 sub-exceptions)
  +-+---------------- 1 ----------------
    | ValueError: invalid value
    +---------------- 2 ----------------
    | TypeError: invalid type
    +---------------- 3 ----------------
    | KeyError: 'missing key'
    +------------------------------------

在这段新的代码里,你把except子句的星号去掉了。那么,你的代码就不会捕获异常组里的任何一个单独的异常。这意思就是如果你想捕获任何异常组里的子异常,你就必须使用except*语法。

然而,如果你想捕获异常组本身,也可以直接使用except(不带星号):

>>> try:
...     raise ExceptionGroup(
...         "several errors",
...         [
...             ValueError("invalid value"),
...             TypeError("invalid type"),
...             KeyError("missing key"),
...         ],
...     )
... except ExceptionGroup:
...     print("Got an exception group!")
...
Got an exception group!

在常规except子句的上下文里,Python像捕获其他任何异常那样捕获了ExceptionGroup。它捕获了组,运行了处理部分的代码。

结语

现在你对于在Python里用raise语句引发异常有了一个扎实的理解。你也学到了何时在代码里引发异常以及根据正在处理的错误或问题决定应该引发的异常。此外,你也深入了解了一些能提高你处理问题/异常的代码水平的最佳做法和建议。

在这个教程里,你学会了:

  • 使用raise语句在Python中引发异常
  • 在代码中决定应该引发哪些异常以及何时引发异常
  • 探索在Python中引发异常的常见用例
  • 在Python代码中应用引发异常的最佳做法

你已经具备了在代码里有效处理错误和异常情景所需的知识和技能。

有了这些新技能,你就能以可靠的、可维护的代码优雅地处理错误和异常情景。总体而言,有效地处理异常是一项在Python编程中至关重要的技能,所以不断实践并进一步完善它,以成为一个更好的开发者!

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐