本文目录
程序跑到一半,int("abc") 会抛 ValueError,解释器沿调用栈向上找有没有匹配的 except。找不到就打印 traceback 并退出(或在 REPL 里把错误抛回给你)。异常是运行时错误的标准传递方式:正常路径走 return,出错路径走 raise,调用方用 try/except 决定是恢复、包装还是继续向上抛。与返回错误码不同,异常会打断当前栈帧的线性执行,直到被捕获或程序终止——这一模型贯穿标准库:文件不存在、键缺失、类型不对,都倾向 raise 而不是返回哨兵值。
上一篇讲属性查找里的描述符;这一篇讲控制流里的「岔路」。语法关键字是 try、except、finally 和 raise;语义问题是「哪一段代码负责处理哪类失败、清理是否一定会做」。Python 没有 Java 那种 checked exception 清单,类型系统也不强制你 catch——约定靠文档和团队规范,但语法本身足够表达精细的控制。社区常说 EAFP(Easier to Ask Forgiveness than Permission):先假定能成功、失败了再 except,相对「先 if 检查再操作」的 LBYL,在并发与 TOCTOU 场景里往往更干净。初学时容易要么从不 try、要么 try 包住整函数;成熟写法是窄 try、明确 except 类型、必要时 finally 只做释放。
try / except:按类型捕获
try 块里写可能出错的代码;except 按异常类型分支。可以捕获多个类型,也可以绑定到名字以便打印或重抛:
def parse_port(raw: str) -> int:
try:
port = int(raw)
except ValueError as exc:
raise ValueError(f"invalid port: {raw!r}") from exc
if not 1 <= port <= 65535:
raise ValueError(f"port out of range: {port}")
return portexcept ValueError 只接 ValueError 及其子类;TypeError 会继续向外冒。as exc 绑定的是当前这一层的异常实例,别在 except 块外引用它——块结束后名字会被清理,避免悬空引用。若写成 except ValueError, TypeError(旧式逗号)在 Python 3 里是语法错误,应写元组形式。
多个 except 按从上到下顺序匹配,更具体的类型应写在更宽泛的前面(例如先 FileNotFoundError 再 OSError)。一个 except 也可以写元组:except (ValueError, TypeError):,表示两种都算命中。没有匹配的 handler 时,异常继续传播,栈上的每一层都可以选择处理或放行。父类 handler 会接住子类异常,因此把 Exception 放在第一个 except 会让后面更具体的分支永远进不去。
捕获后常见三种处理方式:就地修复并重试、记录日志后 bare raise 原样重抛、或 raise NewError(...) from exc 包装成对调用方更友好的类型。选哪种取决于这一层是否「拥有」该错误的语义——底层解析失败可以包装成领域错误,真正的编程错误(写错属性名导致的 AttributeError)通常应直接冒泡,别在每个函数里 blanket catch。
else 与 finally:成功分支与收尾
else 挂在 try 后面:仅当 try 块没有抛异常时执行。适合把「可能失败的操作」和「依赖其成功的后续步骤」分开,避免把正常逻辑全塞进 try 里:
def read_version(path: str) -> str:
try:
f = open(path, encoding="utf-8")
except OSError as exc:
raise RuntimeError(f"cannot open {path}") from exc
else:
with f:
return f.read().strip()有人习惯把所有代码都写进 try;能跑,但会把「打开失败」和「读内容失败」混在同一视觉块里。else 让读者一眼看出:前面步骤成功了,这里才做后续。else 里若再抛异常,不会被同一组 except 捕获(除非外层还有 try),写嵌套时要注意层级。
finally 无论是否抛异常、是否被 except 接住,几乎总会执行(进程被强杀、os._exit 等极端情况除外)。典型用途:释放锁、关连接、还原工作目录、删除临时文件。若 try 里已经 return,finally 仍会在 return 真正生效前跑完:
def demo() -> int:
try:
return 1
finally:
print("always runs")
print(demo()) # 先打印 always runs,再打印 1try / except / else / finally 的完整组合合法;日常最常见的是 try/except 与 try/finally,需要「成功才做某步」时加上 else 更清晰。在 finally 里写 return 会覆盖 try/except 里的 return,属于少见反模式,别依赖这种控制流。多个 finally 不存在——一个 try 最多一组 finally,清理逻辑应集中写在这里。
raise:主动抛错与异常链
raise SomeError("msg") 在当前位置构造并抛出异常。在 except 块里写 bare raise(不带参数)会原样重抛当前异常,保留 traceback——适合「这一层处理不了,只记日志」的场景:
def load_config(path: str) -> dict[str, str]:
try:
text = open(path, encoding="utf-8").read()
except OSError:
raise # 仍是 OSError,栈不变
return {"raw": text}跨层包装时用 raise NewError("...") from original 建立异常链:__cause__ 指向原始异常,traceback 里会看到「The above exception was the direct cause...」段。Python 3 里这是推荐写法,比 raise NewError(str(original)) 只留一条字符串、丢掉类型和栈好得多。
异常链还有隐式形式:在 except 块里 raise 另一个异常而未写 from,解释器会把前一个异常挂在 __context__ 上。调试时两者都可能出现在 traceback 里;要明确因果时用 from 更干净。raise SomeError from None 会抑制显式链(__cause__ 为 None),只在确实不想暴露底层细节时用,别当成默认习惯。
自定义异常通常继承 Exception(不要继承 BaseException,除非你要写类似 SystemExit 的系统级退出)。项目里可以定义 AppError 基类,再分 ValidationError、NotFoundError,调用方按类型 catch 即可。内置异常树以 BaseException 为根,Exception 是几乎所有业务错误的父类;KeyboardInterrupt、GeneratorExit 等与 Exception 并列,别随便 catch。
别写裸 except 和过宽的 except Exception
except: 会接住包括 KeyboardInterrupt、SystemExit 在内的几乎所有 BaseException 子类,REPL 里 Ctrl+C 都可能被吞掉,极难调试。except Exception: 稍窄,但仍可能拦住本该向上传播的业务错误,若块内 pass 更是静默失败。
原则:
- 只捕获你预期能处理的类型。
- 日志里用
repr(exc)或exc.__class__.__name__,需要栈时用logging.exception(...)。 - 框架最外层可以 catch
Exception转成统一 HTTP 500,业务代码尽量窄。
# 反例:吞掉一切
try:
risky()
except Exception:
pass
# 较好:明确类型 + 记录
try:
risky()
except ValueError as exc:
logger.warning("bad input: %s", exc)
raise异常不是 goto:滥用 try/except 绕开正常分支,可读性不如先校验再执行。但在 I/O、解析、外部 API 这类失败是常态的路径上,异常模型比返回 (ok, err) 元组更贴合 Python 生态。读懂 traceback:最底行是出错点与异常类型,往上是调用链;^^^^ 标记往往指向具体子表达式。复制 traceback 给他人时保留完整栈,别只贴最后一行。
内置层次与 EAFP 小例
常见内置异常按树状继承组织:LookupError 下分 KeyError、IndexError;OSError 下分 FileNotFoundError、PermissionError 等。捕获父类等于捕获其所有子类,写 handler 时要 conscious 这一层关系——宽了容易误吞,窄了可能漏掉子类。
EAFP 与 LBYL 的对照:
def get_item(mapping: dict[str, int], key: str) -> int:
try:
return mapping[key]
except KeyError:
return 0等价于先 if key in mapping 再取值,但在多线程改 dict 的极端情况下,「先检查再取」两步之间 key 可能被删;try/except 在 CPython 里对单次 __getitem__ 更原子。并非所有场景都要 EAFP,可读性仍是首位:纯业务校验(金额不能为负)用 if raise 往往更直观。
重试逻辑别写无限 except:限定次数、限定异常类型、记录最后一次失败原因。网络抖动可以 retry ConnectionError,解析错误 retry 十次也救不了。
在库代码边界,常见模式是:底层保留原始异常类型,服务层包装成带错误码的业务异常,最外层(HTTP 中间件、CLI main)catch 后转成对用户友好的消息。每一层只处理自己「语义上拥有」的错误,其余一律 raise。测试异常路径时,用 pytest.raises(ValueError) 断言类型与 match 字符串,比 try/except 样板更简洁。
ExceptionGroup 与 except*(3.11+)用于一次抛出多个相关异常的场景(如 asyncio TaskGroup);日常业务可先掌握单异常模型,遇到并发聚合错误再查文档。无论版本如何,finally 里做资源释放的习惯不变。
再补一条实践:except Exception as e: 之后若要把异常转成 HTTP 响应,保留 e.__class__.__name__ 与内部 message 即可,别把完整 traceback 返回给终端用户;日志层用 logger.exception 记全栈。安全审查里常见问题是 except 太宽且返回 200,把失败伪装成成功——窄类型 + 明确状态码是底线。
单元测试里断言异常除了类型,还应检查 str(exc) 或 match= 正则是否覆盖关键信息,避免「抛了对的类但消息丢了字段」的回归。集成测试则更像真实调用栈,有时需要断言 __cause__ 链上是否保留了底层 OSError。异常是可观测行为的一部分,和返回值一样值得写断言。维护遗留代码时,先把最宽的 except 收窄到具体类型,往往是最低风险的改进之一。
小结
| 语法 | 作用 |
|---|---|
try | 包住可能失败的操作 |
except | 按类型处理或包装 |
else | try 成功后的后续步骤 |
finally | 必做清理 |
raise | 主动抛错;from 保留链 |
下一篇进入另一种「逐步推进」的机制:迭代协议与 yield,用懒取值代替一次性算完整条序列。