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

Python 增强提案

PEP 572 – 赋值表达式

作者:
Chris Angelico <rosuav at gmail.com>, Tim Peters <tim.peters at gmail.com>, Guido van Rossum <guido at python.org>
状态:
最终版
类型:
标准跟踪
创建日期:
2018年2月28日
Python 版本:
3.8
发布历史:
2018年2月28日,2018年3月2日,2018年3月23日,2018年4月4日,2018年4月17日,2018年4月25日,2018年7月9日,2019年8月5日
决议:
Python-Dev 消息

目录

摘要

这是一个关于在表达式中使用 NAME := expr 符号为变量赋值的提案。

作为此更改的一部分,字典推导式求值顺序也进行了更新,以确保键表达式在值表达式之前执行(允许将键绑定到名称,然后作为计算相应值的一部分进行重用)。

在讨论此 PEP 期间,该运算符被非正式地称为“海象运算符”。该构造的正式名称是“赋值表达式”(根据 PEP 标题),但它们也可以被称为“命名表达式”(例如,CPython 参考实现内部使用该名称)。

基本原理

命名表达式的结果是编程的重要组成部分,它允许使用描述性名称代替更长的表达式,并允许重用。目前,此功能仅以语句形式提供,因此在列表推导式和其他表达式上下文中不可用。

此外,命名大型表达式的子部分可以帮助交互式调试器,提供有用的显示钩子和部分结果。如果没有捕获内联子表达式的方法,这将需要重构原始代码;有了赋值表达式,这只需插入几个 name := 标记。消除重构的需要减少了代码在调试过程中被无意更改的可能性(Heisenbug 的常见原因),并且更容易向其他程序员说明。

真实代码的重要性

在本 PEP 的开发过程中,许多人(支持者和批评者)都倾向于一方面关注玩具示例,另一方面关注过于复杂的示例。

玩具示例的危险有两方面:它们通常过于抽象,无法让任何人觉得“哦,这很有说服力”,而且它们很容易被“我反正不会那样写”所驳斥。

过于复杂的示例的危险在于,它们为提案的批评者提供了一个方便的稻草人来攻击(“这太晦涩了”)。

然而,极其简单和极其复杂的示例都有一些用处:它们有助于澄清预期的语义。因此,下面将同时包含这两种示例。

然而,为了具有**说服力**,示例应植根于真实代码,即在编写时没有考虑此 PEP,作为有用应用程序(无论大小)的一部分而编写的代码。Tim Peters 在回顾他自己的个人代码存储库并挑选出他编写的(在他看来)如果(谨慎地)使用赋值表达式重写会**更清晰**的代码示例方面非常有帮助。他的结论是:当前的提案将允许在相当多的代码中实现适度但清晰的改进。

真实代码的另一个用途是间接观察程序员对紧凑性的重视程度。Guido van Rossum 搜索了 Dropbox 代码库,发现了一些证据表明程序员更看重编写更少的行而不是更短的行。

例如:Guido 发现了几个示例,其中程序员重复了一个子表达式,减慢了程序速度,只是为了节省一行代码,例如,而不是编写

match = re.match(data)
group = match.group(1) if match else None

他们会这样写

group = re.match(data).group(1) if re.match(data) else None

另一个例子说明程序员有时会做更多工作来节省额外的缩进级别

match1 = pattern1.match(data)
match2 = pattern2.match(data)
if match1:
    result = match1.group(1)
elif match2:
    result = match2.group(2)
else:
    result = None

即使 pattern1 有匹配,此代码也会尝试匹配 pattern2(在这种情况下,pattern2 上的匹配永远不会使用)。更有效的重写是

match1 = pattern1.match(data)
if match1:
    result = match1.group(1)
else:
    match2 = pattern2.match(data)
    if match2:
        result = match2.group(2)
    else:
        result = None

语法和语义

在大多数可以使用任意 Python 表达式的上下文中,可以出现**命名表达式**。其形式为 NAME := expr,其中 expr 是任何有效的 Python 表达式(除了未加括号的元组),NAME 是一个标识符。

这种命名表达式的值与合并表达式相同,并具有额外的副作用,即目标被赋予该值

# Handle a matched regex
if (match := pattern.search(data)) is not None:
    # Do something with match

# A loop that can't be trivially rewritten using 2-arg iter()
while chunk := file.read(8192):
   process(chunk)

# Reuse a value that's expensive to compute
[y := f(x), y**2, y**3]

# Share a subexpression between a comprehension filter clause and its output
filtered_data = [y for x in data if (y := f(x)) is not None]

异常情况

