1. 写在前面

Python 的每个新版本都会为语言添加新特性。对于 Python 3.8,最大的变化就是通过:=操作符,在表达式中间赋值变量提供了一种新语法,这个运算符俗称为海象运算符。本文将解释 Walrus Operator的差别、使用案例、将其与现有方法进行比较并权衡利弊。:)

【注意】本文所有 Walrus Operator 示例都需要 Python 3.8 或更高版本才能运行。

公众号:滑翔的纸飞机

2. Walrus Operator 基础知识

运算符 := 的正式名称是赋值表达式运算符(“Assignment Expression Operator”)。在早期的讨论中,它被称为海象运算符,因为 := 语法很像海象的眼睛和獠牙。也可以将 := 运算符称为冒号等号运算符。当然,另一个用于赋值表达式的术语是命名表达式。本文统一通过“海象运算符”来称呼。

2.1 初识海象运算符

初步了解海象运算符的作用,请运行以下代码:

In [1]: walrus = False
In [2]: walrus
Out[2]: False

In [3]: (walrus := True)
Out[3]: True

In [4]: walrus
Out[4]: True

第 1 行: 显示了一个传统的赋值语句,将值 False 赋给了 walrus。 第 3 行: 使用海象运算符将值 True 赋给 walrus。

在上面的“walrus”变量中,我们可以看到两种赋值类型之间有一个微妙但重要的区别。海象运算符返回值,而传统赋值不返回值。在第 1 行的 “walrus = False” 之后,没有打印任何值,而在第 3 行的海象运算符表达式之后,则打印出了 True。

从这个例子中,我们可以看到海象运算符一个重要方面。虽然 := 操作符看起来很新颖,它只是让某些结构更方便,有时还能更清晰地传达代码的意图。

你可能想知道为什么要在第 3 行使用括号,本文稍后会告诉你为什么要使用括号。

现在你对 := 运算符及其功能有了基本的了解。它是赋值表达式中使用的运算符,与传统的赋值语句不同,它可以返回被赋值的值。要深入了解海象运算符,请继续阅读,看看在哪些地方应该使用它,哪些地方不应该使用它。

2.2 背景

与 Python 中的大多数新特性一样,海象运算符是通过 Python 增强提案 (PEP) 引入的。PEP 572 描述了引入海象运算符的动机、语法细节以及 := 运算符可用于改进代码的示例。

海象运算符由 Emily Morehouse 实现,并在 Python 3.8 的第一个 alpha 版本中发布。

2.3 为什么增加海象运算符?

在包括 C 语言及其衍生语言在内的许多语言中,赋值语句都具有表达式的功能。这既可能是非常强大的功能,也可能是令人困惑的错误根源。例如,以下代码是有效的 C 语言,但并不能按预期执行:

int x = 3, y = 8;
if (x = y) {
    printf("x and y are equal (x = %d, y = %d)", x, y);
}

在这里,如果 (x = y) 的值为 true,代码片段将打印出:"x and y are equal (x = 8, y = 8)"。这并不是期望的结果,试图比较 x 和 y,那么 x 的值是如何从 3 变为 8 的?

问题在于你使用的是赋值运算符 (=) 而不是相等比较运算符 (==)。在 C 语言中,x = y 是一个求 y 值的表达式。在本例中,x = y 的值为 8,在 if 语句中被认为是真实的。

请看 Python 中的相应示例。这段代码会引发语法错误:

In [1]: x, y = 3, 8
   ...: if x = y:
   ...:     print(f"x and y are equal ({x = }, {y = })")
  Cell In [1], line 2
    if x = y:
         ^
SyntaxError: invalid syntax

与 C 示例不同,这段 Python 代码给出的是一个明确的错误,而不是一个隐含的BUG。
Python 中赋值语句和赋值表达式的区别对于避免这类难以发现的BUG非常有用。 PEP 572 中认为 Python 应该为赋值语句和表达式使用不同的语法,而不是将现有的赋值语句变成表达式。

支持海象运算符的一个设计原则是,在没有相同的代码上下文中,使用 = 运算符的赋值语句和使用 := 运算符的赋值表达式都是有效的。例如,你不能使用海象运算符进行普通赋值:

In [7]: walrus := True
  Cell In [7], line 1
    walrus := True
                  ^
SyntaxError: invalid syntax

在许多情况下,你可以在海象运算符周围添加括号 () 使其成为有效的 Python 表达式:

>>> (walrus := True)  # Valid, but regular statements are preferred
True

在这些括号内不允许使用 = 来编写传统的赋值语句。这有助于发现潜在的错误。

