Python 中的异常处理

Python 用于处理异常一共有 4 个关键字,分别是 try、except、else、finally,该怎么用呢?

为什么要有 try

假如你是 Python 的开发者,刚开始的时候你很高兴地发现自己创造的语言很棒,并为此感到自豪。有一天,你写了一个函数,作用是 5 分钟爬一次电影网站,并将结果存到文件里:

1
2
3
4
while 1: 
json = spider('www.tr0y.wang')
save(json, 'result.json')
time.sleep(5*60) # 5min 爬一次

spider 就是爬取页面的爬虫,save 是保存 json 格式的结果到 result.json 里。

有一天,你发现网络环境不稳定的时候,spider 函数会报错:timeout。一旦出错,程序就会报错退出:

1
NetworkError: timeout!

网络波动导致 timeout 的话,多试一次不就行了?于是,你发明了 try 关键字用于捕获异常,出现异常则跳过 try 块里剩下的语句。这样就可以愉快地重试了:

1
2
3
4
5
6
7
8
9
while 1:
for i in range(10): # 尝试 10 次
try:
json = spider('www.tr0y.wang')
save(json, 'result.json')
break # 成功则退出
time.sleep(60) # 歇口气再试

time.sleep(5*60) # 5min 爬一次

又过了一段时间,你发现爬虫虽然不会因为 timeout 报错而终止了,但是又出现了新的报错:

1
DecodeError: Json format error!

经过一番 debug,你发现了由于这个网站的服务器相当土豆,请求人数太多的时候反应不过来会导致 500 的 http status。这时返回的是非 json 格式的结果,而 spider 函数会将返回的结果当做 json 格式来处理,拿到的数据是非 json 格式的自然会导致 spider 函数报错。这个时候,应当等待更长的时间再爬。那么问题来了,如何区分不同的报错并进行处理呢?只用 try 关键字的话肯定是没法解决了,毕竟 try 没有区分异常的功能。

except 抵达战场

为了解决这个问题,你发明了 except 关键字,能够根据不同的异常类捕获不同的报错,并且报错的信息通过 as 赋给变量,比如下面就是赋给变量 e

1
2
3
4
5
6
7
8
9
10
11
12
13
14
while 1:
for i in range(10): # 尝试 10 次
try:
json = spider('www.tr0y.wang')
save(json, 'result.json')
break # 成功则退出
except NetworkError as e:
print('网络波动:', e)
time.sleep(60) # 歇口气再试
except DecodeError as e:
print('服务器扛不住了:', e)
time.sleep(3*60) # 歇一大口气再试

time.sleep(5*60) # 5min 爬一次

如果 except 后面没加具体的异常,则就和之前的第一版 try 一样,捕获所有错误。并且多个异常可以进行合并:

1
2
3
4
try:
# do something wrong
except (ValueError, IndexError) as e:
print(e)

这样代表捕获 ValueErrorIndexError时都进行print(e)

这下就解决了上面的问题。舒服!

解决了 json 获取的问题,接下来,你写了各种函数用于解析、展示、存储 json 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
while 1:
for i in range(10): # 尝试 10 次
try:
json = spider('www.tr0y.wang')
save(json, 'result.json')

# 新增的函数
analysis('result.json')
... # 省略 n 个函数
show('result.json')
break # 成功则退出
except NetworkError as e:
print('网络波动:', e)
time.sleep(60) # 歇口气再试
except DecodeError as e:
print('服务器扛不住了:', e)
time.sleep(3*60) # 歇一大口气再试

time.sleep(5*60) # 5min 爬一次

else 的诞生

随着处理函数的增加,try 语句块越来越长...直到有一天,有人在 github 看到了你的代码,费了半天的劲才明白你的 try 到底是在捕获哪个语句可能出现的错误,并顺手在 issue 留下了评论:

1
2
3
4
5
6
7
8
9
10
11
12
13
Your `try` is so 
l
o
o
o
o
o
o
o
o
o
o
ng

你焕然大悟,在 try 里堆砌过多的语句将导致 try 可读性降低。再说了,try 块里的语句的异常,并不是你都想捕获的。于是你灵机一动,那我设置一个 flag 好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
while 1:
for i in range(10): # 尝试 10 次
error = 1
try:
json = spider('www.tr0y.wang')
error = 0
except NetworkError as e:
print('网络波动:', e)
time.sleep(60) # 歇口气再试
except DecodeError as e:
print('服务器扛不住了:', e)
time.sleep(3*60) # 歇一大口气再试

if not error: # 说明没报错
save(json, 'result.json')
# 新增的函数
analysis('result.json')
... # 省略 n 个函数
show('result.json')
break # 成功则退出

