PEP 572 和海象运算符

摘要

PEP 572 是 Python3 历史上最具争议的提案之一,因为它在表达式中赋值似乎没有必要。此外,开发人员难以区分**海象运算符** (:=) 和等号运算符 (=) 之间的区别。即使经验丰富的开发者能够流畅地使用“:=”,他们也可能担心代码的可读性。为了更好地理解“:=” 的用法,本文讨论了其设计理念以及它试图解决的问题。

引言

对于 C/C++ 开发人员来说,由于错误代码样式处理,将函数返回值赋给变量是很常见的。管理函数错误包括两个步骤:一是检查返回值;二是检查 errno。例如,

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>

int main(int argc, char *argv[]) {
    int rc = -1;

    // assign access return to rc and check its value
    if ((rc = access("hello_walrus", R_OK)) == -1) {
        fprintf(stderr, "%s", strerror(errno));
        goto end;
    }
    rc = 0;
end:
    return rc;
}

在这种情况下,access 首先会将其返回值赋给变量 rc。然后,程序将 rc 的值与 -1 进行比较,以检查 access 的执行是否成功。然而,在 3.8 之前,Python 不允许在表达式中将值赋给变量。因此,为了解决这个问题,PEP 572 为开发人员引入了海象运算符。以下 Python 代码片段等效于之前的 C 代码示例。

>>> import os
>>> from ctypes import *
>>> libc = CDLL("libc.dylib", use_errno=True)
>>> access = libc.access
>>> path = create_string_buffer(b"hello_walrus")
>>> if (rc := access(path, os.R_OK)) == -1:
...     errno = get_errno()
...     print(os.strerror(errno), file=sys.stderr)
...
No such file or directory

为什么使用 := ?

开发人员可能会混淆“:=” 和“=” 之间的区别。事实上,它们的作用相同,都是将某些东西赋给变量。为什么 Python 引入“:=” 而不是使用“=”?使用“:=” 的好处是什么?一个原因是为了加强视觉识别,因为 C/C++ 开发人员常犯一个错误。例如,

int rc = access("hello_walrus", R_OK);

// rc is unintentionally assigned to -1
if (rc = -1) {
    fprintf(stderr, "%s", strerror(errno));
    goto end;
}

变量 rc 被错误地赋值为 -1,而不是进行比较。为了防止此错误,一些人提倡在表达式中使用 Yoda 条件

int rc = access("hello_walrus", R_OK);

// -1 = rc will raise a compile error
if (-1 == rc) {
    fprintf(stderr, "%s", strerror(errno));
    goto end;
}

然而,Yoda 风格的可读性并不够好,就像 Yoda 说非标准英语一样。此外,与 C/C++ 可以通过编译器选项(例如,-Wparentheses)在编译时检测赋值错误不同,Python 解释器很难在运行时区分此类错误。因此,PEP 572 的最终结果是使用新的语法作为实现赋值表达式的解决方案。

海象运算符并不是 PEP 572 的第一个解决方案。最初的提案使用 EXPR as NAME 将值赋给变量。不幸的是,此解决方案和其他解决方案中有一些被否决的原因。经过激烈的讨论,最终决定使用 :=

作用域

与其他表达式将变量绑定到作用域不同,赋值表达式属于当前作用域。此设计的目的是允许以紧凑的方式编写代码。

>>> if not (env := os.environ.get("HOME")):
...     raise KeyError("env HOME does not find!")
...
>>> print(env)
/root

在 PEP 572 中,另一个好处是可以方便地为 any()all() 表达式捕获“见证”。虽然捕获函数输入可以帮助交互式调试器,但优势并不明显,并且示例缺乏可读性。因此,此优点此处不作讨论。请注意,其他语言(例如,C/C++ 或 Go)可能会将赋值绑定到作用域。以 Golang 为例。

package main

import (
    "fmt"
    "os"
)

func main() {
    if env := os.Getenv("HOME"); env == "" {
        panic(fmt.Sprintf("Home does not find"))
    }
    fmt.Print(env) // <--- compile error: undefined: env
}

陷阱

虽然赋值表达式允许编写紧凑的代码,但在开发人员在列表推导中使用它时,存在许多陷阱。一个常见的 SyntaxError 是重新绑定迭代变量。

>>> [i := i+1 for i in range(5)]  # invalid

但是,更新迭代变量会降低可读性并引入错误。即使 Python 3.8 没有实现海象运算符,程序员也应该避免在一个作用域内重复使用迭代变量。

另一个陷阱是 Python 禁止在类作用域下的推导中使用赋值表达式。

>>> class Example:
...     [(j := i) for i in range(5)] # invalid
...

此限制来自 bpo-3692。当类声明包含列表推导时,解释器的行为是不可预测的。为了避免这种情况,赋值表达式在类中无效。

>>> class Foo:
...     a = [1, 2, 3]
...     b = [4, 5, 6]
...     c = [i for i in zip(a, b)]  # b is defined
...
>>> class Bar:
...     a = [1,2,3]
...     b = [4,5,6]
...     c = [x * y for x in a for y in b] # b is undefined
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in Bar
  File "<stdin>", line 4, in <listcomp>
NameError: name 'b' is not defined

结论

海象运算符 (:=) 如此有争议的原因是代码的可读性可能会降低。事实上,在讨论 邮件线程 中,PEP 572 的作者 Christoph Groth 曾经考虑过使用“=” 来实现类似 C/C++ 的内联赋值。不评判“:=” 是否丑陋,许多开发人员认为区分“:=” 和“=” 之间的功能很困难,因为它们的作用相同,但行为不一致。此外,编写紧凑的代码并不能令人信服,因为越小并不总是越好。但是,在某些情况下,海象运算符可以增强可读性(如果您理解如何使用 :=)。例如,

buf = b""
while True:
    data = read(1024)
    if not data:
        break
    buf += data

通过使用 :=,前面的示例可以简化。

buf = b""
while (data := read(1024)):
    buf += data

Python 文档 和 GitHub issue-8122 提供了许多关于通过“:=” 提高代码可读性的优秀示例。但是,使用海象运算符应谨慎。在某些情况下,例如 foo(x := 3, cat='vector'),如果开发人员没有意识到作用域,可能会引入新的错误。虽然 PEP 572 可能会使开发人员编写错误代码的风险增加,但深入理解设计理念和有用的示例将帮助我们在正确的时间使用它来编写可读的代码。

参考文献

  1. PEP 572 - 赋值表达式

  2. Python 3.8 中的新功能

  3. PEP 572 和 Python 中的决策

  4. PEP 572 的最终阶段

  5. 在标准库中使用赋值表达式(合并的 PR)

  6. 列表推导中不正确的范围,在类声明中使用时