在某些地方不允许使用赋值表达式,以避免歧义或用户混淆

  • 在表达式语句的顶层禁止使用未加括号的赋值表达式。示例:
    y := f(x)  # INVALID
    (y := f(x))  # Valid, though not recommended
    

    此规则旨在简化用户在赋值语句和赋值表达式之间的选择——没有哪个语法位置两者都有效。

  • 在赋值语句右侧的顶层禁止使用未加括号的赋值表达式。示例:
    y0 = y1 := f(x)  # INVALID
    y0 = (y1 := f(x))  # Valid, though discouraged
    

    同样,此规则旨在避免两种视觉上相似的表达方式。

  • 在函数调用的关键字参数值中禁止使用未加括号的赋值表达式。示例:
    foo(x = y := f(x))  # INVALID
    foo(x=(y := f(x)))  # Valid, though probably confusing
    

    此规则是为了禁止过度混淆的代码,并且因为解析关键字参数已经足够复杂了。

  • 在函数默认值的顶层禁止使用未加括号的赋值表达式。示例:
    def foo(answer = p := 42):  # INVALID
        ...
    def foo(answer=(p := 42)):  # Valid, though not great style
        ...
    

    此规则旨在阻止在某些用户已经感到困惑的位置产生副作用(参见关于可变默认值的常见样式建议),并且也呼应了调用中类似的禁令(上一条)。

  • 在参数、返回值和赋值的注解中禁止使用未加括号的赋值表达式。示例:
    def foo(answer: p := 42 = 5):  # INVALID
        ...
    def foo(answer: (p := 42) = 5):  # Valid, but probably never useful
        ...
    

    这里的推理类似于前两种情况;这种由 := 组成的未分组的符号和运算符难以正确阅读。

  • 在 lambda 函数中禁止使用未加括号的赋值表达式。示例:
    (lambda: x := 1) # INVALID
    lambda: (x := 1) # Valid, but unlikely to be useful
    (x := lambda: 1) # Valid
    lambda line: (m := re.match(pattern, line)) and m.group(1) # Valid
    

    这使得 lambda 始终比 := 绑定更松散;在 lambda 函数内部的顶层进行名称绑定不太可能有价值,因为没有办法使用它。在名称将被多次使用的情况下,表达式很可能无论如何都需要加括号,所以这个禁令很少会影响代码。

  • f-string 中的赋值表达式需要加括号。示例:
    >>> f'{(x:=10)}'  # Valid, uses assignment expression
    '10'
    >>> x = 10
    >>> f'{x:=10}'    # Valid, passes '=10' to formatter
    '        10'
    

    这表明 f-string 中看起来像赋值运算符的并不总是赋值运算符。f-string 解析器使用 : 来表示格式选项。为了保持向后兼容性,f-string 内部的赋值运算符使用必须加括号。如上所述,不建议使用这种赋值运算符。

目标的范围

赋值表达式不引入新的作用域。在大多数情况下,目标将绑定的作用域是自解释的:它是当前作用域。如果此作用域包含目标的 nonlocalglobal 声明,赋值表达式将遵守该声明。lambda(作为显式的,即使是匿名的函数定义)为此目的也算作一个作用域。

有一个特殊情况:出现在列表、集合或字典推导式或生成器表达式(以下统称为“推导式”)中的赋值表达式,如果其包含作用域中存在针对该目标的 nonlocalglobal 声明,则会将目标绑定到该包含作用域中。对于此规则,嵌套推导式的包含作用域是包含最外层推导式的作用域。lambda 算作一个包含作用域。

这种特殊情况的动机有两方面。首先,它允许我们方便地捕获 any() 表达式的“证人”,或 all() 的反例,例如

if any((comment := line).startswith('#') for line in lines):
    print("First comment:", comment)
else:
    print("There are no comments")

if all((nonblank := line).strip() == '' for line in lines):
    print("All lines are blank")
else:
    print("First non-blank line:", nonblank)

其次,它提供了一种从推导式中更新可变状态的紧凑方式,例如

# Compute partial sums in a list comprehension
total = 0
partial_sums = [total := total + v for v in values]
print("Total:", total)

然而,赋值表达式的目标名称不能与包含赋值表达式的任何推导式中出现的 for 目标名称相同。后者名称在它们出现的推导式中是局部的,因此如果包含的相同名称的使用引用包含最外层推导式的作用域,则会产生矛盾。

例如,[i := i+1 for i in range(5)] 是无效的:for i 部分规定 i 在推导式中是局部的,但 i := 部分坚持认为 i 在推导式中不是局部的。同样的原因也使得这些示例无效

[[(j := j) for i in range(5)] for j in range(5)] # INVALID
[i := 0 for i, j in stuff]                       # INVALID
[i+1 for i in (i := stuff)]                      # INVALID

虽然技术上可以为这些情况分配一致的语义,但在没有实际用例的情况下,很难确定这些语义是否真正**有意义**。因此,参考实现 [1] 将确保此类情况引发 SyntaxError,而不是以实现定义的行为执行。

即使赋值表达式从不执行,此限制也适用