time.sleep(5*60) # 5min 爬一次

问题解决了,但是怎么看怎么不顺眼,作为一个牛逼的语言发明者,这样简单的语法需求都没支持,还得每次都让开发者设置 flag,太不(mei)优(bi)雅(ge)了。于是你大手一挥,又发明了 else 关键字,配合 try 在 try 块没报错的情况下被执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
while 1:
for i in range(10): # 尝试 10 次
try:
json = spider('www.tr0y.wang')
except NetworkError as e:
print('网络波动:', e)
time.sleep(60) # 歇口气再试
except DecodeError as e:
print('服务器扛不住了:', e)
time.sleep(3*60) # 歇一大口气再试
else: # 负责帮助 try 块执行后续命令
save(json, 'result.json')

# 新增的函数
analysis('result.json')
... # 省略 n 个函数
show('result.json')
break # 成功则退出

time.sleep(5*60) # 5min 爬一次

爬虫从此稳定得一批。

finally:Python 的简洁

又双过了一段时间,由于没有时间记录,你不知道每次爬取尝试运行了多久。于是你补充了几个语句,用于计算每次尝试执行的时间:

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
while 1:
for i in range(10): # 尝试 10 次
start = time.time() # 起始时间点
try:
json = spider('www.tr0y.wang')
except NetworkError as e:
print('网络波动:', e)
time.sleep(60) # 歇口气再试
end = time.time() # 结束时间点
print(end-start, 's')
except DecodeError as e:
print('服务器扛不住了:', e)
time.sleep(3*60) # 歇一大口气再试
end = time.time() # 结束时间点
print(end-start, 's')
else: # 负责帮助 try 块执行后续命令
save(json, 'result.json')
analysis('result.json')
... # 省略 n 个函数
show('result.json')
end = time.time() # 结束时间点
print(end-start, 's')
break # 成功则退出

time.sleep(5*60) # 5min 爬一次

看上去挺好的,但是有大量的重复语句,能不能简洁一点呢?当然可以,你瞬间想起了用函数来封装记录时间的语句。但是只不过是把 n 个重复的语句变成 1 个重复语句罢了,还是不够简洁。于是灵机一动,调整了一下语句的位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
while 1:
for i in range(10): # 尝试 10 次
start = time.time() # 起始时间点
try:
json = spider('www.tr0y.wang')
except NetworkError as e:
print('网络波动:', e)
time.sleep(60) # 歇口气再试
except DecodeError as e:
print('服务器扛不住了:', e)
time.sleep(3*60) # 歇一大口气再试
else: # 负责帮助 try 块执行后续命令
save(json, 'result.json')
analysis('result.json')
... # 省略 n 个函数
show('result.json')
break # 成功则退出
# 总是需要执行的代码放这里
end = time.time() # 结束时间点
print(end-start, 's')

time.sleep(5*60) # 5min 爬一次

看上去不错,简洁了很多,但是实际上有个 bug,就是如果执行成功后,是不会执行记录时间的语句的,因为 else 里有 break,直接跳过了记录时间的语句。
于是,你又发明了 finally 语句,不管 try 的语句块执行报不报错,都会被执行,而且吸收了之前的教训,try、except、else 的语句中的 break、continue、return 语句不会跳过 finally 块里的语句,甚至连它们执行时出现的未捕获异常,也要在执行完 finally 块后才会抛出。
于是,代码就成为了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
while 1:
for i in range(10): # 尝试 10 次
start = time.time() # 起始时间点
try:
json = spider('www.tr0y.wang')
except NetworkError as e:
print('网络波动:', e)
time.sleep(60) # 歇口气再试
except DecodeError as e:
print('服务器扛不住了:', e)
time.sleep(3*60) # 歇一大口气再试
else: # 负责帮助 try 块执行后续命令
save(json, 'result.json')
analysis('result.json')
... # 省略 n 个函数
show('result.json')
break # 成功则退出
finally: # 就算 else 里 break 也会执行 finally
# 总是需要执行的代码放这里
end = time.time() # 结束时间点
print(end-start, 's')

time.sleep(5*60) # 5min 爬一次

raise 异常制造机

叒叕叒叕过了一段时间,你发现这个网站的服务器变得更渣了,一旦人数过多崩了,第二天才会恢复。这时候之前的 time.sleep(3*60) # 歇一大口气再试 意义也不大了,还不如停下来,等第二天手动恢复。但是你又想记录这个状态,这就意味着不能简单地把 except DecodeError as e: 删了,因为还要打印呢。于是你灵机一动,将代码改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
while 1:
for i in range(10): # 尝试 10 次
start = time.time() # 起始时间点
try:
json = spider('www.tr0y.wang')
except NetworkError as e:
print('网络波动:', e)
time.sleep(60) # 歇口气再试
except DecodeError as e:
print('服务器扛不住了:', e)
# time.sleep(3*60) # 歇一大口气再试
kill
else: # 负责帮助 try 块执行后续命令
save(json, 'result.json')
analysis('result.json')
... # 省略 n 个函数
show('result.json')
break # 成功则退出
finally: # 就算 else 里 break 也会执行 finally
# 总是需要执行的代码放这里
end = time.time() # 结束时间点
print(end-start, 's')

