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
声明。如果不存在此类绑定或声明,则将在编译时引发TargetNameError
(SyntaxError
的一个新子类)。
例如,以下代码将像今天一样编译和运行。
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 和静态类型检查器将被鼓励对NAME := EXPR
强制执行与编译器在函数作用域中强制执行的相同的“使用前需要声明或赋值”行为。
推迟关于表达式级目标声明的决定
至少对于 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