Python异常调用栈

一般来说,当异常发生时,其异常栈应该从主调用者的入口一直到异常发生点,例如Java里经常出现的长达一两页的stack trace,这其中可能存在中间层代码收到异常时,进行一些动作(关闭数据库连接或者文件等),然后再次抛出异常的情况。

Python 3中,在except块内进行处理,然后重新抛出异常即可,例如下面的测试代码:

# -*- coding: utf-8 -*-
import sys


def a():
    b()


def b():
    c()  # call the c


def c():
    raise Exception("Hello World!")


class MyException(Exception):
    pass


def m_a():
    m_b()


def m_b():
    m_c()  # call the m_c


def m_c():
    try:
        a()
    except Exception as e:
        raise e


m_a()

运行时会打印异常调用栈为:

Traceback (most recent call last):
  File "test.py", line 36, in 
    m_a()
  File "test.py", line 22, in m_a
    m_b()
  File "test.py", line 26, in m_b
    m_c()  # call the m_c
  File "test.py", line 33, in m_c
    raise e
  File "test.py", line 31, in m_c
    a()
  File "test.py", line 6, in a
    b()
  File "test.py", line 10, in b
    c()  # call the c
  File "test.py", line 14, in c
    raise Exception("Hello World!")
Exception: Hello World!

这是因为Python 3在异常对象内添加了 __traceback__ 属性,用于存放异常栈信息,并且在重抛出的时候自动追加当前调用栈。在Python 2中,相同的写法会输出下面的内容:

Traceback (most recent call last):
  File "exception_test.py", line 37, in 
    m_a()
  File "exception_test.py", line 22, in m_a
    m_b()
  File "exception_test.py", line 26, in m_b
    m_c()  # call the m_c
  File "exception_test.py", line 34, in m_c
    raise e
Exception: Hello World!

可以看到,打印出来的异常栈内容只能追踪到 m_c 函数的 raise 语句,而引起这个异常的原因丢失掉了。

为了让Python 2也能显示像Python 3一样的异常栈,我曾经尝试过使用某个包装类将当前异常和栈保存起来,然后在Top-Level通过操作字符串来拼凑成一个完整的异常栈,但实现上不是很优雅,而且要求代码必须使用这个异常包装类才行。

后来某一天突然在Python官方文档上看见raise语句的解释:

6.9. The raise statement

raise_stmt ::=  "raise" [expression ["," expression ["," expression]]]

If no expressions are present, raise re-raises the last exception that was active in the current scope. If no exception is active in the current scope, a TypeError exception is raised indicating that this is an error (if running under IDLE, a Queue.Empty exception is raised instead).

Otherwise, raise evaluates the expressions to get three objects, using None as the value of omitted expressions. The first two objects are used to determine the type and value of the exception.

If the first object is an instance, the type of the exception is the class of the instance, the instance itself is the value, and the second object must be None.

If the first object is a class, it becomes the type of the exception. The second object is used to determine the exception value: If it is an instance of the class, the instance becomes the exception value. If the second object is a tuple, it is used as the argument list for the class constructor; if it is None, an empty argument list is used, and any other object is treated as a single argument to the constructor. The instance so created by calling the constructor is used as the exception value.

If a third object is present and not None, it must be a traceback object (see section The standard type hierarchy), and it is substituted instead of the current location as the place where the exception occurred. If the third object is present and not a traceback object or None, a TypeError exception is raised. The three-expression form of raise is useful to re-raise an exception transparently in an except clause, but raise with no expressions should be preferred if the exception to be re-raised was the most recently active exception in the current scope.

Additional information on exceptions can be found in section Exceptions, and information about handling exceptions is in section The try statement.

什么?raise语句竟然还能有第二个和第三个参数?把上面那段文字读完之后,我把 raise e 改成了 raise Exception, e, sys.exc_info()[2]

def m_c():
    try:
        a()
    except Exception as e:
        raise Exception, e, sys.exc_info()[2]

然后,异常调用栈打印出来就变成了:

Traceback (most recent call last):
  File "exception_test.py", line 37, in 
    m_a()
  File "exception_test.py", line 22, in m_a
    m_b()
  File "exception_test.py", line 26, in m_b
    m_c()  # call the m_c
  File "exception_test.py", line 31, in m_c
    a()
  File "exception_test.py", line 6, in a
    b()
  File "exception_test.py", line 10, in b
    c()  # call the c
  File "exception_test.py", line 14, in c
    raise Exception("Hello World!")
Exception: Hello World!