[False and (i := 0) for i, j in stuff]     # INVALID
[i for i, j in stuff if True or (j := 1)]  # INVALID

对于推导式主体(第一个“for”关键字之前的部分)和筛选表达式(“if”之后和任何嵌套“for”之前的部分),此限制仅适用于也用作推导式中迭代变量的目标名称。出现在这些位置的 Lambda 表达式引入了一个新的显式函数作用域,因此可以使用赋值表达式而没有额外的限制。

由于参考实现中的设计限制(符号表分析器无法轻易检测到最左侧的推导式可迭代表达式和推导式其余部分之间名称的重用),因此命名表达式在推导式可迭代表达式(每个“in”之后,以及任何后续“if”或“for”关键字之前的部分)中完全不允许

[i+1 for i in (j := stuff)]                    # INVALID
[i+1 for i in range(2) for j in (k := stuff)]  # INVALID
[i+1 for i in [j for j in (k := stuff)]]       # INVALID
[i+1 for i in (lambda: (j := stuff))()]        # INVALID

当赋值表达式出现在其包含作用域为类作用域的推导式中时,会适用另一项例外。如果上述规则导致目标在该类的作用域中被赋值,则该赋值表达式明确无效。这种情况也会引发 SyntaxError

class Example:
    [(j := i) for i in range(5)]  # INVALID

(后一项例外的原因是为推导式创建的隐式函数作用域——目前没有运行时机制可以让函数引用包含类作用域中的变量,我们也不想添加这样的机制。如果这个问题得到解决,这个特殊情况可能会从赋值表达式的规范中删除。请注意,从推导式中**使用**在类作用域中定义的变量已经存在问题。)

有关推导式中目标规则如何转换为等效代码的一些示例,请参见附录 B。

:= 的相对优先级

:= 运算符在所有合法的语法位置都比逗号结合得更紧密,但比所有其他运算符结合得更松散,包括 orandnot 和条件表达式 (A if C else B)。根据上述“异常情况”部分,它绝不允许与 = 处于同一级别。如果需要不同的分组,应使用括号。

:= 运算符可以直接用于位置函数调用参数;但是,它不能直接用于关键字参数。

一些例子来澄清什么是技术上有效或无效的

# INVALID
x := 0

# Valid alternative
(x := 0)

# INVALID
x = y := 0

# Valid alternative
x = (y := 0)

# Valid
len(lines := f.readlines())

# Valid
foo(x := 3, cat='vector')

# INVALID
foo(cat=category := 'vector')

# Valid alternative
foo(cat=(category := 'vector'))

上面大多数“有效”的例子不建议使用,因为快速浏览代码的 Python 源代码阅读者可能会忽略其区别。但简单的情况并无可厚非

# Valid
if any(len(longline := line) >= 100 for line in lines):
    print("Extremely long line:", longline)

本 PEP 建议在 := 周围始终留有空格,类似于 PEP 8 关于赋值时使用 = 的建议,而后者则不允许关键字参数中使用 = 周围留有空格。)

求值顺序的改变

为了精确定义语义,该提案要求求值顺序明确。这在技术上并不是一个新要求,因为函数调用可能已经有副作用。Python 已经有一个规则,即子表达式通常从左到右求值。然而,赋值表达式使这些副作用更明显,我们提出对当前求值顺序进行单一更改

  • 在字典推导式 {X: Y for ...} 中,Y 目前在 X 之前求值。我们建议将此更改为 XY 之前求值。(在字典显示式如 {X: Y} 中,情况已经是这样了,在 dict((X, Y) for ...) 中也是如此,这应该明确等同于字典推导式。)

赋值表达式与赋值语句的区别

最重要的是,由于 := 是一个表达式,它可以在语句非法的上下文中使用,包括 lambda 函数和推导式。

反之,赋值表达式不支持赋值语句中的高级特性

  • 不支持多目标直接赋值
    x = y = z = 0  # Equivalent: (z := (y := (x := 0)))
    
  • 不支持除单个 NAME 之外的单一赋值目标
    # No equivalent
    a[i] = x
    self.rest = []
    
  • 逗号的优先级不同
    x = 1, 2  # Sets x to (1, 2)
    (x := 1, 2)  # Sets x to 1
    
  • 不支持可迭代打包和解包(常规或扩展形式)
    # Equivalent needs extra parentheses
    loc = x, y  # Use (loc := (x, y))
    info = name, phone, *rest  # Use (info := (name, phone, *rest))
    
    # No equivalent
    px, py, pz = position
    name, phone, email, *other_info = contact
    
  • 不支持行内类型注解
    # Closest equivalent is "p: Optional[int]" as a separate declaration
    p: Optional[int] = None
    
  • 不支持增量赋值
    total += tax  # Equivalent: (total := total + tax)
    

实现过程中的规范变更

