0%

流畅的 Python 第 2 版(5):数据类构造器

Python 提供了几种构建简单类的方式,这些类只是字段的容器,几乎没有额外功能。这种模式称为 数据类(data class),dataclasses 包就支持该模式。

数据类构建器概述

如下是一个简单的类,表示地理位置的经纬度:

1
2
3
4
class Coordinate:
def __init__(self, lat, lon):
self.lat = lat
self.lon = lon

Coordinate 类的作用是保存经纬度属性。为 __init__ 方法编写样板代码容易让人感到枯燥,尤其是属性较多的时候。而且样板代码并没有给我们提供 Python 对象都有的基本功能:

  • 例如继承自 object 的 __repr__ 作用不大
  • == 没有意义,因为继承自 object 的 eq 方法比较对象的ID

本章要讲的数据类构建器自动提供必要的 __init____repr____eq__ 等方法,此外还有其他有用的功能。而且这些类构建器都不依赖继承,它们使用不同的元编程技术把方法和数据属性注入要构建的类。

namedtuple 是一个工厂方法,使用指定的名称和字段构建 tuple 的子类。它创建的子类型提供了有意义的 __repr____eq__ 方法。

1
2
3
4
5
6
>>> from collections import namedtuple
>>> Coordinate = namedtuple('Coordinate', 'lat lon')
>>> issubclass(Coordinate, tuple)
True
>>> moscow == Coordinate(lat=1.0, lon=2.0)
True

新出现的 typing.NamedTuple 具有一样的功能,不过可为各个字段添加类型注解。

1
2
3
>>> Coordinate = NamedTuple('Coordinate', [('lat', float), ('lon', float)])
>>> typing.get_type_hints(Coordinate)
{'lat': <class 'float'>, 'lon': <class 'float'>}

构建带类型的具名元组,也可以通过关键字参数指定字段:

1
Coordinate = typing.NamedTuple('Coordinate', lat=float, lon=float)

typing.NamedTuple 也可以在 class 语句中使用,类型注解按 PEP 526—Syntax for Variable Annotations 标准编写,这样写出的代码可读性更高,而且方便覆盖方法或添加新方法。在 typing.NamedTuple 生成的 __init__ 方法中,字段参数的顺序与在 class 语句中出现的顺序相同:

1
2
3
4
5
6
7
8
9
10
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}'

在 class 语句中,虽然 NamedTuple 出现在超类的位置上,但其实它不是超类。typing.NamedTuple 使用元类这一高级功能创建用户类。

与 typing.NamedTuple 一样,dataclass 装饰器也支持使用 PEP 526 句法来声明实例属性。dataclass装饰器读取变量注解,自动为构建的类生成方法。@dataclass 装饰器不依赖继承或元类,如果你想使用这些机制,则不受影响。

1
2
3
4
5
6
7
8
9
10
11
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}'

比较 3 个数据类构建器的部分功能:

  • 3 个数据类构建器之间主要的区别在于,collections.namedtupletyping.NamedTuple 构建的类是 tuple 的子类,因此实例是不可变的。@dataclass 默认构建可变的类,但 @dataclass 装饰器接受一个关键字参数 frozen,指定 frozen=True,初始化实例之后,如果为字段赋值,则抛出异常
  • 只有 typing.NamedTupledataclass 支持常规的 class 语句句法,方便为构建的类添加方法和文档字符串
  • 两种具名元组都提供了构造 dict 对象的实例方法 ._asdict,可根据数据类实例的字段构造字典。dataclasses 模块也提供了构造字典的函数,即 dataclasses.asdict
  • 3 个类构建器都支持获取字段名称和可能配置的默认值
  • typing.NamedTuple@dataclass 构建的类有一个 __annotations__ 属性,存放字段的类型提示。然而,不建议直接读取 __annotations__ 属性。推荐使用 inspect.get_annotations(MyClass)typing.get_type_hints(MyClass) 获取类型信息
  • 对于具名元组实例 x,x._replace(**kwargs) 根据指定的关键字参数替换某些属性的值,返回一个新实例。模块级函数 dataclasses.replace(x, **kwargs)dataclass 装饰的类具有相同的作用。
  • class 句法虽然可读性更高,但毕竟还是硬编码的。框架可能需要在运行时动态构建数据类。为此,可以使用默认的函数调用句法,collections.namedtupletyping.NamedTuple 都支持。 dataclasses 模块提供的 make_dataclass 函数也是出于这个目的

