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-02-28
Python 版本:
3.8
历史记录:
2018-02-28, 2018-03-02, 2018-03-23, 2018-04-04, 2018-04-17, 2018-04-25, 2018-07-09, 2019-08-05
决议:
Python-Dev 消息

目录

摘要

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

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

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

基本原理

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

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

真实代码的重要性

在开发此 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

此代码尝试匹配 pattern2,即使 pattern1 匹配(在这种情况下,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 ...} 中,当前 YX 之前进行评估。我们建议更改这一点,使 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 发布之前额外的审查,已进行了以下更改。

  • 为了与其他类似的异常保持一致,并避免锁定一个不一定能提高最终用户清晰度的异常名称,最初提出的 TargetScopeError 类,它是 SyntaxError 的子类,被直接引发 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)
    

    执行顺序颠倒(缩进的正文首先执行,然后是“标题”)。这需要一个新的关键字,除非一个现有的关键字被重新使用(最可能是 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 循环或其他结构没有什么不同,并且可以用相同的方式解决:在不再需要名称时将其删除,或者在其前面加上一个下划线。

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

风格指南建议

由于表达式赋值有时可以等效地用于语句赋值,因此会产生应该首选哪一种的问题。为了 PEP 8 等风格指南的利益,建议了两个建议。

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

致谢

作者感谢 Alyssa Coghlan 和 Steven D’Aprano 对该提案做出的巨大贡献,以及 core-mentorship 邮件列表的成员在实现方面的帮助。

附录 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() 函数的例子,该函数位于 copy.py

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

为什么它有效并不明显,但它在 "循环半" 形式中也不更明显。在没有建立正确的见解("算术平均数 - 几何平均数不等式")的情况下,很难证明其正确性,并且需要了解一些关于嵌套地板函数行为的非平凡内容。也就是说,挑战在于数学,而不是编码本身。

如果你了解了这一切,那么赋值表达式形式很容易理解为 "当当前猜测过大时,得到一个更小的猜测",其中 "过大?" 测试和新猜测共享一个昂贵的子表达式。

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

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

最后修改:2023-10-11 12:05:51 GMT