Python 中的异常处理
Python 用于处理异常一共有 4 个关键字,分别是 try、except、else、finally,该怎么用呢?
为什么要有 try
假如你是 Python 的开发者,刚开始的时候你很高兴地发现自己创造的语言很棒,并为此感到自豪。有一天,你写了一个函数,作用是 5 分钟爬一次电影网站,并将结果存到文件里:
1
2
3
4while 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
9while 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
14while 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
4try:
# do something wrong
except (ValueError, IndexError) as e:
print(e)
这样代表捕获 ValueError
与IndexError
时都进行print(e)
。
这下就解决了上面的问题。舒服!
解决了 json 获取的问题,接下来,你写了各种函数用于解析、展示、存储 json 文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19while 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
13Your `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
22while 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
20while 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
25while 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
22while 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
23while 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
24while 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
5In [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
9In [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
24while 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 的语法也不允许)。那么我们自然而然会想到以下组合:
- try + except:如果出错,执行 except 块里的语句
- try + except + else:如果出错,执行 except 块里的语句,否则执行 else 里的语句
- try + except + else + finally:如果出错,执行 except 块里的语句,否则执行 else 里的语句,最后不管有没有出错,都执行 finally 里的语句
- try + else:如果没出错,执行 else 块里的语句(本质上是忽略所有异常)
- try + else + finally:如果没出错,执行 else 块里的语句,最后不管有没有出错,都执行 finally 里的语句(本质上是忽略所有异常)
- try + finally:最后不管有没有出错,都执行 finally 里的语句
如果你去试,你会发现第 4、5 点是不行的,会报语法错误。为什么不设计 try-else 的语法呢?我们看一下怎么使用 try-except-else 来实现我们想要的 try-else:
效果:
1
2
3
4try:
may be wrong
else:
do something...
实现:
1
2
3
4
5
6try:
may be wrong
except:
pass
else:
do something...
有些人可能觉得第一种很简洁。但是第二种写法很清晰地传达了一个意思:捕获并忽略所有异常,比较符合 Python 之禅的中的:explicit is better than implicit。至于采用哪种更好,Guido 说了算 :P
最后,既然提到了“忽略所有异常”,再说一下 except 后写不写 Exception 的区别吧:
1
2
3
4try:
do something
except:
pass
与
1
2
3
4try:
do something
except Exception:
pass
捕获异常到底怎么写?except:
还是 except Exception, e:
还是 except Exception as e:
?
首先,except Exception, e:
已经是很旧很旧的写法了,还不兼容 3.x。并且有时候会坑死你(以下代码当然只在 2.x 运行):
1
2
3
4try:
1/0
except ZeroDivisionError, IndexError:
print('You are WRONG!')
乍一看这段代码的作用是捕获 ZeroDivisionError
与 IndexError
2 个异常,来试试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22In [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
6In [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
14BaseException # 前者
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception # 后者
├── StopIteration
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── ... # 省略了
├── AssertionError
├── AttributeError
├── ImportError
├── IndexError
└── ... # 省略了
完整的这里有:传送门🚪
可以看到,前者包含的 SystemExit、KeyboardInterrupt、GeneratorExit 是后者没有的。所以前者捕获的异常更加彻底,包括如键盘中断和程序退出请求(用 sys.exit()
就无法退出程序了,因为异常被捕获了)。这三个异常不推荐随意捕获,因为它们被认为是比较严重的异常。举例:
1
2
3
4
5
6
7import sys
while 1:
try:
while 1: pass
except:
print sys.exc_info()[2]
按下 ctrl+c 也不会中断:
而这样就可以:
1
2
3
4
5
6
7import 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
9import 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。在使用的时候,如果放入能打断运行(return
、sys.exit
等等)的语句,要非常小心。猜猜下面这个示例代码,test(1)
的结果是什么?
1
2
3
4
5
6
7
8
9
10
11
12
13def 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
15def 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.
总结
总结一下,异常捕获、处理的流程是这样的:
即:
- try 块:可能出现错误的语句
- except:出错时的处理语句
- else:没出错的时候继续执行的语句
- finally:不管有没有出错,都会执行的语句
最后还有个 raise
:用于(重新)抛出异常。
来呀快活呀