解析一批数据往列表里塞,塞到第三条抛异常,列表已经脏了一半------这类「能跑但结果不对」的 bug 不报错也不崩溃。Rust 1.100.0 把 core::mem::DropGuard 稳定了:包裹一个值并登记一个闭包,值被 drop 时闭包执行;调用 dismiss() 则把值取回、闭包不跑。官方示例就是在解析失败时用守卫把 Vec 截断回原始长度,全部成功后 dismiss。Python 没有同名类型,但 contextlib 能搭出完全一样的语义。本文用纯标准库把成功、失败、取消三条路径各跑一遍,重点看两个最容易写错的地方:清理动作把异常吞掉,以及 except Exception 漏掉的退出路径。
热榜里的新 API 解决的是什么
Rust 官方博客 2026-11-12 发布《Announcing Rust 1.100.0》,目录里明确列了 DropGuard API;Hacker News 2026-11-13 日报把它排在第 4 位(721 分),同一天的中文 HN 日报也用「可显式取消的 DropGuard」描述它。官方对 DropGuard 的定义是「run a closure on every exit path」------不管正常返回、? 提前返回还是 panic 展开,只要没 dismiss,闭包都会执行;dismiss() 返回被包裹的值并跳过闭包,因此适合「全有或全无」的事务型操作。本文不贴 Rust 代码,只把这个机制用标准库复刻一遍,复制即可跑,所有输出都来自本机实跑。
这个模式在 Python 里对应的原语是 contextlib.contextmanager:进入时记下状态,退出时按标记决定是否撤销。Rust 靠类型系统保证清理一定发生,Python 这边得靠 finally 兜住。
环境准备
零第三方依赖,只用 contextlib 和 sys:
bash
python -c "import sys; print(sys.version.split()[0])"
本机输出 3.13.14。下面所有 python 代码块按出现顺序拼起来就是一个完整脚本,可直接复制运行;每段后面的 text 块是它的真实输出。
用 contextmanager 复刻可取消的守卫
守卫的核心是「默认回滚,显式保留」:进入时记下 len(seq),退出时若没被 dismiss 就把新增部分删掉。清理动作放在 finally 里,这一点后面会解释为什么不能省。
python
import sys
from contextlib import contextmanager, suppress, ExitStack
print(sys.version.split()[0])
@contextmanager
def rollback(seq):
mark = len(seq)
state = {"keep": False}
def dismiss():
state["keep"] = True
try:
yield dismiss
finally:
if not state["keep"]:
del seq[mark:]
text
3.13.14
state 用字典是为了让 dismiss 改动外层状态;yield 把它交给 with 块。先跑两条成功路径------第一条即使没抛异常、没出错,只要没调用 dismiss(),守卫照样回滚:
python
buf = [10, 20]
with rollback(buf) as dismiss:
buf.extend([1, 2, 3])
print(buf)
buf = [10, 20]
with rollback(buf) as dismiss:
buf.extend([1, 2, 3])
dismiss()
print(buf)
text
[10, 20]
[10, 20, 1, 2, 3]
这正是 DropGuard 与普通「出错才清理」写法的差别:清理不是失败才做,而是没显式提交就做。手写版本若把回滚挂在 except 上,遇到提前 return 就会漏掉。
失败路径:解析到一半抛异常
把逐条解析包进守卫,全部成功才 dismiss。第三个 token 是 oops,int() 会抛 ValueError:
python
def parse_all(tokens, buf):
with rollback(buf) as dismiss:
for tok in tokens:
buf.append(int(tok))
dismiss()
return buf
base = [0]
try:
parse_all(["1", "2", "oops", "4"], base)
except ValueError as e:
print(type(e).__name__, ":", e)
print(base)
text
ValueError : invalid literal for int() with base 10: 'oops'
[0]
异常照常冒泡,列表回到进入前的 [0]。关键点:回滚由守卫负责,跟调用方处不处理异常无关。下面用 suppress 把异常吞掉,回滚仍然发生;而如果在异常路径上误调了 dismiss(),脏数据就会留下:
python
buf = [0]
with suppress(ValueError):
parse_all(["1", "x"], buf)
print("suppress", buf)
buf = [0]
try:
with rollback(buf) as dismiss:
buf.append(1)
dismiss()
raise ValueError("boom")
except ValueError:
pass
print("误调 dismiss", buf)
text
suppress [0]
误调 dismiss [0, 1]
所以 dismiss() 的位置很硬:必须是 with 块里最后一行,放在任何还可能抛异常的语句之前都等于放弃保护。Rust 里 dismiss() 会消费守卫并返回值,天然逼你把它放在成功分支末尾;Python 这边只能靠自己守规矩。
清理动作把异常吞了
手写版本最常见的写法是 try/finally 加一个 committed 标记。看着没问题,但如果把 return 顺手写进 finally,Python 会直接用这个返回值覆盖掉正在抛出的异常:
python
def parse_manual(tokens, buf):
mark = len(buf)
committed = False
try:
for tok in tokens:
buf.append(int(tok))
committed = True
finally:
if not committed:
del buf[mark:]
return buf
try:
print("未抛异常,返回", parse_manual(["1", "x"], [0]))
except ValueError as e:
print("抛出", type(e).__name__, e)
text
未抛异常,返回 [0]
数据确实回滚成了 [0],但 except 分支完全没进。调用方拿到一个「成功」的返回值,日志里连一行错误都没有------这比数据没回滚更难查。finally 里只放清理,不放 return、break 或 continue,是这类守卫能用的前提。
另一个真实报错来自守卫本身写错:@contextmanager 的生成器和普通生成器不同,只能 yield 一次。多写一次,退出 with 时装饰器会抛异常:
python
@contextmanager
def bad_guard():
yield 1
yield 2
try:
with bad_guard():
pass
except RuntimeError as e:
print(type(e).__name__, ":", e)
text
RuntimeError : generator didn't stop
别凭记忆写成 didn't stop after throw():那是 with 内有异常、装饰器用 throw 注入时的报错;正常退出走 next(),本机给的是上面这条更短的原文。守卫里要做两件事,应该拆成两个 with,不是两个 yield。
多个守卫:ExitStack 与批量 dismiss
一层守卫不够用时,ExitStack 提供成组管理:回调按后进先出执行,与 Rust 里值按 drop 逆序清理一致。pop_all() 则相当于一次性 dismiss 整组------把回调转移到新栈,原栈退出时什么也不做:
python
order = []
with ExitStack() as stack:
stack.callback(lambda: order.append("A"))
stack.callback(lambda: order.append("B"))
stack.callback(lambda: order.append("C"))
print(order)
order = []
with ExitStack() as stack:
stack.callback(lambda: order.append("R1"))
stack.callback(lambda: order.append("R2"))
deferred = stack.pop_all()
print(order)
deferred.close()
print(order)
text
['C', 'B', 'A']
[]
['R2', 'R1']
空列表说明整组清理被推迟到显式 close() 才逆序执行,适合「先攒一批可逆操作,事务提交时整体放行」的场景。
踩坑记录:except Exception 不等于所有退出路径
把回滚写在 except Exception: 里是另一个高发错误。KeyboardInterrupt、SystemExit 继承自 BaseException,不走这个分支,守卫会带着脏数据退出。下面两版只差一个 finally:
python
@contextmanager
def guard_except(seq):
mark = len(seq)
try:
yield
except Exception:
del seq[mark:]
raise
buf = [0]
try:
with guard_except(buf):
buf.append(1)
raise KeyboardInterrupt()
except KeyboardInterrupt:
pass
print("except Exception", buf)
buf = [0]
try:
with rollback(buf) as dismiss:
buf.append(1)
raise KeyboardInterrupt()
except KeyboardInterrupt:
pass
print("finally", buf)
text
except Exception [0, 1]
finally [0]
第一版留下 [0, 1]:用户按 Ctrl-C 中断,回滚没跑,半截数据留在共享列表里。第二版用 finally 无条件回滚,异常照常冒泡给外层。结论很直接------守卫的清理只认 finally,不要靠异常类型去猜退出路径。
小结
三条可复制的规则:清理动作放 finally,且 finally 里不写 return;提交用显式 dismiss() 或 pop_all(),别用「有没有异常」当判据;多个守卫交给 ExitStack 保证逆序。Rust 1.100 把这套语义做成了标准库类型,Python 这边十几行 contextmanager 就能复刻,差别只在于取消动作要自己管住位置。