典型的具名元组

collections.namedtuple 是一个工厂函数,用于构建增强的 tuple 子类,具有字段名称、类名和提供有用信息的 __repr__ 方法。namedtuple 构建的类可在任何需要元组的地方使用(以前标准库中返回元组的很多函数,现在都返回具名元组,这对用户的代码没有任何影响)。而且 namedtuple 构建的类,其实例占用的内存量与元组相同,因为字段名称存储在类中。

  • 创建具名元组需要指定两个参数:一个类名和一个字段名称列表。后一个参数可以是产生字符串的可迭代对象,也可以是一整个以空格分隔的字符串
  • 字段的值必须以单个位置参数传给构造函数
  • 可以通过名称或位置访问字段
  • 作为 tuple 的子类,创建的命名元组类继承了一些有用的方法,例如 __eq____lt__ 等,以及类型属性 _fields(返回一个元组,存储类的字段名称)、_make(iterable)(根据可迭代对象构建类的实例) 和实例方法 _as_dict()(返回对应的 dict 对象)。
  • namedtuple 接受 defaults 关键字参数,值为一个产生 N 项的可迭代对象,为从右数的 N 个字段指定默认值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
>>> from collections import namedtuple
>>> point = namedtuple("point", "x y")
>>> p = point(1, 2)
>>> p.x
1
>>> p[1]
2
>>> point._fields
('x', 'y')
>>> p2 = point._make([3, 4])
>>> p2
point(x=3, y=4)
>>> p2._asdict()
{'x': 3, 'y': 4}
>>> import json
>>> json.dumps(p2._asdict())
'{"x": 3, "y": 4}'

>>> point3D = namedtuple("point3D", "x y z", defaults=[10])
>>> p = point3D(1, 2)
>>> p
point3D(x=1, y=2, z=10)

namedtuple() 返回的类型也能增加自定义方法,只不过过程有些曲折:要先定义一个函数,再把这个函数赋值给一个类属性:

1
2
3
4
5
6
7
point3D(x=1, y=2, z=10)
>>> def add_one(point):
... return point3D(point.x + 1, point.y + 1, point.z + 1)
...
>>> point3D.add_one = add_one
>>> p.add_one()
point3D(x=2, y=3, z=11)

定义的方法函数第一个参数不必命名为 self,但是调用时指代的就是接收方。这种动态添加方法也展示了动态语言的强大。

带类型的具名元组

通过 typing.NamedTUple 也可以定义命名元组,这种定义方式提供了 class 语法:

1
2
3
4
5
6
from typing import NamedTuple

class Coordinate(NamedTuple):
lat: float
lon: float
reference: str = 'WGS84'
  • 每个实例字段都要注解类型
  • 实例字段 reference 注解了类型,还指定了默认值

使用 typing.NamedTuple 构建的类基本和 collections.namedtuple 生成的类类似,唯一的区别是多了类属性 __annotations__。鉴于 typing.NamedTuple 的主要功能是类型注解,接下来先简单介绍类型注解。

类型提示入门

类型提示(也叫类型注解)声明函数参数、返回值、变量和属性的预期类型。关于类型提示,首先你要知道,Python 字节码编译器和解释器根本不强制你提供类型信息。

运行时没有作用

Python 类型提示可以看作 供IDE和类型检查工具验证类型的文档类型提示对 Python 程序的运行时行为没有影响

1
2
3
4
5
6
7
8
>>> import typing
>>> class Point(typing.NamedTuple):
... x: float
... y: float
...
>>> p = Point("str", None)
>>> p
Point(x='str', y=None)

