PEP 577 – 增强赋值表达式
- 作者:
- Alyssa Coghlan <ncoghlan at gmail.com>
- 状态:
- 已撤回
- 类型:
- 标准跟踪
- 创建日期:
- 2018 年 5 月 14 日
- Python 版本:
- 3.8
- 发布历史:
- 2018 年 5 月 22 日
PEP 撤回
在处理此 PEP 时,我意识到它并没有真正解决我对 PEP 572 针对先前未引用的赋值目标的建议作用域规则感到困扰的问题,并且还产生了一些严重的负面影响(最值得注意的是,允许 >>= 和 <<= 作为内联增强赋值运算符,其含义与 >= 和 <= 比较运算符完全不同)。
我还意识到,即使没有自己的专用语法,PEP 572 实际上也允许使用 operator 模块编写内联增强赋值
from operator import iadd
if (target := iadd(target, value)) < limit:
...
对简单名称作为内联赋值目标的限制意味着目标表达式始终可以重复而不产生副作用,因此避免了允许实际嵌入的增强赋值所产生的歧义(这仍然是一个坏主意,因为它几乎肯定难以供人类阅读,此注释仅关于语言级别表达能力的理论限制)。
因此,我撤回了此 PEP,未将其提交审议。当时我还开始撰写一个替代 PEP,专门关注当前作用域中尚未声明为局部变量的赋值目标的处理(包括常规块作用域和作用域表达式),但该草稿从未达到连我都认为比 PEP 572 中最终接受的提案更好的阶段,因此从未发布过,也没有分配 PEP 编号。
摘要
这是一个提议,允许将诸如 x += 1 之类的增强赋值用作表达式,而不仅仅是语句。
作为其中的一部分,提议将 NAME := EXPR 作为内联赋值表达式,它使用新的增强赋值作用域规则,而不是像现有的名称绑定语句那样隐式定义一个新的局部变量名。将允许表达式级别局部变量声明在函数作用域中与允许表达式级别名称绑定的问题故意分开,并推迟到后续 PEP。
此 PEP 是 PEP 572 的直接竞争者(尽管它大量借鉴了该 PEP 的动机,甚至共享了内联赋值的建议语法)。有关两个 PEP 之间联系的更多详细信息,请参阅 与 PEP 572 的关系。
为了提高新表达式的可用性,建议在普通块作用域(模块、类和函数)中的增强赋值处理与作用域表达式(lambda 表达式、生成器表达式和推导式)中的增强赋值处理之间进行语义分割,以便所有内联赋值默认都以最近的包含块作用域为目标。
添加了一个新的编译时 TargetNameError,作为 SyntaxError 的子类,用于处理内联赋值当前尚不清楚目标是什么,或者目标作用域因其他原因无效的情况。
语法和语义
增强赋值表达式
语言语法将被调整,以允许增强赋值出现在表达式中,增强赋值表达式的结果与绑定到给定目标的事后计算引用相同。
例如
>>> n = 0
>>> n += 5
5
>>> n -= 2
3
>>> n *= 3
9
>>> n
9
对于可变目标,结果始终是原始对象
>>> seq = []
>>> seq_id = id(seq)
>>> seq += range(3)
[0, 1, 2]
>>> seq_id == id(seq)
True
允许对属性和容器下标进行增强赋值,结果是绑定到目标的计算后引用,正如对简单名称目标一样
def increment(self, step=1):
return self._value += step
在这些情况下,__getitem__ 和 __getattribute__ 在赋值完成后将*不会*被调用(它们只会在需要时被调用以评估原地操作)。
添加内联赋值运算符
仅凭增强赋值表达式的添加,就有可能滥用像 |= 这样的符号作为通用赋值运算符,方法是定义一个 Target 包装器类型,其工作方式如下
>>> class Target:
... def __init__(self, value):
... self.value = value
... def __or__(self, other):
... return Target(other)
...
>>> x = Target(10)
>>> x.value
10
>>> x |= 42
<__main__.Target object at 0x7f608caa8048>
>>> x.value
42
这类似于过去长期用于解决缺少 nonlocal 关键字的变通方法,即存储一个对列表的引用,并且今天仍然可以使用(结合 operator.itemsetter)来解决缺少表达式级别赋值的问题。
此 PEP 没有要求这种变通方法,而是建议采用 PEP 572 的“NAME := EXPR”语法作为新的内联赋值表达式,该表达式使用下面描述的增强赋值作用域规则。
这干净利落地处理了仅新值有意义,而之前绑定的值(如果有)可以完全丢弃的情况。
请注意,对于简单名称和复杂赋值目标,内联赋值运算符在赋值前*不会*读取前一个引用。但是,当在函数作用域中使用时(无论是直接使用还是在作用域表达式内部使用),它*不会*隐式定义一个新的局部变量,而是会引发 TargetNameError(如下面为增强赋值所述)。
赋值运算符优先级
为了保持增强赋值语句的现有语义,内联赋值运算符将被定义为具有比所有其他运算符(包括逗号伪运算符)更低的优先级。这确保了当作为顶层表达式使用时,表达式的整个右侧仍然被解释为要处理的值(即使该值是没有括号的元组)。
与 PEP 572 相比,这引入了区别,即在 PEP 572 中 (n := first, second) 设置 n = first,而在本 PEP 中,它将设置 n = (first, second),并且要获得第一种含义需要额外的括号(((n := first), second))。
PEP 572 合理地指出,这在使用赋值表达式作为函数调用参数时会导致歧义。本 PEP 通过要求赋值表达式作为函数调用参数时必须加括号(除非它们是唯一的参数)来以不同的方式解决此问题。
这比对生成器表达式施加的限制(除非是函数调用的唯一参数,否则始终需要括号)更宽松。
块作用域中名称的增强赋值
对于模块或类作用域中的增强赋值,不提议进行任何目标名称绑定更改(这还包括使用“exec”或“eval”执行的代码)。它们将继续像今天一样隐式声明一个新的局部变量作为绑定目标,并且(如有必要)将能够在绑定本地变量之前从外部作用域解析该名称。
在函数作用域中,增强赋值将被更改为要求存在先前的名称绑定或变量声明,以显式将目标名称建立为函数本地变量,或者需要显式的 global 或 nonlocal 声明。如果不存在此类绑定或声明,将在编译时引发新的 SyntaxError 子类 TargetNameError。
例如,以下代码将像今天一样编译和运行
x = 0
x += 1 # Sets global "x" to 1
class C:
x += 1 # Sets local "x" to 2, leaves global "x" alone
def local_target():
x = 0
x += 1 # Sets local "x" to 1, leaves global "x" alone
def global_target():
global x
x += 1 # Increments global "x" each time this runs
def nonlocal_target():
x = 0
def g():
nonlocal x
x += 1 # Increments "x" in outer scope each time this runs
return x
return g
以下示例仍将像今天一样编译,然后在运行时引发错误
n += 1 # Raises NameError at runtime
class C:
n += 1 # Raises NameError at runtime
def missing_global():
global n
n += 1 # Raises NameError at runtime
def delayed_nonlocal_initialisation():
def f():
nonlocal n
n += 1
f() # Raises NameError at runtime
n = 0
def skipped_conditional_initialisation():
if False:
n = 0
n += 1 # Raises UnboundLocalError at runtime
def local_declaration_without_initial_assignment():
n: typing.Any
n += 1 # Raises UnboundLocalError at runtime
而以下代码将首先引发编译时 DeprecationWarning,最终改为报告编译时 TargetNameError
def missing_target():
x += 1 # Compile time TargetNameError due to ambiguous target scope
# Is there a missing initialisation of "x" here? Or a missing
# global or nonlocal declaration?
作为一种保守的实现方法,编译时函数名称解析更改将在 Python 3.8 中作为 DeprecationWarning 引入,然后在 Python 3.9 中转换为 TargetNameError。这可以避免在某些情况下出现问题,即如果代码实际上未使用,一个未使用的函数当前会引发 UnboundLocalError,但将此潜在的运行时缺陷转换为编译时错误被视为向后不兼容的更改,需要一个弃用期。
当增强赋值用作函数作用域中的表达式时(而不是作为独立的语句),没有向后兼容性的问题,因此编译时名称绑定检查将在 Python 3.8 中立即强制执行。
同样,新的内联赋值表达式至少对于 Python 3.8 来说,在用作函数的一部分时,将始终要求显式预先声明其目标作用域。(有关未来可能重新审视该限制的说明,请参阅设计讨论部分)。
作用域表达式中名称的增强赋值
作用域表达式是一个新的集合术语,被提议用于引入新的嵌套执行作用域的表达式,无论是作为其操作的内在部分(lambda 表达式、生成器表达式),还是作为将名称绑定操作隐藏在包含作用域之外的方法(容器推导式)。
与常规函数不同,这些作用域表达式不能包含显式的 global 或 nonlocal 声明来直接在外部作用域中重新绑定名称。
相反,它们对于增强赋值表达式的名称绑定语义将如下定义:
- 作用域表达式中使用的增强赋值目标预计要么已经在包含的块作用域中绑定,要么在包含的块作用域中显式声明了其作用域。如果在该作用域中找不到合适的名称绑定或声明,则将在编译时引发
TargetNameError(而不是在作用域表达式中创建新的绑定)。 - 如果包含的块作用域是函数作用域,并且目标名称被显式声明为
global或nonlocal,那么它将在作用域表达式的函数体中使用相同的作用域声明 - 如果包含的块作用域是函数作用域,并且目标名称是该函数中的局部变量,那么它将在作用域表达式的函数体中被隐式声明为
nonlocal - 如果包含的块作用域是类作用域,则将始终引发
TargetNameError,并附带专用消息,表明将类作用域与作用域表达式中的增强赋值结合使用目前不允许。 - 如果名称被声明为形式参数(lambda 表达式)或迭代变量(生成器表达式、推导式),则该名称被视为局部于该作用域表达式,并且尝试在同一作用域或任何嵌套作用域表达式中使用它作为增强赋值操作的目标将引发
TargetNameError(这是一个可能以后被取消的限制,但目前提议是为了简化语言参考需要涵盖和编译器/解释器需要处理的初始编译时和运行时语义集)。
例如,以下代码将按所示方式工作
>>> global_target = 0
>>> incr_global_target = lambda: global_target += 1
>>> incr_global_target()
1
>>> incr_global_target()
2
>>> global_target
2
>>> def cumulative_sums(data, start=0)
... total = start
... yield from (total += value for value in data)
... return total
...
>>> print(list(cumulative_sums(range(5))))
[0, 1, 3, 6, 10]
而以下示例将全部引发 TargetNameError
class C:
cls_target = 0
incr_cls_target = lambda: cls_target += 1 # Error due to class scope
def missing_target():
incr_x = lambda: x += 1 # Error due to missing target "x"
def late_target():
incr_x = lambda: x += 1 # Error due to "x" being declared after use
x = 1
lambda arg: arg += 1 # Error due to attempt to target formal parameter
[x += 1 for x in data] # Error due to attempt to target iteration variable
由于增强赋值目前不能出现在作用域表达式内部,因此上述编译时名称解析异常将包含在初始实现中,而不是作为可能向后不兼容的更改来逐步引入。
设计讨论
允许复杂的赋值目标
此 PEP 的早期草稿保留了 PEP 572 对增强赋值作为表达式时限制为单个名称目标,而仅为语句形式允许属性和下标目标。
然而,强制执行这一点要求根据增强赋值是顶层表达式还是非顶层表达式来改变允许的目标,并且解释了为什么 n += 1、(n += 1) 和 self.n += 1 都是合法的,但 (self.n += 1) 被禁止,因此简化了该提案,允许所有现有的增强赋值目标也用于表达式形式。
由于此 PEP 将 TARGET := EXPR 定义为增强赋值的变体,因此它也获得了对赋值和下标目标的支持。
增强赋值还是仅名称绑定?
PEP 572 合理地指出,内联增强赋值的潜在用例明显弱于一般内联赋值的用例,因此要求它们拼写为 x := x + 1,绕过任何原地增强赋值方法是可接受的。
虽然这至少对于内置类型来说是可争辩的(潜在的反例可能需要关注集合操作的用例,而 PEP 作者个人没有这些用例),但它也会排除更占用内存的用例,例如 NumPy 数组的操作,其中非原地操作涉及的数据复制使其无法替代其原地对应物。
话虽如此,本 PEP 主要存在是因为 PEP 作者发现内联赋值提案更容易理解为“它就像 +=,只是跳过了加法步骤”,并且还喜欢该框架在函数作用域中为 NAME = EXPR 和 NAME := EXPR 提供了实际的语义差异。
目标作用域行为的差异意味着 NAME := EXPR 语法预计将有两个主要用例:
- 作为允许赋值嵌入
if或while语句作为表达式,或作为作用域表达式的一部分的方法 - 作为请求编译时检查目标名称已预先声明或绑定到当前函数作用域的请求
在模块或类作用域中,NAME = EXPR 和 NAME := EXPR 将在语义上等效,因为编译器无法看到运行时可解析的名称集,但鼓励代码 Linter 和静态类型检查器强制执行与编译器在函数作用域中强制执行的“使用前声明或赋值”相同的行为。
推迟关于表达式级别目标声明的决定
至少在 Python 3.8 中,在函数作用域中使用内联赋值(无论是增强的还是非增强的)将始终需要预先进行名称绑定或作用域声明,以避免出现 TargetNameError,即使在作用域表达式之外使用也是如此。(有关潜在地在未来重新审视该限制的说明,请参阅设计讨论部分)。
此要求的目的是清楚地区分以下两个语言设计问题:
- 表达式是否可以重新绑定当前作用域中的名称?
- 表达式是否可以声明当前作用域中的新名称?
对于模块全局作用域,这两个问题的答案是明确的“是”,因为这是一个语言级别保证,修改 globals() 字典将立即影响运行时模块作用域,并且函数中的 global NAME 声明可以产生相同的影响(如导入当前正在执行的模块并修改其属性)。
对于类作用域,实际上这两个问题的答案也是“是”,尽管不太明确,因为 locals() 的语义目前尚未正式指定。但是,如果将类作用域中 locals() 的当前行为视为规范(正如 PEP 558 所建议的那样),那么这基本上与操作模块全局变量相同,只是使用 locals() 而不是。
然而,对于函数作用域,这两个问题的当前答案分别是“是”和“否”。由于词法嵌套作用域和显式的 nonlocal NAME 表达式,表达式级别重新绑定函数局部变量已成为可能。虽然此 PEP 可能会使表达式级别重新绑定比今天更常见,但这对于该语言来说并不是一个根本性的新概念。
相比之下,声明一个*新的*函数局部变量目前是语句级别的操作,涉及以下一项或多项:
- 赋值语句(
NAME = EXPR、OTHER_TARGET = NAME = EXPR等) - 变量声明(
NAME : EXPR) - 嵌套函数定义
- 嵌套类定义
for循环with语句- 一个
except子句(访问范围有限)
语言的历史趋势实际上是*移除*对函数局部名称的表达式级别声明的支持,首先是引入“快速局部变量”语义(这使得通过 locals() 引入名称对于函数作用域不再受支持),然后在 Python 3.0 中隐藏推导式迭代变量。
现在,在 Python 3.9 中,我们可能会根据我们在 Python 3.8 中关于表达式级别名称绑定的经验,重新审视这个问题,并决定我们确实想要表达式级别的函数局部变量声明,并且我们希望 NAME := EXPR 是其写法(而不是,例如,更明确地写内联声明为 NAME := EXPR given NAME,这允许它们携带类型注解,并且还允许它们在作用域表达式中声明新的局部变量,而不必污染其包含作用域的命名空间)。
但是本 PEP 中的提案是明确地给我们一个完整的版本来决定我们想要多少这个功能,以及我们在哪里发现它的缺失令人烦恼。Python 在没有表达式级别名称绑定*或*声明的情况下已经愉快地生存了几十年,所以我们可以花几年时间来决定我们是否真的想要*两者*,或者表达式级别绑定是否足够。
确定增强赋值目标时忽略作用域表达式
在讨论 PEP 572 的赋值表达式的潜在绑定语义时,Tim Peters 提出了一个合理的论据 [1],[2],[3],认为赋值表达式应以包含的块作用域为目标,实际上忽略任何中间的作用域表达式。
这种方法允许像累加和,或从生成器表达式中提取最终值这样的用例以相对直接的方式编写
total = 0
partial_sums = [total := total + value for value in data]
factor = 1
while any(n % (factor := p) == 0 for p in small_primes):
n //= factor
Guido 也表示赞同这种通用方法 [4]。
本 PEP 中的提案在三个主要方面与 Tim 的原始提案不同:
- 它将该提案应用于所有增强赋值运算符,而不仅仅是一个新的名称绑定运算符
- 在实际可行的情况下,它将增强赋值要求名称已定义的规则扩展到新的名称绑定运算符(在函数作用域中引发
TargetNameError,而不是隐式声明新的局部变量) - 它将 lambda 表达式包含在被忽略的名称绑定作用域集中,使这种对赋值的透明度成为所有作用域表达式共有的,而不是特定于推导式和生成器表达式的。
当作用域表达式在计算绑定目标时被忽略时,再次难以检测生成器表达式和推导式中最外层可迭代表达式之间的作用域差异(您必须通过类作用域或尝试重新绑定迭代变量来检测它),因此也没有必要对其进行调整。
将内联赋值视为增强赋值的变体
PEP 572 的挑战之一是 NAME = EXPR 和 NAME := EXPR 在所有作用域中完全语义等效。这使得这两种形式难以教授,因为在语句级别没有固有的选择一个而不是另一个的倾向,所以您最终不得不诉诸于“NAME = EXPR 更受欢迎,因为它存在的时间更长”(并且 PEP 572 提议在编译器级别强制执行这种历史上的怪癖)。
在模块和类作用域中,这种语义等效性很难避免,同时仍然能让 if NAME := EXPR: 和 while NAME := EXPR: 有意义地工作,但在函数作用域中,编译器对所有局部名称的全面了解使得可以要求在使用之前赋值或声明该名称,从而提供了继续默认使用 NAME = EXPR 形式的合理激励,同时还允许使用 NAME := EXPR 作为一种简单的编译时断言(即,明确指示目标名称已被绑定或声明,因此编译器应该已经知道它)。
如果 Guido 宣布支持内联声明是硬性设计要求,那么此 PEP 将被更新,提议也引入 EXPR given NAME 作为支持任意表达式后的内联名称声明的方法(这将允许内联名称声明推迟到复杂表达式的末尾,而不是需要嵌入其中,并且 PEP 8 将获得一项建议,鼓励这种风格)。
在类级别作用域表达式中不允许增强赋值
虽然现代类确实定义了一个对方法实现可见的隐式闭包(以便在零参数 super() 调用中使用 __class__),但用户级代码无法显式地向该作用域添加其他名称。
与此同时,在类主体中定义的属性被忽略,不用于定义方法的词法闭包,这意味着在那里添加它们将无法在实现级别工作。
本 PEP 不尝试解决这种固有的歧义,而是简单地禁止此类用法,并要求任何受影响的逻辑必须写在类主体之外的其他地方(例如,在单独的辅助函数中)。
比较运算符与赋值运算符
作为表达式的 OP= 构造目前表示一个比较操作
x == y # Equals
x >= y # Greater-than-or-equal-to
x <= y # Less-than-or-equal-to
本 PEP 和 PEP 572 都提议添加至少一个外观相似的运算符,但定义为赋值
x := y # Becomes
本 PEP 则更进一步,允许所有 *13* 个增强赋值符号用作二元运算符
x += y # In-place add
x -= y # In-place minus
x *= y # In-place multiply
x @= y # In-place matrix multiply
x /= y # In-place division
x //= y # In-place int division
x %= y # In-place mod
x &= y # In-place bitwise and
x |= y # In-place bitwise or
x ^= y # In-place bitwise xor
x <<= y # In-place left shift
x >>= y # In-place right shift
x **= y # In-place power
在这些附加的二元运算符中,最值得怀疑的是位移赋值运算符,因为它们每个都只差一个双字符就可能变成包含性顺序比较运算符之一。
示例
简化重试循环
目前有几种不同的选项可以编写重试循环,包括:
# Post-decrementing a counter
remaining_attempts = MAX_ATTEMPTS
while remaining_attempts:
remaining_attempts -= 1
try:
result = attempt_operation()
except Exception as exc:
continue # Failed, so try again
log.debug(f"Succeeded after {attempts} attempts")
break # Success!
else:
raise OperationFailed(f"Failed after {MAX_ATTEMPTS} attempts") from exc
# Loop-and-a-half with a pre-incremented counter
attempt = 0
while True:
attempts += 1
if attempts > MAX_ATTEMPTS:
raise OperationFailed(f"Failed after {MAX_ATTEMPTS} attempts") from exc
try:
result = attempt_operation()
except Exception as exc:
continue # Failed, so try again
log.debug(f"Succeeded after {attempts} attempts")
break # Success!
可用的每个选项都会隐藏循环体内的预期循环结构的一部分,无论是状态修改、退出条件,还是两者兼有。
本 PEP 中的提案允许将状态修改和退出条件都直接包含在循环头中
attempt = 0
while (attempt += 1) <= MAX_ATTEMPTS:
try:
result = attempt_operation()
except Exception as exc:
continue # Failed, so try again
log.debug(f"Succeeded after {attempts} attempts")
break # Success!
else:
raise OperationFailed(f"Failed after {MAX_ATTEMPTS} attempts") from exc
简化 if-elif 链
需要重新绑定检查条件的 if-elif 链目前需要使用嵌套的 if-else 语句来编写
m = pattern.match(data)
if m:
...
else:
m = other_pattern.match(data)
if m:
...
else:
m = yet_another_pattern.match(data)
if m:
...
else:
...
与 PEP 572 一样,本 PEP 允许压缩 else/if 部分,使它们一致且互斥的结构更加明显
m = pattern.match(data)
if m:
...
elif m := other_pattern.match(data):
...
elif m := yet_another_pattern.match(data):
...
else:
...
与 PEP 572 不同,本 PEP 要求在第一次使用 := 目标之前显式将其指示为本地变量,方法是将其绑定到一个值(如上所示),或者包含适当的显式类型声明。
m: typing.re.Match
if m := pattern.match(data):
...
elif m := other_pattern.match(data):
...
elif m := yet_another_pattern.match(data):
...
else:
...
从推导式中捕获中间值
本 PEP 中的提案使得在推导式和生成器表达式中捕获和重用中间值变得简单,方法是将它们导出到包含的块作用域
factor: int
while any(n % (factor := p) == 0 for p in small_primes):
n //= factor
total = 0
partial_sums = [total += value for value in data]
允许 lambda 表达式更像可重用代码块
此 PEP 允许经典的闭包使用示例
def make_counter(start=0):
x = start
def counter(step=1):
nonlocal x
x += step
return x
return counter
缩写为
def make_counter(start=0):
x = start
return lambda step=1: x += step
虽然后一种形式仍然是概念上复杂的代码,但可以合理地认为,缺乏样板代码(其中“def”、“nonlocal”和“return”关键字以及“x”变量名的两个额外重复已被“lambda”关键字替换)可能会使其在实践中更容易阅读。
与 PEP 572 的关系
允许内联赋值的理由已在 PEP 572 中给出。这个竞争性的 PEP 最初打算提出一个替代的表面语法(EXPR given NAME = EXPR),同时保留 PEP 572 的表达式语义,但当讨论允许嵌入赋值的最初动机用例之一时,情况发生了变化:使在推导式和生成器表达式中轻松计算累加和成为可能。
因此,与 PEP 572 不同,本 PEP 主要关注内联增强赋值的用例。它还将导致当前在函数调用时不可避免地引发 UnboundLocalError 的情况转换为报告新的编译时 TargetNameError。
然后添加了用于名称重绑定表达式(NAME := TARGET)的新语法,不仅用于处理 PEP 572 中识别的相同用例,而且还作为较低级别的原始类型,以帮助说明、实现和解释新的增强赋值语义,而不是作为唯一提出的更改。
此 PEP 的作者认为,这种方法使新的名称重绑定灵活性更有价值,同时还减轻了 PEP 572 中围绕解释何时使用 NAME = EXPR 而非 NAME := EXPR(反之亦然)的许多潜在担忧,而无需禁止 NAME := EXPR 的裸语句形式(导致 NAME := EXPR 是编译错误,但 (NAME := EXPR) 是允许的)。
致谢
PEP 作者希望感谢 Chris Angelico 在 PEP 572 上所做的工作,以及他为创建 Python-ideas 和 Python-dev 上涌现的大量广泛讨论所做的努力,还有 Tim Peters 关于本地作用域的深入讨论,这促使了上述对作用域表达式内部增强赋值的作用域建议。
Eric Snow 对此 PEP 预发布版本的反馈使其更具可读性。
参考资料
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0577.rst