PEP 765 – 禁止从 finally 块中使用 return/break/continue 跳出
- 作者:
- Irit Katriel <irit at python.org>, Alyssa Coghlan <ncoghlan at gmail.com>
- 讨论至:
- Discourse 帖子
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2024年11月15日
- Python 版本:
- 3.14
- 发布历史:
- 2024年11月9日, 2024年11月16日
- 取代:
- 601
- 决议:
- Discourse 消息
摘要
本 PEP 提议撤销对从 finally
块中跳出的 return
、break
和 continue
语句的支持。这在过去由 PEP 601 提出。当前的 PEP 基于关于这一变化成本/收益的实证证据,而这些证据在 PEP 601 被拒绝时并不存在。它还提出了一个与 PEP 601 提出的略有不同的解决方案。
动机
在 finally 块中 return
、break
和 continue
的语义让许多开发者感到惊讶。文档 提到:
- 如果
finally
子句执行break
、continue
或return
语句,则异常不会重新抛出。 - 如果
finally
子句包含return
语句,则返回的值将是finally
子句的return
语句的值,而不是try
子句的return
语句的值。
这两种行为都容易引起混淆,但第一种尤其危险,因为被吞噬的异常比不正确的返回值更容易在测试中漏掉。
2019年,PEP 601 提议修改 Python,在几个版本中发出 SyntaxWarning
,然后将其转变为 SyntaxError
。它被拒绝了,因为它被视为一个编程风格问题,应由 linter 和 PEP 8 处理。事实上,PEP 8 现在建议不要在 finally
块中使用控制流语句,并且像 Pylint、Ruff 和 flake8-bugbear 这样的 linter 会将其标记为问题。
基本原理
最近对实际代码的分析显示
- 这些特性很少见(在最受欢迎的8000个 PyPI 包中每百万行代码出现2次,在随机选择的包中每百万行代码出现4次)。这可能归功于标记这种模式的 linter。
- 大多数用法都不正确,并引入了意想不到的异常吞噬错误。
- 代码所有者通常乐于修复这些错误,并发现修复起来很容易。
详情请参阅附录。
这项新数据表明,如果 Python 本身能让用户远离这个有害的特性,将对 Python 用户有利。
PEP 601 讨论中提出的论点之一是,语言特性应该是正交的,并且在没有基于上下文的限制的情况下进行组合。然而,与此同时,PEP 654 已经实施,它禁止在 except*
子句中使用 return
、break
和 continue
,因为其语义会违反 except*
子句 并行 运行的特性,因此一个子句的代码不应抑制另一个子句的调用。在这种情况下,我们接受了某些功能组合的危害性足以使其被禁止。
规范
此项更改将作为语言规范的一部分,明确规定当 return
、break
或 continue
会将控制流从 finally
块内部转移到外部时,Python 编译器可以发出 SyntaxWarning
或 SyntaxError
。
这些示例可能会发出 SyntaxWarning
或 SyntaxError
def f():
try:
...
finally:
return 42
for x in o:
try:
...
finally:
break # (or continue)
这些示例不会发出警告或错误
try:
...
finally:
def f():
return 42
try:
...
finally:
for x in o:
break # (or continue)
CPython 将在 3.14 版本中发出 SyntaxWarning
,我们将保留是否以及何时将其升级为 SyntaxError
的决定。然而,我们在此明确指出,语言规范允许 SyntaxError
,以便其他 Python 实现可以选择实现它。
CPython 实现将在 AST
构建期间发出 SyntaxWarning
,以确保警告在静态分析和编译期间显示,而不是在执行预编译代码期间显示。我们期望项目维护者(当他们运行静态分析或没有预编译文件的 CI 时)会看到警告。但是,项目的最终用户只有在安装时跳过预编译、检查安装时警告或对依赖项运行静态分析时才会看到警告。
向后兼容性
出于向后兼容性原因,我们提议 CPython 仅发出 SyntaxWarning
,没有具体的计划将其升级为错误。一旦引入此功能,使用 -We
运行的代码可能会停止工作。
安全隐患
该警告/错误将帮助程序员避免一些难以发现的错误,因此将具有安全效益。我们不了解与引发新的 SyntaxWarning
或 SyntaxError
相关的安全问题。
如何教授此内容
此更改将在语言规范和“新特性”文档中进行记录。SyntaxWarning
将提醒用户他们的代码需要更改。实证证据表明,必要的更改通常非常简单。
被拒绝的想法
在 CPython 中抛出 SyntaxError
PEP 601 曾提议 CPython 在几个版本中发出 SyntaxWarning
,之后再发出 SyntaxError
。我们保留了关于 CPython 何时以及是否将其转变为 SyntaxError
的决定,因为我们相信 SyntaxWarning
可以在风险较小的情况下提供大部分益处。
改变语义
有人建议更改 finally
中控制流指令的语义,使其在飞行中的异常优先于它们。换句话说,允许 return
、break
或 continue
,并会退出 finally
块,但异常仍将被引发。
此提议因两个原因被拒绝。首先,它会以难以调试的方式改变正在运行的代码的语义:一个旨在吞噬所有异常的 finally
(正确使用了文档中说明的语义)现在将允许异常传播。这可能只在运行时极少数的边缘情况下发生,并且不能保证在测试中被检测到。即使代码错误且存在异常吞噬错误,用户也很难理解为什么程序在 3.14 中开始引发异常,而在 3.13 中却没有。相比之下,SyntaxWarning
很可能在测试期间被看到,它会指向代码中问题的精确位置,并且不会阻止程序运行。
第二个反对意见是关于所提议的语义。允许控制流语句的动机并不是因为这有用,而是出于对功能正交性的渴望(正如我们在引言中提到的,在 except*
子句的情况下,这一点已经被违反了)。然而,所提议的语义是复杂的,因为它建议 return
、break
和 continue
在 finally
执行时没有在飞行中的异常时表现正常,但在有异常时却变成像裸 raise
一样的东西。如果一个特性的存在改变了另一个特性的语义,那么很难声称这些特性是正交的。
附录
在 finally
中使用 return
被认为是有害的
以下是 Irit Katriel 于 2024 年 11 月 9 日发布的研究报告的删节版。它描述了一项针对实际代码中 finally
子句中使用 return
、break
和 continue
的调查,旨在回答以下问题:人们是否在使用它?他们错误使用它的频率如何?所提议的更改会带来多少搅动?
方法
该分析基于过去 30 天内下载量排名前 8,000 的 PyPI 包。它们是在 10 月 17 日至 18 日下载的,使用了 Guido van Rossum 编写的一个脚本,该脚本又依赖于 Hugo van Kemenade 的工具,该工具用于创建最受欢迎的包列表。
下载完成后,使用第二个脚本为每个文件构建一个 AST,并遍历它以识别直接位于 finally
块内的 break
、continue
和 return
语句。
然后,我找到了每个出现位置的当前源代码,并对其进行了分类。对于代码看起来不正确的情况,我在项目的错误跟踪器中创建了一个问题。对这些问题的回复也构成了本次调查收集数据的一部分。
结果
我决定不列出不正确用法的清单,以免这份报告看起来像是一次羞辱性的行动。相反,我将一般性地描述结果,但会提到我发现的一些问题出现在非常流行的库中,包括一个云安全应用程序。对于那些有兴趣的人来说,复制我的分析应该不难,因为我在方法部分提供了我使用的脚本链接。
所检查的项目总共包含 120,964,221 行 Python 代码,其中脚本发现了 203 个 finally
块中的控制流指令。大多数是 return
,少数是 break
,没有 continue
。其中:
- 46 个是正确的,并出现在针对这种模式作为特性的测试中(例如,检测它的 linter 的测试)。
- 8 个看起来可能是正确的——要么是故意吞噬异常,要么出现在不可能发生活跃异常的地方。尽管它们是正确的,但重写它们以避免不良模式并不难,并且会使代码更清晰:故意吞噬异常可以用
except BaseException:
更明确地完成,并且不吞噬异常的return
可以移动到finally
块之后。 - 149 个明显不正确,可能导致意外的异常吞噬。这些将在下一节中进行分析。
错误案例
许多错误案例遵循此模式
try:
...
except SomeSpecificError:
...
except Exception:
logger.log(...)
finally:
return some_value
这样的代码显然是不正确的,因为它故意记录并吞噬 Exception
子类,同时默默地吞噬 BaseExceptions
。这里的意图要么是允许 BaseExceptions
继续传播,要么是(如果作者不知道 BaseException
问题)记录并吞噬所有异常。然而,即使将 except Exception
更改为 except BaseException
,此代码仍然存在 finally
块吞噬从 except
块内部引发的所有异常的问题,这可能不是本意(如果是,可以用另一个 try
-except BaseException
使其明确)。
在实际代码中发现的另一个问题变体如下所示
try:
...
except:
return NotImplemented
finally:
return some_value
这里,意图似乎是在引发异常时返回 NotImplemented
,但 finally
块中的 return
将覆盖 except
块中的那个。
注意
在讨论之后,我对随机选择的 PyPI 包重复了分析(以分析由 普通 程序员编写的代码)。样本总共包含 77,398,892 行代码,其中在 finally
中有 316 个 return
/break
/continue
实例。因此,大约每百万行代码有 4 个实例。
作者的反应
在 finally
子句中 149 个不正确的 return
或 break
实例中,有 27 个已经过时,这意味着它们不再出现在库的主/master分支中,因为代码已被删除或修复。其余 122 个分布在 73 个不同的包中,我为每个包创建了一个问题以提醒作者这些问题。两周内,73 个问题中有 40 个收到了代码维护者的回复
- 15 个问题已打开 PR 来解决问题。
- 20 个收到了确认问题值得关注的回复。
- 3 个回复说代码不再维护,因此不会修复。
- 2 个将问题标记为“按预期工作”,一个说他们打算吞噬所有异常,但另一个似乎不知道
Exception
和BaseException
之间的区别。
一个问题链接到一个关于对 Ctrl-C 无响应的现有开放问题,推测存在关联。
其中两个问题被标记为“好的第一个问题”。
正确用法
在非测试代码中,8 个看似正确使用此功能的案例也值得关注。这些代表了因阻止此功能而造成的“搅动”,因为这是需要更改工作代码的地方。我没有联系这些案例的作者,所以我们需要自己评估这些更改的难度。在完整报告中显示,每种情况所需的更改都很小。
讨论
首先要注意的是,在 finally
块中很少出现 return
/break
/continue
:在超过 1.2 亿行代码中,只有 203 个实例。这可能要归功于警告此问题的 linter。
第二个观察是,大多数用法都是不正确的:在我们的样本中占 73%(203 个中的 149 个)。
最后,作者的反馈绝大多数是积极的。在两周内收到的 40 个回复中,有 35 个承认了问题,其中 15 个还创建了 PR 来修复它。只有两个认为代码没问题,三个表示代码不再维护,因此他们不会去处理。
8个看似按预期工作的实例,重写起来并不难。
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0765.rst