原文:towardsdatascience.com/python-tuple-named-tuples-edaee4d1b191

PYTHON 编程

https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/5cfae5b21a54133c3973fb060319c608.png

命名元组结合了名称和元组的优势。图片由Ainur ImanUnsplash上提供

最受欢迎的三个 Python 数据类型是列表、字典和元组。列表和字典是可变的,这意味着它们在创建后可以更改其元素。另一方面,元组是不可变的,因此创建后不能更改。如果你确实需要修改元组的内容,你必须创建一个新的实例,包含所需更改,并将其分配给相同的变量。

本文重点介绍 Python 命名元组,这是一种特殊的元组类型,它结合了常规元组的强大功能和命名字段的额外灵活性。与常规元组相比,命名元组可以使代码更简单、更易读、更易于维护——甚至更符合 Python 风格。然而,你必须小心,因为有时过度使用命名元组可能会无意中降低代码的可读性,而不是提高它。

继续阅读以了解更多信息!


要理解命名元组,你必须首先理解常规 Python 元组。如果你不熟悉它们,我强烈建议你首先阅读以下两篇文章关于这种数据类型的内容:

Python 元组,全部真相,仅此而已:你好,元组!

Python 元组,全部真相,仅此而已:让我们深入挖掘

命名元组的奇妙之处在于它们像常规元组一样工作:对常规元组有效的一切对命名元组也有效。但不仅如此,命名元组还提供了额外的功能——因此得名“元组+”。因此,我将假设你已经熟悉上述两篇文章中涵盖的关键概念,我们将专注于命名元组的优势。

首先要记住的是,所有元组都是不可变的。当你开始向命名元组定义添加方法时,可能会很容易忘记这个关键特性。虽然这是可能的,但永远不要忘记,命名元组仍然是一个元组——一个不可变的数据容器。

永远不要忘记,命名元组仍然是一个元组——一个不可变的数据容器。

不,不是每次你使用元组时都应该使用命名元组。虽然前面的段落可能暗示了相反的情况,但事实是,在某些场景下使用命名元组不仅不能增强代码,反而可能降低其简洁性和可读性。

命名元组是什么

“命名元组”这个术语巧妙地封装了这个数据结构的本质:一个带有命名元素的元组,或者更正式地说,一个带有命名字段的元组——或者命名属性。

对于中等大小和长元组,为每个元素命名可能看起来没有意义。为具有 100 个字段的元组的第二十八或第五十五个元素命名有什么好处?

然而,对于小元组,情况就不同了。当一个元组有两个或三个元素时,为每个元素命名是有意义的。在 Python 中使用这种小元组最常见的情况可能是函数返回,如下所示:

def foo(*args, **kwargs) -> tuple[int, str]
    n: int
    st: str
    ...
    return n, st

由于这个函数返回一个元组,你可以以下列方式捕获输出:

output = foo() 
output[0] # an integer
output[1] # a string

或者你可以使用元组解包:

n, st = foo()
# n is an integer
# st is a string

现在,想象一下foo()返回一个带有命名元素的元组。你可以这样访问它们:

output.n
output.st

在 Python 中,使用命名属性并不是一个新概念——事实上,它是使 Python 成为如此易读的编程语言的因素之一。以下是一些比较示例:

https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/e98b5d33f7584d8a4594d3104caf57ec.png

命名和常规元组的创建和访问的可读性。图片由作者提供

从左到右:

  • 带有完整规范的命名元组,这意味着它们带有名称。你可以使用名称来访问每个字段,但也可以使用索引,就像常规元组的情况一样(未显示)。

  • 你可以生成一个命名元组的实例,而不使用属性名称。

  • 一个常规元组。你不能使用Point名称,它本身提供了一些关于元组内容的信息。你可以使用索引访问元素。

  • 在第三面板中,元组是在没有括号的情况下构建的。这是一个个人偏好的问题——我更喜欢省略括号,如第四面板所示。虽然有时括号可能会有所帮助,但我不认为在这个特定的例子中它们是必要的;它们只增加了视觉上的杂乱,没有其他作用。

上述截图旨在说明如何使用常规和命名元组,但我们还没有深入探讨如何生成命名元组或它们提供的附加功能——是的,它们提供的不仅仅是字段名称。我们将在本文中涵盖所有这些内容。

命名元组的两种风味

自从 Python 3.6 以来,我们有两种命名元组的风格:

  • collections.namedtuple:一个传统的、无类型的命名元组。

  • typing.NamedTuplecollections.namedtuple的有类型版本,于 Python 3.6 引入,增加了类型提示。

尽管我上面说过——后者是前者的类型化版本——与未类型化的对应物相比,类型化命名元组提供了更多类型之外的东西,尽管代价也更大。本文的一个目标就是揭示这些区别,并展示何时更适合使用collections命名元组,何时使用typing命名元组。

