Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 3103 – switch/case 语句

作者:
Guido van Rossum <guido at python.org>
状态:
已拒绝
类型:
标准轨迹
创建:
2006年6月25日
Python 版本:
3.0
历史记录:
2006年6月26日

目录

拒绝通知

我在 PyCon 2007 的主题演讲期间进行了一次快速投票,结果表明此提案没有得到大众支持。因此我拒绝了它。

摘要

Python-dev 最近看到了一波关于添加 switch 语句的讨论热潮。在这个 PEP 中,我试图从各种提案中提取我自己的偏好,讨论替代方案并在可能的情况下解释我的选择。我还会表明我对讨论的替代方案的强烈程度。

此 PEP 应被视为 PEP 275 的替代方案。我的观点与该 PEP 的作者略有不同,但我感谢该 PEP 中所做的工作。

此 PEP 为已讨论的语法和语义各个方面的多种变体引入了规范名称,例如“方案 1”、“学派 II”、“选项 3”等等。希望这些名称能够帮助讨论。

基本原理

一个常见的编程习惯用法是考虑一个表达式,并根据其值执行不同的操作。这通常使用 if/elif 测试链来完成;我将此形式称为“if/elif 链”。引入此习惯用法的新的语法有两个主要动机

  • 它具有重复性:变量和测试运算符(通常为 '==' 或 'in')在每个 if/elif 分支中都重复出现。
  • 它效率低下:当一个表达式与最后一个测试值(或根本没有测试值)匹配时,它会与每个前面的测试值进行比较。

这两点抱怨都相对温和;通过以不同的方式编写它,在可读性和性能方面并没有太多可获得的收益。然而,许多语言中都存在某种 switch 语句,并且期望将其添加到 Python 中将允许我们比以前更简洁高效地编写某些代码,这并非不合理。

某些分派形式不适合提议的 switch 语句;例如,当情况的数量不是静态已知的,或者当希望将不同情况的代码放在不同的类或文件中时。

基本语法

我正在考虑 PEP 275 中首次提出的几种语法变体。还有很多其他的可能性,但我认为它们没有增加任何东西。

我最近转向了方案 1。

我应该注意到,这里的所有方案都具有“隐式 break”属性:在特定情况下的代码块结束时,控制流跳转到整个 switch 语句的末尾。无法将控制从一个情况传递到另一个情况。这与 C 形成对比,在 C 中,需要显式的 'break' 语句来防止贯穿到下一个情况。

在所有方案中,else 代码块都是可选的。在这里使用 'else' 比像 C 中那样引入新的保留字 'default' 更符合 Python 风格。

语义在下一个顶级部分中讨论。

方案 1

这是 PEP 275 中的首选形式

switch EXPR:
    case EXPR:
        SUITE
    case EXPR:
        SUITE
    ...
    else:
        SUITE

主要的缺点是所有操作所在的代码块缩进了两个级别;这可以通过将情况“缩进半个级别”(例如,如果通用缩进级别为 4,则缩进 2 个空格)来解决。

方案 2

这是 Fredrik Lundh 首选的形式;它与不缩进情况不同

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 标签一起编写而没有任何代码在它们之间来完成的。然后,“贯穿”语义意味着这些都由相同的代码处理。由于 Python switch 不会具有贯穿语义(尚未找到支持者),因此我们需要其他解决方案。以下是一些方案。

方案 A

使用

case EXPR:

匹配单个表达式;使用

case EXPR, EXPR, ...:

匹配多个表达式。对 is 的解释是,如果 EXPR 是一个带括号的元组或另一个其值为元组的表达式,则 switch 表达式必须等于该元组,而不是其元素之一。这意味着我们不能使用变量来表示多个情况。虽然这在 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 *(EXPR, EXPR, ...):

或者也许这样(尽管有点奇怪,因为 '*' 和 ',' 的相对优先级与其他地方不同)

case * EXPR, EXPR, ...:

讨论

方案 B、C 和 D 的动机是希望使用表示集合(通常是元组)的变量来指定具有相同处理的多个情况,而不是将它们全部写出来。这样做的动机通常是,如果对同一组情况进行多次切换,则每次都必须写出所有备选方案很可惜。另一个动机是能够轻松有效地指定要匹配的 *范围*,类似于 Pascal 的“1..1000:”表示法。同时,我们希望防止在异常处理中常见的错误(并且将在 Python 3000 中通过更改 except 子句的语法来解决):编写“case 1, 2:”而不是“case (1, 2):”,反之亦然。