time.sleep(5*60) # 5min 爬一次

由于变量 kill 没有定义,代码便在进入except DecodeError as e:后,print完就报错退出了。这显然是一个骚操作(我之前就是这么干的 :P)。于是你又发明了 raise关键字用于触发报错:

1
2
3
4
5
In [3]: raise RuntimeError('123')
Traceback (most recent call last):
File "<ipython-input-3-81afeb2aaf23>", line 1, in <module>
raise RuntimeError('123')
RuntimeError: 123

raise 后面可以加上异常的类型,也可以不加,不加的话则重新抛出捕获的异常:

1
2
3
4
5
6
7
8
9
In [4]: try:
...: 1/0
...: except ZeroDivisionError:
...: raise # 重新抛出 ZeroDivisionError
...:
Traceback (most recent call last):
File "<ipython-input-4-2791351c601e>", line 2, in <module>
1/0
ZeroDivisionError: division by zero

最最最最最后,代码就成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
while 1:
for i in range(10): # 尝试 10 次
start = time.time() # 起始时间点
try:
json = spider('www.tr0y.wang')
except NetworkError as e:
print('网络波动:', e)
time.sleep(60) # 歇口气再试
except DecodeError as e:
print('服务器扛不住了:', e)
# time.sleep(3*60) # 歇一大口气再试
raise RuntimeError('服务器扛不住了,需要等第二天手动重启!')
else: # 负责帮助 try 块执行后续命令
save(json, 'result.json')
analysis('result.json')
... # 省略 n 个函数
show('result.json')
break # 成功则退出
finally: # 就算 else 里 break 也会执行 finally
# 总是需要执行的代码放这里
end = time.time() # 结束时间点
print(end-start, 's')

time.sleep(5*60) # 5min 爬一次

其他细节

再说一下总体细节:
如果没有 try 关键字,那么使用后三个关键字是没有意义的(Python 的语法也不允许)。那么我们自然而然会想到以下组合:

  1. try + except:如果出错,执行 except 块里的语句
  2. try + except + else:如果出错,执行 except 块里的语句,否则执行 else 里的语句
  3. try + except + else + finally:如果出错,执行 except 块里的语句,否则执行 else 里的语句,最后不管有没有出错,都执行 finally 里的语句
  4. try + else:如果没出错,执行 else 块里的语句(本质上是忽略所有异常)
  5. try + else + finally:如果没出错,执行 else 块里的语句,最后不管有没有出错,都执行 finally 里的语句(本质上是忽略所有异常)
  6. try + finally:最后不管有没有出错,都执行 finally 里的语句

如果你去试,你会发现第 4、5 点是不行的,会报语法错误。为什么不设计 try-else 的语法呢?我们看一下怎么使用 try-except-else 来实现我们想要的 try-else:
效果:

1
2
3
4
try:
may be wrong
else:
do something...

实现:

1
2
3
4
5
6
try:
may be wrong
except:
pass
else:
do something...

有些人可能觉得第一种很简洁。但是第二种写法很清晰地传达了一个意思:捕获并忽略所有异常,比较符合 Python 之禅的中的:explicit is better than implicit。至于采用哪种更好,Guido 说了算 :P

最后,既然提到了“忽略所有异常”,再说一下 except 后写不写 Exception 的区别吧:

1
2
3
4
try:
do something
except:
pass


1
2
3
4
try:
do something
except Exception:
pass

捕获异常到底怎么写?except: 还是 except Exception, e: 还是 except Exception as e: ?

首先,except Exception, e: 已经是很旧很旧的写法了,还不兼容 3.x。并且有时候会坑死你(以下代码当然只在 2.x 运行):

1
2
3
4
try:
1/0
except ZeroDivisionError, IndexError:
print('You are WRONG!')

乍一看这段代码的作用是捕获 ZeroDivisionErrorIndexError 2 个异常,来试试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
In [1]: try:
...: 1/0
...: except ZeroDivisionError, IndexError:
...: print('You are WRONG!')
...:
You are WRONG!