在 PEP 最初被接受之后、Python 3.8 发布之前,根据实现经验和额外审查,进行了以下更改

  • 为了与其他类似异常保持一致,并避免锁定一个不一定能提高最终用户清晰度的异常名称,最初提议的 SyntaxErrorTargetScopeError 子类被删除,转而直接引发 SyntaxError[3]
  • 由于 CPython 符号表分析过程的限制,参考实现对推导式可迭代表达式中所有命名表达式的使用都会引发 SyntaxError,而不仅仅是在命名表达式目标与推导式中的某个迭代变量冲突时引发。如果存在足够有说服力的示例,这可能会重新考虑,但为了纯粹的假设用例而实现更具选择性的限制所需的额外复杂性似乎不值得。

示例

Python 标准库中的示例

site.py

_env_base_ 仅在此行使用,将其赋值移到 if 语句中,使其成为块的“头部”。

  • 当前
    env_base = os.environ.get("PYTHONUSERBASE", None)
    if env_base:
        return env_base
    
  • 改进后
    if env_base := os.environ.get("PYTHONUSERBASE", None):
        return env_base
    

_pydecimal.py

避免嵌套 if 并减少一个缩进级别。

  • 当前
    if self._is_special:
        ans = self._check_nans(context=context)
        if ans:
            return ans
    
  • 改进后
    if self._is_special and (ans := self._check_nans(context=context)):
        return ans
    

copy.py

代码看起来更规范,避免了多个嵌套的 if 语句。(有关此示例的来源,请参见附录 A。)

  • 当前
    reductor = dispatch_table.get(cls)
    if reductor:
        rv = reductor(x)
    else:
        reductor = getattr(x, "__reduce_ex__", None)
        if reductor:
            rv = reductor(4)
        else:
            reductor = getattr(x, "__reduce__", None)
            if reductor:
                rv = reductor()
            else:
                raise Error(
                    "un(deep)copyable object of type %s" % cls)
    
  • 改进后
    if reductor := dispatch_table.get(cls):
        rv = reductor(x)
    elif reductor := getattr(x, "__reduce_ex__", None):
        rv = reductor(4)
    elif reductor := getattr(x, "__reduce__", None):
        rv = reductor()
    else:
        raise Error("un(deep)copyable object of type %s" % cls)
    

datetime.py

tz 仅用于 s += tz,将其赋值移到 if 语句内部有助于显示其作用域。

  • 当前
    s = _format_time(self._hour, self._minute,
                     self._second, self._microsecond,
                     timespec)
    tz = self._tzstr()
    if tz:
        s += tz
    return s
    
  • 改进后
    s = _format_time(self._hour, self._minute,
                     self._second, self._microsecond,
                     timespec)
    if tz := self._tzstr():
        s += tz
    return s
    

sysconfig.py

while 条件中调用 fp.readline() 并在 if 语句行中调用 .match(),使代码更紧凑,而不会使其更难理解。

  • 当前
    while True:
        line = fp.readline()
        if not line:
            break
        m = define_rx.match(line)
        if m:
            n, v = m.group(1, 2)
            try:
                v = int(v)
            except ValueError:
                pass
            vars[n] = v
        else:
            m = undef_rx.match(line)
            if m:
                vars[m.group(1)] = 0
    
  • 改进后
    while line := fp.readline():
        if m := define_rx.match(line):
            n, v = m.group(1, 2)
            try:
                v = int(v)
            except ValueError:
                pass
            vars[n] = v
        elif m := undef_rx.match(line):
            vars[m.group(1)] = 0
    

简化列表推导式

列表推导式可以通过捕获条件来高效地映射和过滤

results = [(x, y, x/y) for x in input_data if (y := f(x)) > 0]

同样,子表达式可以在主表达式中重用,通过在第一次使用时给它一个名称

stuff = [[y := f(x), x/y] for x in range(5)]

请注意,在这两种情况下,变量 y 都绑定在包含作用域中(即与 resultsstuff 处于同一级别)。

捕获条件值

赋值表达式可以在 ifwhile 语句的头部发挥很好的作用

# Loop-and-a-half
while (command := input("> ")) != "quit":
    print("You entered:", command)

# Capturing regular expression match objects
# See, for instance, Lib/pydoc.py, which uses a multiline spelling
# of this effect
if match := re.search(pat, text):
    print("Found:", match.group(0))
# The same syntax chains nicely into 'elif' statements, unlike the
# equivalent using assignment statements.
elif match := re.search(otherpat, text):
    print("Alternate found:", match.group(0))
elif match := re.search(third, text):
    print("Fallback found:", match.group(0))

# Reading socket data until an empty string is returned
while data := sock.recv(8192):
    print("Received data:", data)

特别是对于 while 循环,这可以消除无限循环、赋值和条件的需要。它还在简单地使用函数调用作为其条件的循环与使用函数调用作为其条件但同时使用实际值的循环之间创建了一个平滑的并行关系。