可以看到,运行时根本不考虑类型提示,也不检查类型。类型提示主要为第三方类型检查工具提供支持,例如 Mypy 和 PyCharm IDE 内置的类型检查器。这些是静态分析工具,在 静止 状态下检查 Python 源码,不运行代码。为了看到类型提示的效果,必须使用相关工具(例如linter)检查代码。

使用 mypy 检查之前的示例,看到的输出如下所示:

1
2
3
4
# mypy point.py
point.py:6: error: Argument 1 to "Point" has incompatible type "str"; expected "float" [arg-type]
point.py:6: error: Argument 2 to "Point" has incompatible type "None"; expected "float" [arg-type]
Found 2 errors in 1 file (checked 1 source file)

变量注解句法

typing.NamedTuple@dataclass 使用 PEP 526 定义的句法注解变量。这里先介绍在 class 语句中定义属性的注解句法。变量注解的基本句法如下所示:

1
var_name: some_type

允许使用的类型在 PEP 484 中的 Acceptable type hints 一节规定,不过定义数据类时,最常使用以下类型:

  • 一个具体类,例如 strFrenchDeck
  • 一个参数化容器类型,例如 list[int]​、tuple[str, float]
  • typing.Optional,例如 Optional[str]​,声明一个字段的类型可以是 strNone

另外,还可以为变量指定初始值。在 typing.NamedTuple@dataclass 声明中,指定的初始值作为属性的默认值,防止调用构造函数时没有提供对应的参数。

1
var_name: some_type = a_value

变量注解的意义

类型提示在运行时没有作用。然而,Python 在导入时(加载模块时)会读取类型提示,构建 __annotations__ 字典,供 typing.NamedTuple@dataclass 使用,增强类的功能。接下来先定义一个简单的类,然后再讨论 typing.NamedTuple@dataclass 增加的额外功能。

1
2
3
4
class DemoPlainClass:
a: int
b: float = 1.1
c = 'spam'
  • a 出现在 __annotations__ 中,但被抛弃了,因为该类没有名为 a 的属性
  • b 作为注解记录在案,而且是一个类属性,值为 1.1
  • c 是普通的类属性,没有注解
1
2
3
4
5
6
7
8
9
10
11
12
>>> from demo 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 存储为类属性,因为它们绑定了值
  • 这 3 个属性都不出现在 DemoPlainClass 的实例中。使用 o = DemoPlainClass() 创建一个对象,o.a 抛出 AttributeErroro.bo.c 检索类属性,值分别为 1.1spam,行为与常规的 Python 对象相同

接下来研究一个使用 typing.NamedTuple 构建的类,如下所示:

1
2
3
4
5
6
import typing

class DemoNTClass(typing.NamedTuple):
a: int
b: float = 1.1
c = 'spam'
  • a 是注解,也是实例属性
  • b 是注解,也是实例属性
  • c 是普通的类属性,没有注解
1
2
3
4
5
6
7
8
9
10
>>> DemoNTClass.__annotations__
{'a': <class 'int'>, 'b': <class 'float'>}
>>> DemoNTClass.a
_tuplegetter(0, 'Alias for field number 0')
>>> DemoNTClass.b
_tuplegetter(1, 'Alias for field number 1')
>>> DemoNTClass.c
'spam'
>>> DemoNTClass.__doc__
'DemoNTClass(a, b)'
  • typing.NamedTuple 创建了类属性 a 和 b。c 是普通的类属性,值为 spam
  • 类属性 a 和 b 是描述符。可以把描述符理解为特性(property)读值(getter)方法,即不带调用 运算符() 的方法,用于读取实例属性
  • 这意味着 a 和 b 是只读实例属性。这一点不难理解,因为 DemoNTClass 实例是某种高级的元组,而元组是不可变的
  • DemoNTClass 还有定制的文档字符串
