异常不是"错误",而是 Python 里一套正常的控制流。学会用它,程序才不会一崩到底,还能告诉你到底哪里出了事。
你将学到
try/except/else/finally的完整语义与执行顺序- 为什么该捕获具体异常,而不是一句裸
except: - 多异常写法、
raise from异常链、自定义异常 - EAFP 与 LBYL 两种风格,以及
assert的正确定位 - 用
logging记录问题,并写出一个健壮的读取/解析函数
前置知识
上一篇《模块与包》把代码拆成了多个文件。你至少见过 import、函数和类,就能看懂这一篇。
try / except 基本形态
把"可能出错"的代码放进 try,出错时用 except 接住。
try:
n = int("abc") # 这里会抛 ValueError
except ValueError:
print("转换失败,输入不是数字") # 输出: 转换失败,输入不是数字
没有 try 的话,int("abc") 会把整个程序打挂,抛一堆红色 traceback。接住之后,程序能继续往下走。要拿到异常对象本身,用 as:
try:
n = int("abc")
except ValueError as e:
print(f"出错了:{e}") # 输出: 出错了:invalid literal for int() with base 10: 'abc'
else 与 finally
完整形态有四个块,执行顺序值得记牢。
def process(s):
try:
n = int(s) # 可能抛异常
except ValueError as e:
print("转换失败:", e)
else:
print("转换成功:", n) # 只有 try 没抛异常时才执行
finally:
print("收尾,无论如何都执行") # 无论成败都会执行
process("42")
# 输出: 转换成功: 42
# 输出: 收尾,无论如何都执行
process("abc")
# 输出: 转换失败: invalid literal for int() with base 10: 'abc'
# 输出: 收尾,无论如何都执行
else:try顺利跑完时执行,把"成功后的逻辑"和"可能出错的逻辑"分开,更清晰。finally:无论有没有异常、有没有return,都执行。清理资源(关文件、断连接)放这里。
不过清理文件更推荐用 with open(...),它会自动关闭,本质就是 try/finally 的封装。
捕获具体异常,别裸 except
# ❌ 裸 except 会吞掉所有异常,包括 Ctrl+C 和程序 bug
try:
do_something()
except:
pass
问题在于:它连 KeyboardInterrupt(你按 Ctrl+C)和拼写错误导致的 NameError 都一起吞了,调试时你会一头雾水。
# ✅ 只接住你预期的那种异常
try:
result = compute()
except ValueError as e:
log_the_problem(e)
如果实在想兜底,至少写 except Exception(不包含系统级退出异常),并且不要 pass 掉——记录日志或重新抛出。
try:
do_something()
except Exception as e:
print(f"未预期的错误:{e}") # 至少让人知道出事了
raise # 需要时再抛出去,别默默吞掉
多个异常与异常链
一个 try 可以配多个 except,Python 从上到下匹配,只执行第一个匹配的分支。多种同类处理可以合并成一个元组。
data = {"a": 1}
try:
print(data["b"] / 0)
except KeyError as e:
print("键不存在:", e) # 输出: 键不存在: 'b'
except (ZeroDivisionError, TypeError) as e:
print("运算非法:", e) # 上面已匹配,这里不会执行
想让一个异常"带上"原始原因,用 raise ... from ... 建立异常链:
try:
n = int("abc")
except ValueError as e:
raise RuntimeError("配置解析失败") from e
这样 traceback 会显示:由 ValueError 直接导致了 RuntimeError,排查时一路看得到根因。想刻意隐藏原因可用 raise X from None(不推荐)。
自定义异常
当内置异常表达不了业务含义时,定义自己的异常。惯例是继承 Exception。
class ConfigError(Exception):
"""配置相关错误。"""
class MissingKeyError(ConfigError): # 继承自己的异常,便于分层捕获
"""缺少必需的配置项。"""
def get_config(cfg, key):
if key not in cfg:
raise MissingKeyError(f"缺少配置项: {key}")
return cfg[key]
try:
get_config({}, "host")
except MissingKeyError as e:
print(e) # 输出: 缺少配置项: host
except ConfigError as e: # 更宽泛的同类异常也能接住
print("配置错误:", e)
自定义异常的价值:调用方可以只捕获你的异常,从而区分"业务问题"和"程序 bug"。
EAFP vs LBYL
- LBYL(Look Before You Leap):先检查再动手。
- EAFP(Easier to Ask Forgiveness than Permission):先动手,出错再处理。
Python 社区更推崇 EAFP。原因之一是"检查"和"使用"之间存在时间差(尤其在并发场景),检查通过不代表使用时就安全。尤其是字符串转数字,别先检查是数字再转换:
s = "12a"
# ❌ LBYL:又啰嗦又不严谨("1"、"²" 这类字符能骗过 isdigit)
if s.isdigit():
n = int(s)
# ✅ EAFP:直接尝试,让 int 自己判断
try:
n = int(s)
except ValueError:
n = 0
assert 的定位
assert 用来表达程序内部的不变量,是"我坚信这里必然成立"的断言。
def apply_discount(price, rate):
assert 0 <= rate <= 1, "折扣率必须在 0~1 之间"
return price * (1 - rate)
关键点:assert 会被 python -O 优化掉。所以它不能用来校验用户输入或做安全判断,那些必须用真实的 if ... raise。
# ❌ 用 assert 校验外部输入,一旦跑在 -O 下就形同虚设
assert user_input, "输入不能为空"
# ✅ 该报错就明确报错
if not user_input:
raise ValueError("输入不能为空")
一句话:assert 给开发者自己看,用来抓 bug;raise 给运行时看,用来处理真问题。
logging 基础
用 print 调试,上线后没法关;用 logging 可以分级、带上下文、输出到文件。
import logging
logging.basicConfig(
level=logging.INFO, # 低于这个级别的日志不显示
format="%(asctime)s %(levelname)s %(message)s",
)
logging.debug("调试信息,通常不显示") # 级别低于 INFO,被过滤
logging.info("程序启动") # 输出: ... INFO 程序启动
logging.warning("磁盘快满了") # 输出: ... WARNING 磁盘快满了
日志级别从低到高:DEBUG < INFO < WARNING < ERROR < CRITICAL。在 except 里想连 traceback 一起记下来,用 logging.exception:
try:
n = int("abc")
except ValueError:
logging.exception("转换失败") # 会自动带上完整的异常堆栈
常见内置异常速查
| 异常 | 典型触发场景 |
|---|---|
ValueError |
类型对但值不对,如 int("abc") |
TypeError |
类型本身不适合该操作,如 "a" + 1 |
KeyError |
字典里没有这个键 |
IndexError |
列表/元组下标越界 |
AttributeError |
对象没有这个属性或方法 |
FileNotFoundError |
打开的文件不存在 |
ZeroDivisionError |
除以零 |
注意它们的共同父类是 Exception;而 KeyboardInterrupt、SystemExit 属于 BaseException 的分支,所以 except Exception 不会误伤它们。
一个健壮的读取与解析函数
把上面的知识串起来。
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
class ParseError(Exception):
"""自定义:解析失败。"""
def load_numbers(path):
"""读取文件,每行转成整数,跳过坏行。返回整数列表。"""
numbers = []
try:
with open(path, encoding="utf-8") as f:
for lineno, line in enumerate(f, start=1):
line = line.strip()
if not line:
continue # 空行跳过
try:
numbers.append(int(line))
except ValueError:
logging.warning("第 %d 行不是整数,已跳过: %r", lineno, line)
except FileNotFoundError as e:
raise ParseError(f"文件不存在: {path}") from e
except OSError as e:
raise ParseError(f"读取文件出错: {e}") from e
return numbers
if __name__ == "__main__":
print(load_numbers("numbers.txt")) # 输出: [1, 2, 3](依文件内容而定)
这个函数体现了几个好习惯:坏行只警告不中断、明确捕获 FileNotFoundError、把底层异常用 raise ... from 包装成业务异常。
常见坑
❌ 裸 except 加 pass,把错误吞得一干二净;✅ 捕获具体类型并记录日志。
try:
run()
except: # ❌ 出错也不说话,排查时抓瞎
pass
try:
run()
except ValueError as e: # ✅
logging.warning("参数错误: %s", e)
❌ 用 assert 校验用户输入;✅ 用 if ... raise。
assert age > 0, "年龄必须为正" # ❌ 加了 -O 就失效
if age <= 0:
raise ValueError("年龄必须为正") # ✅
❌ 抛出宽泛的 Exception 丢失信息;✅ 用 raise ... from 保留因果链。
try:
int(s)
except ValueError as e:
raise RuntimeError("解析失败") from e # ✅ traceback 里有根因
# raise Exception("出错了") # ❌ 原始原因被埋没
小结
try/except接住异常;else处理成功路径;finally做清理,总会执行。- 捕获具体异常,别裸
except,更别pass掉。 - 多个
except自上而下匹配;raise ... from e建立异常链。 - 业务错误用自定义异常;
assert只用于内部不变量,别校验输入。 - 优先 EAFP:先做、出错再处理,尤其别"先检查再转换"。
- 用
logging分级记录,logging.exception会带上完整堆栈。
延伸阅读
- 大师篇《上下文管理器与 with》会告诉你
with背后正是try/finally的封装,理解它就知道资源为什么总能被释放。 - 进阶篇《测试 pytest 与 TDD》里会讲
pytest.raises,专门用来断言"这段代码确实抛了预期的异常"。
上一篇:模块与包 · 下一篇:推导式与函数式工具
文章回复
0 条公开回复