In [9]: (walrus = True)
  Cell In [9], line 1
    (walrus = True)
            ^
SyntaxError: invalid syntax

本文档稍后将详细介绍不允许使用海象运算符的情况,但首先要了解可能需要使用海象运算符的情况。

3. 海象运算符使用示例

在本节中,你将看到几个使用海象运算符简化代码的示例。在所有这些示例中,一个重要的目的就是避免各种重复:

  • 重复的函数调用会比预期效率更慢;
  • 重复的语句会使代码难以维护;
  • 重复调用迭代器可能会使代码过于复杂;

你将看到海象运算符如何在这些情况下提供帮助。

3.1 列表、字典及推导式

列表是 Python 中强大的数据结构,通常代表一系列相关的属性。同样,字典在 Python 中也被广泛使用,是结构化信息的主要载体。有时,在设置这些数据结构时,会多次执行相同的操作。

  • 示例一:

    作为第一个例子,统计一个数字列表并将其存储在字典中:

    In [1]: numbers = [2, 8, 0, 1, 1, 9, 7, 7]
    
    In [2]: description = {
       ...:    "length": len(numbers),
       ...:    "sum": sum(numbers),
       ...:    "mean": sum(numbers) / len(numbers),
       ...: }
    
    In [3]: description
    Out[3]: {'length': 8, 'sum': 35, 'mean': 4.375}
    

    请注意,数字列表的总和(sum)和长度(len)都要计算两次。在这个简单的示例中,后果并不严重,但如果列表更大或计算更复杂,可能需要优化代码。为此,可以先将函数调用移出字典定义:

    In [4]: numbers = [2, 8, 0, 1, 1, 9, 7, 7]
    
    In [5]: num_length = len(numbers)
    
    In [6]: num_sum = sum(numbers)
    
    In [7]: description = {
       ...:     "length": num_length,
       ...:     "sum": num_sum,
       ...:     "mean": num_sum / num_length,
       ...: }
    
    In [8]: description
    Out[8]: {'length': 8, 'sum': 35, 'mean': 4.375}
    

    变量 num_lengthnum_sum 仅用于优化字典内部的计算。通过使用海象运算符,可以使这一作用更加明确:

    In [9]: numbers = [2, 8, 0, 1, 1, 9, 7, 7]
    
    In [10]: description = {
        ...:     "length": (num_length := len(numbers)),
        ...:     "sum": (num_sum := sum(numbers)),
        ...:     "mean": num_sum / num_length,
        ...: }
    
    In [11]: description
    Out[11]: {'length': 8, 'sum': 35, 'mean': 4.375}
    

    num_lengthnum_sum 现在定义在 description 字典的定义中。这清楚地提示了任何阅读这段代码的人,这些变量只是用来优化这些计算,以后不会再使用。

    【注意】:在使用海象运算符的示例中,num_lengthnum_sum变量的作用域与不使用海象运算符的示例中相同。这意味着在这两个示例中,变量都可以在定义描述后使用。 尽管两个示例在功能上非常相似,但使用:=操作符传达了这些变量作为一次性优化变量的意图。

  • 示例二:

    # 传统方式
    results = []
    for line in lines:
        stripped_line = line.strip()
        if stripped_line:
            results.append(other_fun(stripped_line))
    
    # 海象操作符
    results = [other_fun(stripped_line) for line in lines if (stripped_line := line.strip())]
    

    在传统方式中,必须先去掉前后空格,然后检查其是否为空,才能将其添加到结果列表中。然而,使用海象运算符后,该行将作为列表的一部分,去掉前后空格并检查是否为空,从而无需额外的 if 语句。这使得代码更加简洁易读,同时也减少了重复变量赋值的需要。

  • 示例三:将使用 wc.py 来计算文本文件中的行数、字数和字符数:

    # wc.py
    import pathlib
    import sys
    
    # sys.argv 是一个包含命令行参数的列表
    for filename in sys.argv[1:]:
        # 将每个文件名字符串转换为 pathlib.Path 对象。将文件名存储在 Path 对象中,可以方便地读取下一行的文本文件。
        path = pathlib.Path(filename)
        # 构造一个计数元组,表示一个文本文件中的行数、字数和字符数。
        counts = (
            # 读取文本文件,通过计算换行来计算行数
            path.read_text().count("\n"),
            # 读取文本文件,通过分割空白来计算字数
            len(path.read_text().split()),
            # 读取文本文件,通过计算字符串的长度来计算字符数
            len(path.read_text()),
        )
        # 将所有三个计数和文件名一起打印到控制台,`*counts` 语法会解包计数元组,等同于 print(counts[0],counts[1],counts[2],path)
        print(*counts, path)
    

    该脚本可以读取一个或多个文本文件,并报告每个文件包含多少行、字和字符。下面是代码的详细说明:

    运行wc.py检查自身,如下所示:

    $ python wc.py wc.py
    13 34 316 wc.py
    

    换句话说,wc.py 文件共有 13 行、34 个单词和 316 个字符。

    分析代码,会发现并非最佳实现,尤其对 path.read_text() 的调用要重复三次。这意味着每个文本文件都要读取三次。这里就可以使用海象运算符来避免重复:

    # wc.py
    
    import pathlib
    import sys
    
    for filename in sys.argv[1:]:
        path = pathlib.Path(filename)
        counts = [
            (text := path.read_text()).count("\n"),  # Number of lines
            len(text.split()),  # Number of words
            len(text),  # Number of characters
        ]
        print(*counts, path)
    

    文件内容被分配给text变量,并在接下来的两次计算中重复使用。程序的功能仍然相同:

    $ python wc.py wc.py
    13 36 302 wc.py
    

    当然除海象运算符外,另一种常规方法是在定义计数之前先定义文本:

    # wc.py
    
    import pathlib
    import sys
    
    for filename in sys.argv[1:]:
        path = pathlib.Path(filename)
        text = path.read_text()
        counts = [
            text.count("\n"), 
            len(text.split()),
            len(text),
        ]
        print(*counts, path)
    

    虽然该方式在代码量上多于采用海象运算符的方式,但它可能在可读性和效率之间取得了最佳平衡。因此即使 := 操作符能使代码更简洁,但它并不总是最易读的解决方案。

  • 示例四:列表推导式

    列表推导式对于构建和过滤列表非常有用。它们清楚地表达了代码的意图,通常运行速度相当快。

    在下面列表推导式用例中,海象运算符特别有用。比方说,想对列表中的元素应用某个计算“昂贵”的函数 slow(),并对结果值进行过滤。可以这样做

    numbers = [7, 6, 1, 4, 1, 8, 0, 6]
    
    results = [slow(num) for num in numbers if slow(num) > 0]
    

    此例中,不仅需要过滤数字列表,并保留应用 slow() 后的结果。这段代码的问题在于,这个“昂贵”的函数被调用了两次。

    应对这种情况,一种非常常见的解决方案是重写代码,使用显式 for 循环:

    results = []
    for num in numbers:
        value = slow(num)
        if value > 0:
            results.append(value)
    

    这样只会调用 slow() 函数一次。遗憾的是,现在的代码更加冗长,代码的意图也更难理解。列表推导式清楚地表明我们正在创建一个新列表,而在显式 for 循环中,这一点更加隐蔽,因为创建列表和使用 .append()之间隔着好几行代码。另外,列表推导式比重复调用 .append() 运行得更快。

    当然我们可以使用 filter() 表达式或双列表推导式来编写其他解决方案:

    # filter()
    results = filter(lambda value: value > 0, (slow(num) for num in numbers))
    
    # 双列表推导式
    results = [value for num in numbers for value in [slow(num)] if value > 0]
    

    上述两种方式都只需调用一次 slow(),但这两种表达式都降低了代码的可读性。

    现在我们可以使用海象运算符重写列表推导式,如下所示:

    results = [value for num in numbers if (value := slow(num)) > 0]
    

    【注意】,value := slow(num) 括号是必需的。

    可以明显看到采用海象运算符后代码有效、可读性强,并很好地传达了代码的意图。

    该示例中,重点举例说明如何使用海象运算符重写列表推导式。同样的原则也适用于字典推导式、集合推导式或生成器表达式中重复操作的情况。