可以认为这种需求不足以增加复杂性;C 也没有表达范围的方式,而且现在它的使用频率比 Pascal 高得多。此外,如果选择基于字典查找的分派方法作为语义,则较大的范围可能效率低下(考虑 range(1, sys.maxint))。

总而言之,我的偏好(从最喜欢到最不喜欢)是 B、A、D'、C,其中 D' 是没有第三种可能性的 D。

语义

在我们可以选择正确的语义之前,需要审查几个问题。

if/elif 链与基于字典的分派

关于 switch 语句的语义,有几个主要的思想流派

  • 学派 I 希望根据等效的 if/elif 链来定义 switch 语句(可能还加入了一些优化)。
  • 学派 II 更喜欢将其视为对预先计算的字典的分派。预计算发生的时间有多种选择。
  • 还有学派 III,它同意学派 I 的观点,即 switch 语句的定义应根据等效的 if/elif 链来定义,但承认优化阵营的所有相关表达式都必须是可散列的。

我们需要进一步将学派 I 分成学派 Ia 和学派 Ib

  • 学派 Ia 拥有一个简单的立场:switch 语句被翻译成等效的 if/elif 链,就是这样。它根本不应该与优化相关联。这也是我反对该学派的主要原因:没有任何优化提示,switch 语句的吸引力不足以保证新的语法。
  • 学派 Ib 拥有一个更复杂的立场:它同意学派 II 的观点,即优化很重要,并且愿意让编译器拥有某些自由来允许这样做。(例如,PEP 275 方案 1。)特别是,switch 和 case 表达式的 hash() 可能被调用也可能不被调用(因此它应该是无副作用的);并且 case 表达式可能不会像 if/elif 链行为所期望的那样每次都被评估,因此 case 表达式也应该是无副作用的。我对此的反对意见(在下面详细说明)是,如果 hash() 或 case 表达式不是无副作用的,则优化后的代码和未优化后的代码的行为可能不同。

学派 II 产生于认识到常见情况的优化并不容易,并且最好正面解决这个问题。这将在下面变得清晰。

学派 I(主要是学派 Ib)和学派 II 之间的差异有三方面

  • 当使用分发字典进行优化时,如果开关表达式或情况表达式不可哈希(在这种情况下,hash() 会引发异常),学校 Ib 要求捕获 hash() 失败并回退到 if/elif 链。学校 II 只是让异常发生。根据学校 Ib 的要求,在 hash() 中捕获异常的问题在于,这可能会隐藏真正的错误。一个可能的解决方法是,只有在所有情况表达式都是整数、字符串或其他具有良好哈希行为的内置类型时才使用分发字典,并且只有在开关表达式也是这些类型之一时才尝试对其进行哈希。这里可能也应该支持类型对象。这是学校 III 解决的(唯一)问题。
  • 当使用分发字典进行优化时,如果任何涉及的表达式的 hash() 函数返回错误的值,在学校 Ib 下,优化后的代码的行为将与未优化的代码不同。这是优化相关错误的一个众所周知的问题,并且浪费了大量的开发人员时间。在学校 II 下,在这种情况下,至少会一致地产生不正确的结果,这应该会使调试更容易一些。前面要点提出的解决方法在这里也有帮助。
  • 如果情况表达式是命名常量,学校 Ib 没有良好的优化策略。编译器无法确定其值,也无法知道它们是否确实是常量。作为一种解决方法,有人建议在字典确定应采用哪个情况后重新计算对应于该情况的表达式,以验证表达式的值是否已更改。但严格来说,为了保留真正的 if/elif 链语义,还必须检查在此情况之前发生的所有情况表达式,从而完全取消优化。另一个提议的解决方案是使用回调通知分发字典情况表达式中涉及的变量或属性的值的变化。但这在一般情况下不太可能实现,并且需要许多命名空间承担支持此类回调的负担,而这些命名空间目前根本不存在。
  • 最后,关于重复情况(即两个或多个匹配表达式计算结果相同的情况)的处理,存在意见分歧。学校 I 希望像 if/elif 链一样处理它(即第一个匹配获胜,第二个匹配的代码被静默地无法访问);学校 II 希望在分发字典冻结时将其视为错误(因此不会出现未诊断的死代码)。

学校 I 认为学校 II 预先冻结分发字典的方法存在问题,因为它给程序员带来了新的、不寻常的负担,要求他们准确理解允许冻结哪种情况值以及何时冻结情况值,否则他们可能会对 switch 语句的行为感到惊讶。

