PEP 3103 – Switch/Case 语句
- 作者:
- Guido van Rossum <guido at python.org>
- 状态:
- 已拒绝
- 类型:
- 标准跟踪
- 创建日期:
- 25-Jun-2006
- Python 版本:
- 3.0
- 发布历史:
- 26-Jun-2006
拒绝通知
在 2007 年 PyCon 大会上,我在主题演讲期间进行的一次快速投票显示,该提案没有得到普遍支持。因此,我驳回它。
摘要
最近,Python 开发邮件列表上关于添加 switch 语句的讨论非常热烈。在这份 PEP 中,我试图从各种提案中提炼出我自己的偏好,讨论不同的替代方案,并解释我的选择。我还将表明我对讨论的替代方案有多么强烈的看法。
此 PEP 应被视为 PEP 275 的替代方案。我的观点与该 PEP 的作者有些不同,但我感谢该 PEP 所做的努力。
此 PEP 为已讨论的语法和语义的许多变体引入了规范名称,例如“替代方案 1”、“学校 II”、“方案 3”等等。希望这些名称有助于讨论。
基本原理
一个常见的编程习惯是考虑一个表达式并根据其值执行不同的操作。这通常通过 if/elif 测试链来完成;我将这种形式称为“if/elif 链”。我们希望为这种习惯引入新语法有两个主要原因:
- 它很重复:变量和测试运算符(通常是‘==’或‘in’)在每个 if/elif 分支中都会重复出现。
- 它效率低下:当表达式匹配最后一个测试值(或根本不匹配任何测试值)时,它将与前面每个测试值进行比较。
这两个抱怨都相对温和;通过不同的写法,在可读性或性能方面并不能获得太多收益。然而,许多语言中都存在某种形式的 switch 语句,并且可以合理地期望将其添加到 Python 中将允许我们比以往更清晰、更高效地编写某些代码。
有些分派形式不适合拟议的 switch 语句;例如,当 case 的数量不是静态已知时,或者当希望将不同 case 的代码放在不同的类或文件中时。
基本语法
我在这里考虑 PEP 275 中首次提出的语法的几种变体。还有许多其他可能性,但我认为它们并没有增加什么。
我最近已经转向了替代方案 1。
我应该注意到,此处所有替代方案都具有“隐式 break”属性:在特定 case 的 suite 结束时,控制流会跳转到整个 switch 语句的末尾。没有办法将控制权从一个 case 传递到另一个 case。这与 C 不同,在 C 中,需要显式的‘break’语句来防止“fall through”到下一个 case。
在所有替代方案中,else suite 是可选的。在这里使用‘else’而不是引入新的保留字‘default’(如 C 中)更符合 Python 的习惯。
语义将在下一个顶级部分讨论。
替代方案 1
这是 PEP 275 中首选的形式。
switch EXPR:
case EXPR:
SUITE
case EXPR:
SUITE
...
else:
SUITE
主要缺点是,包含所有操作的 suites 会缩进两级;这可以通过将 cases 缩进“半级”(例如,如果通用缩进级别为 4,则为 2 个空格)来解决。
替代方案 2
这是 Fredrik Lundh 首选的形式;它通过不缩进 cases 来区分。
switch EXPR:
case EXPR:
SUITE
case EXPR:
SUITE
....
else:
SUITE
不选择它的原因包括对自动缩进编辑器、折叠编辑器等的预期困难;以及用户感到困惑。在 Python 中,没有哪种情况是行尾以冒号开头,后面跟着一个未缩进的行。
替代方案 3
这与替代方案 2 相同,但省略了 switch 后面的冒号。
switch EXPR
case EXPR:
SUITE
case EXPR:
SUITE
....
else:
SUITE
这个替代方案的希望是它不会像平均 Python 感知文本编辑器那样扰乱自动缩进逻辑。但对我来说,它看起来很奇怪。
替代方案 4
这省略了‘case’关键字,因为它是多余的。
switch EXPR:
EXPR:
SUITE
EXPR:
SUITE
...
else:
SUITE
不幸的是,现在我们被迫缩进 case 表达式,因为否则(至少在没有‘else’关键字的情况下)解析器将很难区分未缩进的 case 表达式(它会继续 switch 语句)或以表达式开头的无关语句(例如赋值或过程调用)。一旦看到冒号,解析器就不够智能以进行回溯。这是我最不喜欢的替代方案。
扩展语法
还有一个额外的问题需要通过语法来解决。通常需要两个或多个值进行相同的处理。在 C 中,这是通过将多个 case 标签写在一起而不加任何代码来实现的。“fall through”语义意味着这些都会由相同的代码处理。由于 Python 的 switch 将没有 fall-through 语义(这还没有找到支持者),我们需要另一种解决方案。这里有一些替代方案。
替代方案 A
使用
case EXPR:
来匹配单个表达式;使用
case EXPR, EXPR, ...:
来匹配多个表达式。这里的‘is’被解释为,如果 EXPR 是一个带括号的元组或其他其值为元组的表达式,则 switch 表达式必须等于该元组,而不是其元素之一。这意味着我们不能使用变量来指示多个 cases。虽然这在 C 的 switch 语句中也存在,但在 Python 中却相对常见(例如,参见 sre_compile.py)。
替代方案 B
使用
case EXPR:
来匹配单个表达式;使用
case in EXPR_LIST:
来匹配多个表达式。如果 EXPR_LIST 是一个单独的表达式,‘in’强制将其解释为可迭代对象(或支持 __contains__ 的对象,在少数语义替代方案中)。如果它由多个表达式组成,则每个表达式都会被考虑进行匹配。
替代方案 C
使用
case EXPR:
来匹配单个表达式;使用
case EXPR, EXPR, ...:
来匹配多个表达式(如替代方案 A);并使用
case *EXPR:
来匹配其值为可迭代对象的表达式的元素。后两种情况可以合并,因此实际的语法更像是这样
case [*]EXPR, [*]EXPR, ...:
‘*’符号类似于已用于可变长度参数列表和传递计算出的参数列表的‘*’前缀的使用,并且经常被提议用于值解包(例如,a, b, *c = X 作为 (a, b), c = X[:2], X[2:] 的替代方案)。
替代方案 D
这是替代方案 B 和 C 的混合;语法类似于替代方案 B,但它使用‘*’而不是‘in’关键字。这更有限,但仍允许相同的灵活性。它使用
case EXPR:
来匹配单个表达式,并使用
case *EXPR:
来匹配可迭代对象的元素。如果一个人想指定多个 case,可以这样写
case *(EXPR, EXPR, ...):
或者也许是这样(尽管有点奇怪,因为‘*’和‘,’的相对优先级与其他地方不同)
case * EXPR, EXPR, ...:
讨论
替代方案 B、C 和 D 的动机是希望使用代表集合(通常是元组)的变量来指定具有相同处理的多个 case,而不是一一列举。这样做的动机通常是,如果你对同一组 case 有几个 switch,那么每次都必须一一列举所有替代方案是很可惜的。另一个动机是能够轻松高效地指定要匹配的*范围*,类似于 Pascal 的“1..1000:”表示法。同时,我们希望防止在异常处理中常见的错误(Python 3000 将通过更改 except 子句的语法来解决):写入“case 1, 2:”而本意是“case (1, 2):”,反之亦然。
可以认为,这种需求不足以增加复杂性;C 没有表达范围的方法,而且它比 Pascal 更常用。此外,如果选择基于 dict 查找的分派方法作为语义,大范围可能会效率低下(考虑 range(1, sys.maxint))。
总而言之,我的偏好是(从最喜欢到最不喜欢)B、A、D'、C,其中 D' 是 D 去掉第三种可能性。
语义
在选择正确的语义之前,我们需要审查几个问题。
If/Elif 链 vs. Dict 分派
关于 switch 语句的语义,有几种主要的思想流派。
- 第一种思想(School I)希望将 switch 语句定义为等效的 if/elif 链(可能加入了一些优化)。
- 第二种思想(School II)倾向于将其视为对预先计算的 dict 进行分派。关于何时进行预计算,有不同的选择。
- 还有第三种思想(School III),它同意第一种思想,即 switch 语句的定义应基于等效的 if/elif 链,但它承认优化阵营,即所有涉及的表达式都必须是可哈希的。
我们需要进一步将第一种思想分为 Ia 和 Ib。
- Ia 思想的立场很简单:switch 语句被转换为等效的 if/elif 链,就这样。它不应与优化挂钩。这也是我反对这种思想的主要原因:如果没有任何优化提示,switch 语句的吸引力不足以证明新语法的合理性。
- Ib 思想的立场更复杂:它同意第二种思想,认为优化很重要,并且愿意赋予编译器一定的自由度来实现这一点(例如,PEP 275 解决方案 1)。特别地,switch 和 case 表达式的 hash() 可能被调用,也可能不被调用(因此它应该是无副作用的);并且 case 表达式可能不会每次都像 if/elif 链行为预期那样被评估,因此 case 表达式也应该是无副作用的。我反对这一点(稍后详述),即如果 hash() 或 case 表达式不是无副作用的,则优化代码和未优化代码的行为可能不同。
第二种思想源于这样的认识:对常见情况进行优化并不容易,并且最好直面这个问题。这将在下文中清楚。
第一种思想(主要是 Ib)和第二种思想之间的区别有三方面:
- 当使用分派 dict 进行优化时,如果 switch 表达式或 case 表达式不可哈希(在这种情况下 hash() 引发异常),Ib 思想要求捕获 hash() 失败并回退到 if/elif 链。II 思想只是让异常发生。在 hash() 中捕获异常(如 Ib 思想所要求的)的问题在于,这可能会隐藏真正的错误。一种可能的解决方案是,仅当所有 case 表达式都是 int、string 或其他具有已知良好哈希行为的内置类型时,才使用分派 dict,并且仅当 switch 表达式也是这些类型之一时才尝试对其进行哈希。类型对象可能也应该在这里支持。这是 III 思想所解决的(唯一)问题。
- 当使用分派 dict 进行优化时,如果任何涉及表达式的 hash() 函数返回错误值,在 Ib 思想下,优化代码的行为将与未优化代码不同。这是优化相关错误的一个众所周知的问题,并浪费了大量开发时间。在 II 思想下,在这种情况下,会产生不正确的结果,但至少是一致的,这应该使调试更容易。为上一项提出的解决方案也有助于解决此问题。
- Ib 思想对于 case 表达式是命名常量的情况没有好的优化策略。编译器无法确定它们的值,也无法确定它们是否是真正的常量。作为一种解决方案,有人提议在 dict 确定应该采取哪个 case 后,重新评估与 case 对应的表达式,以验证表达式的值是否未改变。但严格来说,为了保留真正的 if/elif 链语义,所有在此 case 之前的 case 表达式也必须被检查,从而完全破坏了优化。另一个提出的解决方案是使用回调来通知分派 dict 变量或属性值(涉及 case 表达式)的变化。但这不太可能在一般情况下实现,并且需要许多命名空间来承担支持此类回调的负担,而这些回调目前根本不存在。
- 最后,关于对重复 case(即两个或多个 case 具有计算结果相同的匹配表达式)的处理存在意见分歧。I 思想希望将其处理为 if/elif 链会如何处理(即第一个匹配获胜,第二个匹配的代码将悄悄地无法到达);II 思想希望将其视为在冻结分派 dict 时出错(以便死代码不会被诊断出来)。
I 思想认为 II 思想预冻结分派 dict 的方法存在问题,因为它给程序员增加了新的、不寻常的负担,让他们确切地了解哪些类型的 case 值被允许冻结以及何时冻结 case 值,否则他们可能会对 switch 语句的行为感到惊讶。
II 思想不认为 Ia 思想的未优化 switch 值得付出努力,并且它认为 Ib 思想关于优化的提议存在问题,这可能导致优化代码和未优化代码的行为不同。
此外,II 思想认为允许涉及不可哈希值的 case 没有多大价值;毕竟,如果用户期望这些值,他们可以轻易地编写 if/elif 链。II 思想也不认为允许因重叠 case 而导致的死代码未被标记是正确的,因为基于 dict 的分派实现使得捕获它如此容易。
然而,重叠/重复 case 存在一些用例。假设你正在根据一些特定于操作系统的常量(例如,由 os 模块或类似模块导出的)进行 switch。每个常量都有一个 case。但在某些操作系统上,两个不同的常量具有相同的值(因为在这些操作系统上,它们的实现方式相同——就像 Unix 上的 O_TEXT 和 O_BINARY)。如果将重复 case 标记为错误,则在这些操作系统上,你的 switch 将根本无法工作。如果你能够安排 cases,使得一个 case 比另一个 case 具有更高的优先级,那将要好得多。
还有(更有可能)的用例是,你需要对一组 cases 进行相同处理,但其中一个成员需要进行不同处理。将异常放在前面的 case 中并处理掉会很方便。
(是的,未能诊断意外 case 重复导致的死代码似乎很可惜。也许这不那么重要,并且 pychecker 可以处理它?毕竟,我们也没有诊断重复的方法定义。)
这表明了 IIb 思想:类似于 II 思想,但冗余 case 必须通过选择第一个匹配来解决。在构建分派 dict 时(跳过已存在的键)这很容易实现。
(另一种选择是引入新语法来指示“允许重叠 case”或“即使此 case 是死代码也没关系”,但我认为这有点过度。)
就我个人而言,我属于 II 思想:我相信基于 dict 的分派是 switch 语句的唯一真正实现,我们应该正面面对其限制,以便获得最大的好处。我倾向于 IIb 思想——重复的 case 应该通过 case 的顺序来解决,而不是被标记为错误。
何时冻结分派字典
对于 II 思想(基于 dict 的分派)的支持者来说,下一个大的分歧点是何时创建用于切换的 dict。我称之为“冻结 dict”。
使这个问题变得有趣的主要原因是 Python 没有命名的编译时常量。概念上是常量的东西,例如 re.IGNORECASE,对编译器来说是一个变量,没有任何东西可以阻止恶意代码修改其值。
方案 1
最受限制的选项是在编译器中冻结 dict。这将要求 case 表达式全部是字面量或仅包含字面量和操作符的编译时表达式,其语义是编译器已知的,因为以 Python 当前的动态语义和单模块编译状态,编译器无法足够确定地知道这些表达式中出现的任何变量的值。这被广泛(但不普遍)认为过于严格。
Raymond Hettinger 是这种方法的主要倡导者。他提出了一种语法,其中 case 表达式只允许是特定类型的单个字面量。它的优点是明确且易于实现。
我对这个的主要抱怨是,通过不允许“命名常量”,我们迫使程序员放弃了良好的习惯。在大多数语言中引入命名常量是为了解决源代码中出现“魔法数字”的问题。例如,sys.maxint 比 2147483647 更具可读性。Raymond 提议使用字符串字面量代替命名的“枚举”,并指出字符串字面量的内容可以是该常量否则将具有的名称。因此,我们可以写“case ‘IGNORECASE’:”而不是“case re.IGNORECASE:”然而,如果字符串字面量中有拼写错误,该 case 将被悄悄忽略,谁知道何时会检测到错误。然而,如果在 NAME 中有拼写错误,则在求值时就会捕获该错误。此外,有时常量是外部定义的(例如,在解析 JPEG 等文件格式时),我们无法轻松选择合适的字符串值。使用显式映射 dict 听起来像是一个糟糕的 hack。
方案 2
处理此问题的最古老的提议是,在 switch 第一次执行时冻结分派 dict。此时,我们可以假设所有用作 case 表达式的命名“常量”(在程序员心中是常量,但对编译器不是)都已定义——否则 if/elif 链成功的机会也很小。假设 switch 将被执行多次,第一次执行时做一些额外的工作,通过稍后非常快的调度时间来快速获得回报。
这个选项的一个反对意见是,没有明显的对象可以存储分派 dict。它不能存储在代码对象上,代码对象应该是不可变的;它不能存储在函数对象上,因为对于同一个函数可能会创建许多函数对象(例如,对于嵌套函数)。实际上,我确定可以找到解决办法;它可以存储在代码对象的某个部分,当比较两个代码对象或进行 pickling 或 marshalling 代码对象时不考虑该部分;或者所有 switches 都可以存储在一个以代码对象弱引用为索引的 dict 中。解决方案还应注意不要在多个解释器之间泄漏 switch dict。
另一个反对意见是,第一次使用规则允许混淆代码,如下所示:
def foo(x, y):
switch x:
case y:
print 42
对于未经训练的人(不熟悉 Python)来说,这段代码将等同于这段代码
def foo(x, y):
if x == y:
print 42
但它实际上并非如此(除非它总是用第二个参数相同的值调用)。这已经通过建议 case 表达式不应引用局部变量来解决,但这有些武断。
最后一个反对意见是,在多线程应用程序中,第一次使用规则需要复杂的锁定才能保证正确的语义。(第一次使用规则暗示着 case 表达式的副作用仅发生一次。)这可能与 import lock 被证明的那样棘手,因为在评估所有 case 表达式时必须持有锁。
方案 3
一个正在获得支持(包括我的支持)的提议是,在包含 switch 的最内层函数被定义时冻结 switch 的 dict。switch dict 存储在函数对象上,就像参数默认值一样,事实上 case 表达式在同一时间和相同的作用域中被评估,就像参数默认值一样(即,在包含函数定义的范围内)。
这个选项的优点是避免了使选项 2 工作所需的许多精妙之处:无需锁定,无需担心不可变的代码对象或多个解释器。它还为 locals 不能在 case 表达式中引用的原因提供了清晰的解释。
此选项同样适用于通常使用 switch 的情况;涉及导入的或全局的命名常量的情况表达式与选项 2 中的工作方式完全相同,只要它们在遇到函数定义之前被导入或定义。
然而,一个缺点是,嵌套函数内的 switch 的分派 dict 必须在每次定义嵌套函数时重新计算。对于某些“函数式”编程风格,这可能会使 switch 在嵌套函数中不具吸引力。(除非所有 case 表达式都是编译时常量;那么编译器当然可以自由地优化掉 switch 冻结代码,并将分派表作为代码对象的一部分。)
另一个缺点是,根据此选项,对于不在函数内的 switch,没有一个明确的冻结分派 dict 的时刻。对于如何处理函数外的 switch,有几种实际的选择:
- 不允许。
- 将其转换为 if/elif 链。
- 只允许编译时常量表达式。
- 每次到达 switch 时计算分派 dict。
- 类似于 (b) 但测试所有求值的表达式都是可哈希的。
其中,(a) 似乎过于严格:它比 (c) 普遍更差;而 (d) 的性能比 (b) 差,但收益很小或没有收益。在模块级别有一个性能关键的内层循环没有意义,因为所有局部变量的引用在那里都很慢;因此 (b) 是我的(微弱)最爱。也许我应该赞成 (e),它试图防止 switch 的非典型使用;在交互模式下有效但在函数中无效的示例很令人讨厌。最终,我认为这个问题并不那么重要(除非它必须以某种方式解决),并且我愿意将其留给最终实现它的人。
当 switch 出现在类中但不在函数中时,我们可以在创建表示类体的临时函数对象的同时冻结分派 dict。这意味着 case 表达式可以引用模块全局变量,但不能引用类变量。或者,如果我们选择上面的 (b),我们也可以在类定义内部选择这种实现。
方案 4
有许多提案向该语言添加一个构造,该构造使得在函数定义时预先计算的值的概念普遍可用,而无需将其与参数默认值或 case 表达式绑定。一些提议的关键字包括‘const’、‘static’、‘only’或‘cached’。相关的语法和语义各不相同。
这些提案超出了此 PEP 的范围,除非建议*如果*接受此类提案,switch 有两种受益方式:我们可以要求 case 表达式为编译时常量或预先计算的值;或者我们可以将预先计算的值作为 case 表达式的默认(且唯一)求值模式。后者将是我的偏好,因为我认为没有比编写显式 if/elif 链更适合动态 case 表达式的用途了。
结论
现在下结论还为时过早。我希望至少看到一个关于预先计算值的完成的提案,然后再做决定。在此期间,Python 没有 switch 语句也很好,也许那些声称添加它是错误的人是正确的。
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-3103.rst