3.2 While 循环

Python 有两种不同的循环结构:for 循环和 while 循环。当需要遍历已知的元素序列时,通常会使用 for 循环。而 while 循环则用于事先不知道需要循环多少次的情况。

看个简单例子:

# 常规方式1:
data = get_data()
while data:
   other_fun(data)
   data = get_data()

# 常规方式2:
while True:
    data = get_data()
    if data is None:
        break
   other_fun(data)

# 海象运算符
while (data := get_data()) is not None:
    other_fun(data)

使用常规的 while 循环条件方法,必须使用重复或者冗余代码来验证数据是否为None。然而,通过使用海象运算符,代码变得更加简洁和优雅。循环条件的验证是赋值的一部分,因此无需额外的 if 语句。

4. 避免陷阱

在 Python 中,赋值操作符 (=) 和相等比较操作符 (==) 在视觉上的相似性可能会导致 bug。在引入海象运算符时,为了避免类似bug,一个重要的特点是 := 运算符绝不允许直接替换 = 运算符,反之亦然。

正如本文开头所述,海象运算符不能使用普通赋值表达式赋值:

>>> walrus := True
  File "<stdin>", line 1
    walrus := True
           ^
SyntaxError: invalid syntax

当然,使用赋值表达式只赋值在语法上是合法的,但同时必须加上括号:

