详解Python异常处理

-- TOC --

异常处理在程序代码中占的分量,一般都要多于正常处理代码,本文总结与Python异常处理有关的知识点。

程序员都知道,大部分代码都是在处理各种异常情况。

Python内置异常对象的继承关系

Python内置异常对象,就像内置函数一样,直接使用。他们的继承关系如下:

BaseException
 +-- SystemExit
 +-- KeyboardInterrupt
 +-- GeneratorExit
 +-- Exception
      +-- StopIteration
      +-- StopAsyncIteration
      +-- ArithmeticError
      |    +-- FloatingPointError
      |    +-- OverflowError
      |    +-- ZeroDivisionError
      +-- AssertionError
      +-- AttributeError
      +-- BufferError
      +-- EOFError
      +-- ImportError
      |    +-- ModuleNotFoundError
      +-- LookupError
      |    +-- IndexError
      |    +-- KeyError
      +-- MemoryError
      +-- NameError
      |    +-- UnboundLocalError
      +-- OSError
      |    +-- BlockingIOError
      |    +-- ChildProcessError
      |    +-- ConnectionError
      |    |    +-- BrokenPipeError
      |    |    +-- ConnectionAbortedError
      |    |    +-- ConnectionRefusedError
      |    |    +-- ConnectionResetError
      |    +-- FileExistsError
      |    +-- FileNotFoundError
      |    +-- InterruptedError
      |    +-- IsADirectoryError
      |    +-- NotADirectoryError
      |    +-- PermissionError
      |    +-- ProcessLookupError
      |    +-- TimeoutError
      +-- ReferenceError
      +-- RuntimeError
      |    +-- NotImplementedError
      |    +-- RecursionError
      +-- SyntaxError
      |    +-- IndentationError
      |         +-- TabError
      +-- SystemError
      +-- TypeError
      +-- ValueError
      |    +-- UnicodeError
      |         +-- UnicodeDecodeError
      |         +-- UnicodeEncodeError
      |         +-- UnicodeTranslateError
      +-- Warning
           +-- DeprecationWarning
           +-- PendingDeprecationWarning
           +-- RuntimeWarning
           +-- SyntaxWarning
           +-- UserWarning
           +-- FutureWarning
           +-- ImportWarning
           +-- UnicodeWarning
           +-- BytesWarning
           +-- ResourceWarning

除了以上全局的内置异常对象,Python标准库中的模块也有一些模块自定义的异常对象,这些异常对象与模块的关系更加紧密,代码语义表达更加精确!

异常对象的继承关系,用来实现用父类异常可以捕获子类异常的情况。因此,用户自定义的异常类,需要认真考虑继承自哪个父类。

如何捕获异常

try...except...语句来捕获异常。如果except后面不跟任何异常名称,表示此处捕获所有异常,但这不是一个良好的代码风格。

捕获所有异常的方式:

>>> try:
...     a = 1/0
... except BaseException as e:
...     print(repr(e))
...
ZeroDivisionError('division by zero')

因为BaseException对象是所有异常对象的基类!以上代码,就可以捕获所有异常。

如果使用Exception,那么SystemExitKeyboardInterrupt,还有GeneratorExit这三个异常就捕获不到了!

as e后,下面block的代码就可以直接使用e这个对象,它代表那个异常对象。

下面的测试代码,演示了如何捕获某个具体的异常:

>>> def test_exception(num):
...     try:
...         a = 1/num
...         if num == 1:
...             import os
...             os.remove('not existed file')
...     except ZeroDivisionError as e:
...         print('1---',repr(e))
...     except FileNotFoundError as e:
...         print('2---',repr(e))
...
>>> test_exception(0)
1--- ZeroDivisionError('division by zero')
>>> test_exception(1)
2--- FileNotFoundError(2, 'No such file or directory')

如何获取errno

如果是系统调用出现错误,errno变量会被设置,在Python中,有一个标准库errno,但几乎用不到,用户代码获取errno值和对应的错误字符串,都不需要import这个库,而是直接通过异常对象:

import os


try:
    1/0