1
2
3
4
5
6
7
8
9
>>> t = DemoNTClass(8)
>>> t
DemoNTClass(a=8, b=1.1)
>>> t.a
8
>>> t.b
1.1
>>> t.c
'spam'
1
2
3
4
5
6
7
8
>>> t.a = 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
>>> t.c = "test"
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'DemoNTClass' object attribute 'c' is read-only
  • 构造 DemoNTClass 实例时,需要提供 a 参数,b 也是构造函数的参数,不过它有默认值 1.1,因此可以不提供。
  • 实例有 a 和 b 两个属性,这在预期之中。但是,没有 c 属性,像往常一样,Python 从类中检索该属性
  • 为属性赋值时设置抛出抛出 AttributeError 异常,因为对象是元组,是不可变的

如下是使用 dataclass 装饰类的类:

1
2
3
4
5
6
7
from dataclasses import dataclass

@dataclass
class DemoDataClass:
a: int
b: float = 1.1
c = 'spam'
  • a 是注解,也是受描述符控制的实例属性
  • 同样,b 是注解,也是受描述符控制的实例属性,默认值为 1.1
  • c 是普通的类属性,没有注解
1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> from demo_dataclass import DemoDataClass
>>> DemoDataClass.__annotations__
{'a': <class 'int'>, 'b': <class 'float'>}
>>> DemoDataClass.__doc__
'DemoDataClass(a: int, b: float = 1.1)'

>>> DemoDataClass.a
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: type object 'DemoDataClass' has no attribute 'a'
>>> DemoDataClass.b
1.1
>>> DemoDataClass.c
'spam'
  • 这里需要说明的是,a 属性只在DemoDataClass实例中存在。如果冻结 DemoDataClass 类,那么 a 就变成可获取和设定的公开属性
  • b 和 c 作为类属性存在,b 存储实例属性 b 的默认值,而 c 本身就是类属性,不绑定到实例上
1
2
3
4
5
6
7
8
9
10
>>> dc = DemoDataClass(9)
>>> dc.a
9
>>> dc.b
1.1
>>> dc.c
'spam'
>>> dc.a = "test"
>>> dc
DemoDataClass(a='test', b=1.1)
  • a 和 b 是实例属性,而 c 是通过实例获取的类属性
  • DemoDataClass 实例是可变的,而且运行时不检查类型

甚至还可以为不存在的属性赋值。dc 实例有 c 属性,这对类属性 c 没有影响。我们还可以新增一个 z 属性。这是 Python 正常的行为:常规实例自身可以有未出现在类定义中的属性

1
2
>>> dc.c = 10
>>> dc.z = []

PEP 526提出的实例属性和类属性注解句法与我们在 class 语句中养成的习惯相反。以前,**class 块顶层声明的全是类属性(方法也是类属性)**​。而对于 PEP 526NamedTuple@dataclass在顶层声明的带有类型提示的属性变成了实例属性

1
2
3
@dataclass
class Spam:
repeat: int # 实例属性
1
2
3
@dataclass
class Spam:
repeat: int # 实例属性

但是,如果没有类型提示,我们一下子就回到了从前,在类顶层声明的属性只属于类自身。

1
2
3
@dataclass
class Spam:
repeat = 99 # 类属性!

如果你想为类属性注解类型,则不能使用常规的类型,否则就变成实例属性了。正确的做法是使用伪类型 ClassVar 注解(下文详解)。

1
2
3
@dataclass
class Spam:
repeat: ClassVar[int] = 99 # 真乱!

这是例外中的例外,语法的可读性的确不高,不太符合 Python 风格。

@dataclass 详解

@dataclass 装饰器可以接受多个关键字参数,完整签名如下:

1
2
@dataclass(*, init=True, repr=True, eq=True, order=False,
unsafe_hash=False, frozen=False)
  • 如果 eq 和 frozen 参数的值都是 True,那么 @dataclass 将生成一个合适的__hash__方法,确保实例是可哈希的。生成的 __hash__ 方法使用所有字段的数据,通过字段选项也不能排除
  • 对于 frozen=False(默认值)​,@dataclass 把 hash 设为 None,覆盖从任何超类继承的 hash 方法,表明实例不可哈希

生成的数据类还可以在字段层面进一步定制。我们已经见过最基本的字段选项,即在提供类型提示的同时设定默认值,声明的字段将作为参数传给生成的 __init__ 方法。Python 规定,带默认值的参数后面不能有不带默认值的参数。因此,为一个字段声明默认值之后,余下的字段都要有默认值

