Rust 1.100 的 DropGuard:用 Python 实测失败回滚与吞异常

解析一批数据往列表里塞,塞到第三条抛异常,列表已经脏了一半------这类「能跑但结果不对」的 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 就能复刻,差别只在于取消动作要自己管住位置。

相关推荐
用户019027581612 小时前
天勤 tqsdk 能做 A 股行情吗?期货强项与股票数据选型
python
2601_967979123 小时前
pip在Python中的9个必须知道的用法
python·pip·版本控制·包管理工具·requirements.txt
曲径海盗3 小时前
【Python 学习第 58 天】列表方法
python·学习笔记·编程入门
DeepVisionary3 小时前
Runway开源机器人大模型Praxis-1,推演延迟压到62毫秒
python·自动化
Cooper255 小时前
DDC 命令成功但屏幕不动:用 gamma 表重做亮度映射
python·mac
师夷长技以自愈5 小时前
手搓 AI Agent 第 7 篇:菜做砸了别硬炒——错误分类 + 双账本熔断
人工智能·python
用户956256254295 小时前
15 分钟社媒简报 Agent:我只用了搜索和模型两步
python·api
Dumbness5 小时前
关键词回复里写了 {订单号},群机器人整条消息都炸了
python·微信
调接口的阿光5 小时前
调用对话接口时输出被截断怎么办:先看 finish_reason,再决定是续写一次、缩小范围,还是换成更短的格式(Python)
人工智能·python