其实就是文档中的写法,raise语句允许三种形式:第一种就是最常用的直接raise一个异常对象,此时异常对象的type会被自动计算,并被设置为当前的异常类型,异常栈就是当前的调用栈;第二种是 raise 异常类型, 异常值 或者 raise 异常类型, 一个tuple,前者表明了当前异常的类型和值,后者表明了异常类型,传入的tuple将被作为参数传入对应类型的__init__函数用于构造一个异常对象。这种写法是过时的,并且不再被推荐使用。第三种是 raise 异常类型,异常值或者tuple,调用栈,也就是这里用到的这种写法。新增的第三个参数可以传入一个调用栈,此时raise引发异常的异常栈将自动添加到调用栈上,于是就形成了我们想要的非常直观的调用链显示。

另外,raise还可以不接任何参数,此时raise将抛出当前上下文存在的异常,如果当前不在异常处理上下文中,TypeError会被抛出。也就是说这里直接写 raise 也是一样的效果。不过使用带调用栈的raise语句有一个好处:包装异常并统一异常类型。假设下层代码会抛出多种异常,此时作为模块作者希望让调用者看到异常发生的具体地点,但又不希望调用者except每种异常类型,此时可以定义一个 class MyException,然后在重抛出异常的时候写 raise MyException, 参数, sys.exc_info()[2] 。此时调用者会得到完整的异常栈,同时异常类型变为了MyException。

在Python 3中,若想实现类似的效果,可使用异常对象的 with_traceback 方法,例如将 m_c 方法改写成:

def m_c():
    try:
        a()
    except Exception as e:
        raise MyException(e).with_traceback(e.__traceback__)

此时Top-Level打印的异常栈为:

Traceback (most recent call last):
  File "test.py", line 31, in m_c
    a()
  File "test.py", line 6, in a
    b()
  File "test.py", line 10, in b
    c()  # call the c
  File "test.py", line 14, in c
    raise Exception("Hello World!")
Exception: Hello World!

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "test.py", line 38, in 
    m_a()
  File "test.py", line 22, in m_a
    m_b()
  File "test.py", line 26, in m_b
    m_c()  # call the m_c
  File "test.py", line 34, in m_c
    raise MyException(e).with_traceback(e.__traceback__)
  File "test.py", line 31, in m_c
    a()
  File "test.py", line 6, in a
    b()
  File "test.py", line 10, in b
    c()  # call the c
  File "test.py", line 14, in c
    raise Exception("Hello World!")
__main__.MyException: Hello World!

可以看到,一共打印了两个异常,最后打印的异常附带了完成的异常调用栈信息,但是多了一条重抛出语句。

为了不向外界展示异常重抛出流程,可以直接改写 __traceback__ 属性,如下:

def m_c():
    try:
        a()
    except Exception as e:
        e.__traceback__ = sys.exc_info()[2]
        raise

此时异常栈就是预期的形式了:

Traceback (most recent call last):
  File "test.py", line 41, in 
    m_a()
  File "test.py", line 22, in m_a
    m_b()
  File "test.py", line 26, in m_b
    m_c()  # call the m_c
  File "test.py", line 31, in m_c
    a()
  File "test.py", line 6, in a
    b()
  File "test.py", line 10, in b
    c()  # call the c
  File "test.py", line 14, in c
    raise Exception("Hello World!")
Exception: Hello World!

Python 3还提供了raise ... from 的语法,但我个人感觉这个功能不是那么实用... 如果将异常语句改写成 raise MyException(e) from e,会得到下面的异常栈:

Traceback (most recent call last):
  File "test.py", line 31, in m_c
    a()
  File "test.py", line 6, in a
    b()
  File "test.py", line 10, in b
    c()  # call the c
  File "test.py", line 14, in c
    raise Exception("Hello World!")
Exception: Hello World!

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

Traceback (most recent call last):
  File "test.py", line 38, in 
    m_a()
  File "test.py", line 22, in m_a
    m_b()
  File "test.py", line 26, in m_b
    m_c()  # call the m_c
  File "test.py", line 33, in m_c
    raise MyException(e) from e
__main__.MyException: Hello World!

虽然两个异常都显示出来了,但是没有连接在一起,并不是很直观,对debug帮助没有那么大。而且就算不写 from 子句,Python 3一样会把在异常处理环节中遇到的所有异常都打印出来。

 

PS:在写Python 2 三参数 raise 的时候出现了一个小插曲,VSCode的Flake8 Linter会将第三种写法当成第二种,并提示写法已过时,忽略掉就好。下面是Flake8 Rules给出的W602警告解释:

Deprecated form of raising exception (W602)

The raise Exception, message form of raising exceptions is deprecated. Use the new form.

Anti-pattern

def can_drive(age):
    if age < 16:
        raise ValueError, 'Not old enough to drive'
    return True

Best practice

def can_drive(age):
    if age < 16:
        raise ValueError('Not old enough to drive')
    return True

你可能感兴趣的:(语言相关)