第五章:数据类构建器
数据类就像孩子一样。它们作为一个起点是可以的,但要作为一个成熟的对象参与,它们需要承担一些责任。
马丁·福勒和肯特·贝克¹
Python 提供了几种构建简单类的方法,这些类只是一组字段,几乎没有额外功能。这种模式被称为“数据类”,而dataclasses
是支持这种模式的包之一。本章涵盖了三种不同的类构建器,您可以将它们用做编写数据类的快捷方式:
collections.namedtuple
最简单的方法——自 Python 2.6 起可用。
typing.NamedTuple
一种需要在字段上添加类型提示的替代方法——自 Python 3.5 起,3.6 中添加了class
语法。
@dataclasses.dataclass
一个类装饰器,允许比以前的替代方案更多的定制化,增加了许多选项和潜在的复杂性——自 Python 3.7 起。
在讨论完这些类构建器之后,我们将讨论为什么数据类也是一个代码异味的名称:一种可能是糟糕面向对象设计的症状的编码模式。
注意
typing.TypedDict
可能看起来像另一个数据类构建器。它使用类似的语法,并在 Python 3.9 的typing
模块文档中的typing.NamedTuple
之后描述。
但是,TypedDict
不会构建您可以实例化的具体类。它只是一种语法,用于为将用作记录的映射值接受的函数参数和变量编写类型提示,其中键作为字段名。我们将在第十五章的TypedDict
中看到它们。
本章的新内容
本章是流畅的 Python第二版中的新内容。第一版的第二章中出现了“经典命名元组”一节,但本章的其余部分是全新的。
我们从三个类构建器的高级概述开始。
数据类构建器概述
考虑一个简单的类来表示地理坐标对,如示例 5-1 所示。
示例 5-1。class/coordinates.py
class Coordinate: def __init__(self, lat, lon): self.lat = lat self.lon = lon
那个Coordinate
类完成了保存纬度和经度属性的工作。编写__init__
样板变得非常乏味,特别是如果你的类有超过几个属性:每个属性都被提及三次!而且那个样板并没有为我们购买我们期望从 Python 对象中获得的基本功能:
>>> from coordinates import Coordinate >>> moscow = Coordinate(55.76, 37.62) >>> moscow <coordinates.Coordinate object at 0x107142f10> # ① >>> location = Coordinate(55.76, 37.62) >>> location == moscow # ② False >>> (location.lat, location.lon) == (moscow.lat, moscow.lon) # ③ True
①
从object
继承的__repr__
并不是很有用。
②
无意义的==
;从object
继承的__eq__
方法比较对象 ID。
③
比较两个坐标需要显式比较每个属性。
本章涵盖的数据类构建器会自动提供必要的__init__
、__repr__
和__eq__
方法,以及其他有用的功能。
注意
这里讨论的类构建器都不依赖继承来完成工作。collections.namedtuple
和typing.NamedTuple
都构建了tuple
子类的类。@dataclass
是一个类装饰器,不会以任何方式影响类层次结构。它们每个都使用不同的元编程技术将方法和数据属性注入到正在构建的类中。
这里是一个使用namedtuple
构建的Coordinate
类——一个工厂函数,根据您指定的名称和字段构建tuple
的子类:
>>> from collections import namedtuple >>> Coordinate = namedtuple('Coordinate', 'lat lon') >>> issubclass(Coordinate, tuple) True >>> moscow = Coordinate(55.756, 37.617) >>> moscow Coordinate(lat=55.756, lon=37.617) # ① >>> moscow == Coordinate(lat=55.756, lon=37.617) # ② True
①
有用的__repr__
。
②
有意义的__eq__
。
较新的typing.NamedTuple
提供了相同的功能,为每个字段添加了类型注释:
>>> import typing >>> Coordinate = typing.NamedTuple('Coordinate', ... [('lat', float), ('lon', float)]) >>> issubclass(Coordinate, tuple) True >>> typing.get_type_hints(Coordinate) {'lat': <class 'float'>, 'lon': <class 'float'>}
提示
一个带有字段作为关键字参数构造的类型命名元组也可以这样创建:
Coordinate = typing.NamedTuple('Coordinate', lat=float, lon=float)
这更易读,也让您提供字段和类型的映射作为 **fields_and_types
。
自 Python 3.6 起,typing.NamedTuple
也可以在 class
语句中使用,类型注解的写法如 PEP 526—变量注解的语法 中描述的那样。这样更易读,也方便重写方法或添加新方法。示例 5-2 是相同的 Coordinate
类,具有一对 float
属性和一个自定义的 __str__
方法,以显示格式为 55.8°N, 37.6°E 的坐标。
示例 5-2. typing_namedtuple/coordinates.py
from typing import NamedTuple class Coordinate(NamedTuple): lat: float lon: float def __str__(self): ns = 'N' if self.lat >= 0 else 'S' we = 'E' if self.lon >= 0 else 'W' return f'{abs(self.lat):.1f}°{ns}, {abs(self.lon):.1f}°{we}'
警告
尽管 NamedTuple
在 class
语句中出现为超类,但实际上并非如此。typing.NamedTuple
使用元类的高级功能² 来自定义用户类的创建。看看这个:
>>> issubclass(Coordinate, typing.NamedTuple) False >>> issubclass(Coordinate, tuple) True
在 typing.NamedTuple
生成的 __init__
方法中,字段按照在 class
语句中出现的顺序作为参数出现。
像 typing.NamedTuple
一样,dataclass
装饰器支持 PEP 526 语法来声明实例属性。装饰器读取变量注解并自动生成类的方法。为了对比,可以查看使用 dataclass
装饰器编写的等效 Coordinate
类,如 示例 5-3 中所示。
示例 5-3. dataclass/coordinates.py
from dataclasses import dataclass @dataclass(frozen=True) class Coordinate: lat: float lon: float def __str__(self): ns = 'N' if self.lat >= 0 else 'S' we = 'E' if self.lon >= 0 else 'W' return f'{abs(self.lat):.1f}°{ns}, {abs(self.lon):.1f}°{we}'
请注意,示例 5-2 和 示例 5-3 中的类主体是相同的——区别在于 class
语句本身。@dataclass
装饰器不依赖于继承或元类,因此不应干扰您对这些机制的使用。³ 示例 5-3 中的 Coordinate
类是 object
的子类。
主要特点
不同的数据类构建器有很多共同点,如 表 5-1 所总结的。
表 5-1. 三种数据类构建器之间的选定特点比较;x
代表该类型数据类的一个实例
namedtuple | NamedTuple | dataclass | |
可变实例 | 否 | 否 | 是 |
class 语句语法 | 否 | 是 | 是 |
构造字典 | x._asdict() | x._asdict() | dataclasses.asdict(x) |
获取字段名 | x._fields | x._fields | [f.name for f in dataclasses.fields(x)] |
获取默认值 | x._field_defaults | x._field_defaults | [f.default for f in dataclasses.fields(x)] |
获取字段类型 | 不适用 | x.annotations | x.annotations |
使用更改创建新实例 | x._replace(…) | x._replace(…) | dataclasses.replace(x, …) |
运行时新类 | namedtuple(…) | NamedTuple(…) | dataclasses.make_dataclass(…) |
警告
typing.NamedTuple
和 @dataclass
构建的类具有一个 __annotations__
属性,其中包含字段的类型提示。然而,不建议直接从 __annotations__
中读取。相反,获取该信息的推荐最佳实践是调用 inspect.get_annotations(MyClass)
(Python 3.10 中添加)或 typing.get_type_hints(MyClass)
(Python 3.5 到 3.9)。这是因为这些函数提供额外的服务,如解析类型提示中的前向引用。我们将在本书的后面更详细地讨论这个问题,在 “运行时注解问题” 中。
现在让我们讨论这些主要特点。
可变实例
这些类构建器之间的一个关键区别是,collections.namedtuple
和 typing.NamedTuple
构建 tuple
的子类,因此实例是不可变的。默认情况下,@dataclass
生成可变类。但是,装饰器接受一个关键字参数 frozen
—如 示例 5-3 中所示。当 frozen=True
时,如果尝试在初始化实例后为字段分配值,类将引发异常。
类语句语法
只有typing.NamedTuple
和dataclass
支持常规的class
语句语法,这样可以更容易地向正在创建的类添加方法和文档字符串。
构造字典
这两种命名元组变体都提供了一个实例方法(._asdict
),用于从数据类实例中的字段构造一个dict
对象。dataclasses
模块提供了一个执行此操作的函数:dataclasses.asdict
。
获取字段名称和默认值
所有三种类构建器都允许您获取字段名称和可能为其配置的默认值。在命名元组类中,这些元数据位于._fields
和._fields_defaults
类属性中。您可以使用dataclasses
模块中的fields
函数从装饰的dataclass
类中获取相同的元数据。它返回一个具有多个属性的Field
对象的元组,包括name
和default
。
获取字段类型
使用typing.NamedTuple
和@dataclass
帮助定义的类具有字段名称到类型的映射__annotations__
类属性。如前所述,使用typing.get_type_hints
函数而不是直接读取__annotations__
。
具有更改的新实例
给定一个命名元组实例x
,调用x._replace(**kwargs)
将返回一个根据给定关键字参数替换了一些属性值的新实例。dataclasses.replace(x, **kwargs)
模块级函数对于dataclass
装饰的类的实例也是如此。
运行时新类
尽管class
语句语法更易读,但它是硬编码的。一个框架可能需要在运行时动态构建数据类。为此,您可以使用collections.namedtuple
的默认函数调用语法,该语法同样受到typing.NamedTuple
的支持。dataclasses
模块提供了一个make_dataclass
函数来实现相同的目的。
在对数据类构建器的主要特性进行概述之后,让我们依次专注于每个特性,从最简单的开始。
经典的命名元组
collections.namedtuple
函数是一个工厂,构建了增强了字段名称、类名和信息性__repr__
的tuple
子类。使用namedtuple
构建的类可以在需要元组的任何地方使用,并且实际上,Python 标准库的许多函数现在用于返回元组的地方现在返回命名元组以方便使用,而不会对用户的代码产生任何影响。
提示
由namedtuple
构建的类的每个实例占用的内存量与元组相同,因为字段名称存储在类中。
示例 5-4 展示了我们如何定义一个命名元组来保存有关城市信息的示例。
示例 5-4. 定义和使用命名元组类型
>>> from collections import namedtuple >>> City = namedtuple('City', 'name country population coordinates') # ① >>> tokyo = City('Tokyo', 'JP', 36.933, (35.689722, 139.691667)) # ② >>> tokyo City(name='Tokyo', country='JP', population=36.933, coordinates=(35.689722, 139.691667)) >>> tokyo.population # ③ 36.933 >>> tokyo.coordinates (35.689722, 139.691667) >>> tokyo[1] 'JP'
①
创建命名元组需要两个参数:一个类名和一个字段名称列表,可以作为字符串的可迭代对象或作为单个以空格分隔的字符串给出。
②
字段值必须作为单独的位置参数传递给构造函数(相反,tuple
构造函数接受一个单一的可迭代对象)。
③
你可以通过名称或位置访问这些字段。
作为tuple
子类,City
继承了一些有用的方法,比如__eq__
和用于比较运算符的特殊方法,包括__lt__
,它允许对City
实例的列表进行排序。
除了从元组继承的属性和方法外,命名元组还提供了一些额外的属性和方法。示例 5-5 展示了最有用的:_fields
类属性,类方法_make(iterable)
和_asdict()
实例方法。
示例 5-5. 命名元组属性和方法(继续自上一个示例)
>>> City._fields # ① ('name', 'country', 'population', 'location') >>> Coordinate = namedtuple('Coordinate', 'lat lon') >>> delhi_data = ('Delhi NCR', 'IN', 21.935, Coordinate(28.613889, 77.208889)) >>> delhi = City._make(delhi_data) # ② >>> delhi._asdict() # ③ {'name': 'Delhi NCR', 'country': 'IN', 'population': 21.935, 'location': Coordinate(lat=28.613889, lon=77.208889)} >>> import json >>> json.dumps(delhi._asdict()) # ④ '{"name": "Delhi NCR", "country": "IN", "population": 21.935, "location": [28.613889, 77.208889]}'
①
._fields
是一个包含类的字段名称的元组。
②
._make()
从可迭代对象构建 City
;City(*delhi_data)
将执行相同操作。
③
._asdict()
返回从命名元组实例构建的 dict
。
④
._asdict()
对于将数据序列化为 JSON 格式非常有用,例如。
警告
直到 Python 3.7,_asdict
方法返回一个 OrderedDict
。自 Python 3.8 起,它返回一个简单的 dict
——现在我们可以依赖键插入顺序了。如果你一定需要一个 OrderedDict
,_asdict
文档建议从结果构建一个:OrderedDict(x._asdict())
。
自 Python 3.7 起,namedtuple
接受 defaults
关键字参数,为类的 N 个最右字段的每个字段提供一个默认值的可迭代对象。示例 5-6 展示了如何为 reference
字段定义一个带有默认值的 Coordinate
命名元组。
示例 5-6。命名元组属性和方法,继续自示例 5-5
>>> Coordinate = namedtuple('Coordinate', 'lat lon reference', defaults=['WGS84']) >>> Coordinate(0, 0) Coordinate(lat=0, lon=0, reference='WGS84') >>> Coordinate._field_defaults {'reference': 'WGS84'}
在“类语句语法”中,我提到使用 typing.NamedTuple
和 @dataclass
支持的类语法更容易编写方法。你也可以向 namedtuple
添加方法,但这是一种 hack。如果你对 hack 不感兴趣,可以跳过下面的框。
现在让我们看看 typing.NamedTuple
的变化。
带类型的命名元组
Coordinate
类与示例 5-6 中的默认字段可以使用 typing.NamedTuple
编写,如示例 5-8 所示。
示例 5-8。typing_namedtuple/coordinates2.py
from typing import NamedTuple class Coordinate(NamedTuple): lat: float # ① lon: float reference: str = 'WGS84' # ②
①
每个实例字段都必须带有类型注释。
②
reference
实例字段带有类型和默认值的注释。
由 typing.NamedTuple
构建的类除了那些 collections.namedtuple
生成的方法和从 tuple
继承的方法外,没有任何其他方法。唯一的区别是存在 __annotations__
类属性——Python 在运行时完全忽略它。
鉴于 typing.NamedTuple
的主要特点是类型注释,我们将在继续探索数据类构建器之前简要介绍它们。
类型提示 101
类型提示,又称类型注释,是声明函数参数、返回值、变量和属性预期类型的方式。
你需要了解的第一件事是,类型提示完全不受 Python 字节码编译器和解释器的强制执行。
注意
这是对类型提示的非常简要介绍,足以理解 typing.NamedTuple
和 @dataclass
声明中使用的注释的语法和含义。我们将在第八章中介绍函数签名的类型提示,以及在第十五章中介绍更高级的注释。在这里,我们将主要看到使用简单内置类型的提示,比如 str
、int
和 float
,这些类型可能是用于注释数据类字段的最常见类型。
无运行时效果
将 Python 类型提示视为“可以由 IDE 和类型检查器验证的文档”。
这是因为类型提示对 Python 程序的运行时行为没有影响。查看示例 5-9。
示例 5-9。Python 不会在运行时强制执行类型提示
>>> import typing >>> class Coordinate(typing.NamedTuple): ... lat: float ... lon: float ... >>> trash = Coordinate('Ni!', None) >>> print(trash) Coordinate(lat='Ni!', lon=None) # ①
①
我告诉过你:运行时不进行类型检查!
如果你在 Python 模块中键入示例 5-9 的代码,它将运行并显示一个无意义的 Coordinate
,没有错误或警告:
$ python3 nocheck_demo.py Coordinate(lat='Ni!', lon=None)
类型提示主要用于支持第三方类型检查器,如Mypy或PyCharm IDE内置的类型检查器。这些是静态分析工具:它们检查 Python 源代码“静止”,而不是运行代码。
要看到类型提示的效果,你必须在你的代码上运行其中一个工具—比如一个检查器。例如,这是 Mypy 对前面示例的看法:
$ mypy nocheck_demo.py nocheck_demo.py:8: error: Argument 1 to "Coordinate" has incompatible type "str"; expected "float" nocheck_demo.py:8: error: Argument 2 to "Coordinate" has incompatible type "None"; expected "float"
正如你所看到的,鉴于Coordinate
的定义,Mypy 知道创建实例的两个参数必须是float
类型,但对trash
的赋值使用了str
和None
。⁵
现在让我们谈谈类型提示的语法和含义。
变量注释语法
typing.NamedTuple
和@dataclass
都使用在PEP 526中定义的变量注释语法。这是在class
语句中定义属性的上下文中对该语法的快速介绍。
变量注释的基本语法是:
var_name: some_type
PEP 484 中的“可接受的类型提示”部分解释了什么是可接受的类型,但在定义数据类的上下文中,这些类型更有可能有用:
- 一个具体的类,例如,
str
或FrenchDeck
- 一个参数化的集合类型,如
list[int]
,tuple[str, float]
,等等。 typing.Optional
,例如,Optional[str]
—声明一个可以是str
或None
的字段
你也可以用一个值初始化变量。在typing.NamedTuple
或@dataclass
声明中,如果在构造函数调用中省略了相应的参数,那个值将成为该属性的默认值:
var_name: some_type = a_value
变量注释的含义
我们在“无运行时效果”中看到类型提示在运行时没有效果。但在导入时—模块加载时—Python 会读取它们以构建__annotations__
字典,然后typing.NamedTuple
和@dataclass
会使用它们来增强类。
我们将从示例 5-10 中的一个简单类开始这个探索,这样我们以后可以看到typing.NamedTuple
和@dataclass
添加的额外功能。
示例 5-10. meaning/demo_plain.py:带有类型提示的普通类
class DemoPlainClass: a: int # ① b: float = 1.1 # ② c = 'spam' # ③
①
a
成为__annotations__
中的一个条目,但在类中不会创建名为a
的属性。
②
b
被保存为注释,并且也成为一个具有值1.1
的类属性。
③
c
只是一个普通的类属性,不是一个注释。
我们可以在控制台中验证,首先读取DemoPlainClass
的__annotations__
,然后尝试获取其名为a
、b
和c
的属性:
>>> from demo_plain import DemoPlainClass >>> DemoPlainClass.__annotations__ {'a': <class 'int'>, 'b': <class 'float'>} >>> DemoPlainClass.a Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: type object 'DemoPlainClass' has no attribute 'a' >>> DemoPlainClass.b 1.1 >>> DemoPlainClass.c 'spam'
注意,__annotations__
特殊属性是由解释器创建的,用于记录源代码中出现的类型提示—即使在一个普通类中也是如此。
a
只作为一个注释存在。它不会成为一个类属性,因为没有值与它绑定。⁶ b
和c
作为类属性存储,因为它们绑定了值。
这三个属性都不会出现在DemoPlainClass
的新实例中。如果你创建一个对象o = DemoPlainClass()
,o.a
会引发AttributeError
,而o.b
和o.c
将检索具有值1.1
和'spam'
的类属性——这只是正常的 Python 对象行为。
检查一个typing.NamedTuple
现在让我们检查一个使用与示例 5-10 中DemoPlainClass
相同属性和注释构建的类,该类使用typing.NamedTuple
(示例 5-11)。
示例 5-11. meaning/demo_nt.py:使用typing.NamedTuple
构建的类
import typing class DemoNTClass(typing.NamedTuple): a: int # ① b: float = 1.1 # ② c = 'spam' # ③
①
a
成为一个注释,也成为一个实例属性。
②
b
是另一个注释,也成为一个具有默认值1.1
的实例属性。
③
c
只是一个普通的类属性;没有注释会引用它。
检查DemoNTClass
,我们得到:
>>> from demo_nt import DemoNTClass >>> DemoNTClass.__annotations__ {'a': <class 'int'>, 'b': <class 'float'>} >>> DemoNTClass.a <_collections._tuplegetter object at 0x101f0f940> >>> DemoNTClass.b <_collections._tuplegetter object at 0x101f0f8b0> >>> DemoNTClass.c 'spam'
这里我们对a
和b
的注释与我们在示例 5-10 中看到的相同。但是typing.NamedTuple
创建了a
和b
类属性。c
属性只是一个具有值'spam'
的普通类属性。
a
和b
类属性是描述符,这是第二十三章中介绍的一个高级特性。现在,将它们视为类似于属性获取器的属性:这些方法不需要显式调用运算符()
来检索实例属性。实际上,这意味着a
和b
将作为只读实例属性工作——当我们回想起DemoNTClass
实例只是一种花哨的元组,而元组是不可变的时,这是有道理的。
DemoNTClass
也有一个自定义的文档字符串:
>>> DemoNTClass.__doc__ 'DemoNTClass(a, b)'
让我们检查DemoNTClass
的一个实例:
>>> nt = DemoNTClass(8) >>> nt.a 8 >>> nt.b 1.1 >>> nt.c 'spam'
要构造nt
,我们至少需要将a
参数传递给DemoNTClass
。构造函数还接受一个b
参数,但它有一个默认值1.1
,所以是可选的。nt
对象具有预期的a
和b
属性;它没有c
属性,但 Python 会像往常一样从类中检索它。
如果尝试为nt.a
、nt.b
、nt.c
甚至nt.z
分配值,您将收到略有不同的错误消息的AttributeError
异常。尝试一下并思考这些消息。
流畅的 Python 第二版(GPT 重译)(三)(2)https://developer.aliyun.com/article/1484431