学校 II 不认为学校 Ia 的未优化 switch 值得付出努力,并且认为学校 Ib 的优化提案存在问题,这会导致优化后的代码和未优化后的代码的行为不同。

此外,学校 II 认为允许涉及不可哈希值的情况价值不大;毕竟,如果用户期望这样的值,他们可以轻松地编写 if/elif 链。学校 II 也不认为允许由于重叠情况导致的死代码未加标记地发生是正确的,因为基于字典的分发实现很容易捕获这一点。

但是,重叠/重复情况有一些用例。假设您正在切换一些特定于操作系统的常量(例如,由 os 模块或类似模块导出)。每个都有一个情况。但在某些操作系统上,两个不同的常量具有相同的值(因为在该操作系统上它们以相同的方式实现——例如 Unix 上的 O_TEXT 和 O_BINARY)。如果重复情况被标记为错误,那么您的 switch 在该操作系统上根本无法工作。如果您能够安排情况,以便一个情况优先于另一个情况,将会好得多。

还有一个(更有可能)的用例,您有一组要以相同方式处理的情况,但该集合的一个成员必须以不同的方式处理。将异常放在较早的情况中并完成它会很方便。

(是的,似乎很可惜无法诊断由于意外情况重复导致的死代码。也许这不太重要,pychecker 可以处理它?毕竟我们也没有诊断重复的方法定义。)

这表明学校 IIb:与学校 II 相同,但冗余情况必须通过选择第一个匹配来解决。这在构建分发字典时很容易实现(跳过已经存在的键)。

(另一种方法是引入新的语法来指示“可以有重叠的情况”或“如果此情况是死代码则可以”,但我认为这有点过分。)

就我个人而言,我在学校 II:我相信基于字典的分发是 switch 语句的唯一正确实现,我们应该预先面对限制,以便我们能够获得最大的收益。我倾向于学校 IIb——重复情况应该通过情况的顺序来解决,而不是标记为错误。

何时冻结分派字典

对于学校 II(基于字典的分发)的支持者来说,下一个重大的分歧问题是何时创建用于切换的字典。我称之为“冻结字典”。

使这个问题变得有趣的主要问题是,Python 没有命名编译时常量。从概念上讲,例如 re.IGNORECASE 这样的常量,对于编译器来说是一个变量,没有什么可以阻止不规范的代码修改其值。

选项 1

最限制性的选项是在编译器中冻结字典。这要求所有情况表达式都是文字或仅包含文字和编译器已知语义的运算符的编译时表达式,因为在 Python 当前的动态语义和单模块编译状态下,编译器无法以足够的确定性知道任何变量的值出现在此类表达式中。这被广泛认为(尽管并非普遍)过于严格。

Raymond Hettinger 是这种方法的主要倡导者。他提出了一种语法,其中仅允许某些类型的单个文字作为情况表达式。它具有明确且易于实现的优点。

我对此的主要抱怨是,通过不允许“命名常量”,我们迫使程序员放弃良好的习惯。在大多数语言中引入命名常量是为了解决源代码中出现“幻数”的问题。例如,sys.maxint 比 2147483647 更易读。Raymond 建议使用字符串文字而不是命名“枚举”,并观察到字符串文字的内容可以是常量原本具有的名称。因此,我们可以写“case ‘IGNORECASE’:”而不是“case re.IGNORECASE:”。但是,如果字符串文字中存在拼写错误,则该情况将被静默忽略,并且谁知道何时检测到该错误。但是,如果 NAME 中存在拼写错误,则该错误将在对其进行评估时立即被捕获。此外,有时常量是外部定义的(例如,当解析 JPEG 等文件格式时),我们无法轻松地选择合适的字符串值。使用显式映射字典听起来像是糟糕的技巧。

选项 2

处理此问题的最古老的提议是在第一次执行 switch 时冻结分发字典。此时,我们可以假设用作情况表达式的所有命名“常量”(程序员心目中的常量,但对编译器而言并非如此)都已定义——否则 if/elif 链也很难成功。假设 switch 将被多次执行,那么第一次做一些额外的工作很快就会通过以后非常快的分发时间得到回报。