在接下来的内容中,我们将通过几个示例来讨论这两种类型的命名元组,一些比较简单,一些则稍微复杂一些。两个主要示例将使用大学环境。第一个元组将存储有关学生的信息,有三个字段:

  • name: 一个字符串;例如,"John Smith"

  • year: 一个表示学习年份的整数;例如,2

  • gpa: 一个表示平均绩点的浮点数;例如,3.43

第二个元组在本质上将与第一个相似,但将包含更多关于大学员工的信息,具有以下属性:

  • name: 一个字符串;例如,"John Smith"

  • dept: 一个表示院系的两个字符串的元组;例如,("计算机科学", "数据科学")

  • position: 一个表示员工职位的字符串;例如,"助理教授"

  • started: 一个表示员工合同开始日期的datetime对象;例如,2020–08–01

我们将从传统的命名元组类型开始,即collections.namedtuple。然后我们将转向使用前者的typing.NamedTuple

collections.namedtuple

定义

与其typing对应物相比,collections.namedtuple的最大优势是其定义的简单性:

from collections import namedtuple

Student = namedtuple("Student", "name year gpa")

你可以用以下方式达到相同的效果:

Student = namedtuple("Student", ["name", "year", "gpa"])
Student = namedtuple("Student", "name, year, gpa")

在前面的例子中,我们使用了列表,但它可以是任何序列,例如列表、元组,甚至是生成器。请记住,使用有效的 Python 标识符作为属性名,这是 Python 命名的典型规则;然而,有一个不寻常的例外——名称不能以下划线开头。

创建一个实例很简单:

student1 = Student(name="John Smith", year=2, gpa=3.32)
# or shorter, without names
student1 = Student("John Smith", 2, 3.32)

虽然最终使用字符串来定义字段名与使用字符串的可迭代对象达到相同的目的,但底层过程可能略有不同,可能导致不同的性能。因此,让我们使用以下代码(参见这篇文章了解如何使用timeit进行基准测试)来基准测试它们:

from timeit import repeat

setup = "from collections import namedtuple"

n = 1_000_000

code1 = 'namedtuple("Student", "name year gpa")'
code2 = 'namedtuple("Student", ["name", "year", "gpa"])'

t1 = repeat(code1, setup=setup, number=n)
t2 = repeat(code2, setup=setup, number=n)

print(
    f"{code1}: {round(min(t1), 4)}", "n",
    f"{code2}: {round(min(t2), 4)}"
)

我得到了以下结果:

namedtuple("Student", "name year gpa"): 66.2871
namedtuple("Student", ["name", "year", "gpa"]): 69.3802

这些是使用两种方式创建一百万个命名元组所需的时间(以秒为单位)。正如你所见,使用单个字符串创建命名元组比使用字符串列表稍微快一点。然而,差异很小,两种方法都几乎同样易于阅读。我个人更喜欢单字符串方法的可读性。

让我们创建一个员工命名元组:

Employee = namedtuple("Employee", "name dept position start")

这个元组稍微复杂一些,但还没有完全反映这种复杂性。这种复杂性源于dept本身也是一个元组,这是我们还没有遇到但将在创建实例时遇到的情况。为了解决这个问题,让我们首先定义一个名为Dept的元组:

Dept = namedtuple("Dept", "faculty department")

现在,我们已经准备好创建实例:

employee1 = Employee(
    "John Smart",
    Dept("Agriculture", "Agronomy"),
    "assistant professor",
    datetime(year=2020,month=9,day=1)
)

让我们看看它的表示¹:

>>> employee1 # doctest: +NORMALIZE_WHITE_SPACE
Employee(
    name='John Smart',
    dept=Dept(faculty='Agriculture', department='Agronomy'),
    position='assistant professor',
    start=datetime.datetime(2020, 9, 1, 0, 0)
)

参数

下面,我们将讨论在创建collections.namedtuple时可以使用的参数。其中两个是必需的,而其他的都是可选的——并且使用频率更低。

typenamefield_names

这两个是必需的参数。我们在创建上面的collections.namedtuple类型时已经使用了它们。让我们回到Dept的定义。我们也可以使用关键字参数来做这件事:

Dept = namedtuple(
    typename="Dept",
    field_names="faculty department"
)

我们已经涵盖了field_names参数。typename参数非常有趣。对于第一次使用命名元组的人来说,一个自然的问题是要问,我能否为类型和typename使用不同的标识符?

是的,你可以:

>>> from collections import namedtuple
>>> XY = namedtuple("Point", "x y")
>>> XY
<class '__main__.Point'>

这可以工作,但:

>>> Point
Traceback (most recent call last):
  ...