In [2]: try:
...: [][0]
...: except ZeroDivisionError, IndexError:
...: print('You are WRONG!')
...:
---------------------------------------------------------------------------
Traceback (most recent call last)
<ipython-input-2-69cefe22dccf> in <module>()
1 try:
----> 2 [][0]
3 except ZeroDivisionError, IndexError:
4 print('You are WRONG!')
5

IndexError: list index out of range

IndexError 居然没被捕获,那 IndexError 是拿去干嘛用了呢?实际上充当了之前变量 e 的作用:
1
2
3
4
5
6
In [4]: try:
...: 1/0
...: except ZeroDivisionError, IndexError:
...: print(IndexError)
...:
integer division or modulo by zero

巨大的深坑 :)

所以,except Exception, e: 先不考虑。

except:except Exception as e: 的区别在于,异常的继承的类型不同。前者捕获的异常涵盖了直接、间接继承 BaseException 的所有异常;而后者只包括了直接、间接继承 Exception 的异常。

我们知道,要想捕获哪一种异常,就在 except 后面加上这个异常类。那么 Exception 到底指的是哪个异常呢?
下面是异常的继承的关系图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BaseException # 前者
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception # 后者
├── StopIteration
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── ... # 省略了
├── AssertionError
├── AttributeError
├── ImportError
├── IndexError
└── ... # 省略了

完整的这里有:传送门🚪

可以看到,前者包含的 SystemExit、KeyboardInterrupt、GeneratorExit 是后者没有的。所以前者捕获的异常更加彻底,包括如键盘中断程序退出请求(用 sys.exit() 就无法退出程序了,因为异常被捕获了)。这三个异常不推荐随意捕获,因为它们被认为是比较严重的异常。举例:

1
2
3
4
5
6
7
import sys

while 1:
try:
while 1: pass
except:
print sys.exc_info()[2]

按下 ctrl+c 也不会中断:

而这样就可以:

1
2
3
4
5
6
7
import sys

while 1:
try:
while 1: pass
except Exception as e:
print sys.exc_info()[2]

最后,在 except Exception as e: 中,e 作为 Exception 的一个实例,会有很多用处。比如,当你在使用 urllib 抓取一个页面的时候,服务器返回 500。这个时候 Python 会报错,但是页面却有返回内容,而你又想拿到这个页面的内容时,就可以在异常中获取:

1
2
3
4
5
6
7
8
9
import urllib.request
from urllib.error import HTTPError

result = urllib.request.Request(url="xxx")
try:
handler = urllib.request.urlopen(result)
handler.read() # 由于 500 会报错,所以代码不会走到这里
except HTTPError as e:
content = e.read() # 输出 500 页面的内容

所以,对于 except Exception 来说,Exception 指的是 Exception 异常类,所有直接、间接继承 Exception 的异常都会被捕获。若不写具体的异常类,则视为 BaseException,即 except:except BaseException: 是一样的~

最后的最后,再说一下 finally。在使用的时候,如果放入能打断运行(returnsys.exit 等等)的语句,要非常小心。猜猜下面这个示例代码,test(1) 的结果是什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
def test(num):
try:
result = 1/num
except Exception as e:
print('got an error:', e)
result = None
else:
print('success')
kill
finally:
return result

>>> test(1)

try 里的语句不会报错,那么接下来执行的就是 else,else 里的语句由于 kill 是没有定义过的,所以应当抛出 NameError: name 'kill' is not defined,但是 上面说了,finally 里的语句一定会被执行,即使前面的语句抛出异常,也需要让 finally 执行之后才能抛出。
但是由于 finally 中有 return,直接打断了函数的运行,所以这个异常就被吃掉了。也就是这段代码实际上等价于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def test(num):
try:
result = 1/num
except Exception as e:
print('got an error:', e)
result = None
else:
try:
print('success')
kill
except:
pass
finally:
return result


没错,所有直接、间接继承 BaseException 的异常都会被吃掉。
如果你有作进一步的尝试,你会发现 finally 中是不能使用 continue 的,原因在于...这是个 bug,py3.8 应该会对此进行修复:issue32489 - Allow 'continue' in 'finally' clause.

总结

总结一下,异常捕获、处理的流程是这样的:

即:

  1. try 块:可能出现错误的语句
  2. except:出错时的处理语句
  3. else:没出错的时候继续执行的语句
  4. finally:不管有没有出错,都会执行的语句

最后还有个 raise:用于(重新)抛出异常。

我认为这些关键字覆盖的语句最好精简一些,如果语句不需要与处理异常相关的东西打交道就不要放入对应的代码块中去,除非你很有信心不会不小心踩坑。

来呀快活呀


Python 中的异常处理
https://www.tr0y.wang/2019/03/27/Python异常处理/
作者
Tr0y
发布于
2019年3月27日
更新于
2024年4月19日
许可协议