对这种选择的一个反对意见是,没有明显的对象可以存储分发字典。它不能存储在代码对象上,代码对象应该是不变的;它不能存储在函数对象上,因为可以为同一个函数创建许多函数对象(例如,对于嵌套函数)。在实践中,我相信可以找到一些东西;它可以存储在代码对象的一个部分中,在比较两个代码对象或在对代码对象进行腌制或封送处理时不会考虑该部分;或者所有 switch 都可以存储在一个字典中,该字典以对代码对象的弱引用为索引。解决方案还应注意不要在多个解释器之间泄漏 switch 字典。

另一个反对意见是,首用规则允许如下所示的模糊代码

def foo(x, y):
    switch x:
    case y:
        print 42

对于未经训练的眼睛(不熟悉 Python)来说,此代码等效于以下代码

def foo(x, y):
    if x == y:
        print 42

但事实并非如此(除非始终使用与第二个参数相同的值调用它)。这已通过建议不允许情况表达式引用局部变量来解决,但这有点武断。

最后一个反对意见是在多线程应用程序中,首用规则需要复杂的锁定才能保证正确的语义。(首用规则暗示了一个承诺,即情况表达式的副作用恰好发生一次。)这可能与导入锁已被证明一样棘手,因为在评估所有情况表达式时都必须持有锁。

选项 3

一个已获得支持(包括我的支持)的提议是在包含它的最内层函数定义时冻结 switch 的字典。switch 字典存储在函数对象上,就像参数默认值一样,实际上,情况表达式是在与参数默认值相同的时间和相同的作用域中计算的(即在包含函数定义的作用域中)。

此选项具有避免使选项 2 正常工作所需的许多细微差别的优点:无需锁定,无需担心不可变的代码对象或多个解释器。它还提供了关于为什么在情况表达式中不能引用局部变量的明确解释。

此选项对于通常使用 switch 的情况同样有效;涉及导入或全局命名常量的情况表达式的工作方式与选项 2 完全相同,只要在遇到函数定义之前导入或定义它们即可。

然而,一个缺点是,嵌套函数内的 switch 的分发字典必须在每次定义嵌套函数时重新计算。对于某些“函数式”编程风格,这可能会使 switch 在嵌套函数中变得没有吸引力。(除非所有情况表达式都是编译时常量;那么编译器当然可以自由地优化掉 switch 冻结代码,并将分发表作为代码对象的一部分。)

另一个缺点是,在此选项下,对于不在函数内部发生的 switch,没有明确的时刻来冻结分发字典。对于如何在函数外部处理 switch,有一些务实的选择

  1. 不允许它。
  2. 将其转换为 if/elif 链。
  3. 仅允许编译时常量表达式。
  4. 每次到达 switch 时计算分发字典。
  5. 像 (b) 一样,但测试所有计算出的表达式是否可哈希。

在这些选项中,(a) 似乎过于严格:它普遍比 (c) 差;而 (d) 与 (b) 相比,性能较差,且收益很少或没有。在模块级别拥有性能关键的内部循环没有意义,因为那里所有局部变量引用都很慢;因此 (b) 是我的(弱)最爱。也许我应该支持 (e),它试图防止 switch 的非典型使用;在交互式环境中工作但在函数中不工作的示例很烦人。最后,我认为这个问题并不那么重要(除了必须以某种方式解决之外),并且愿意将其留给最终实施它的人。

当开关发生在类中而不是函数中时,我们可以在创建表示类体的临时函数对象的同时冻结分发字典。这意味着 case 表达式可以引用模块全局变量,但不能引用类变量。或者,如果我们选择上面提到的 (b),我们也可以在类定义内部选择此实现。

选项 4

有很多提案建议向语言中添加一个构造,使函数定义时预先计算的值的概念普遍可用,而无需将其绑定到参数默认值或 case 表达式。一些提议的关键字包括“const”、“static”、“only”或“cached”。相关的语法和语义各不相同。

这些提案超出了本 PEP 的范围,只是建议如果接受这样的提案,则开关有两个方面可以从中受益:我们可以要求 case 表达式是编译时常量或预先计算的值;或者我们可以使预先计算的值成为 case 表达式的默认(也是唯一)的求值模式。后者是我更倾向的选择,因为我认为没有看到对更动态的 case 表达式的使用,而这些使用不能通过编写显式的 if/elif 链来充分解决。

结论

现在下结论还为时过早。我希望在决定之前至少看到一个关于预先计算值的完整提案。在此期间,Python 没有 switch 语句也很好,也许那些声称添加 switch 语句会是一个错误的人是对的。


来源:https://github.com/python/peps/blob/main/peps/pep-3103.rst

上次修改:2023-09-09 17:39:29 GMT