PSJay Blog

#FIXME, seriously

Python 异常捕获的动态性

| Comments

本文翻译自 The Dynamics of Catching Exceptions in Python


我要讨论的异常捕获的动态性,它让我感到吃惊而且可能会隐藏一些 Bug,或者是让我觉得很有趣。

有问题的代码

下面的代码——从产品中稍微(!)抽象出来的代码——看起来很完美。它调用了一个函数来获取一些统计数据然后再以某种方式处理这些数据。最初的数据是从一个套接字(Socket)连接中获取的,这可能会因为一个 Socket Error 而失败。不过由于这些统计数据不是系统中至关重要的一部分,我们仅仅只是用日志记录这个错误然后继续运行。

(注意,我使用 doctest 来检查这篇文章——这就意味着这里的脚本都是真正的代码!)

1
2
3
4
5
6
7
8
9
10
11
12
>>> def get_stats():
...     pass
...
>>> def do_something_with_stats(stats):
...     pass
...
>>> try:
...     stats = get_stats()
... except socket.error:
...     logging.warning("Can't get statistics")
... else:
...     do_something_with_stats(stats)

发现问题

我们的测试工具并没有发有什么地方不对,但实际上只需要注意一下我们的静态分析报告就可以看到问题:

$ flake8 filename.py
filename.py:351:1: F821 undefined name 'socket'
filename.py:352:1: F821 undefined name 'logging'

这段代码的问题就在于我们没有导入(import)socketlogging 模块(module)——并且显然我们没有测试这种情况。让我感到吃惊的是这段代码竟然没有引起一个 NameError ——我觉得异常子句(exception clauses)应该有一些积极的名字搜寻工作(eager name lookup)——毕竟它是用来捕获这些异常的,那么它当然需要知道这些异常的具体类型!

看来不是这样—— except 子句的搜寻行为是非常懒惰的,只有在异常真正抛出的时候它才会被解析。不仅名字搜寻是懒惰的,except 的「参数(arguments)」也可以是任意的表达式。

这也许是好事也许是坏事,又或者是糟糕事。

好事

异常声明可以是其他任何值,这就使动态异常声明成为了可能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> def do_something():
...    blob
...
>>> def attempt(action, ignore_spec):
...     try:
...         action()
...     except ignore_spec:
...         pass
...
>>> attempt(do_something, ignore_spec=(NameError, TypeError))
>>> attempt(do_something, ignore_spec=TypeError)
Traceback (most recent call last):
  ...
NameError: global name 'blob' is not defined

坏事

这种动态性的坏处就是异常声明中的错误常常不会被及时发现,除非异常真的发生了。当使用异常机制来捕获那些很少发生的事件(例如在写文件时遇到打开文件失败),除非有一个专门的测试针对这种情况,否则在异常发生之前这个错误都不会被发现。也就是说,异常发生时,Python 才会检查异常是不是和声明的异常相匹配,这时,检查匹配本身又引起了一个错误——通常是NameError