except Exception as e:
    try:
        print(e.errno, os.strerror(e.errno))
    except AttributeError as e:
        print(repr(e))


try:
    open('file_do_not_exited')
except Exception as e:
    print(e.errno, os.strerror(e.errno))

输入:

$ python3 test_exc.py
AttributeError("'ZeroDivisionError' object has no attribute 'errno'")
2 No such file or directory

我理解,只有OSError异常对象,才会被设置errno属性。

捕获KeyboardInterrupt异常

命令行程序运行期间,如果用户想终止程序,一般都会采用Ctrl-C快捷键,这个快捷键会引发python程序抛出KeyboardInterrupt异常。我们可以捕获这个异常,在用户按下Ctrl-C的时候,进行一些清理工作。

下面两种代码风格,都可以捕获KeyboardInterrupt异常:

try:
    ...  # many code here
except BaseException as e:  # can not be Exception
    if isinstance(e, KeyboardInterrupt):
        ...  # handler for ctrl-c goes here 

或者:

try:
    ...  # many code here
except KeyboardInterrupt as e:
    ...  # do something

Ctrl-C本质上是给进程发送SIGINT信号,因此我们可以在代码中截获SIGINT信号,也可以实现捕获KeyboardInterrupt异常的效果,参考忽略SIGINT的Python脚本

try...except...else...finally...

Python在加强代码可读性方面真是“不遗余力”。异常处理有else,还有finally,我们需要好好理解这种比较独特的语法结构。

先说一下异常处理的else分支:

有异常,进入except,没异常,进入else。try...语句后面不能直接跟else,必须要有except分支。

再说一下finally分支:

无论如何,finally分支都会被执行。try...语句后面,可以直接跟finally,可以没有except分支。

自定义异常类

如果Python自带的异常对象不能满足使用需要,比如名称不够准确,或代码结构需要,这时可以自定义异常对象。

class ReplyError(Exception):
    """Exception for receiving wrong reply"""

    def __init__(self, msg=None):
        self.msg = msg

    def __str__(self):
        return str(self.msg)

    def __repr__(self):
        return 'ReplyError('+str(self.msg)+')'

以上代码,从Exception类继承一个自定义异常类ReplyError。我们一般都是从Exception类继承,这是Python一般异常基类。

自定义的这个ReplyError,实现了3个函数,__init__初始化,__str__用于str()函数,__repr__用于repr()函数。

这样就算完成了自定义异常类,在代码中,就可以直接使用ReplyError了。

>>> try:
...     raise ReplyError('test customized exception class')
... except ReplyError as e:
...     print(str(e))
...     print(repr(e))
...
test customized exception class
ReplyError(test customized exception class)

raise可能是更好的return

编写软件,大部分代码都是在处理各种异常和错误。我们常常会遇到这样的场景,代码流程需要一层层的判断底层的返回是否成功,这样的代码写起来其实很费劲,为了一个可能出现的错误,要在每一个获取返回值的地方写if判断。其实,这个时候,使用raise来抛出一个异常,比用return返回标志位(True或False),更加简单,代码的可读性和可维护性也更好,代码的层次感也越强。

return语句只能返回到上一层调用的地方,如果调用层次比较多,底层的问题,要层层传递上来就太费劲了,这样代码写起来看起来都很别扭。return主要还是用来返回数据的,而raise是更好的“返回异常”的方式。

在一个处于层层调用关系的流程中,不管哪个地方raise抛出一个异常,我们只需要在流程需要的地方try...except...捕获异常,就可以了。raise抛出异常后,代码返回到最近的try...except...的地方(这是个与return很不一样的细节),这样中间流程的代码,写起来就会很轻松惬意优雅。而且,如果中间虽捕获了异常,但是不对异常进行处理,也可以直接独立的依据raise,再次将异常抛出,交给更上层来处理。

举个例子:

>>> def level_1():
...     raise ValueError('this is a value error')
...
>>> def level_2():
...     level_1()
...     print('in level 2')
...
>>> def level_3():
...     level_2()
...     print('in level 3')
...
>>> def top():
...     level_3()
...     print('in top')
...
>>> try:
...     top()
... except:
...     print('catch exception from level 1')
...
catch exception from level 1
>>>