分叉

一个来自低级 UNIX 世界的例子

if pid := os.fork():
    # Parent code
else:
    # Child code

被拒绝的替代提案

与此提案大致相似的提案在 python-ideas 上经常出现。下面是一些替代语法,其中一些特定于推导式,它们已被拒绝,而倾向于上述提案。

改变推导式的作用域规则

本 PEP 的先前版本提出了对推导式作用域规则的细微更改,以使其在类作用域中更可用,并统一“最外层可迭代对象”和推导式其余部分的作用域。然而,提案的这一部分会导致向后不兼容,因此已被撤回,以便 PEP 可以专注于赋值表达式。

替代拼写

大致与当前提案的语义相同,但拼写不同。

  1. EXPR as NAME:
    stuff = [[f(x) as y, x/y] for x in range(5)]
    

    由于 EXPR as NAMEimportexceptwith 语句中已经有含义(具有不同的语义),这会造成不必要的混淆或需要特殊处理(例如,禁止在这些语句的头部进行赋值)。

    (请注意,with EXPR as VAR 并不是简单地将 EXPR 的值赋给 VAR – 它调用 EXPR.__enter__() 并将其结果赋给 VAR。)

    偏爱 := 而非这种拼写的其他原因包括

    • if f(x) as y 中,赋值目标并不显眼——它读起来就像 if f x blah blah,并且在视觉上与 if f(x) and y 太相似了。
    • 在允许 as 子句的所有其他情况下,即使是中级读者也会被行开头的关键字引导,从而预期该子句(无论是否可选),并且语法将该关键字与 as 子句紧密联系起来
      • import foo as bar
      • except Exc as var
      • with ctxmgr() as var

      相反,赋值表达式不属于以 ifwhile 开头的行,我们也有意允许赋值表达式在其他上下文中使用。

    • 两者之间的并行节奏
      • NAME = EXPR
      • if NAME := EXPR

      强化了对赋值表达式的视觉识别。

  2. EXPR -> NAME:
    stuff = [[f(x) -> y, x/y] for x in range(5)]
    

    此语法受到 R 和 Haskell 等语言以及一些可编程计算器的启发。(请注意,在 Python 中不可能使用左向箭头 y <- f(x),因为它将被解释为小于和一元负号。)此语法比“as”略有优势,因为它不与 withexceptimport 冲突,但除此之外是等效的。但它与 Python 中 -> 的其他用途(函数返回类型注解)完全无关,并且与 :=(可追溯到 Algol-58)相比,它的传统要弱得多。

  3. 用前导点装饰语句局部名称
    stuff = [[(f(x) as .y), x/.y] for x in range(5)] # with "as"
    stuff = [[(.y := f(x)), x/.y] for x in range(5)] # with ":="
    

    这有一个优点,就是可以很容易地检测到泄漏的使用,从而消除某些形式的语法歧义。但是,这将是 Python 中唯一将变量的作用域编码到其名称中的地方,这使得重构变得更加困难。

  4. 向任何语句添加 where: 以创建局部名称绑定
    value = x**2 + 2*x where:
        x = spam(1, 4, 7, q)
    

    执行顺序颠倒(先执行缩进主体,然后是“header”)。这需要一个新关键字,除非重用现有关键字(最可能是 with:)。有关此主题的先前讨论,请参阅 PEP 3150(其中提议的关键字是 given:)。

  5. TARGET from EXPR:
    stuff = [[y from f(x), x/y] for x in range(5)]
    

    这种语法比 as 的冲突更少(仅与 raise Exc from Exc 符号冲突),但其他方面与它相当。它没有 with expr as target: 的并行(这可能有用但也可能令人困惑),但它很有启发性。

条件语句的特殊情况

最受欢迎的用例之一是 ifwhile 语句。与其采用更通用的解决方案,不如本提案增强这两个语句的语法,以添加捕获比较值的方法

if re.search(pat, text) as match:
    print("Found:", match.group(0))

只有当所需条件基于捕获值的真实性时,这才能完美运行。因此,它对特定用例有效(正则表达式匹配,套接字读取在完成时返回 ''),而在更复杂的情况下完全无用(例如,条件是 f(x) < 0,并且您想捕获 f(x) 的值)。它对列表推导式也没有任何好处。

优点:没有语法歧义。缺点:只解决了可能的用例中的一小部分,即使在 if/while 语句中也是如此。

推导式的特殊情况