1
2
3
4
5
6
7
8
9
10
`>>> def do_something():
...     return 1, 2
...
>>> try:
...     a, b = do_something()
... except ValuError:  # oops - someone can't type
...     print("Oops")
... else:
...     print("OK!")   # we are 'ok' until do_something returns a triple...
OK!

糟糕事

1
2
3
4
5
6
7
8
9
>>> try:
...    TypeError = ZeroDivisionError  # now why would we do this...?!
...    1 / 0
... except TypeError:
...    print("Caught!")
... else:
...    print("ok")
...
Caught!

异常声明不仅仅可以是名字搜寻——任意的表达式都是可以的:

1
2
3
4
5
6
7
8
>>> try:
...     1 / 0
... except eval(''.join('Zero Division Error'.split())):
...     print("Caught!")
... else:
...     print("ok")
...
Caught!

异常声明不但可以是运行时决定的,它甚至可以使用活动的异常信息。下面的例子是一个复杂的方式来捕获当前被抛出的异常——而不做其他任何事情:

1
2
3
4
5
6
7
8
9
10
>>> import sys
>>> def current_exc_type():
...     return sys.exc_info()[0]
...
>>> try:
...     blob
... except current_exc_type():
...     print ("Got you!")
...
Got you!

显然这应该就是我们一直在__苦苦寻觅__的编写异常处理器的方式,这应该马上就会成为最佳实践了:-p

字节码(Byte Code)

为了确认为什么异常处理会这样工作,我在一个异常的例子中执行了 dis.dis()。(注意,这次反编译是在 Python 2.7 下进行的——在 Python 3.3 下面会产生不同的字节码,但是基本上是类似的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
>>> import dis
>>> def x():
...     try:
...         pass
...     except Blobbity:
...         print("bad")
...     else:
...         print("good")
...
>>> dis.dis(x)  # doctest: +NORMALIZE_WHITESPACE
  2           0 SETUP_EXCEPT             4 (to 7)
<BLANKLINE>
  3           3 POP_BLOCK
              4 JUMP_FORWARD            22 (to 29)
<BLANKLINE>
  4     >>    7 DUP_TOP
              8 LOAD_GLOBAL              0 (Blobbity)
             11 COMPARE_OP              10 (exception match)
             14 POP_JUMP_IF_FALSE       28
             17 POP_TOP
             18 POP_TOP
             19 POP_TOP
<BLANKLINE>
  5          20 LOAD_CONST               1 ('bad')
             23 PRINT_ITEM
             24 PRINT_NEWLINE
             25 JUMP_FORWARD             6 (to 34)
        >>   28 END_FINALLY
<BLANKLINE>
  7     >>   29 LOAD_CONST               2 ('good')
             32 PRINT_ITEM
             33 PRINT_NEWLINE
        >>   34 LOAD_CONST               0 (None)
             37 RETURN_VALUE

这就可以说明我最开始的「问题」了。异常处理的时机就是在异常发生时才完成的。异常处理的准备阶段是不需要了解之后的 ‘catching’ 子句的,如果没有异常被抛出,它会被完全的忽略。SETUP_EXCEPT 不关心到底会发生什么,它做的只是,如果有异常发生了,就解析第一个异常处理程序段,然后再解析第二个,等等。

每一个异常处理器(Exception Handler)由两部分组成:异常声明,以及它与刚刚抛出来的异常的比较。这两个行为都是懒惰的,所有的事情都和你面对一个幼稚的解释器,看着代码一行一行地被解释差不多。没有什么事情是巧妙的,但这点又让它看起来很巧妙。

总结

异常声明的动态性让我有一点点吃惊,但它的确可以写出一些有趣的应用。当然在三次元中真的去实现这些应用可能是个坏主意;-)

Python 中到底支持多少动态特性不是很直观——比如类的定义(而不是函数,方法或者全局范围)中表达式和语句都是可以被接受的这点就不是很明显,但并不是所有的东西都如此灵活。虽然(我认为)在使用装饰器(decorator)时使用表达式可能是个不错的特性,但 Python 是禁止这样做的,下面的代码会产生一个错误:

1
2
3
@(lambda fn: fn)
def x():
   pass

最后,这里有一个使用动态异常声明用来抛出首个捕获的某种类型的异常,忽略接下来的同类型的异常的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
>>> class Pushover(object):
...     exc_spec = set()
...
...     def attempt(self, action):
...         try:
...             return action()
...         except tuple(self.exc_spec):
...             pass
...         except BaseException as e:
...             self.exc_spec.add(e.__class__)
...             raise
...
>>> pushover = Pushover()
>>>
>>> for _ in range(4):
...     try:
...         pushover.attempt(lambda: 1 / 0)
...     except:
...         print ("Boo")
...     else:
...         print ("Yay!")
Boo
Yay!
Yay!
Yay!

Comments