完整讲解 · 6 段教学·配 5 道练习题·预计 40 分钟
本页是本章的通读版,可直接读完全部讲解。想动手写代码、跑判分,去 闯关模式。
写代码这件事,最让新手崩溃的瞬间是哪种?想象一下:你刚学完语法,兴冲冲写了几十行代码,按下回车,结果终端弹出一坨红字:
Traceback (most recent call last):
File "main.py", line 5, in <module>
print(10 / 0)
ZeroDivisionError: division by zero
一脸懵。这玩意儿是啥?我哪里写错了?为啥程序就崩了?
其实啊,这就是 Python 的「异常机制」在工作。程序遇到了它处理不了的情况,比如除以零、读不存在的文件、把字符串当数字加,于是它就「抛」一个异常出来,整个程序停下来,告诉你:「我不干了,你来收拾」。
可是真实世界里的程序怎么可能因为一个小问题就停摆?银行 ATM 你输错密码三次它会提示,不会直接死机;网页加载图片失败会显示一个占位图,不会整页白屏;微信收消息网络不通它会重试,不会闪退。这些场景背后,全都是「异常处理」在做事。
我们先从最简单的例子开始:
print(10 / 0)输出:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
这就叫「异常」。Python 在执行 10 / 0 的时候发现:除数是零,这事儿数学上没法干,于是它创建了一个 ZeroDivisionError 对象,把这个对象「抛」出来。如果没人接,这个异常就会一路冒泡到最外层,最后由 Python 解释器接住,打印 traceback,然后退出程序。
再看几个:
nums = [1, 2, 3]
print(nums[10])
# IndexError: list index out of range
d = {'name': '小明'}
print(d['age'])
# KeyError: 'age'每一种「错误」都有它自己的名字。这些名字就是异常的「类型」,是 Python 内置的一组类。它们不是给你看着玩的,是供你用来「精确捕捉」的——不同类型用不同的处理方式。
Python 早就给我们准备好了一整套工具:
try / except:基本捕获结构else / finally:完整四件套raise:主动抛出异常学完这一节,再也不怕红字 traceback。
把第一段代码改造一下。我们不希望除以零让整个程序崩溃,而是希望它在崩之前给出一句友好的提示,然后继续往下走:
try:
result = 10 / 0
print(result)
except ZeroDivisionError:
print('哎呀,除数不能是零')
print('程序继续运行')输出:
哎呀,除数不能是零
程序继续运行
是不是发现,红字 traceback 没了,程序也没崩,最后那句「程序继续运行」也乖乖打印出来了。
try / except 的语法就这么简单,记住三件事:
try: 块里放「可能出问题」的代码except XxxError: 块里放「出了问题该怎么办」的代码try 块没出错,except 块完全跳过,跟没写一样如果 try 块里有十行代码,第三行炸了,后面七行还会执行吗?
答案是:不会。Python 一旦在 try 里发现异常,就立刻跳到对应的 except,try 里剩下的代码全部跳过。我们写个例子验证一下:
try:
print('第一行')
print(10 / 0)
print('第三行——这一行不会被执行')
except ZeroDivisionError:
print('我接住了')
print('程序继续')输出:
第一行
我接住了
程序继续
果然,「第三行」没打印出来。这个细节非常重要,很多新手 debug 半天找不到问题,就是因为以为 try 里抛了异常之后下面还会跑——不会的,跳过了。
刚才的例子只捕了 ZeroDivisionError,但实际写代码时,一段代码往往可能抛好几种异常。比如:
def parse_age(s):
try:
age = int(s)
return 100 / age
except ZeroDivisionError:
return '年龄不能是 0'
except ValueError:
return '输入的不是数字'
print(parse_age('25'))
print(parse_age('0'))
print(parse_age('abc'))输出:
4.0
年龄不能是 0
输入的不是数字
两个 except 分支,分别处理不同的异常类型。Python 会按顺序匹配,第一个匹配上的分支被执行,后面的跳过。
那如果两种异常想用同一段代码处理呢?写两次太啰嗦。有简便写法:
def parse_age(s):
try:
age = int(s)
return 100 / age
except (ZeroDivisionError, ValueError):
return '输入有问题'
print(parse_age('25'))
print(parse_age('0'))
print(parse_age('abc'))输出:
4.0
输入有问题
输入有问题
把多个异常类型用一对小括号括起来,逗号分隔,就能一锅端。注意这个括号,不写括号语法就不对了。
as e光知道「出错了」还不够,有时候你需要知道「具体错在哪儿」。Python 允许你用 as 把异常对象抓出来:
def parse_age(s):
try:
age = int(s)
return 100 / age
except (ZeroDivisionError, ValueError) as e:
return f'出错啦:{type(e).__name__} - {e}'
print(parse_age('0'))
print(parse_age('abc'))输出:
出错啦:ZeroDivisionError - division by zero
出错啦:ValueError - invalid literal for int() with base 10: 'abc'
e 就是异常对象本身。type(e).__name__ 是它的类名,str(e) 是它的错误信息。在做日志、做调试的时候,这种写法比简单打印「出错啦」要有用得多。
except Exception有时候不知道代码里到底会抛什么异常,又不想写一长串 except。这时候可以用一个「父类」来兜底:
try:
risky_operation()
except Exception as e:
print(f'未知错误:{e}')Exception 是绝大多数异常的「祖宗」,写它就等于「能接住几乎所有异常」。
但是!这里有个坑:很多新手喜欢写 except: 后面什么类型都不写,或者写 except BaseException:。这两种写法都太狠了——它们会把 KeyboardInterrupt(你按 Ctrl+C 想中断程序)和 SystemExit(程序主动 sys.exit())也接住,导致你的程序按 Ctrl+C 都停不下来。
记住:兜底用 Exception,不要用 BaseException,更不要写裸 except:。
Python 异常有一棵继承树:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── OverflowError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── ValueError
├── TypeError
├── AttributeError
├── NameError
├── OSError
│ └── FileNotFoundError
└── ...
读懂这棵树有几个要点:
BaseException,所有异常都继承自它SystemExit / KeyboardInterrupt / GeneratorExit 跟「程序流程控制」相关,不应该被普通的 try/except 接住Exception 下面理解了这个层级,可以玩一个小技巧:捕获父类等于捕获所有子类。
try:
nums = [1, 2, 3]
print(nums[10])
except LookupError as e:
print(f'查找错误:{e}')输出:
查找错误:list index out of range
IndexError 是 LookupError 的子类,所以用父类去接子类,完全没问题。
那么如果 except 写了多个,顺序应该「父在前」还是「子在前」?
原则是:子在前,父在后。具体的异常先写,宽泛的兜底放最后。否则父类一上来就把所有子类一锅端,后面那些 except 形同虚设。
try / except 还有两个好搭档:else 和 finally。
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print('除数不能是零')
else:
print(f'计算成功,结果是 {result}')
divide(10, 2)
divide(10, 0)输出:
计算成功,结果是 5.0
除数不能是零
else 块只在 try 块没有抛异常的时候执行。可能要问:「我把那行 print 直接写在 try 里不也一样吗?」
不一样。区别在于「捕获范围」:
def divide(a, b):
try:
result = a / b
print(f'计算成功,结果是 {result}') # 假设这里也可能抛异常
except ZeroDivisionError:
print('除数不能是零')如果 print 那一行也抛了 ZeroDivisionError(举例而已),它会被同一个 except 接住——可这就误伤了,因为本意是只接住「计算」那一步的错误。
else 把「成功后才做的事」隔离出来,让 try 块里只放「真正可能出错的那一行」,逻辑更干净。这是它的核心价值。
finally 块更狠:不管 try 块成功还是失败,不管异常被接住还是没被接住,finally 一定会执行。
def safe_divide(a, b):
try:
result = a / b
print(f'结果:{result}')
except ZeroDivisionError:
print('除数不能是零')
finally:
print('清理工作执行了')
safe_divide(10, 2)
print('---')
safe_divide(10, 0)输出:
结果:5.0
清理工作执行了
---
除数不能是零
清理工作执行了
「清理工作执行了」这句话,无论 try 出不出错,都跑了。
想想平时写代码:打开文件、连数据库、申请资源……这些操作完事儿都得「关闭」「释放」。如果中间出错了直接跳走,资源就泄露了。finally 就是用来确保「无论如何这段清理代码都得跑」的。
def read_first_line(path):
f = open(path, 'r')
try:
return f.readline()
finally:
f.close()
print(f'已关闭 {path}')不过呢,开文件这种场景,现代 Python 推荐用 with 语句,它内部就用了 finally 的机制,写法更简洁。with 自带「无论如何都关文件」的能力,本质上跟 finally 是一回事。
try:
# 可能出错的代码
pass
except SomeError:
# 出错时怎么办
pass
except AnotherError:
# 多个 except
pass
else:
# 没出错的话执行
pass
finally:
# 不管出不出错,都要执行
pass顺序是固定的:try → except(多个) → else → finally。else 和 finally 都是可选的,但写的话只能按这个顺序。
到目前为止都是在「接」异常,那能不能「抛」一个?当然能。raise 关键字就是干这个的。
def set_age(age):
if age < 0:
raise ValueError(f'年龄不能是负数,你传了 {age}')
if age > 200:
raise ValueError(f'年龄不能超过 200,你传了 {age}')
return age
try:
set_age(-5)
except ValueError as e:
print(f'参数有问题:{e}')输出:
参数有问题:年龄不能是负数,你传了 -5
raise XxxError('msg') 的意思是「我现在就抛一个 XxxError,里面带这条消息」。平时写函数的时候,遇到「调用方传的参数不合理」「业务规则不满足」这种情况,就应该主动 raise,让调用方知道「你这数据我不收」,而不是闷头返回一个奇怪的值。
想想看:
def set_age(age):
if age < 0:
return None # 不好的写法
return age调用方拿到 None,根本不知道是「这次没数据」还是「我传错了」。但是 raise ValueError('年龄不能是负数'),调用方一看就明白:「啊,我传的参数不合规」。
异常是 Python 里传递错误信息的标准方式,比 return None、return -1、return False 表达力强得多。
raise ... from e再来看一个稍微高级的场景。假设有这么一个函数:
def load_config(path):
with open(path, 'r') as f:
return f.read()如果 path 不存在,会抛 FileNotFoundError。但是站在「使用配置」这一层来看,「文件不存在」这个错误太底层了。我希望对外抛一个更业务化的错误,比如 ConfigError('配置加载失败'),但又不想丢掉「底层到底是啥错」这个信息。怎么办?
「异常链」给出了答案:
class ConfigError(Exception):
pass
def load_config(path):
try:
with open(path, 'r') as f:
return f.read()
except FileNotFoundError as e:
raise ConfigError(f'配置加载失败:{path}') from e
try:
load_config('not_exist.json')
except ConfigError as e:
print(f'业务错误:{e}')输出:
业务错误:配置加载失败:not_exist.json
注意 raise ConfigError(...) from e 这个写法。from e 就是把「原始异常」挂在新异常的 __cause__ 上。如果不被外层接住,traceback 会同时打印两层错误:
Traceback (most recent call last):
File "...", line X, in load_config
with open(path, 'r') as f:
FileNotFoundError: [Errno 2] No such file or directory: 'not_exist.json'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
...
ConfigError: 配置加载失败:not_exist.json
「The above exception was the direct cause of the following exception」——这句话就是异常链的标志。看到这种 traceback 要知道:底下那个错才是根因,上面那个错是它包装出来的。
显式 from e 表达的是「我故意把底层异常包装成业务异常」;不写 from e 表达的是「我处理上一个异常的时候不小心又出错了」。语义不同,建议显式包装的时候一定加 from e。
刚才那个 ConfigError 看到了吧?自定义异常类其实就一行:
class ConfigError(Exception):
pass继承 Exception 就行。但是为啥要自定义?直接用内置的 ValueError、RuntimeError 不行吗?
来用一个「打卡」业务场景说明。某打卡系统业务规则有:
如果用内置异常:
def punch(user, time, ip):
if time < 9:
raise ValueError('未到上班时间')
if user.already_punched_today:
raise ValueError('今天已经打过卡了')
if not ip.startswith('192.168.'):
raise ValueError('不在公司网络')调用方接到 ValueError,根本分不清是哪种情况。如果想分别处理,只能去字符串匹配——这非常脆弱。
class PunchError(Exception):
"""打卡相关错误的基类。"""
pass
class PunchTimeError(PunchError):
pass
class PunchDuplicateError(PunchError):
pass
class PunchLocationError(PunchError):
pass
def punch(user_already_punched, hour, ip):
if hour < 9:
raise PunchTimeError('未到上班时间')
if user_already_punched:
raise PunchDuplicateError('今天已经打过卡了')
if not ip.startswith('192.168.'):
raise PunchLocationError('不在公司网络')
return '打卡成功'
# 调用方
try:
punch(False, 8, '192.168.1.1')
except PunchTimeError as e:
print(f'时间问题:{e},请等到 9 点')
except PunchDuplicateError as e:
print(f'已打卡:{e}')
except PunchLocationError as e:
print(f'位置问题:{e},请连公司 wifi')
except PunchError as e:
print(f'打卡失败:{e}')输出:
时间问题:未到上班时间,请等到 9 点
这个写法的好处:
PunchError,调用方也可以一锅端兜底这就是自定义异常类的价值——让错误也变成代码结构的一部分。
自定义异常类还可以带数据,比如:
class PunchError(Exception):
def __init__(self, message, user_id=None, hour=None):
super().__init__(message)
self.user_id = user_id
self.hour = hour
try:
raise PunchError('未到上班时间', user_id=123, hour=8)
except PunchError as e:
print(f'消息:{e}')
print(f'用户:{e.user_id}')
print(f'时间:{e.hour}')输出:
消息:未到上班时间
用户:123
时间:8
需要更多上下文信息时,把它放进异常对象。后端写日志、做监控的时候,这种结构化错误就特别值钱。
工作里 90% 的场景能覆盖:
| 异常类 | 触发场景 | 例子 |
|---|---|---|
ValueError | 类型对了但值不合法 | int('abc') |
TypeError | 类型不对 | 'a' + 1 |
KeyError | 字典里没这个键 | {'a': 1}['b'] |
IndexError | 序列下标越界 | [1, 2][5] |
AttributeError | 对象没这个属性 | 'abc'.foo |
NameError | 引用了未定义的变量 | print(undefined_x) |
FileNotFoundError | 文件不存在 | open('not_exist.txt') |
ZeroDivisionError | 除以零 | 1 / 0 |
RuntimeError | 运行时通用错误 | 用得不多,能避就避 |
KeyboardInterrupt | 用户按 Ctrl+C | 这个别接住 |
SystemExit | sys.exit() 抛的 | 这个也别接住 |
最后那两个再强调一下:KeyboardInterrupt 和 SystemExit 都是 BaseException 的直接子类,不是 Exception 的子类。所以正常写 except Exception 是接不到它们的——这是好事,避免误伤。
把这一节的内容捋一遍:
try / except 是 Python 处理错误的核心结构,记住「子在前,父在后」except (A, B) 一锅端;用 as e 拿到异常对象else 是「没出错才走」,finally 是「无论如何都走」raise 主动抛异常,raise ... from e 显式异常链异常处理写得好不好,是新手和老手最大的分水岭之一。新手要么完全不写——程序一炸就完蛋;要么乱写——except Exception: pass 把所有错误都吞了,出问题神仙都救不回来。老手会精确捕捉、合理传递、必要时包装、绝不偷偷吞。
读完了?动手练一遍才算真会。
去闯关模式练习 →