另一个常见的用例是推导式(列表/集合/字典,以及生成器表达式)。如上所述,已经提出了针对推导式特定解决方案的提案。

  1. whereletgiven
    stuff = [(y, x/y) where y = f(x) for x in range(5)]
    stuff = [(y, x/y) let y = f(x) for x in range(5)]
    stuff = [(y, x/y) given y = f(x) for x in range(5)]
    

    这将子表达式带到“for”循环和表达式之间的一个位置。它引入了一个额外的语言关键字,这会产生冲突。在这三个中,where 阅读起来最清晰,但也最有可能产生冲突(例如,SQLAlchemy 和 numpy 都有 where 方法,标准库中的 tkinter.dnd.Icon 也有)。

  2. with NAME = EXPR:
    stuff = [(y, x/y) with y = f(x) for x in range(5)]
    

    如上,但重用 with 关键字。阅读起来不算太差,不需要额外的语言关键字。但是,它仅限于推导式,并且不能轻易转换为“长手”for 循环语法。它有 C 语言的问题,即表达式中的等号现在可以创建名称绑定,而不是执行比较。这将引发一个问题:为什么“with NAME = EXPR:”不能单独作为语句使用。

  3. with EXPR as NAME:
    stuff = [(y, x/y) with f(x) as y for x in range(5)]
    

    与选项 2 相同,但使用 as 而非等号。在语法上与其他用于名称绑定的 as 用法对齐,但简单地转换为 for 循环长格式将创建截然不同的语义;推导式中 with 的含义将与独立语句的含义完全不同,同时保留相同的语法。

无论选择哪种拼写,这都会在推导式和等效的展开长循环形式之间引入明显的区别。在不重写任何名称绑定的情况下,不再可能将循环解包为语句形式。唯一可以重用此任务的关键字是 with,因此在推导式中赋予它与语句中截然不同的语义;或者,需要一个新的关键字,并带来所有相关的成本。

降低运算符优先级

:= 运算符有两种逻辑优先级。它应该尽可能松散地绑定,就像语句赋值一样;或者它应该比比较运算符绑定得更紧密。将其优先级置于比较运算符和算术运算符之间(精确地说:仅低于按位或),允许在 whileif 条件中使用时省略括号,因为您最有可能希望捕获某个值,然后对其执行比较

pos = -1
while pos := buffer.find(search_term, pos + 1) >= 0:
    ...

一旦 find() 返回 -1,循环就会终止。如果 :== 一样松散绑定,这将捕获比较的结果(通常是 TrueFalse),这不太有用。

虽然这种行为在许多情况下会很方便,但它也比“:= 运算符的行为与赋值语句完全相同”更难解释,因此,:= 的优先级已尽可能接近 = 的优先级(除了它比逗号绑定更紧密)。

允许右侧使用逗号

一些批评者声称赋值表达式应该允许右侧使用未加括号的元组,以便这两者是等效的

(point := (x, y))
(point := x, y)

(根据提案的当前版本,后者将等同于 ((point := x), y)。)

然而,采取这种立场将逻辑上导致这样的结论:当在函数调用中使用时,赋值表达式的绑定也比逗号松散,所以我们将有以下令人困惑的等价关系

foo(x := 1, y)
foo(x := (1, y))

不那么令人困惑的选择是使 := 的绑定比逗号更紧密。

始终要求使用括号

有人提议总是要求在赋值表达式周围加上括号。这可以解决许多歧义,并且确实经常需要括号来提取所需的子表达式。但在以下情况下,多余的括号显得多余

# Top level in if
if match := pattern.match(line):
    return match.group(1)

# Short call
len(lines := f.readlines())

常见异议

为什么不直接将现有赋值变成表达式?

C 及其派生语言将 = 运算符定义为一个表达式,而不是 Python 的方式,一个语句。这允许在更多上下文中使用赋值,包括比较更常见的上下文。if (x == y)if (x = y) 之间的语法相似性掩盖了它们截然不同的语义。因此,本提案使用 := 来澄清区别。

有了赋值表达式,为什么还要费心使用赋值语句?

这两种形式具有不同的灵活性。:= 运算符可以在更大的表达式内部使用;= 语句可以扩展为 += 及其伙伴,可以链式使用,并且可以赋值给属性和下标。

为什么不使用子局部作用域并防止命名空间污染?

本提案的先前版本涉及子局部作用域(仅限于单个语句),防止名称泄漏和命名空间污染。虽然在许多情况下具有明确的优势,但这在许多其他情况下增加了复杂性,并且成本与收益不符。为了语言的简洁性,此处创建的名称绑定与任何其他名称绑定完全等效,包括在类或模块作用域中使用将创建外部可见名称。这与 for 循环或其他构造没有什么不同,并且可以用相同的方式解决:一旦不再需要,就 del 该名称,或在其前面加上下划线。