可变的默认值很容易导致 bug。如果在函数定义中使用可变默认值,调用函数时很容易破坏默认值,则导致后续调用的行为发生变化。@dataclass 使用类型提示中的默认值生成传给 __init__ 方法的参数默认值。为了避免bug,@dataclass拒绝如下那样定义类:

1
2
3
4
5
6
7
8
>>> @dataclass
... class A:
... a: list[int] = []
...

......
raise ValueError(f'mutable default {type(f.default)} for field '
ValueError: mutable default <class 'list'> for field a is not allowed: use default_factory

使用 default_factory 可以解决这个问题:

1
2
3
4
5
from dataclasses import dataclass, field

@dataclass
class A:
a: list[int] = field(default_factory=list)

通过调用 dataclasses.field 函数,把参数设为 default_factory=list,以此设定默认值。default_factory 参数的值可以是一个函数、一个类,或者其他可调用对象,在每次创建数据类的实例时调用(不带参数)​,构建默认值。这样每次数据类的实例都将拥有自己的 list,而不是所有实例共用 list

@dataclass 主动拒绝这种方案只适用于部分情况,只对 list、dict 和 set 有效。除此之外,其他可变的值不会引起 @dataclass 的注意。遇到这样的问题,你要自己处理,为可变的默认值设置默认工厂。

另外,这里的类型注解语法 list[int] 表示由 int 构成的列表。这是一种参数化泛型。从 Python3.9 开始,内置类型 list 可以使用方括号表示法指定列表中项的类型。在 Python3.9 之前,内置容器类型不支持泛型表示法。为了临时解决这一问题,typing 模块提供了对应的容器类型。此时如果需要参数化 list 类型提示,则必须使用从 typing 模块中导入的 List 类型,写作 List[str]​

  • guests: list 表示 guests 列表可以由任何类型的对象构成
  • guests: list[str] 的意思是 guests 列表中的每一项都必须是字符串。如果在列表中存储无效的项,或者读取到无效的项,则类型检查工具将报错

default_factory 应该是 field 函数最常使用的参数,不过除此之外还有其他参数可用。例如 default 参数,之所以有 default 参数,是因为在字段注解中设置默认值的位置被 field 函数调用占据了。

1
2
3
4
5
@dataclass
class ClubMember:
name: str
guests: list = field(default_factory=list)
athlete: bool = field(default=False, repr=False)

初始化后处理

@dataclass 生成的 __init__ 方法只做一件事:把传入的参数及其默认值(如果没有提供参数)赋值给实例属性,变成实例字段。有些时候初始化实例要做的不只是这些。为此,可以提供一个 __post_init__ 方法。如果存在这个方法,则 @dataclass 将在生成的 __init__ 方法最后调用 __post_init__ 方法。__post_init__ 经常用于执行验证,以及根据其他字段计算一个字段的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from dataclasses import dataclass

@dataclass
class ClubMember:
name: str
handle: str = ''
all_handles = set()

def __post_init__(self):
cls = self.__class__
if self.handle == "":
self.handle = self.name.split()[0]

if self.handle in cls.all_handles:
raise ValueError(f"handle {self.handle!r} already exist")

cls.all_handles.add(self.handle)



c1 = ClubMember("jack lee")
c2 = ClubMember("jack song")
1
2
3
......
raise ValueError(f"handle {self.handle!r} already exist")
ValueError: handle 'jack' already exist

带类型的类属性

虽然能实现我们的需求,能实现我们的需求,但不能让静态类型检查工具满意。如下是 mypy 的报错:

1
2
3
# mypy all_handler.py
all_handler.py:7: error: Need type annotation for "all_handles" (hint: "all_handles: set[<type>] = ...") [var-annotated]
Found 1 error in 1 file (checked 1 source file)

mypy 认为需要改 all_handlers 提供类型注解,但是如果为 all_handles 添加类型提示,例如 set[...]​,那么 @dataclass 将把 all_handles 变成实例字段。所以要解决这个问题,同时仍然将 all_handles 保留为类属性,则要使用一个名为 typing.ClassVar 的伪类型,借助泛型表示法 [​] 设定变量的类型,同时声明为类属性。这样就可以为类变量添加类型提示。

1
all_handles: ClassVar[set[str]] = set()

它表示 all_handles 是一个类属性,类型为字符串构成的集合,默认值是一个空集合@dataclass 装饰器不关心注解中的类型,但有两种例外情况,这是其中之一,即类型为 ClassVar 时,不为属性生成实例字段。另外一种情况是声明 仅作初始化的变量”

初始化不作为字段的变量

有时,我们需要把不作为实例字段的参数传给 __init__ 方法。按照 dataclasses 文档的说法,这种参数叫 仅作初始化的变量(init-only variable)。为了声明这种参数,dataclasses 模块提供了伪类型 InitVar,句法与 typing.ClassVar 一样。

1
2
3
4
5
6
7
8
9
10
11
@dataclass
class C:
i: int
j: int = None
database: InitVar[DatabaseType] = None

def __post_init__(self, database):
if self.j is None and database is not None:
self.j = database.lookup('j')

c = C(10, database=my_database)

这里 database 不会被设为实例属性,也不会出现在 dataclasses.fields 函数返回的列表中。然而,对于生成的 __init__ 方法,database 是参数之一,同时也传给 __post_init__ 方法。所以如果你想自己编写 __post_init__ 方法,需要在在方法签名中增加相应的参数。

一个复杂例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from dataclasses import dataclass, field
from typing import Optional
from enum import Enum, auto
from datetime import date


class ResourceType(Enum):
BOOK = auto()
EBOOK = auto()
VIDEO = auto()


@dataclass
class Resource:
"""描述媒体资源。"""
identifier: str
title: str = '<untitled>'
creators: list[str] = field(default_factory=list)
date: Optional[date] = None
type: ResourceType = ResourceType.BOOK
description: str = ''
language: str = ''
subjects: list[str] = field(default_factory=list)
  • Enum 为 Resource.type 字段提供类型安全的值
  • identifier 是唯一必需的字段
  • title 是第一个有默认值的字段。因此,后续字段都要提供默认值
  • date的值可以是一个 datetime.date 实例或 None
  • type 字段的默认值是 ResourceType.BOOK

数据类导致代码异味

无论是自己编写所有代码实现数据类,还是这里介绍的某个类构建器实现数据类,都要注意一点:这可能表示你的设计存在问题。所谓数据类是指,它们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。这样的类只是一种不会说话的数据容器,它们几乎一定被其他类过分烦琐地操控着

面向对象编程的主要思想是把行为和数据放在同一个代码单元(一个类)中。如果一个类使用广泛,但是自身没有什么重要的行为,那么整个系统中可能遍布处理实例的代码,并出现在很多方法和函数中。这样的系统对维护来说简直就是噩梦。鉴于此,Martin Fowler 提出的重构方案才建议把职责放回数据类中。

尽管如此,仍然有几种情况适合使用没什么行为或者没有任何行为的数据类。例如刚开始创建一个项目或者编写一个模块时,先用数据类简单实现一个类。随着时间的推移,类应该拥有自己的方法,而不是依赖其他类的方法操作该类的实例

数据类也可用于构建将要导出为 JSON 或其他交换格式的记录,也可用于存储刚刚从其他系统导入的数据。Python 中的数据类构建器都提供了把实例转换为普通字典的方法或函数,而且构造函数全部支持通过关键字参数提供一个字典(非常接近 JSON 记录)​,再使用 ** 展开。

在这种情况下,应把数据类实例当作不可变对象处理,即便字段是可变的,也不应在处于中间形式时更改。倘若更改,把数据和行为结合在一起的巨大优势就没有了。假如导入或导出时需要更改值,应该自己实现构建器方法,而不是使用数据类构建器提供的 用作字典 方法或常规的构造函数。

总之,不要滥用数据类,以免违背面向对象编程的一个基本原则,即数据和处理数据的函数应放在同一个类中。不含逻辑的类可能表明你把逻辑放错位置了。

模式匹配类实例

类模式通过类型和属性(可选)匹配类实例。类模式的匹配对象可以是任何类的实例,而不仅仅是数据类的实例。类模式有3种变体:简单类模式、关键字类模式和位置类模式。下面按顺序依次研究。

简单类模式

如下就是一个简单的类模式,该模式匹配项数为 4 的序列,第一项必须是 str 实例,最后一项必须是二元组,两项均为 float 实例。

1
case [str(name), _, _, (float(lat), float(lon))]:

类模式的句法看起来与构造函数调用差不多。下面的类模式匹配 float 值,未绑定变量(在 case 主体中,如果需要可以直接引用 x)​。

1
2
3
match x:
case float():
do_something_with(x)

但是下面这样可能有 bug:

1
2
3
match x:
case float: # 危险!!!
do_something_with(x)

case float: 可以匹配任何对象,因为 Python 把 float 看作匹配对象绑定的变量。float(x) 这种简单模式句法只适用于 9 种内置类型:

1
bytes   dict   float   frozenset   int   list   set   str   tuple

对这些类来说,看上去像构造函数的参数的那个变量,例如 float(x) 中的 x,绑定整个匹配的实例。如果是子模式,则绑定匹配对象的一部分,例如前例中序列模式内的 str(name)

1
case [str(name), _, _, (float(lat), float(lon))]:

除9种内置类型之外,看上去像参数的那个变量表示模式匹配的类实例的属性。

关键字类模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import typing

class City(typing.NamedTuple):
continent: str
name: str
country: str

cities = [
City('Asia', 'Tokyo', 'JP'),
City('Asia', 'Delhi', 'IN'),
City('North America', 'Mexico City', 'MX'),
City('North America', 'New York', 'US'),
City('South America', 'São Paulo', 'BR'),
]

def match_asian_cities():
results = []
for city in cities:
match city:
case City(continent='Asia'):
results.append(city)
return results

print(match_asian_cities())
  • City(continent=‘Asia’) 匹配的 City 实例,continent 属性的值等于 Asia,其他属性的值不考虑

如果你想收集 country 属性的值,可以像下面这样写:

1
2
3
4
5
6
7
def match_asian_countries():
results = []
for city in cities:
match city:
case City(continent='Asia', country=cc):
results.append(cc)
return results

City(continent='Asia', country=cc) 也匹配位于亚洲的城市,不过现在把变量 cc 绑定到了实例的 country 属性上。模式变量叫 country 也没关系。

关键字类模式的可读性非常高,适用于任何有公开的实例属性的类,不过有点烦琐。有时候,使用位置类模式更方便,不过匹配对象所属的类要显式支持。

位置类模式

如下函数使用位置类模式获取亚洲城市列表:

1
2
3
4
5
6
7
def match_asian_cities_pos():
results = []
for city in cities:
match city:
case City('Asia'):
results.append(city)
return results
  • City('Asia') 匹配的 City 实例,第一个属性的值是 ‘Asia’,其他属性的值不考虑

如果你想收集 country 属性的值,可以像下面这样写:

1
2
3
4
5
6
7
def match_asian_countries_pos():
results = []
for city in cities:
match city:
case City('Asia', _, country):
results.append(country)
return results

City 或其他类若想使用位置模式,要有一个名为 __match_args__ 的特殊类属性。本章讲到的类构建器会自动创建这个属性。对于 City 类,__match_args__ 属性的值如下所示:

1
2
>>> City.__match_args__
('continent', 'name', 'country')

位置模式中属性的顺序就是 __match_args__ 声明的顺序。一个模式可以同时使用关键字参数和位置参数。match_args 列出的是可供匹配的实例属性,不是全部属性。因此,有时候除了位置参数之外可能还需要使用关键字参数。

小结

这篇文章主要介绍了 3 个数据类构建器:collections.namedtupletyping.NamedTupledataclasses.dataclass。每个构建器都可以根据传给工厂函数的参数生成数据类,后两个构建器还可以通过 class 语句提供类型提示。