PEP572:赋值表达式(海象符)

PEP572

阅读 PEP 是理解 Python 特性的绝好方式。Python 3.8 引入了赋值表达式,它是什么?怎么用?有什么限制?话不多说,直接看 PEP。


一、简介

本提案建议在 Python 中增加 := 运算符,使我们可以在表达式中直接赋值给变量。

增加这个运算符后,字典推导式的计算顺序也将作出调整,从而确保 的计算先于 的计算(因为 的值可能会被绑定在一个变量名称上,用于 的计算)。

在本提案的讨论过程中,:= 被非正式地称为“海象符”("the walrus operator")。带这种运算符的表达式,正式名称是“赋值表达式”("Assignment Expressions",即本提议的标题),有时也被称为“命名表达式”("Named Expressions",例如 CPython 实现中即以此作为内部名称)。


二、必要性说明

命名某个表达式的结果是编程中的重要一环,使我们只需记住一个简单的名称,而不是一长串的表达式,并且也容易复用。目前,Python 只能在赋值声明中进行命名,因而在列表推导式或一些其它场景下,就无法进行命名。

另外,在交互式 debug 过程中,命名某个大型表达式的一部分可以帮助我们做一些深入的检查。如果无法获取表达式的局部结果,就往往需要在调试过程中重构代码;通过赋值表达式,这些重构将被几个简单的 := 替代。

由于不再需要重构代码,我们在调试过程中不经意地改变代码逻辑的几率也降低了(调试过程中的重构,是导致 海森堡Bug <Heisenbugs> 的常见原因),同时让我们更容易向别的程序员解释程序逻辑。(译注:所谓 Heisenbugs,就是当我们调试的时候,这个 bug 会莫名其妙地消失,命名取自 维尔纳·海森堡 提出的量子力学观察者效应:观察系统的行为将不可避免地将改变其状态。)


2.1 使用真实代码进行讨论的重要性

在本提案的讨论过程中,许多人(不管是支持者还是反对者)都有一种使用过度简化,或者过度复杂的例子的倾向。

使用过度简化的例子时,往往让人感觉是在吹毛求疵,或者可以直接反驳“我反正是绝不会写出这样的代码来的”。而使用过度复杂的例子时,也容易让人感觉含混不清。

当然,这两种例子依然是有意义的:它们可以帮助我们澄清一些语义学的上的概念。因此,我们还是会用到一些这样的示例。

不论如何,讨论中使用的例子,最好还是来自真实的代码。也就是说,来自大大小小的真实应用,并且在写这些代码时,还没有考虑到本提案的存在。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

在上面的代码中,match2 在 match1 已经 match 的时候依然会 match,实际上是没有必要的,更高效的写法应该是:

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 := exprexpr 是一个有效的 Python 表达式,NAME 是一个标识符。

命名表达式的值与对应表达式是一样的,只是可以同时赋值给某个变量:

# 正则匹配
if (match := pattern.search(data)) is not None:
    # Do something with match

# 迭代器循环
while chunk := file.read(8192):
   process(chunk)

# 重用一个计算复杂的变量
[y := f(x), y**2, y**3]

# 重用推导式过滤器中的计算结果
filtered_data = [y for x in data if (y := f(x)) is not None]

3.1 例外情况

赋值表达式不能用于一些特定场景,主要是为了避免语义混淆:

  • 不能用于直接的赋值声明,除非用括号括起来。例如:
y := f(x)  # 错误
(y := f(x))  # 正确,但不推荐

这个设定主要是帮助大家区别 赋值声明赋值表达式 ——任何情况下,它们中最多只有一个符合语法规范。

  • 不能用于直接的赋值声明的右侧,除非用括号括起来。例如:
y0 = y1 := f(x)  # 错误
y0 = (y1 := f(x))  # 正确,但不鼓励

理由同上。

  • 不能用于调用函数时的关键字参数,除非用括号括起来。例如:
foo(x = y := f(x))  # 错误
foo(x=(y := f(x)))  # 正确,但很奇怪

这个设定主要是为了避免一些容易引起混淆的代码,并且获取函数参数的过程本身已经很复杂了。

  • 不能用于函数参数的默认值,除非用括号括起来。例如:
def foo(answer = p := 42):  # 错误
    ...
def foo(answer=(p := 42)):  # 正确,但有点丑陋
    ...

函数参数的具体语法对很多用户来说已经很难理解了(例如,可变对象作为参数默认值等),因此,避免赋值表达式再来添乱,并且也与前一个设定相呼应。

  • 不能用于函数参数的类型注解,除非用括号括起来:
