Python 里的运行时错误最终都会变成一个异常对象被抛出,如果调用栈上没有 except 接住,解释器就会打印 traceback 并终止进程。本文按“异常对象—捕获粒度—else/finally 清理—raise 与异常链—自定义异常—断言—EAFP 风格—配置读取实战”的顺序,把 try/except 这套结构里真正影响代码行为的细节整理清楚。
一、异常对象与常见异常类型
最直观的例子:
- print(10 / 0)
- # ZeroDivisionError: division by zero
复制代码
不处理就会直接崩。按触发场景记住下面这些内置异常,写 except 时才不会乱抓:
- SyntaxError、IndentationError:语法和缩进问题,代码根本跑不起来,属于解释阶段而非运行时。
- NameError:用了未定义的变量名。
- TypeError:类型不匹配,例如 "a" + 1。
- ValueError:类型对但值不对,例如 int("abc")。
- IndexError、KeyError:列表下标越界、字典键不存在。
- AttributeError:对象上没有该属性。
- FileNotFoundError:路径指向的文件不存在。
- ZeroDivisionError:除数为 0。
- ImportError:模块导入失败。
二、try/except 基本结构与捕获粒度
把用户输入、类型转换和除法放进同一个 try,用不同 except 分支区分故障原因:
- try:
- num = int(input("请输入数字:"))
- print(10 / num)
- except ValueError:
- print("输入的不是数字!")
- except ZeroDivisionError:
- print("不能输入 0!")
复制代码
这样用户乱输入时程序只会走对应分支,不会整体崩掉。需要同时按同一逻辑处理多类异常时,可以把异常类型写成元组,并用 as e 绑定异常对象:
- try:
- ...
- except (ValueError, TypeError) as e:
- print(f"输入有误:{e}")
复制代码
as e 拿到的是异常实例,type(e) 是具体的异常类,str(e) 是原始错误消息,例如 int("abc") 会得到 invalid literal for int() with base 10: 'abc',把它写进日志比只打一句“出错了”有用得多。
要“兜底”可以写 except Exception as e,Exception 是所有常规异常的父类,但 KeyboardInterrupt 和 SystemExit 并不继承它。裸 except:(不写类型)会把这两个也一起吞掉,后果是 Ctrl+C 都停不掉程序,属于明确不该写的写法。
三、else 与 finally 的执行时机
try 只负责可能出现异常的语句,else 在 try 未抛异常时执行,finally 则无论成功失败都执行,典型用途是清理资源:
- try:
- f = open("data.txt", encoding="utf-8")
- content = f.read()
- except FileNotFoundError:
- print("文件不存在")
- else:
- print(f"读取成功,{len(content)} 个字符")
- finally:
- print("收尾工作")
复制代码
把业务成功后的逻辑放进 else,可以避免它被本段的 except 误捕获。finally 常用于关闭文件、断开数据库连接、释放锁:
- f = None
- try:
- f = open("data.txt")
- data = f.read()
- except FileNotFoundError:
- data = ""
- finally:
- if f:
- f.close()
复制代码
当然这种场景用 with 上下文管理器更省事,finally 更适合 with 管不到的资源。
这里有一个必须避开的坑:在 finally 里 return 会丢弃正在传播的异常。
- def bad():
- try:
- raise ValueError("出错了")
- finally:
- return "正常"
- print(bad()) # 输出"正常",异常消失
复制代码
四、raise:主动抛出与异常链
参数校验类函数适合在入口处主动 raise,把不合法的调用尽早挡掉:
- def set_age(age):
- if not isinstance(age, int):
- raise TypeError("年龄必须是整数")
- if age < 0 or age > 150:
- raise ValueError(f"年龄 {age} 不合法")
- return age
- set_age(-5)
- # ValueError: 年龄 -5 不合法
复制代码
如果当前层只做记录、希望上层继续处理,可以用裸 raise 原样重抛,不要写成 raise e,裸 raise 保留的是原始 traceback:
- try:
- do_something()
- except Exception as e:
- print(f"记录日志:{e}")
- raise
复制代码
当异常需要换一种语义再往上抛时,用 from 显式建立异常链,保留根因便于排查:
- try:
- int(config["port"])
- except KeyError as e:
- raise ValueError("配置文件缺少 port 项") from e
复制代码
五、自定义异常类
业务异常只需要继承 Exception;需要携带上下文数据时,在 __init__ 里先保存属性,再调用父类构造设置消息:
- class ValidationError(Exception):
- """数据校验失败的异常"""
- pass
- class InsufficientBalanceError(Exception):
- def __init__(self, balance, amount):
- self.balance = balance
- self.amount = amount
- super().__init__(f"余额不足:当前 {balance},需要 {amount}")
- def withdraw(balance, amount):
- if amount > balance:
- raise InsufficientBalanceError(balance, amount)
- return balance - amount
- try:
- withdraw(100, 200)
- except InsufficientBalanceError as e:
- print(e)
- print(f"还差 {e.amount - e.balance} 元")
复制代码
注意除消息之外还把 balance、amount 存成了实例属性,捕获方才能算出差额这类附加信息。相比出错时返回 None,抛自定义异常能让调用方明确区分“失败了”和“返回了一个合法但为空的值”。
六、assert 的正确使用范围
- def divide(a, b):
- assert b != 0, "除数不能为 0"
- return a / b
复制代码
assert 条件, 提示信息 在条件为假时抛 AssertionError。它用来检查“绝对不该发生”的内部不变量,不要用它处理用户输入,因为用 python -O 运行时所有断言会被跳过,校验会静默失效。
七、EAFP 与 LBYL
Python 更推崇 EAFP(先做,出错再处理),而不是 LBYL(先检查再做):
- # LBYL
- if "key" in d:
- value = d["key"]
- else:
- value = default
- # EAFP
- try:
- value = d["key"]
- except KeyError:
- value = default
- # 更简洁的写法
- value = d.get("key", default)
复制代码
EAFP 代码更短,也避免了“检查”和“使用”之间状态被改掉的竞态。但能直接用 get()、setdefault() 这类安全方法解决的场景,优先用方法,不必套 try。
八、实战:带回退的配置读取
配置文件读取是异常处理最典型的落地场景:文件可能不存在,内容可能不是合法 JSON,返回的字典还可能缺字段。
- import json
- from pathlib import Path
- DEFAULT_CONFIG = {
- "theme": "light",
- "language": "zh",
- "max_items": 20
- }
- def load_config(path="config.json"):
- """读取配置,任何问题都回退到默认值"""
- try:
- text = Path(path).read_text(encoding="utf-8")
- config = json.loads(text)
- except FileNotFoundError:
- print("配置文件不存在,使用默认配置")
- return DEFAULT_CONFIG.copy()
- except json.JSONDecodeError as e:
- print(f"配置文件格式错误:{e},使用默认配置")
- return DEFAULT_CONFIG.copy()
- for key, value in DEFAULT_CONFIG.items():
- config.setdefault(key, value)
- return config
复制代码
几个实现要点:
- 把两个 except 分开写,是因为“文件不存在”和“JSON 格式错误”对使用者是完全不同的信息,合并成一句会丢失排查线索。
- 返回 DEFAULT_CONFIG.copy() 而不是默认字典本身,避免调用方修改返回值时污染模块级常量。
- 成功解析后再用 setdefault 补齐缺失键,这样旧版本配置文件也能正常启动,而且不会覆盖用户已有配置。
- 读取时显式指定 encoding="utf-8",规避不同平台默认编码差异带来的乱码问题。
九、常见坑速查
- 裸 except::会吞掉 KeyboardInterrupt,Ctrl+C 无法终止程序。
- except 类型过宽:掩盖真正的 bug,尽量捕获具体异常类型。
- 捕获了但不做任何处理:至少用 logging.exception(e) 留痕。
- finally 里 return:直接丢弃异常,排查时完全看不到报错。
- 用异常做正常流程控制:性能差且难读,正常分支该用 if。
十、小结
try/except/else/finally 四段各司其职:try 放风险语句,except 按类型精确捕获,else 放成功后的逻辑,finally 做资源清理;raise 主动抛错,from 建立异常链;业务错误用继承 Exception 的自定义异常类承载上下文;断言只用于内部不变量,正式校验交给 raise;整体风格上优先 EAFP 与 get/setdefault 这类安全方法。
可以拿来练手的三道题:写一个安全除法函数,处理除零与非数字输入,出错返回 None 并给出原因;自定义 PasswordTooShortError,密码少于 8 位时抛出;把一个 JSON 待办清单程序改造成文件损坏时给出友好提示而不是崩溃。 |