以上示例代码,在最底层的函数raise一个ValueError异常,top函数与直接raise异常的函数,中间还经过了两层调用。不过,运行程序发现,最底层raise之后,在最顶层直接捕获异常,而且,很重要的细节是,代码中所有的打印都没有执行,代码相当于从最底层直接return到了最顶层try...except...的地方。

这种代码的写法,比一层层return再判断,要简单很多。这种层层调用在软件中很常见,稍微封装一下底层接口代码,层次关系就出现了。如果再使用上自定义的Python的异常类,配合这种写法,您的代码一定会更加漂亮性感!

下面这段来自C++的一本书,更技术地讲解这个细节:

Call Stacks and Exception Handling

The runtime seeks the closest exception handler to a thrown exception. If there is a matching exception handler in the current stack frame, it will handle the exception. If no matching handler is found, the runtime will unwind the call stack until it finds a suitable handler. Any objects whose lifetimes end are destroyed in the usual way.

单独一句raise的作用

代码中常常能看到单独使用一句raise,后面不带任何参数,这样写的作用是,将上下文中当前的异常抛出(raise语句不带参数的默认动作)。

def do_raise():
    raise ValueError('test value error')

def middle():
    try:
        do_raise()
    except:
        print('something wrong')
        raise

def top():
    try:
        middle()
    except ValueError as e:
        print(repr(e))

top()

在middle函数中,单独使用raise语句,它将会被do_raise抛出的异常,直接再次抛出(本来已被except捕获了)。middle函数不对此异常进行处理,而是交给上层代码去处理。这段代码的运行效果如下:

$ python3 raise.py
something wrong
ValueError('test value error')

raise在层层调用的代码流程中,简化了异常处理的代码编写,并形成了自己独有的异常处理层次关系,使得代码在处理异常时非常灵活高效。

During handling of the above exception, another exception occurred

调试python代码,常常看到这样的提示,During handling of the above exception, another exception occurred。这是如何发生的?

请看如下代码:

x = 2
y = 0

try:
    result = x / y
except ZeroDivisionError:
    raise ValueError('raise in exception clause')
    print("=== division by zero!")
else:
    print("result is", result)
finally:
    raise ValueError('raise in finally clause')
    print("executing finally clause")

ZeroDivisionError必然发生,然后代码进入except分支,在这个分支中,遇到了一个raise,后面的print得不到执行。由于有finally分支,在raise之前,需要执行finally分支的代码,不幸的是,此时又遇到了raise,它后面的print也得不到执行。因此运行这段代码的效果,就是如下:

E:\py>python try.py
Traceback (most recent call last):
  File "try.py", line 8, in 
    result = x / y
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "try.py", line 10, in 
    raise ValueError('raise in exception clause')
ValueError: raise in exception clause

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "try.py", line 15, in 
    raise ValueError('raise in finally clause')
ValueError: raise in finally clause

这就是During handling of the above exception, another exception occurred的由来!在处理异常的except分支或离开try的finally分支有raise,就会出现这样的提示。

到此,自然而然我们会想到另一个问题,在这种情况下,如果这段代码整体有try护着,抛出来的异常是哪个呢?请看下面的测试代码:

def testA():
    x = 2
    y = 0

    try:
        result = x / y
    except ZeroDivisionError:
        raise ValueError('raise in exception clause')
        print("=== division by zero!")
    else:
        print("result is", result)
    finally:
        raise ValueError('raise in finally clause')
        print("executing finally clause")


try:
    testA()
except Exception as e:
    print(repr(e))

运行结果:

E:\py>python try.py
ValueError('raise in finally clause')

看出来了吧,抛出来的只有最后那一个exception!

raise ... from ...

python有这样的语法: raise ... from ...,用这个语法,可以控制异常信息的输出,在连续多个异常发生的情况下,可以更好的控制输出的异常信息内容,方便查找问题。

直接上代码:

$ cat exc.py
try:
    1/0
except Exception as e:
    raise ValueError('denominator is zero') from e