def foo(answer: p := 42 = 5):  # 错误
    ...
def foo(answer: (p := 42) = 5):  # 正确,但可能没人会这么写
    ...

理由与前面两点的理由相似,各种各样的 := 堆在一起,影响代码可读性。

  • 不能用于匿名函数,除非用括号括起来。例如:
(lambda: x := 1) # 错误
lambda: (x := 1) # 正确,但好像没什么用
(x := lambda: 1) # 正确
lambda line: (m := re.match(pattern, line)) and m.group(1) # 正确

在匿名函数的最外层命名一个变量没有意义,因为无法使用这个变量。为了复用这个变量,总是要加一个括号的,因此,这个设定应该不会影响到大家的代码。

  • 在 f-strings 格式化中使用赋值表达式时,必须使用括号。例如:
>>> f'{(x:=10)}'  # 正确,使用了赋值表达式
'10'
>>> x = 10
>>> f'{x:=10}'    # 正确,正常使用格式化定义,将 '=10' 作为格式化参数
'        10'

这也意味着,在 f-string 中,带 := 不一定就是赋值表达式。f-string 使用 : 传递格式化参数,为了向后兼容,这里的赋值表达式必须使用括号括起来。当然,这种用法并不推荐。


3.2 作用域

赋值表达式并不会引入新的作用域。大多数情况下,它所在的作用域是很明确的:就是当前作用域,如果这个作用域中使用了 nolocalglobal 变量,赋值表达式也可以使用。而一个匿名函数(虽然是匿名的,但也是一个函数)本身也会引入一个作用域。

但有一种特殊情况,列表、集合、字典推导式与生成器表达式(一下统一称为推导式)中的赋值表达式,作用域为这些推导式所在的作用域,并且可以使用原作用域中的 nolocal 或 global 变量。为了更好地支持这一规则,递归推导式中的赋值表达式,作用域在最外层推导式所在的作用域。当然,如果最外层推导式是在一个匿名函数中的话,赋值表达式的作用域就是这个匿名函数自身的作用域。

这样设计有两个目的,一是使我们能方便地调用 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)

二是使我们能很容易地计算推导式中的累计状态,例如:

# 计算列表推导式中的累计和
total = 0
partial_sums = [total := total + v for v in values]
print("Total:", total)

当然,赋值表达式中的标识符名称不能与推导式所用的变量名称相同。因为推导式本身所用的变量,作用域只在推导式中,而命名表达式中的标识符,作用域在最外层推导式所在的作用域中,两者相同必然会产生冲突。

例如,[i := i+1 for i in range(5)] 是错误的,推导过程中所用的变量名 i 作用域在推导式中,而 i := 部分的 i 的作用域并不局限于这个推导式。同样,以下这些示例也都是错误的:

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

就以上示例来说,技术上,我们也可以为它们设计一个统一的语法规则,但很难说这种规则在实践中有什么用处。因此,内核实现中,遇到这些场景,会直接抛出 SyntaxError

这个限制即使在赋值表达式并不会被执行时也是生效的:

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

对于推导式中的推导部分(第一个 for 之前的部分)或过滤器部分( if 之后,任意嵌套的 for 之前的部分),不能重名的限制只针对推导式中的迭代变量。如果在这些地方有匿名函数,则由于匿名函数引入了新的作用域,因此依然可以无限制地使用赋值表达式。

由于内核实现上的设计限制(符号表分析器 symbol table analyser 很难判断推导式最左侧的迭代部分是否与其它部分重用名称 ),推导式的迭代部分完全禁用命名表达式( in 之后,并在可能的 iffor 之前的部分):

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

另外一个特例就是,如果推导式在一个类作用域中,并且其中的赋值表达式的赋值结果也在这个类作用域中,也会抛出 SyntaxError

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

(这个特例是由推导式所创建的隐式函数作用域导致的——目前还没有让函数直接调用该函数所在的类作用域中的变量的运行时机制,并且我们也无意于增加这种机制。如果之后这个问题解决了,针对赋值表达式的这个限制也可能会取消。请注意,在推导式中无法使用其所在的类作用域中所定义的变量,是一个已经存在的问题。)

(译注:这个问题有历史原因,与生成器表达式的设计有关,想要理解具体是什么问题可以参考 stackover上的回答,想要理解这样设计的原因,可以参考 PEP289,之后有机会的话,也会翻译推荐给大家。)

参考附录 B ,可以看到一些将推导式转换为等效代码,从而绕过命名冲突的例子。


3.3 := 运算符的优先级

