
小王最近在写一个数据库操作模块,为了避免每次手动开启事务、提交、回滚,他决定自己写一个上下文管理器,用with语句来管理事务。想法很美好:进入with块时自动开启事务,正常退出时自动提交,发生异常时自动回滚。代码大概长这样:
class Transaction:
def __init__(self, conn):
self.conn = conn
def __enter__(self):
self.conn.begin()
return self.conn
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
self.conn.commit()
else:
self.conn.rollback()
return True # 他觉得返回True表示“处理成功”然后他这样使用:
with Transaction(conn) as db:
db.execute("INSERT INTO users (name) VALUES ('Alice')")
db.execute("INSERT INTO users (name) VALUES ('Bob')")
raise ValueError("模拟一个业务异常")小王的本意是:发生异常时回滚,然后异常继续往上抛,让调用者知道出错了。但实际运行后,程序既没有报错,也没有回滚成功——数据莫名其妙地插入了一半,而且没有任何异常信息。他盯着日志看了半天,甚至怀疑数据库驱动有bug。
最后,他在调试器里一步步跟踪,才发现问题出在那行return True上。他以为return True表示“我处理好了”,但在with语句的规则里,__exit__返回True意味着“异常已经被我吃掉了,不要往外抛”。所以那个ValueError被静默吞掉,程序继续往下跑,事务也没回滚。
他拍着桌子说:“这玩意儿怎么和直觉反着来?”
要理解这个坑,得先搞清楚with语句背后发生了什么。
我们平时写文件操作,最熟悉的用法是:
with open("data.txt", "r") as f:
content = f.read()这行代码做了两件事:打开文件,把文件对象绑定到f;离开with块时,自动关闭文件。即使块内发生异常,文件也会被关闭。
这背后的机制就是上下文管理器协议。一个对象如果实现了__enter__和__exit__两个方法,就可以用在with语句里。
with语句的执行过程,等价于下面这段代码:
manager = open("data.txt", "r")
resource = manager.__enter__()
try:
# with 块内的代码
content = resource.read()
finally:
# 无论是否发生异常,都会执行 __exit__
manager.__exit__(exc_type, exc_val, exc_tb)注意几个关键点:
第一,__enter__在进入with块之前被调用,它的返回值会赋给as后面的变量。也就是说,as f里的f,拿到的是__enter__的返回值,而不是上下文管理器本身。
第二,__exit__在离开with块时被调用,无论是正常结束还是异常退出,它都会执行。这相当于try/finally里的finally。
第三,__exit__接收三个参数:异常类型、异常值、traceback对象。如果没有异常,这三个参数都是None。
第四,__exit__的返回值决定了异常是否继续传播。如果返回True,异常被抑制;返回False或None,异常继续往外抛。
小王的错误就在第四点。他返回了True,以为是在说“我成功处理了”,但实际上是在说“这个异常不用管了”。异常被吞掉,调用者完全不知道出了问题。
很多人把这两个方法当成“开始”和“结束”的钩子,但它们的职责远不止如此。
__enter__负责准备资源,并返回真正要用的对象。它可以返回self,也可以返回另一个对象。返回什么,as后面的变量就是什么。
比如文件对象,open()返回的是一个文件对象,它的__enter__返回的就是self。所以as f拿到的是文件对象本身。
但如果你自己写一个管理器,__enter__返回什么就要想清楚。小王写的是return self.conn,所以as db拿到的是数据库连接。如果他写return self,那么as db拿到的就是Transaction实例,调用db.execute()就会报错,因为Transaction没有execute方法。
class Transaction:
def __enter__(self):
self.conn.begin()
return self # 如果返回 self,as db 拿到的是 Transaction 实例所以,__enter__的返回值决定了with ... as x中x的身份。这是第一个容易混淆的地方。
__exit__负责释放资源,并决定如何处理异常。它拿到异常信息后,可以选择记录日志、回滚事务、关闭连接,然后决定是否让异常继续传播。
默认情况下,__exit__不应该抑制异常。也就是说,不要随便返回True。只有在你明确知道“这个异常我可以处理,并且不希望调用者知道”的时候,才返回True。比如某些重试逻辑,内部已经处理了异常,外部不需要感知。
但大多数时候,你希望异常继续往上抛。所以__exit__要么不写返回值(默认返回None),要么显式返回False。
小王的本意是回滚后让异常继续抛,所以他应该返回False或什么都不返回。但他画蛇添足写了return True,结果把异常吞了。
很多人写__exit__时,会直接使用exc_type,比如:
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type == ValueError:
print("捕获到 ValueError")
self.conn.close()但如果with块正常结束,没有异常,exc_type就是None。所以判断异常类型时,要先确认exc_type不是None。
正确的写法:
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
print(f"发生异常: {exc_type.__name__}: {exc_val}")
self.conn.close()另一个常见错误是:在__exit__里做清理时,如果不小心抛出了新的异常,会覆盖原始异常。比如:
def __exit__(self, exc_type, exc_val, exc_tb):
self.conn.rollback() # 如果 rollback 本身失败,抛出异常
return False如果with块里已经有一个异常,而rollback()又抛出异常,那么最终调用者看到的是rollback的异常,原始异常丢失了。这会让调试变得非常困难。所以清理操作要尽量保证不抛异常,或者用try/except包起来。
自己写类实现__enter__和__exit__有点繁琐。Python标准库提供了contextlib.contextmanager装饰器,可以把一个生成器函数变成上下文管理器。
from contextlib import contextmanager
@contextmanager
def transaction(conn):
conn.begin()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise这个生成器的用法是:yield之前相当于__enter__,yield的值相当于__enter__的返回值,yield之后相当于__exit__。如果with块内发生异常,异常会在yield处抛出,所以需要用try/except来捕获并处理。
注意最后的raise:它把异常重新抛出,让调用者知道出错了。如果你不写raise,异常就会被静默吞掉——这和__exit__返回True的效果一样。
所以用contextmanager时,也要小心:如果不想抑制异常,一定要在except块里重新raise。
这个设计其实是有意为之的。with语句的目标是让资源管理更安全,但同时也给开发者一个机会去“消化”异常。
想象一个场景:你写了一个重试的上下文管理器,当网络请求失败时,它自动重试三次,如果最终成功,就不需要让调用者知道中间发生过异常。这时候,__exit__返回True就是合理的。
但问题在于,这个语义和很多人的直觉相反。大多数人看到return True,第一反应是“成功”,而不是“异常已处理”。所以 Python 官方文档特意强调:__exit__返回True表示异常被抑制,而不是表示成功。
小王的代码里,他既想回滚,又想抛出异常,却返回了True。结果回滚可能执行了,但异常被吞了,调用者以为一切正常。这种 bug 非常隐蔽,因为程序不会崩溃,只是数据悄悄出错。
如果__enter__本身抛出了异常,__exit__不会被调用。因为with语句的等价代码里,__enter__是在try之前调用的。
manager = Transaction(conn)
resource = manager.__enter__() # 如果这里抛异常,下面的 try 不会执行
try:
...
finally:
manager.__exit__(...)所以,如果__enter__里获取资源失败,你不需要在__exit__里释放,因为__exit__根本不会执行。但这也意味着,__enter__里如果做了部分资源分配然后失败,需要自己清理,不能依赖__exit__。
回到小王的代码,正确的写法应该是:
class Transaction:
def __init__(self, conn):
self.conn = conn
def __enter__(self):
self.conn.begin()
return self.conn
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
self.conn.commit()
else:
self.conn.rollback()
return False # 或者不写 return,默认就是 None,异常继续传播这样,正常结束时提交,异常时回滚,异常继续往上抛。调用者能感知到错误,数据也不会不一致。
如果用contextmanager:
@contextmanager
def transaction(conn):
conn.begin()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise效果一样,代码更简洁。
__enter__和__exit__虽然只有两个方法,但职责完全不同。
__enter__是“进入”时的准备。它返回什么,as后面的变量就是什么。如果返回self,拿到的是管理器本身;如果返回资源对象,拿到的是资源。想清楚你要让调用者操作哪个对象。
__exit__是“退出”时的清理。它拿到异常信息,可以决定是否抑制异常。默认不要返回True,除非你明确知道自己在做什么。返回True意味着异常被吞掉,调用者永远不会知道发生过什么。
with语句的本质是try/finally的语法糖,但它多了一个“异常处理决策”的环节。这个决策就落在__exit__的返回值上。
小王的坑在于把return True当成了“成功”的标志。实际上,return True是“异常已处理,不要传播”的信号。一字之差,行为天壤之别。
后来小王把代码里的return True删掉,问题立刻消失。他把这个教训写在了团队文档里:**__exit__的返回值不是状态码,而是异常过滤器。除非你想吞掉异常,否则永远别返回True。**
with语句用好了是神器,用错了就是静默的灾难。理解__enter__和__exit__的分工,才能让它真正为你所用。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。