先前的错误被当前异常上下文掩盖

11 投票
1 回答
1838 浏览
提问于 2025-04-18 06:49

下面是我在Doug Hellman的网站上找到的一个例子,文件名叫“masking_exceptions_catch.py”。我现在找不到链接。这个例子中,throws()函数抛出的异常被忽略了,而cleanup()函数抛出的异常则被报告出来。

在他的文章中,Doug提到这种处理方式让人感觉不太直观。我一开始以为这可能是个bug,或者是当时Python版本的限制(大约在2009年)。于是我在当前的Mac版Python(2.7.6)上运行了这个例子,结果还是报告了来自cleanup()的异常。我觉得这有点不可思议,想看看这实际上是怎样的正确或理想的行为。

#!/usr/bin/env python

import sys
import traceback

def throws():
    raise RuntimeError('error from throws')

def nested():
    try:
        throws()
    except:
        try:
            cleanup()
        except:
            pass # ignore errors in cleanup
        raise # we want to re-raise the original error

def cleanup():
    raise RuntimeError('error from cleanup')

def main():
    try:
        nested()
        return 0
    except Exception, err:
        traceback.print_exc()
        return 1

if __name__ == '__main__':
    sys.exit(main())

程序输出:

$ python masking_exceptions_catch.py
Traceback (most recent call last):
  File "masking_exceptions_catch.py", line 24, in main
    nested()
  File "masking_exceptions_catch.py", line 14, in nested
    cleanup()
  File "masking_exceptions_catch.py", line 20, in cleanup
    raise RuntimeError('error from cleanup')
RuntimeError: error from cleanup

1 个回答

18

回到之前的问题。我先不回答你的问题。:-)

这个真的有效吗?

def f():
    try:
        raise Exception('bananas!')
    except:
        pass
    raise

那么,上面的代码到底做了什么呢?请准备好音乐。


好了,大家停止思考。

# python 3.3
      4     except:
      5         pass
----> 6     raise
      7 

RuntimeError: No active exception to reraise

# python 2.7
      1 def f():
      2     try:
----> 3         raise Exception('bananas!')
      4     except:
      5         pass

Exception: bananas!

嗯,这真是有收获。为了好玩,我们来给异常命名。

def f():
    try:
        raise Exception('bananas!')
    except Exception as e:
        pass
    raise e

接下来怎么办?

# python 3.3
      4     except Exception as e:
      5         pass
----> 6     raise e
      7 

UnboundLocalError: local variable 'e' referenced before assignment

# python 2.7
      4     except Exception as e:
      5         pass
----> 6     raise e
      7 

Exception: bananas!

在Python 2和3之间,异常的处理方式发生了很大的变化。如果你觉得Python 2的行为让你感到惊讶,那就考虑一下:它基本上和Python在其他地方的行为是一致的。

try:
    1/0
except Exception as e: 
    x=4
#can I access `x` here after the exception block?  How about `e`?

tryexcept并不是作用域。实际上,在Python中,只有少数东西是作用域;我们有个“LEGB规则”来记住四个命名空间——局部、封闭、全局和内置。其他的代码块并不是作用域;我可以在for循环中声明x,并且在循环结束后仍然可以引用它。

所以,这就有点尴尬了。异常是否应该被特别处理,只在它们的封闭词法块内?Python 2说不,Python 3说是的。但我在这里简化了;你最初问的是裸raise,这些问题是紧密相关的,但实际上并不完全相同。Python 3可以规定命名异常的作用域仅限于它们的块,而不涉及裸raise的问题。

raise到底做了什么‽

常见的用法是使用裸raise来保留堆栈跟踪。捕获异常,进行日志记录/清理,然后重新抛出。很好,我的清理代码不会出现在堆栈跟踪中,这样99.9%的情况下都能正常工作。但是,当我们尝试在异常处理器中处理嵌套异常时,事情可能会变得复杂。有时候。(请查看底部的示例,了解何时会/不会出现问题)

直观上来说,没有参数的raise应该能正确处理嵌套异常处理器,并找出正确的“当前”异常来重新抛出。但现实并非如此。实际上,异常信息作为当前帧对象的一个成员被保存。在Python 2中,没有机制来处理在单个帧内推入/弹出异常处理器;只有一个字段保存了最后一个异常,而不管我们对它做了什么处理。这就是裸raise所获取的。

6.9. raise语句

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

如果没有表达式,raise会重新抛出当前作用域中最后一个活动的异常。

所以,是的,这在Python 2中是一个深层次的问题,涉及到堆栈跟踪信息的存储——根据高地人传统,只有一个(保存到给定堆栈帧的堆栈跟踪对象)。因此,裸raise重新抛出的异常是当前帧认为的“最后”异常,这不一定是我们人脑认为的在当前词法嵌套异常块中特定的那个异常。唉,作用域!

那么,在Python 3中修复了吗?

是的。怎么修复的?新的字节码指令(实际上有两个,还有一个隐式的在异常处理器开始时),但谁在乎呢——它“直观地”都能正常工作。你的示例代码不会再抛出RuntimeError: error from cleanup,而是像预期的那样抛出RuntimeError: error from throws

我不能给你一个官方的理由,说明为什么这个没有包含在Python 2中。这个问题自PEP 344以来就被知道了,文中提到Raymond Hettinger在2003年提出了这个问题。如果我必须猜测,修复这个问题是一个破坏性更改(其中之一,它影响sys.exc_info的语义),而这通常是一个不在小版本中做这个的好理由。

如果你在使用Python 2,有哪些选择:

1) 给你打算重新抛出的异常命名,并接受在堆栈跟踪底部多加一两行的事实。你的示例nested函数变成:

def nested():
    try:
        throws()
    except BaseException as e:
        try:
            cleanup()
        except:
            pass 
        raise e

以及相关的堆栈跟踪:

Traceback (most recent call last):
  File "example", line 24, in main
    nested()
  File "example", line 17, in nested
    raise e
RuntimeError: error from throws

所以,堆栈跟踪被修改了,但它仍然有效。

1.5) 使用raise的三参数版本。很多人不知道这个,这是一个合法的(虽然有点笨重)保留堆栈跟踪的方法。

def nested():
    try:
        throws()
    except:
        e = sys.exc_info()
        try:
            cleanup()
        except:
            pass 
        raise e[0],e[1],e[2]

sys.exc_info给我们一个包含(类型、值、堆栈跟踪)的三元组,这正是raise的三参数版本所需要的。请注意,这个三参数语法仅在Python 2中有效。

2) 重构你的清理代码,使其不可能抛出未处理的异常。记住,这一切都与作用域有关——把try/except移出nested,放到它自己的函数中。

def nested():
    try:
        throws()
    except:
        cleanup()
        raise

def cleanup():
    try:
        cleanup_code_that_totally_could_raise_an_exception()
    except:
        pass

def cleanup_code_that_totally_could_raise_an_exception():
    raise RuntimeError('error from cleanup')

现在你就不用担心了;因为异常从未进入nested的作用域,它不会干扰你打算重新抛出的异常。

3) 像之前那样使用裸raise,然后接受这个事实;清理代码通常不会抛出异常,对吧?:-)

撰写回答