:= 的优先级高于逗号,低于其它所有操作符,包括 orand,以及条件表达式(A if C else B)。如前文所说,:= 永远不会与 = 比较优先级(除非通过括号分隔开了)。

:= 可直接用于函数的位置参数,但不能用于关键字参数。

以下例子或许有助于我们理解这些规则:

# 错误
x := 0

# 替代写法
(x := 0)

# 错误
x = y := 0

# 替代写法
x = (y := 0)

# 正确
len(lines := f.readlines())

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

# 错误
foo(cat=category := 'vector')

# 替代写法
foo(cat=(category := 'vector'))

以上大多数所谓“正确”的写法都是不推荐的写法,因为阅读代码的人往往一扫而过,可能容易看混。但在一些简单场景中还是可以使用的:

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

本提案推荐大家在 := 两侧分别留一个空格,正如 PEP8 对 = 作为赋值符号时的建议一样。当然,在指定关键字参数时,= 的两侧不用留空格 : )


3.4 计算顺序的调整

为确保语法定义精确,计算顺序也需要被精确定义。技术上说,计算顺序不是一个新问题,因为函数调用过程可能本身就要有一些控制。Python 已有的规则是,子表达式会逐步从左往右计算。赋值表达式使我们在函数调用过程中进行控制的需要更明确了,因此,我们对当前计算顺序做了一个调整:

在字典推导式 {X: Y for ...} 中,按原来的规则,Y 是先于 X 计算的,我们建议让 X 的计算先于 Y。(其实,在形如 {X: Y}dict((X, Y) for ...) 的字典创建过程中,X 的计算就是先于 Y 的,我们只是把同样的规则也推广到字典推导式中。)


3.5 赋值表达式与赋值声明的区别

最重要的区别是,:= 是一个表达式,因此可以被用于很多赋值声明不能使用的场景,包括匿名函数与推导式。

反过来说,赋值表达式也不能支持一些赋值声明的特性:

  • 不直接支持多个对象赋值:
x = y = z = 0  # 等效代码: (z := (y := (x := 0)))
  • 不支持非名称的赋值对象:
# 无对应的等效代码
a[i] = x
self.rest = []
  • 对逗号的运算优先级不同:
x = 1, 2  # x 为 (1, 2)
(x := 1, 2)  # x 为 1
  • 不支持迭代器拆包(包括常规形式与扩展形式):
# 等效代码需要加括号
loc = x, y  # 等效代码 (loc := (x, y))
info = name, phone, *rest  # 等效代码 (info := (name, phone, *rest))

# 无等效代码
px, py, pz = position
name, phone, email, *other_info = contact
  • 不支持行内类型注释:Inline type annotations are not supported:
# 最接近的等效代码是单独声明 "p: Optional[int]" 然后赋值
p: Optional[int] = None
  • 不支持增量赋值:
total += tax  # 等效代码 (total := total + tax)

四、使用示例


4.1 标准库中的使用示例

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.readling(),在 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

4.2 简化列表推导式

通过获取过滤器计算结果,可以更高效地进行列表推导:

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 为同一个作用域)。


4.3 获取条件计算结果Capturing condition values

赋值表达式可用于获取 if 或 while 语句中的条件计算结果:

# 循环交互
while (command := input("> ")) != "quit":
    print("You entered:", command)

# 获取正则表达式的 match 结果
# 可以查看 Lib/pydoc.py 中的更多示例
if match := re.search(pat, text):
    print("Found:", match.group(0))
# 把 match 赋值放在 elif 语句中,避免了多层缩进
elif match := re.search(otherpat, text):
    print("Alternate found:", match.group(0))
elif match := re.search(third, text):
    print("Fallback found:", match.group(0))

# 读取 socket 数据,直到遇到空字符串:
while data := sock.recv(8192):
    print("Received data:", data)

在 while 循环中,赋值表达式往往可以避免无限循环的引入。用户可以直接调用函数作为循环条件,并在之后的循环体中使用函数调用的结果。


4.4 Fork

一个来自 UNIX 底层的示例:

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

五、代码风格建议

有些地方可以等效地使用赋值表达式与赋值声明,那么,应该优先使用哪一种呢?我们有以下两条建议:

  1. 如果可以,优先使用赋值声明,它可以更清楚地表明意图。
  2. 如果使用赋值表达式可能导致计算顺序不明确,应重构为使用赋值声明的代码。

(译注:本提案还有 3 个附录,本文已经较长,之后再翻译推荐给大家,请多多见谅!)


欢迎关注我的公众号:ReadingPython


发表评论

评论列表,共 0 条评论