>>> (walrus := True)
True

虽然支持这样做,但这不是好的处理方式,在这里使用普通赋值表达式赋值更优,代码语意更清楚。

PEP 572 还显示了其他几个例子,在这些例子中,:= 操作符要么是非法的,要么是不鼓励使用的。以下示例都会引发语法错误:

>>> lat = lon := 0
SyntaxError: invalid syntax

>>> angle(phi = lat := 59.9)
SyntaxError: invalid syntax

>>> def distance(phi = lat := 0, lam = lon := 0):
SyntaxError: invalid syntax

这些情况下,最好使用 = 代替。接下来的示例与此类似,都是合法代码。但是,在这些情况下,海象运算符并不能改善你的代码:

>>> lat = (lon := 0)  

>>> angle(phi = (lat := 59.9))  

>>> def distance(phi = (lat := 0), lam = (lon := 0)):  
...     pass
...

上面示例都无法提高代码的可读性。应该使用传统的赋值语句赋值。请参阅 PEP 572

其他情况:

  • 在 f-strings 中,冒号 : 用于分隔数值和格式规范。例如

    >>> x = 3
    >>> f"{x:=8}"
    '       3'
    

    在这种情况下,:= 看起来确实像海象运算符,但效果却截然不同。为了解释 f-strings 中的 x:=8,表达式被分成三个部分:x,:,和 =8

    这里 x 是值,: 是分隔符,=8 是格式规范。根据 Python 的格式规范,在这种情况下,= 表示对齐选项。上例值会在宽度为 8 的字段中填充空格。

    要在 f-strings 内使用海象运算符,需要添加括号:

    >>> x = 3
    >>> f"{(x := 8)}"
    '8'
    
    >>> x
    8
    

    不过最好还是在f-strings内使用普通赋值表达式。

让我们看看赋值表达式不合法的其他一些情况:

  • 只能为简单名称赋值,不能为带点名称或索引名称赋值:

    >>> (mapping["hearts"] := "love")
    SyntaxError: cannot use assignment expressions with subscript
    
    >>> (number.answer := 42)
    SyntaxError: cannot use assignment expressions with attribute
    
  • 使用海象运算符时不能解包:

    >>> lat, lon := 59.9, 10.8
    SyntaxError: invalid syntax
    

    如果在整个表达式最外层加上括号(lat, lon := 59.9, 10.8),将被解释为包含三个元素 "lat、59.9 和 10.8"的元组。

  • 不能将海象运算符与增强赋值运算符(如 +=)结合使用,会语法错误:

    >>> count +:= 1
    SyntaxError: invalid syntax
    

    最简单的解决方法,例如:可以执行 (count := count + 1)。

  • 作用域与传统赋值语句类似;

  • 括号使if语句更清晰:

    例如:

    >>> number = 3
    >>> if square := number ** 2 > 5:
    ...     print(square)
    ...
    True
    

    square 得到的值是 True(number ** 2 > 5),而不是 number ** 2 的值。这种情况下,可以用括号来限定表达式:

    >>> number = 3
    >>> if (square := number ** 2) > 5:
    ...     print(square)
    ...
    9
    
  • 使用海象运算符赋值元组时,必须在元组周围使用括号:

    >>> walrus = 3.7, False
    >>> walrus
    (3.7, False)
    
    >>> (walrus := 3.8, True)
    (3.8, True)
    >>> walrus
    3.8
    
    >>> (walrus := (3.8, True))
    (3.8, True)
    >>> walrus
    (3.8, True)
    

5. 最后

在 Python 中,海象运算符 (:=) 是一个很有用的工具,它可以提高代码的简洁性和表达能力。但是,在使用它之前,必须考虑项目要求和目标 Python 版本的兼容性限制。通过使用海象运算符,可以创建高效、优雅的代码,但同时需要避免滥用导致代码可读性降低。

希望继续分享优秀的内容,不要失去希望。保持乐观,永不放弃。

感谢您花时间阅读文章,关注公众号(滑翔的纸飞机)获取更多 :)