这段代码执行的效果如下:

$ python3 exc.py
Traceback (most recent call last):
  File "exc.py", line 2, in 
    1/0
ZeroDivisionError: division by zero

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

Traceback (most recent call last):
  File "exc.py", line 4, in 
    raise ValueError('denominator is zero') from e
ValueError: denominator is zero

通过 raise ... from ...,异常发生后,输出的信息变成了 The above exception was the direct cause of the following exception,将连续两个异常关联起来了!比较nice。。。

再来一个示例:

$ cat exc.py
try:
    1/0
except Exception as e:
    raise ValueError('denominator is zero') from None

把 raise ... from e,写成 raise ... from None,执行效果如下:

$ python3 exc.py
Traceback (most recent call last):
  File "exc.py", line 4, in 
    raise ValueError('denominator is zero') from None
ValueError: denominator is zero

上一个异常被抑制了,不再输出。

finally抑制异常raise的情况

不是只有except分支语句可以抑制异常的raise,finally分支语句在某些情况下,也能够抑制异常不要raise出来!

finally是python异常处理块最后执行的部分代码,下面是python官方对finally在re-raise时的一个说明:

The following points discuss more complex cases when an exception occurs:

If an exception occurs during execution of the try clause, the exception may be handled by an except clause. If the exception is not handled by an except clause, the exception is re-raised after the finally clause has been executed. (没有被except捕获的异常,在finally执行后被抛出)

An exception could occur during execution of an except or else clause. Again, the exception is re-raised after the finally clause has been executed.(在except和else中发生的异常,在finally执行后被抛出)

If the finally clause executes a break, continue or return statement, exceptions are not re-raised. (如果finally执行了break,continue或return,此时就不会抛出那些原本应该抛出的异常)

If the try statement reaches a break, continue or return statement, the finally clause will execute just prior to the break, continue or return statement’s execution. (如果在try中执行break,continue或return,finally的执行会刚好在这些语句的前面)

If a finally clause includes a return statement, the returned value will be the one from the finally clause’s return statement, not the value from the try clause’s return statement. (如果finally中也存在return,最终执行的将不是try中的return)

当有except能够接住某个异常的时候,不会有异常被re-raise;当没有except被执行时,finally执行后,异常会re-raise出来;或者当异常发生在except或else分支中时,这时等同于没有except与承接那个异常,在finally执行后,异常会re-raise。

但是,当在finally中执行break,continue,return的时候,异常不会做re-raise,测试代码:

>>> while True:
...   try:
...     1/0
...   finally:
...     break
...

没有异常出现。。。再来一段测试代码:

>>> for i in range(10):
...   try:
...     1/0
...   finally:
...     print('finally...')
...     continue
...
finally...
finally...
finally...
finally...
finally...
finally...
finally...
finally...
finally...
finally...

Python从3.8开始,才支持finally语句中嵌套continue!

异常时获取调用栈

Python自带一个traceback模块,可以打印异常时的调用栈:

>>> import traceback
>>>
>>> try:
...   1/0
... except Exception as e:
...   print(repr(e))
...
ZeroDivisionError('division by zero')
>>>
>>> try:
...   1/0
... except Exception:
...   traceback.print_exc()
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero

另一个更简单的方式,是使用python标准库中的logging模块的接口:

try:
    so = socket.create_connection(serv_addr, timeout=2)
    so.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, True)
    so.sendall(cx(magic_bmsg) + b'\n')
    so.sendall(cx(pub_port.encode()) + b'\n')
    rf = so.makefile('rb')
    if dx(rf.readline().strip()) == magic_breply:
        log.info('Connect server %s ok, port %s is ready.',
                                                serv_ip, pub_port)
    else:
        raise ValueError('magic_breply is not match')
except Exception as e:
    log.exception(e)  # here!!
    try:
        so.shutdown(socket.SHUT_RDWR)
        so.close()
    except (NameError,OSError):
        pass
    sys.exit(1)

使用logging.exception接口。

本文链接:https://cs.pynote.net/sf/python/202202121/

-- EOF --

-- MORE --