(作者要感谢 Guido van Rossum 和 Christoph Groth 对将提案朝这个方向发展提出的建议。[2]

风格指南建议

由于表达式赋值有时可以与语句赋值等效使用,因此会出现哪种应优先选择的问题。为了方便 PEP 8 等风格指南,提出了两条建议。

  1. 如果可以使用赋值语句或赋值表达式,请优先选择语句;它们是意图的明确声明。
  2. 如果使用赋值表达式会导致执行顺序的歧义,请将其重构为使用语句。

致谢

作者感谢 Alyssa Coghlan 和 Steven D’Aprano 对本提案的巨大贡献,以及核心指导邮件列表的成员在实现方面提供的帮助。

附录 A:Tim Peters 的发现

这是 Tim Peters 就此主题撰写的一篇短文。

我不喜欢“繁忙”的代码行,也不喜欢将概念上不相关的逻辑放在一行上。所以,例如,我更喜欢

i = j = count = nerrors = 0

而不是

i = j = 0
count = 0
nerrors = 0

相反。所以我怀疑我很少会想使用赋值表达式。我甚至没有考虑已经延伸到屏幕一半的代码行。在其他情况下,“不相关”占了上风

mylast = mylast[1]
yield mylast[0]

这比更简洁的

yield (mylast := mylast[1])[0]

原始的两个语句在概念上做着完全不同的事情,把它们强行组合在一起在概念上是疯狂的。

在其他情况下,组合相关逻辑反而使其难以理解,例如将

while True:
    old = total
    total += term
    if old == total:
        return total
    term *= mx2 / (i*(i+1))
    i += 2

重写为更简洁的

while total != (total := total + term):
    term *= mx2 / (i*(i+1))
    i += 2
return total

那里的 while 测试过于微妙,在非短路或方法链式上下文中,它关键地依赖于严格的从左到右求值。我的大脑不是那样工作的。

但这种情况很少见。名称绑定非常频繁,“稀疏胜于密集”并不意味着“几乎为空胜于稀疏”。例如,我有很多函数返回 None0 来传达“在这种情况下我没有什么有用的东西可以返回,但由于这经常发生,我不会用异常来烦你”。这本质上与正则表达式搜索函数在没有匹配时返回 None 相同。所以有很多代码形式是

result = solution(xs, n)
if result:
    # use result

我发现这样更清晰,而且打字和模式匹配的阅读量肯定会少一些,就像

if result := solution(xs, n):
    # use result

用少量水平空白换取屏幕上多一行代码也很好。起初我并没有太重视这一点,但它非常频繁地累积起来,很快我就开始恼火,因为我实际上无法运行更简洁的代码。这让我很惊讶!

在其他情况下,赋值表达式确实大放异彩。Kirill Balunov 没有从我的代码中挑选另一个例子,而是从标准库 copy.py 中的 copy() 函数中给出了一个很好的例子

reductor = dispatch_table.get(cls)
if reductor:
    rv = reductor(x)
else:
    reductor = getattr(x, "__reduce_ex__", None)
    if reductor:
        rv = reductor(4)
    else:
        reductor = getattr(x, "__reduce__", None)
        if reductor:
            rv = reductor()
        else:
            raise Error("un(shallow)copyable object of type %s" % cls)

不断增加的缩进在语义上具有误导性:逻辑在概念上是扁平的,“第一个成功的测试获胜”

if reductor := dispatch_table.get(cls):
    rv = reductor(x)
elif reductor := getattr(x, "__reduce_ex__", None):
    rv = reductor(4)
elif reductor := getattr(x, "__reduce__", None):
    rv = reductor()
else:
    raise Error("un(shallow)copyable object of type %s" % cls)

使用简单的赋值表达式允许代码的视觉结构强调逻辑的概念扁平性;不断增加的缩进反而模糊了它。

我的代码中一个较小的例子让我很开心,它既可以将固有相关的逻辑放在一行中,又可以消除烦人的“人工”缩进级别

diff = x - x_base
if diff:
    g = gcd(diff, n)
    if g > 1:
        return g

变成了

if (diff := x - x_base) and (g := gcd(diff, n)) > 1:
    return g

那个 if 语句的长度差不多是我希望代码行达到的最长长度,但仍然易于理解。

所以,总而言之,在大多数绑定名称的行中,我不会使用赋值表达式,但因为这种构造非常频繁,所以有很多地方我仍然会使用它。在后者的许多情况中,我发现了一个小小的改进,由于其频繁出现而累积起来,而在其余情况中,我发现了一个中等到主要的改进。我肯定会比三元 if 更频繁地使用它,但比增强赋值的使用频率要低得多。

一个数字示例

我还有另一个当时让我印象深刻的例子。

当所有变量都是正整数,且 a 至少与 x 的 n 次方根一样大时,此算法返回 x 的 n 次方根的底数(并且每次迭代大约将精确位数翻倍)

while a > (d := x // a**(n-1)):
    a = ((n-1)*a + d) // n
return a

这为什么有效并不明显,但在“循环半次”形式中也不比这更明显。如果没有建立在正确的洞察力(“算术平均值 - 几何平均值不等式”)之上,并且不知道关于嵌套 floor 函数行为的一些非平凡事物,很难证明其正确性。也就是说,挑战在于数学,而不在于编码。

如果你知道所有这些,那么赋值表达式形式很容易理解为“当当前猜测太大时,得到一个更小的猜测”,其中“太大?”测试和新猜测共享一个昂贵的子表达式。

在我看来,原始形式更难理解

while True:
    d = x // a**(n-1)
    if a <= d:
        break
    a = ((n-1)*a + d) // n
return a

附录 B:推导式的大致代码转换

本附录试图澄清(但非具体规定)目标出现在推导式或生成器表达式中的规则。对于一些说明性示例,我们展示了包含推导式的原始代码,以及将其替换为等效的生成器函数和一些辅助代码的转换版本。

由于 [x for ...] 等同于 list(x for ...),这些示例都使用列表推导式,不失一般性。而且由于这些示例旨在澄清规则的极端情况,它们并不试图看起来像真实代码。

注意:推导式已经通过合成嵌套生成器函数(如本附录中的那些)来实现。新的部分是添加适当的声明来建立赋值表达式目标的预期作用域(它们解析为与赋值在包含最外层推导式的块中执行时相同的作​​用域)。为了类型推断的目的,这些说明性扩展并不意味着赋值表达式目标始终是可选的(但它们确实指明了目标绑定作用域)。

让我们从回顾不带赋值表达式的生成器表达式生成的代码开始。

  • 原始代码(EXPR 通常引用 VAR)
    def f():
        a = [EXPR for VAR in ITERABLE]
    
  • 翻译(我们不必担心名称冲突)
    def f():
        def genexpr(iterator):
            for VAR in iterator:
                yield EXPR
        a = list(genexpr(iter(ITERABLE)))
    

让我们添加一个简单的赋值表达式。

  • 原始代码
    def f():
        a = [TARGET := EXPR for VAR in ITERABLE]
    
  • 翻译
    def f():
        if False:
            TARGET = None  # Dead code to ensure TARGET is a local variable
        def genexpr(iterator):
            nonlocal TARGET
            for VAR in iterator:
                TARGET = EXPR
                yield TARGET
        a = list(genexpr(iter(ITERABLE)))
    

让我们在 f() 中添加一个 global TARGET 声明。

  • 原始代码
    def f():
        global TARGET
        a = [TARGET := EXPR for VAR in ITERABLE]
    
  • 翻译
    def f():
        global TARGET
        def genexpr(iterator):
            global TARGET
            for VAR in iterator:
                TARGET = EXPR
                yield TARGET
        a = list(genexpr(iter(ITERABLE)))
    

或者,让我们在 f() 中添加一个 nonlocal TARGET 声明。

  • 原始代码
    def g():
        TARGET = ...
        def f():
            nonlocal TARGET
            a = [TARGET := EXPR for VAR in ITERABLE]
    
  • 翻译
    def g():
        TARGET = ...
        def f():
            nonlocal TARGET
            def genexpr(iterator):
                nonlocal TARGET
                for VAR in iterator:
                    TARGET = EXPR
                    yield TARGET
            a = list(genexpr(iter(ITERABLE)))
    

最后,让我们嵌套两个推导式。

  • 原始代码
    def f():
        a = [[TARGET := i for i in range(3)] for j in range(2)]
        # I.e., a = [[0, 1, 2], [0, 1, 2]]
        print(TARGET)  # prints 2
    
  • 翻译
    def f():
        if False:
            TARGET = None
        def outer_genexpr(outer_iterator):
            nonlocal TARGET
            def inner_generator(inner_iterator):
                nonlocal TARGET
                for i in inner_iterator:
                    TARGET = i
                    yield i
            for j in outer_iterator:
                yield list(inner_generator(range(3)))
        a = list(outer_genexpr(range(2)))
        print(TARGET)
    

附录 C:作用域语义不变

因为这是一个容易混淆的点,请注意 Python 的作用域语义没有改变。函数局部作用域继续在编译时解析,并在运行时具有无限时间范围(“完全闭包”)。示例:

a = 42
def f():
    # `a` is local to `f`, but remains unbound
    # until the caller executes this genexp:
    yield ((a := i) for i in range(3))
    yield lambda: a + 100
    print("done")
    try:
        print(f"`a` is bound to {a}")
        assert False
    except UnboundLocalError:
        print("`a` is not yet bound")

然后

>>> results = list(f()) # [genexp, lambda]
done
`a` is not yet bound
# The execution frame for f no longer exists in CPython,
# but f's locals live so long as they can still be referenced.
>>> list(map(type, results))
[<class 'generator'>, <class 'function'>]
>>> list(results[0])
[0, 1, 2]
>>> results[1]()
102
>>> a
42

参考资料


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

最后修改: 2025-02-01 08:55:40 GMT