NameError: name 'Point' is not defined. Did you mean: 'print'?
>>> XY.__name__
'Point'

你很少会遇到变量名与不同的typename值不同的情况,至少在基本程序中是这样。然而,在更复杂的代码中,你可能会发现自己经常使用它。这可能是在元编程中发生的情况。

然而,使用collections.namedtuple创建命名元组的典型方法是将类型和类的名称设置为相同,所以:

>>> Point = namedtuple("Point", "x y")

这是一个首选的方法,因为它使代码更简单,避免了使用与类型名不同的类名可能引起的潜在混淆,就像我们在这一行所做的那样:

>>> XY = namedtuple("Point", "x y")

我们已经完成了必需的参数,即typenamefield_names;其他的都是可选的。尽管使用频率不高,但它们增加了collections.namedtuple的可用性。

defaults

我们从最有用的可选参数开始。它的工作方式与函数和方法定义中默认参数值的工作方式相似,即你为选定的字段名提供默认值。这两种方式的不同之处在于,为了提供命名元组字段的默认值,你不需要一起提供它们。所以,而不是x=10, y=20,你会这样做,例如,"x y", defaults=[10, 20]["x", y"], defaults=[10, 20]

然而,这个方法还有更多内容。defaults的默认值是None,这意味着默认情况下,字段没有默认值。如果你想使用它,你需要提供一个序列给它。

如果你只想为选定的字段提供默认名称,那会怎样呢?与函数定义中的默认参数值不同,这些默认参数值是按位置分配的,而defaults参数采用了一种“反位置”方法。(我发明了这个术语来反映默认值与相应字段结合的方式。)这意味着最后一个默认值被分配给最后一个字段,倒数第二个默认值被分配给倒数第二个字段,依此类推。

这种反位置方法允许选择性地分配默认值。并非所有字段都需要默认值,并且可以在命名元组定义中相应地安排字段的顺序。通过将具有默认值的字段放在右边,没有默认值的字段放在左边,你可以有效地利用反位置方法。

以下示例应解释defaults可迭代对象作为collections.namedtuple参数的工作方式:

>>> Graph = namedtuple(
...     "Graph",
...     "format width height",
...     defaults=["png", 400, 400]
... )
>>> Graph()
Graph(format='png', width=400, height=400)
>>> Graph("tiff")
Graph(format='tiff', width=400, height=400)
>>> Graph("tiff", height=600)
Graph(format='tiff', width=400, height=600)
>>> Graph(width=600)
Graph(format='png', width=600, height=400)

这里,我们有三个字段,每个字段都有一个默认值:

  • format,默认值为"png"

  • width,默认值为400

  • height,默认值为400

现在想象一下,我们还想提供一个图表的标题,但没有默认值。在这种情况下,我们必须将caption作为具有字段名称的迭代器中的第一个字段:

https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/7ad1d51c4f5207542eac94e42c3d3884.png

在命名元组中指定默认值的反位置方法。caption没有默认值,格式为png,宽度=400,高度=400。图片由作者提供

这样,caption没有获取到其默认值,但我们仍然可以使用位置默认值创建一个新的实例:

>>> Graph = namedtuple(
...     "Graph",
...     "caption format width height",
...     defaults=["png", 400, 400]
... )
>>> Graph()
Traceback (most recent call last):
  ...
TypeError: Graph.__new__() missing 1 required positional argument: 'caption'
>>> Graph("Scatterplot")
Graph(caption='Scatterplot', format='png', width=400, height=400)
>>> Graph("Scatterplot", "tiff", height=600)
Graph(caption='Scatterplot', format='tiff', width=400, height=600)
>>> Graph(width=600, caption="Scatterplot")
Graph(caption='Scatterplot', format='png', width=600, height=400)

记住,你不应该使用可变对象作为默认值。一方面,这是因为命名元组旨在是不可变的——尽管你可以将可变对象用作元组字段,你甚至可以更改它们的值。然而,使用可变对象作为默认值可能会导致意外的行为。我们将在另一天讨论这个问题,因为它是一个更普遍的规则,也适用于函数定义——但永远不要忘记这个关键规则。

rename

一个布尔参数,rename设置为True表示用于字段名称的错误字段标识符将被位置名称替换。看:

>>> namedtuple("XYZ", "x y _z")
Traceback (most recent call last):
  ...
ValueError: Field names cannot start with an underscore: '_z'
>>> namedtuple("XYZ", "x y _z", rename=True)(1, 2, 3)
XYZ(x=1, y=2, _2=3)
>>> namedtuple("XYZ", "_x y _z", rename=True)(1, 2, 3)
XYZ(_0=1, y=2, _2=3)

如你所见,位置参数被翻译为像_i这样的名称,其中i代表相应字段的索引位置。所以,你不能以下划线开头命名标识符,但当rename设置为True时,字段可以以下划线开头——它们将不会被使用,而是会被重命名。然而,新的名称将以下划线开头。

现在,我的看法是:对 rename=True 要小心。我更喜欢使用我想要的名称,因为我自己在选择它们,所以我通常看不出使用这个选项的必要性。这并不意味着 rename=True 完全没有用。在元编程时,它有时可能有用,但我认为这种情况很少见。如果你确实使用它,请注意,使用这种方式创建的元组(带有 rename=True)的字段名可能会有风险,所以最好使用元组索引。如果这样 - 如果你不能使用字段名而必须使用索引 - 使用命名元组还有什么意义呢?

因此,在创建使用 collections.namedtuple 的命名元组时,在使用 rename=True 之前要三思。这不是你通常在日常编程中会用到的东西。

module

这是 collections.namedtuple 的一个高级用法,你很少会用到。它的默认值是 None。如 这里 所述,module 参数是在 Python 3.6 中添加的,以便 namedtuple 能够支持使用不同的 Python 实现进行序列化。你可以在 这个 GitHub 问题上了解更多

_ 即时创建*

有两种情况你可能想在运行时创建命名元组。第一种也是最明显的情况是元编程。例如,你可以根据外部输入定义一个命名元组,比如当你事先不知道文件结构时,从外部源文件中的列名。第二种情况是你只想创建一个命名元组来为特定的元组添加字段名,而不实际创建一个稍后可以重用的类型。正如你下面将看到的,在运行时创建 collections.namedtuple 非常简单。

在上面,我们定义了两个命名元组类型:EmployeeDept。你也可以在运行时做同样的事情,这意味着你不需要将命名元组类型赋给一个名称,而是在作用域内创建、使用和丢弃它。

让我们用另一个(更简单)的 foo() 函数定义来说明这一点:

XandY = namedtuple("XandY", "xi yi")

def foo(x, y) -> tuple[str, int]:
    xi: str
    yi: int
    ...
    return XandY(xi, yi)

这允许你使用属性名 xiyi 访问输出元组。我们创建 XandY 类型正是为了这个目的,所以我们不需要在任何其他地方使用这个元组 - 它只在 foo() 函数内部使用。我们真的需要 XandY 类型的定义吗?

在这种情况下,我们不需要定义 XandY;相反,我们可以在运行时创建它:

def foo(x, y) -> tuple[str, int]:
    xi: str
    yi: int
    ...
    return namedtuple("XandY", "xi yi")(xi, yi)

虽然这种方法可能稍微不那么清晰,但它使代码更短,避免了仅为了一个返回语句创建数据结构。创建数据类型的一个好规则是,当:

  • 你需要多次使用这个类型,

  • 你想要导出这个类型以便用户可以使用它,或者

  • 你需要这种类型是为了清晰度。

在这里,我们的目标只是给函数返回的元组的两个字段添加名称。这两个条件都不适用,因此动态创建类型似乎是一个不错的选择。

另一种选择是在函数内部创建数据类型:

def foo(x, y) -> tuple[str, int]:
    xi: str
    yi: int
    ...
    XandY = namedtuple("XandY", "xi yi")
    return XandY(xi, yi)

在这里,XandY 是一个 临时变量,它引用了一个在 foo() 函数作用域内定义的 namedtuple 类型。

在动态创建命名元组或在其内部定义之间进行选择取决于数据结构的复杂性:对于简单的结构,动态创建确实可以是最简单的方法。与 Python 中的许多决策一样,这个决策部分基于个人偏好——但始终注意代码的清晰度。

添加功能

你可以使用两种方式向使用 collections.namedtuple 创建的命名元组添加额外功能,这两种方式都不如使用 typing.NamedTuple(如文章后面所讨论的)那样易读。

第一种方法是继承:

class Point(namedtuple("Point", "x y")):
    def distance(self, other: "Point"):
        return (
            (self.x - other.x)**2
            + (self.y - other.y)**2
        )**.5

如您所见,我们的 Point 类继承了我们想要创建的非常特别的 namedtuple。当然,您可以首先给它命名:

BasePoint = namedtuple("BasePoint", "x y")

class Point(BasePoint):
    def distance(self, other: "NewPoint"):
        return (
            (self.x - other.x)**2
            + (self.y - other.y)**2
        )**.5

这就是我们的 Point 命名元组如何与 .distance() 方法一起工作的:

>>> p1 = Point(1, 1)
>>> p2 = Point(2, 3)
>>> p1.distance(p2) == distance(p1, p2)
True
>>> p1.distance(p2)
2.23606797749979

.distance() 方法在常规 Python 类中的工作方式相同。

上述方法建议用于为 collections.namedtuple 定义方法。还有一个小技巧我想分享,但请不要在实际代码中使用它。这只是我在 Luciano Ramalho 的 Fluent Python. 2nd edition 书中发现的一个巧妙技巧。了解这样的技巧可以拓宽你的 Python 知识——然而,在这种情况下,不要将了解与使用混淆。

虽然它与上面的方法工作方式相似,但代码可能看起来有点奇怪:

Point = namedtuple("Point", "x y")

def distance(point: Point, other: Point):
    return (
        (point.x - other.x)**2
        + (point.y - other.y)**2
    )**.5

Point.distance = distance

就这样!这段代码将以与上面相同的方式工作,因为你得到的是与之前相同的命名元组,只是定义方式不同。

顺便说一句,这个技巧不仅限于命名元组。你可以用它来向常规 Python 类添加方法。记住,要转换成类方法的函数的第一个参数需要代表实例(self)。因此,在将 distance() 函数转换成 Point 方法后,point 参数变成了实例,即 self 参数。

typing.NamedTuple

定义

typing 模块中的 NamedTuplecollections.namedtuple 非常相似的数据结构。实际上,如果你查看前者的实现,你会看到它直接调用了后者:

https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/6153895fee7a109fcf763ba561795973.png

Python 3.11 中 typing.py 模块的 VS Code 截图:创建一个 typing.NamedTuple 函数。图片由作者提供

那么我们为什么还需要另一种命名的元组结构呢?你已经知道答案了:collections.namedtuple 不允许在类型提示中使用,而 typing.NamedTuple 则可以。然而,很快你就会看到这两个之间还有更多的不同。

让我们定义与上面创建的相应的命名元组:

from typing import NamedTuple
from datetime import datetime

class Student(NamedTuple):
    name: str
    year: int
    gpa: float

class Dept(NamedTuple):
    faculty: str
    department: str

class Employee(NamedTuple):
    name: str
    dept: Dept
    position: str
    start: datetime

我认为,这些定义确实表明类型提示在理解这些数据容器的内容方面非常有帮助。有人可能会认为这种 class-based 定义需要更多的行,这是完全正确的。然而,这些额外的行确实带来了更高的可读性。

实例创建与 collections.namedtuple 方法保持一致,所以我们将不会重复这段代码。

使用默认值

使用默认值比在 collections.namedtuple 的情况下要简单得多:

class Graph(NamedTuple):
    format: str = "png"
    width: int = 400
    height: int = 400

再次提醒,你必须首先定义没有默认值的字段,然后是具有默认值的字段:

class Graph(NamedTuple):
    caption: str
    format: str = "png"
    width: int = 400
    height: int = 400

collections.namedtuple 的情况一样,你不应该使用可变对象作为默认值。

动态创建

很不幸,typing.NamedTuple 并没有提供一种在动态中创建命名元组的方法。在这方面,collections.namedtuple 显然是赢家。

添加功能

typing.NamedTuple 不仅在类型提示方面,而且在类定义方面都是命名元组的改进版本。你定义一个新的命名元组作为一个从 typing.NamedTuple 继承的自定义类。

这看起来与我们在上面使用 collections.namedtuple 创建命名元组时采取的继承方法非常相似,但有一个显著的区别。以前,我们是从一个已经创建的命名元组类继承,而在 typing.NamedTuple 的情况下,我们是从这个类本身继承。

当定义一个 typing.NamedTuple 命名元组时,你可以定义常规类、静态和实例方法。看:

from typing import NamedTuple

class Point(NamedTuple):
    x: float
    y: float

    def distance(self, other: Point) -> float:
        return (
            (self.x - other.x)**2
            + (self.y - other.y)**2
        )**.5

你可以使用这个类与之前使用 collections.namedtuple 创建的类完全相同:

>>> p1 = Point(1, 1)
>>> p2 = Point(2, 3)
>>> p1.distance(p2) == distance(p1, p2)
True
>>> round(p1.distance(p2), 3)
2.236

性能

由于 typing.NamedTuple 直接调用 collections.namedtuple,使用前者定义命名元组应该比使用后者定义命名元组花费更多的时间。问题是多多少。

为了研究这个问题,我基于 timeit 实验运行了基准测试。在每次运行中,我创建了三个命名元组(StudentDeptEmployee),并比较了 100_000 次运行的执行时间。这是代码:

from timeit import repeat

setup1 = "from collections import namedtuple"
setup2 = "from typing import NamedTuple"

n = 100_000

code1 = '''
Student = namedtuple("Student", "name age gpa")
Dept = namedtuple("Dept", "faculty department")
Employee = namedtuple("Employee", "name dept position start")
'''

code2 = '''
from typing import NamedTuple
from datetime import datetime

class Student(NamedTuple):
    name: str
    year: int
    gpa: float

class Dept(NamedTuple):
    faculty: str
    department: str

class Employee(NamedTuple):
    name: str
    dept: Dept
    position: str
    start: datetime
'''

t1 = repeat(code1, setup=setup1, number=n)
t2 = repeat(code2, setup=setup2, number=n)

print(
    "n",
    f"code1: {round(min(t1), 2)}",
    "n",
    f"code2: {round(min(t2), 2)}"
)

如预期的那样,来自 typing 模块的命名元组表现出了显著更慢的性能(28.72 秒对比 16.28 秒),这意味着它们创建上面代码中显示的三个命名元组所需的时间几乎是常规命名元组的两倍。

至于内存使用,两种命名元组的实例使用的是完全相同的内存。在我们的例子中,以下实例需要 440 字节:

Employee(
    "John Smart",
    Dept("Agriculture", "Agronomy"),
    "assistant professor",
    datetime(year=2020,month=9,day=1)
)

你可以使用 pympler 包,借助 pympler.asizeof.asizeof() 函数来测量这一点:

>>> from pympler.asizeof import asizeof
>>> john = Employee(
...     "John Smart",
...     Dept("Agriculture", "Agronomy"),
...     "assistant professor",
...     datetime(year=2020,month=9,day=1)
... )
>>> asizeof(john)
440

顺便说一句,一个常规元组:

(
    "John Smart",
    ("Agriculture", "Agronomy"),
    "assistant professor",
    datetime(year=2020,month=9,day=1)
)

需要 440 字节内存!

相应的字典,然而,是通过使用 _asdict() 方法(我们将在下面讨论此方法和其他命名元组方法)从任一命名元组获得的,它使用了 784 字节:

>>> john_dict = john._asdict()
>>> john_dict # doctest: +NORMALIZE_WHITESPACE
{'name': 'John Smart',
 'dept': Dept(faculty='Agriculture',
              department='Agronomy'),
 'position': 'assistant professor',
 'start': datetime.datetime(2020, 9, 1, 0, 0)}
>>> asizeof(john_dict)
784

这表明元组比相应的字典更节省,至少在内存使用方面是这样。

命名元组方法

本节描述了这两种命名元组提供的方法。也就是说,当你创建一个命名元组类型(类)时,它提供了一个你可以使用的方法,但由这个类创建的实例也将拥有这些方法。所有这些方法对于 collections.namedtupletyping.NamedTuple 都是相同的,因此,在示例中,我将只使用前者。

使用 _make() 创建实例

你可以使用 _make() 方法通过可迭代对象创建一个新的实例。此方法适用于类型本身:

>>> Point = namedtuple("Point", "x y")
>>> values = [1.1, 2.2]
>>> point = Point._make(values) 
>>> point
Point(x=1.1, y=2.2)

以及其实例:

>>> point2 = point._make([3.3, 4.4])
>>> point2
Point(x=3.3, y=4.4)

此方法并不真正提供新的功能,而只是以可迭代对象创建实例的另一种方式。你可以使用典型的构造函数和可迭代解包来实现相同的效果:

>>> Point(*[3.3, 4.4])
Point(x=3.3, y=4.4)

你也可以使用一个字典:

>>> Point(**{'x': 3.3, 'y': 4.4})
Point(x=3.3, y=4.4)

使用 _asdict() 将命名元组转换为字典

此方法仅适用于命名元组实例,因为它——正如其名称所示——从命名元组创建相应的字典:

>>> point._asdict()
{'x': 1.1, 'y': 2.2}

这只需要一条注释,即生成的字典中字段的顺序将与原始命名元组中的顺序相同。

使用 _replace() 替换字段

这又是从现有实例(而不是类型本身)创建新实例的另一种方式。如果你想只更改现有实例的一些值,可以使用它:

>>> point._replace(y=6.6)
Point(x=1.1, y=6.6)

记住,生成的命名元组是一个全新的实例,其中一些字段相同,而其他字段已更改;上面,y 已更改。

然而,你可以使用 _replace() 来更改所有字段的值:

>>> point._replace(x=5.5, y=6.6)
Point(x=5.5, y=6.6)

_replace() 调用中字段的顺序并不重要:

>>> point._replace(y=6.6, x=5.5)
Point(x=5.5, y=6.6)

可能会诱使人们认为,由于 _replace() 只更改一个或多个字段值,它应该比常规构造函数更快。但你大错特错了!让我们基准测试两种创建新实例的方法:

from timeit import repeat

setup = """
from collections import namedtuple
Point = namedtuple("Point", "x y")
point = Point(x=1.1, y=2.2)
"""

n = 1_000_000

code1 = "Point(x=1.1, y=3.3)"
code2 = "point._replace(b=11)"

t1 = repeat(code1, setup=setup, number=n)
t2 = repeat(code2, setup=setup, number=n)

print(
    f"{code1}: {round(min(t1), 4)}", "n",
    f"{code2}: {round(min(t2), 4)}"
)

在我的机器上(Windows 11,WSL 1,4 个物理核心和 8 个逻辑核心),我得到了以下结果:

Point(x=1.1, y=3.3): 0.2965
point._replace(y=3.3): 0.539

如你所见,常规构造函数的速度显著——几乎是两倍——更快。当你只更改具有许多字段的命名元组中的一个字段时,你会得到类似的结果。

反汇编这两个调用证实了上述观察:

>>> import dis
>>> dis.dis("Point(x=1.1, y=3.3)")
  0           0 RESUME                   0
<BLANKLINE>
  1           2 PUSH_NULL
              4 LOAD_NAME                0 (Point)
              6 LOAD_CONST               0 (1.1)
              8 LOAD_CONST               1 (3.3)
             10 KW_NAMES                 2
             12 PRECALL                  2
             16 CALL                     2
             26 RETURN_VALUE
>>> dis.dis("point._replace(y=3.3)")
  0           0 RESUME                   0
<BLANKLINE>
  1           2 LOAD_NAME                0 (point)
              4 LOAD_METHOD              1 (_replace)
             26 LOAD_CONST               0 (3.3)
             28 KW_NAMES                 1
             30 PRECALL                  1
             34 CALL                     1
             44 RETURN_VALUE

一方面,后者的字节码更短,但这并不是区别所在。第一次反汇编显示,在第一次调用中,解释器直接调用了 Point 构造函数。字段值作为常量获取,这是一种相对直接且高效的方法。

另一方面,要使用_replace()方法,解释器首先在内存中定位对象,然后在该对象上调用方法,并传递提供的关键字参数。与直接创建新对象相比,这个过程涉及额外的步骤。从零开始创建现有实例的副本比从头创建新实例更复杂。

我会说,那么——不要过度使用_replace()方法。

命名元组方法:是公共的还是私有的?

在命名元组中,有一件事我认为很奇怪,甚至可以说是非 Python 风格的。

https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/5d63ab6dd1606437c907a52f6dcbfa28.png

命名元组属性。Python 会话截图。图片由作者提供。

根据 Python 编码风格的常规规则,以下划线开头的方法——对于命名元组来说,这些是_asdict()_make()_replace()——应被视为私有方法。尽管私有方法在技术上并不是受保护的,但通常认为避免使用它们是最佳实践。这是一条普遍规则——但至少有一个例外:命名元组。

虽然“私有”的 namedtuple 方法并不打算完全隐藏,但它们实际上被设计成可以作为命名元组的标准 API 的一部分:它们根本就不是私有的。那么,这些以下划线开头名称的真正含义是什么?为什么这些公共方法似乎暗示它们是私有的?

只有一个合理的解释浮现在我的脑海中。通过让这些方法以下划线开头,我们可以在我们的命名元组中使用以下字段名:asdictmakereplace(尽管这些似乎不是命名元组中典型的字段名)。正如我们已经提到的,字段名不能以下划线开头。这两件事似乎相互关联,或者至少有很强的相关性:不要以下划线开头字段名,内置的 namedtuple 方法以下划线开头。

尽管这在上述段落中似乎是有道理的,但至少在 Python 中,这仍然是非常不典型的,甚至可以说是不规范的。我不记得有任何其他标准库类有这种不一致性(尽管我的记忆可能出现了问题)。

当然,外部库可能可以避开与 Python 编码标准的矛盾。我自己就做过不止一次。例如,在[perftester](https://github.com/nyggus/perftester)包中,我使用了函数参数NumberRepeat而不是numberrepeat,以便允许自定义函数接受名为numberrepeat的参数。在[tracemem](https://github.com/nyggus/tracemem)包中,我做出了一个更不寻常的决定:我将tracemem对象添加到了builtins的全局变量中。(关于这个话题的更多信息,请参阅关于 Python 全局变量的这篇文章和关于tracemem这篇文章。)

尽管如此,标准库应该比外部包更严格。其代码应该是好的,且符合惯用性。不幸的是,现实情况并非如此,我们都知道标准库并不提供没有一点错误的典范 Python 代码。

小错误是小错误,但惯用代码仍然是惯用代码。我相信——并且希望我是对的!——这种理论上非惯用的命名约定用于命名元组方法,旨在传达一个重要的信息,隐藏在字里行间:

Python 有其惯用性和良好的风格。努力使用惯用和风格化的语言。同时,当有合理的理由这样做时,理论上非惯用代码不应被视为非惯用,除非有更好的或更简单的方法来实现相同的效果。

如果我没错的话,那么我之前提到的两个非惯用决定也许也可以被认为是惯用的?

结论

命名元组。当我想到它们时,首先想到的是元组。这是正确的联想——因为命名元组首先就是元组。

但肯定的是,它们不仅仅是元组——命名元组也是命名的。这是第二个出现在我脑海中的联想。正是这些名称造成了差异。

这种差异可以从两个层面来考虑。首先,命名元组与元组不同,因为它们的字段是命名的,因此作为命名属性工作。其次,名称在实用性方面有影响:普通元组很强大,但命名元组可以更强大。

命名元组不仅仅是牛奶和蜂蜜。是的,由于命名字段,它们是有用的,但并非所有这样的名称都会有用。在某个时候,它们可能会造成混淆而不是帮助。如果一个元组有两个或三个,甚至五个字段——是的,命名它们可以非常有帮助。但如果有十个元素呢?或者二十个?一百个呢?

想象一下这样一个大命名元组的定义。这肯定会使代码更长,除非您会使用我提到的使用可迭代对象创建字段名的技巧。在某些情况下,这仍然是有意义的,但my_tuple[27]my_tuple.i27之间有什么区别呢?我不认为有——至少,使用后者相对于前者没有优势。索引更容易自动化,因此实际上,它可能比名称对于大容器来说是一个更好的选择。(当然,您也可以为命名元组使用索引。)这并不意味着创建大命名元组永远不会有用——但如果您有这样的想法,请三思。

让我们总结一下两种命名元组风格的比较:

https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/d5a221803b674bd7fc599135750384b6.png

collections.namedtuple 与 typing.NamedTuple 的比较。Google Sheets 的截图。图片由作者提供

这有很多要考虑。假设您想在 Python 中使用命名元组,因此您需要在collections.namedtupletyping.NamedTuple之间做出选择。您应该选择哪一个?

当然,这应该取决于项目的具体要求和您以及——也许更重要的是——您团队的编码风格偏好。除此之外,在做出选择时,请考虑以下观点:

当以下至少有一个条件成立时,使用collections.namedtuple

  • 您需要一个简单、轻量级且不可变的数据结构,无需高级功能;当简单性发挥作用时,collections.namedtupletyping.NamedTuple更容易使用且更简洁。

  • 代码可以不使用类型提示。

  • 您需要即时创建类型。

  • 在 Python 3.5 之前,您必须确保向后兼容性。

对于typing.NamedTuple,列表会更短——但事实是,在大多数典型情况下,应该首选typing.NamedTuple。当您需要以下情况时使用它们:

  • 您需要使用类型提示或通过实现方法添加额外的功能。

  • 您需要明确的可读代码,尤其是在您在命名元组定义中使用默认值时。

永远不要忘记命名元组是不可变的。

永远不要忘记命名元组是不可变的。这听起来可能是一个简单的事情,但我在开始为我的命名元组实现自定义方法时,已经发现自己多次忘记这一点。修改实例的诱惑是如此之大,就像在常规 Python 类中一样。因此,请记住:命名元组仍然是一个元组,您想要实现的方法应该返回某些内容,而不是修改实例。后者是不可能的,这正是由于不可变性。

命名元组仍然是一个元组,您想要实现的方法应该返回某些内容,而不是修改实例。

虽然 您可以将可变对象用作元组元素,但您不能使用可变默认值。实际上,完全避免在命名元组中使用可变对象。在不可变容器中使用可变对象不是有点不合逻辑吗?

因此,当你需要一个可变的数据容器时,命名元组并不是你的选择。你可以考虑使用经典的 Python 类或数据类(通过 [dataclasses](https://docs.python.org/3/library/dataclasses.html).dataclass),这在 Python 3.7+ 中可用。

完全避免在命名元组中使用可变对象。在不可变容器中使用可变对象不是有点不合逻辑吗?

希望我成功地说服您,命名元组提供了强大的 Python 数据结构。我自己主要使用它们来创建由几个字段组成的小数据类型。这是一个内存和时间效率高的数据结构,比常规元组更简单易用,这得益于 Python 最大的优势之一:其对象的命名属性。

脚注

¹ 在许多代码块中,我使用 doctest 测试,以确保示例是最新的并且可以正确工作。你可以在其 文档 和以下文章中了解更多关于 doctest 的信息:

使用 doctest 进行 Python 文档测试:简单易行


感谢您的阅读。如果您喜欢这篇文章,您可能还会喜欢我写的其他文章;您可以在这里看到它们。如果您想加入 Medium,请使用以下推荐链接:

通过我的推荐链接加入 Medium – Marcin Kozak

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