引言
这是《流畅的Python第二版》抢先版的读书笔记。Python版本暂时用的是python3.8。为了使开发更简单、快捷,本文使用了JupyterLab。
Python提供了一些方法来构建一个只是字段集合的简单类,几乎没有其他功能。该模式被称为一个“数据类”——dataclasses
是支持该模式的包之一。
本文主要介绍三个不同的编写数据类的构建器:
collections.namedtuple
最简单的方式——由Python2.6引入typing.NamedTuple
一个需要在字段上的类型提示的替代方案——由Python3.5引入@dataclasses.dataclass
一个类装饰器,允许比以前的替代方案进行更多的定制,增加了许多选项和潜在的复杂性——由Python3.7引入
新内容简介
本章内容几乎是第二版的全新内容。
数据类构建器概览
考虑一个简单的代表经纬度坐标的类:
class Coordinate:
def __init__(self, lat, lon):
self.lat = lat
self.lon = lon
Coordinate类用于存储经纬度坐标。编写__init__
样板很快就会变得过时,特别是如果你的类有不止两个属性:每个属性都被提到三次!这种写法并没有给我们提供期望的Python对象的基本特性:
moscow = Coordinate(55.76, 37.62)
moscow # 继承自object的 __repr__ 函数并不是很有帮助
<__main__.Coordinate at 0x7ff60c0a2740>
location = Coordinate(55.76, 37.62)
location == moscow # 无意义的==操作,继承自object的__eq__方法只是比较对象ID
False
(location.lat, location.lon) == (moscow.lat, moscow.lon) # 需要显示比较每个属性
True
数据类构建器自动提供了必须的__init__
、__repr__
和__eq__
方法,还有其他有用的特性。
下面是通过namedtuple
构建的Coordinate
类例子:
from collections import namedtuple
Coordinate = namedtuple('Coordinate', 'lat lon')
issubclass(Coordinate, tuple) # 继承自元组
True
moscow = Coordinate(55.756, 37.617)
moscow # 有意义的__repr__
Coordinate(lat=55.756, lon=37.617)
moscow == Coordinate(lat=55.756, lon=37.617) # 支持根据属性比较
True
而新的type.NamedTuple
提供了一些功能,为每个字段添加类型注解:
import typing
Coordinate = typing.NamedTuple('Coordinate', [('lat', float), ('lon', float)])
# 也可以这样构造 Coordinate = typing.NamedTuple('Coordinate', lat=float, lon=float)
issubclass(Coordinate, tuple)
True
typing.get_type_hints(Coordinate)
{'lat': float, 'lon': float}
自Python3.6之后,typing.NamedTuple
也能用于类声明中。这样可读性更好,而且很容易重写或增加方法。
下面通过自定义__str__
来格式化输出。
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
这样看起来像是父类,但是实际并不是。它使用元类高级特性来自定义用户类的创建:
在通过typing.NamedTuple
生成的__init__
方法中,字段作为参数出现的顺序与它们在类语句中出现的顺序相同。
像typing.NamedTuple
一样,dataclass
装饰器支持PEP 526语法来定义实例属性。
装饰器读取变量注释,并为你的类自动生成方法。
为了比较,检查相等的通过dataclass
装饰器编写的Coordinate
类:
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}'
注意这两个类里面的代码是相同的,不同之处在于类定义处。@dataclass
装饰器并不依赖于继承或元类,所以它不应该干扰你自己对这些机制的使用。上面的Coordinate是object的子类。
主要特性
不同的数据类构建器有很多共同点,如下表所示。
namedtuple | NamedTuple | dataclass | |
---|---|---|---|
可变实例 | NO | NO | YES |
类声明语法 | NO | YES | YES |
构建字典 | 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)] |
获取字段类型 | N/A | x.__annotations__ | x.__annotations__ |
替换新实例 | x._replace(…) | x._replace(…) | dataclasses.replace(x, …) |
运行时新类 | namedtuple(…) | NamedTuple(…) | dataclasses.make_dataclass(…) |
可变实例
这些类构建器的核心区别是collections.namedtuple
和typing.NamedTuple
构建的是元组的子类,所以这些实例都是不可变的。
默认,@dataclass
产生可变类。但该装饰器接收一个关键字参数frozen
,若上面的例子所示,当为True
时,如果你尝试为实例化的对象字段赋值时该类会抛出异常。
类声明语法
只有typing.NamedTuple
和dataclass
支持常规的类声明语法,使得很容易增加方法到你创要创建的类中。
构建字典
两个命名元组变量都提供一个实例方法(._asdict
)可以基于数据类实例中的字段来构建一个字典对象。dataclass
模块提供了一个函数:dataclass.asdict
。
获取字段名称和默认值
所有这三个类构建器都可以让你获取字段名称和默认值。在命名元组类中,该元数据是在._fields
和._fields_defaults
类属性中。
你可以使用fileds
函数从dataclasses
模块中获取同样的元数据。它返回一个Field
对象元组,其中包含name
和default
字段。
获取字段类型
基于typing.NamedTuple
和@dataclass
定义的类有一个字段名到类型__annotations__
类属性的映射。使用typing.get_type_hints
函数而不是直接读取__annotations__
。
替换新实例
给定一个命名元组实例x
,调用x._replace(**kwargs)
基于给定的关键字参数,会返回一个某些属性值替换了的新实例。对于dataclass
装饰的类是通过dataclasses.replace(x, **kwargs)
模块级方法。
运行时新类
随机类声明语法更可读,但它是硬编码的。框架可能需要在运行时构建数据类。为此,你可以使用collections.namedtuple
的默认函数调用语法,同样被typing.NamedTuple
支持。
dataclasses
模块同样提供了一个make_dataclass
函数。
在大概看了这些数据类构建器之后,我们下面仔细探讨下它们。从最简单的开始。
经典的命名元组
collections.namedtuple
函数是一个构建元组子类的工厂方法,实现了字段名称、类名和有意义的__repr__
加强。基于namedtuple
构建的类可以随意替换任何需要元组的地方。
下面演示一个例子,定义一个命名元组来保存城市信息。
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'
作为元组的子类,City
继承了有用的方法,比如__eq__
和用于比较的特殊方法——包括__lt__
,它可以对City
实例列表进行排序。
一个命名元组额外供了一些属性和方法。下面的代码展示了最有用的一些,比如_fields
类属性,类方法_make(iterable)
和_asdict()
实例方法。
City._fields # _fields 返回类字段名称的元组
('name', 'country', 'population', 'coordinates')
Coordinate = namedtuple('Coordinate', 'lat lon')
delhi_data = ('Delhi NCR', 'IN', 21.935, Coordinate(28.613889, 77.208889))
delhi = City._make(delhi_data) # _make从一个可迭代对象中构建City实例,也可以通过 City(*delhi_data)
delhi._asdict() # _asdict返回由命名元组实例构建的字典
{'name': 'Delhi NCR',
'country': 'IN',
'population': 21.935,
'coordinates': Coordinate(lat=28.613889, lon=77.208889)}
import json
json.dumps(delhi._asdict()) # ._asdict()在序列化为JSON格式时很有用
'{"name": "Delhi NCR", "country": "IN", "population": 21.935, "coordinates": [28.613889, 77.208889]}'
自Python3.7后,namedtuple
接收defaults
关键字参数,为类的最右边的N个字段提供N个迭代的默认值。
Coordinate = namedtuple('Coordinate', 'lat lon reference', defaults=['WGS84'])
Coordinate(0, 0) # reference默认值是WGS84
Coordinate(lat=0, lon=0, reference='WGS84')
Coordinate._field_defaults
{'reference': 'WGS84'}
NamedTuple
具有默认值字段的Coordinate
也可以通过typing.NamedTuple
来实现:
from typing import NamedTuple
class Coordinate(NamedTuple):
lat: float # 每个实例字段必须带有类型注解
lon: float
reference: str = 'WGS84' # reference实例字段还有默认值
基于typing.NamedTuple
构建的类除了collections.namedtuple
生成的方法——也是由元组继承的方法之外没有任何其他方法。
由于typing.NamedTuple
的主要特性是类型注释,我们先来快速看一下然后再继续探索数据类构建器。
类型注解
类型注解是定义函数参数、返回值、变量和属性期望类型的方法。
首先关于类型注解你需要知道的是,它们并没有被Python字节码编译器和解释器强制执行。
无运行时效果
可以把Python的类型注解看成“能被IDE和类型检测器验证的文档”。
因为类型注解对于Python程序的运行时表现无影响。来看下面这段代码。
import typing
class Coordinate(typing.NamedTuple):
lat: float
lon: float
trash = Coordinate('Ni!', None)
print(trash) # 看,没有运行时类型检查
Coordinate(lat='Ni!', lon=None)
类型注解主要用于支持第三方类型检查器,比如Mypy或PyCharm IDE内建的类型检查器。这些都是静态分析工具:它们静态地检查Python源码,而不是运行时的代码。
你可以在你的代码上运行这些工具。比如,下面我们使用mypy
(pip install mypy)工具来检测上面的例子(作为nocheck_demo.py
文件保存):
! mypy nocheck_demo.py
/bin/bash: mypy: command not found
可以看到,Mypy
知道这些参数的类型必须是float,但是trash
使用的是str和None。
变量注解语法
typing.NamedTuple
和@dataclass
都使用变量注解。变量注解的基本语法是:
var_name: some_type
在定义数据类的上下文中,下面这些类型是可接受的:
- 一个具体的类,比如,
str
- 一个参数化的集合类型,像
list[int]
,tuple[str, float]
等 typing.Optional
,比如,Optional[str]
也可以为变量赋值,会成为该属性的默认值:
var_name: some_type = a_value
变量注解的意义
我们知道变量注解无法影响运行时。但在加载时——当一个模块被加载——Python会读取它们来构建__annotations__
字典,然后typing.NamedTuple
和@dataclass
用来增加该类。
我们通过下面的例子来解释这一点。
class DemoPlainClass:
a: int # a变成了__annotations__的元素,但没有创建属性
b: float = 1.1 # b保存为一个注解,也变成了类属性
c = 'spam' # c是类属性,并不是注解
DemoPlainClass.__annotations__
{'a': int, 'b': float}
DemoPlainClass.a
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Input In [26], in <cell line: 1>()
----> 1 DemoPlainClass.a
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
下面我们看一个由typing.NamedTuple
构建的类
import typing
class DemoNTClass(typing.NamedTuple):
a: int # a变成了__annotations__的元素,也是一个实例属性
b: float = 1.1 # b保存为一个注解,也变成了实例属性
c = 'spam' # c是类属性,并不是注解
DemoNTClass.__annotations__
{'a': int, 'b': float}
DemoNTClass.a
_tuplegetter(0, 'Alias for field number 0')
DemoNTClass.b
_tuplegetter(1, 'Alias for field number 1')
DemoNTClass.c
'spam'
本例中类里面的代码和上面的一致,但是typing.NamedTuple
会创建a
和b
类属性。
a
和b
的类属性是descriptors——在第23章会介绍。现在,将它们看成属性的getter:一个不需要显示调用operator()
来检索实例属性的方法。这意味着a
和b
会作为只读实例属性——这说得通,我们知道DemoNTClass
实例只是花哨的元组,而元组是不可变的。
DemoNTClass
也会有一个自定义的docstring:
DemoNTClass.__doc__
'DemoNTClass(a, b)'
我们下面看下DemoNTClass
的实例:
nt = DemoNTClass(8)
nt.a
8
nt.b
1.1
nt.c
'spam'
要构造nt
,我们至少需要给DemoNTClass
提供一个参数。构造函数也接受b
参数,但它的默认值为1.1,所以它是可选的。nt
对象具有预期的a
和b
属性;它没有c
属性,但是Python像往常一样从类中检索它。
如果你想为nt.a
,nt.b
或nt.c
,甚至nt.z
赋值,你会得到一个AttributeError
异常。
检视由dataclass装饰的类
from dataclasses import dataclass
@dataclass
class DemoDataClass:
a: int # a是注解,同时也是实例属性
b: float = 1.1 # b是另一个注解,也是带有默认值1.1的descriptor的实例属性
c = 'spam' # c是类属性,并不是注解
DemoDataClass.__annotations__
{'a': int, 'b': float}
DemoDataClass.__doc__
'DemoDataClass(a: int, b: float = 1.1)'
DemoDataClass.a
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Input In [42], in <cell line: 1>()
----> 1 DemoDataClass.a
AttributeError: type object 'DemoDataClass' has no attribute 'a'
DemoDataClass.b
1.1
DemoDataClass.c
'spam'
奇怪的是在DemoDataClass
中没有名为a
的属性——与DemoNTClass
相反,它有一个descriptor。
这是因为a
属性只存在为DemoDataClass
的实例中。它是可读可写的公有属性。但b
和c
可以作为类属性存在。
下面我们看DemoDataClass
的实例是怎样的:
dc = DemoDataClass(9)
dc.a
9
dc.b
1.1
dc.c
'spam'
这里a
和b
是实例属性,c
是通过实例获取的类属性。
正如我们上面所说的,DemoDataClass
的实例是可变的,且没有运行时类型检查:
dc.a = 10
dc.b = 'oops'
dc.c = 'whatever'
dc.z = 'secret stash' # 还可以创建新属性
这里dc
有c
实例属性——但不会修改c
类属性。同时我们可以添加一个新的z
属性。这是正常的Python行为:普通实例可以有不存在类中的自己的属性。
@dataclass的更多知识
到此为止我们只看到了@dataclass
的一个简单例子。该装饰器接收几个关键字参数,签名如下:
@dataclass(*, init=True, repr=True, eq=True, order=False,
unsafe_hash=False, frozen=False)
第一个位置参数*
表示剩下的参数为仅关键词。
参数 | 用途 | 默认值 | 备注 |
---|---|---|---|
init | 生成__init__ | True | 如果用户实现了__init__ ,该设置会被忽略 |
repr | 生成__repr__ | True | 如果用户实现了__repr__ ,该设置会被忽略 |
eq | 生成__eq__ | True | 如果用户实现了__eq__ ,该设置会被忽略 |
order | 生成__lt__ ,__le__ ,__gt__ ,__ge__ | False | 如果eq=False 且该参数为True ,或定义/继承了将生成的任何比较方法,则抛出异常, |
unsafe_hash | 生成__hash__ | False | 比较复杂,建议参考文档 |
frozen | 使实例变成不可变的 | False | 可以防止不小心修改实例,但不是真正不可变 |
默认值在大多数情况下够用了。可能你想修改的参数是:
frozen=True
防止不小心修改了类实例属性order=True
允许对数据类实例进行排序
如果eq
和frozen
参数都是True
,@dataclass
会生成一个合适的__hash__
方法,所以该实例是可哈希的。生成的__hash__
将使用来自所有没有使用一个字段选项单独排除的字段的数据,该字段选项我们会在下一节中看到。如果fronze=False
(默认),@dataclass
会将__hash__
设为None
,表示实例是不可哈希的。
字段选项
我们已经看到了最基本的字段选项:提供(或不提供)具有类型注解的默认值。你声明的实例字段将成为已生成的__init__
中的参数。Python不允许没有默认值的参数在有默认值的参数之后,因此在声明具有默认值的字段后,所有剩余的字段也必须具有默认值。
可变的默认值是引发Python开发人员的常见bug来源。在函数定义中,当一个函数的调用改变默认值时,一个可变的默认值很容易被损坏,从而改变进一步输入的行为——我们将在下一篇文章中探讨这个问题。类属性通常作为实例的默认属性值,包括数据类。@dataclass
使用在类型注解的默认值来在__init__
中生成带默认值的参数。为了防止bug,@dataclass
拒绝像下面这样的类定义:
@dataclass
class ClubMember:
name: str
guests: list = []
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Input In [52], in <cell line: 1>()
1 @dataclass
----> 2 class ClubMember:
3 name: str
4 guests: list = []
File /opt/conda/lib/python3.10/dataclasses.py:1185, in dataclass(cls, init, repr, eq, order, unsafe_hash, frozen, match_args, kw_only, slots)
1182 return wrap
1184 # We're called as @dataclass without parens.
-> 1185 return wrap(cls)
File /opt/conda/lib/python3.10/dataclasses.py:1176, in dataclass.<locals>.wrap(cls)
1175 def wrap(cls):
-> 1176 return _process_class(cls, init, repr, eq, order, unsafe_hash,
1177 frozen, match_args, kw_only, slots)
File /opt/conda/lib/python3.10/dataclasses.py:956, in _process_class(cls, init, repr, eq, order, unsafe_hash, frozen, match_args, kw_only, slots)
953 kw_only = True
954 else:
955 # Otherwise it's a field of some type.
--> 956 cls_fields.append(_get_field(cls, name, type, kw_only))
958 for f in cls_fields:
959 fields[f.name] = f
File /opt/conda/lib/python3.10/dataclasses.py:813, in _get_field(cls, a_name, a_type, default_kw_only)
811 # For real fields, disallow mutable defaults for known types.
812 if f._field_type is _FIELD and isinstance(f.default, (list, dict, set)):
--> 813 raise ValueError(f'mutable default {type(f.default)} for field '
814 f'{f.name} is not allowed: use default_factory')
816 return f
ValueError: mutable default <class 'list'> for field guests is not allowed: use default_factory
ValueError
解释了该问题并给出了建议:使用default_factory
:
from dataclasses import dataclass, field
@dataclass
class ClubMember:
name: str
guests: list = field(default_factory=list)
default_factory
参数让你提供一个函数、类或任何其他可调用对象,每当该数据类的实例创建时,会进行无参调用default_factory
来构建一个默认值。这样,每个ClubMember
的实例会有它自己的list
,而不是所有的实例共享同一个来自类的list
,这通常不是我们想要的,从而容易引起bug。
还有一种新颖语法定义的list
字段:
from dataclasses import dataclass, field
@dataclass
class ClubMember:
name: str
guests: list[str] = field(default_factory=list) # list[str] 意味着 str列表
新语法list[str]
是一个参数化的泛型:自Python3.9引入。我们会在第8章介绍泛型。现在只要知道上面的两种写法都是正确的。
不同之处在guests:list
意味着guests
可以使任何类型的对象列表,而guests: list[str]
说的是该list
中的元素必须是str
。
default_factory
可能是field
函数中最常见的选项,但还是有其他选项,列表如下:
选项 | 用途 | 默认值 |
---|---|---|
default | field的默认值 | _MISSING_TYPE |
default_factory | 产生默认值的无参数函数 | _MISSING_TYPE |
init | 把该字段添加到__init__ | True |
repr | 把该字段添加到__repr__ | True |
compare | 在比较方法__eq__ 、__lt__ 等中使用该字段 | True |
hash | 在__hash__ 计算时考虑该字段 | None |
metadata | 使用用户定义的数据映射,@dataclass 忽略此选项 | None |
default
选项存在,因为field
调用将取代字段注释中的默认值。
如果你想创建一个默认值为False
的athlete
字段,并且从__repr__
方法中省略该字段,可以这么写:
@dataclass
class ClubMember:
name: str
guests: list = field(default_factory=list)
athlete: bool = field(default=False, repr=False)
Post-init处理
由@data_class
生成的__init__
方法只接受传递的参数,并为它们——如果缺少,则将它们的默认值——分配给实例字段的实例属性。
但是你可能需要做更多的事情来初始化实例。如果是这样,你可以提供一个__post_init__
方法。当该方法存在时,@dataclass
将在生成的__init__
中添加代码,在最后的时候调用__post_init__
。
__post_init__
的常见用例是基于其他字段的验证和计算字段值。我们来看一个简单的例子。
首先,让我们来看看一个名为ClubMember
的ClubMember
子类的预期行为,如文档测试所描述的那样:
"""
``HackerClubMember`` 对象接收一个可选的 ``handle`` 参数::
>>> anna = HackerClubMember('Anna Ravenscroft', handle='AnnaRaven')
>>> anna
HackerClubMember(name='Anna Ravenscroft', guests=[], handle='AnnaRaven')
如果 ``handle``参数 被省略, 那么它被设置为name的第一部分::
>>> leo = HackerClubMember('Leo Rochael')
>>> leo
HackerClubMember(name='Leo Rochael', guests=[], handle='Leo')
Members必须有唯一的handle. 下面的 ``leo2``不会被创建,
因为它的 ``handle`` 将会是 'Leo', 已经由 ``leo`` 设定过了::
>>> leo2 = HackerClubMember('Leo DaVinci')
Traceback (most recent call last):
...
ValueError: handle 'Leo' already exists.
为了解决该问题, ``leo2`` 必须通过显示指定``handle``创建::
>>> leo2 = HackerClubMember('Leo DaVinci', handle='Neo')
>>> leo2
HackerClubMember(name='Leo DaVinci', guests=[], handle='Neo')
"""
注意我们必须将handle
设为一个关键词参数,因为HackerClubMember
从ClubMember
中继承了name
和guests
,然后增加handle
字段。
# 保存为hackerclub.py
@dataclass
class HackerClubMember(ClubMember): # 继承自ClubMember
all_handles = set() # all_handles 是类属性
handle: str = '' # handle 是str类型的实例字段,默认值为空字符串
def __post_init__(self):
cls = self.__class__ # 获取实例的class
if self.handle == '': # 如果为空字符串
self.handle = self.name.split()[0] # 设置name的第一部分
if self.handle in cls.all_handles: # 如果handle存在
msg = f'handle {self.handle!r} already exists.' # 则抛出异常
raise ValueError(msg)
cls.all_handles.add(self.handle) # 增加新的handle到已存在的handle集合
为HackerClubMember
生成的docstring显示了构造函数调用的字段顺序:
HackerClubMember.__doc__
"HackerClubMember(name: str, guests: list[str] = <factory>, handle: str = '')"
这里<factory>
是一个简短的说法,说明一些可调用的值将为guests
产生默认值。关键是:要提供一个handle
但不提供guests
,我们必须将handle
作为关键字参数传递。
Typed类属性
如果我们用mypy
进行类型检查
!mypy hackerclub.py
hackerclub.py:6: [1m[31merror:[m Need type annotation for [m[1m"all_handles"[m (hint: [m[1m"all_handles: Set[<type>] = ..."[m)[m
[1m[31mFound 1 error in 1 file (checked 1 source file)[m
不幸的是,Mypy提供的提示在@dataclass
使用的上下文中没有帮助。首先,它建议使用Set
,但我使用Python 3.9,所以我可以使用Set
,并避免从输入中导入Set
。更重要的是,如果我们添加一个类型注解,如set[...]
,到all_handles
,@dataclass
会发现该注解并将all_handles
作为实例字段。
初始化非字段变量
有时,你可能需要将非实例字段的参数传递给__init__
。这类参数称为init-only变量。要声明这样的参数,dataclasses
模块提供了伪类型InitVar
,它使用与typing.ClassVar
相同的输入语法。
给出的示例是一个数据类,它从数据库初始化了一个字段,并且数据库对象必须传递给构造函数。
@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
属性是如何声明的。InitVar
将阻止@dataclass
将database
作为一个常规字段来处理。它不会被设置为实例属性,dataclasses.field
函数也不会列出它。但是,database
将是生成的__init__
将接受的参数之一,并且它也将被传递给__post_init__
。如果编写该方法,则必须向方法签名添加相应的参数。
@dataclass例子:Dublin Core资源记录
通常由@dataclass
构建的类通常比我们所见的例子具有更多的字段。Dublin Core提供了更典型的例子:
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:
"""Media resource description."""
identifier: str # identifier 是唯一必传参数
title: str = '<untitled>' # title作为第一个带默认值的字段,使得下面所有的字段都必须提供默认值
creators: list[str] = field(default_factory=list)
date: Optional[date] = None # date的值可以是datetime.date或None
type: ResourceType = ResourceType.BOOK # 默认值为 ResourceType.BOOK
description: str = ''
language: str = ''
subjects: list[str] = field(default_factory=list)
下面的代码显示了一个doctest来描述Resource
记录是如何在代码中使用的:
description = 'Improving the design of existing code'
book = Resource('978-0-13-475759-9', 'Refactoring, 2nd Edition', ['Martin Fowler', 'Kent Beck'],
date(2018, 11, 19),
ResourceType.BOOK, description, 'EN',
['computer programming', 'OOP'])
book
Resource(identifier='978-0-13-475759-9', title='Refactoring, 2nd Edition', creators=['Martin Fowler', 'Kent Beck'], date=datetime.date(2018, 11, 19), type=<ResourceType.BOOK: 1>, description='Improving the design of existing code', language='EN', subjects=['computer programming', 'OOP'])
由@dataclass
生成的__repr__
看起来不错,但我们也可以让它可读性更好。我们期望的是这种格式:
Resource(
identifier = '978-0-13-475759-9',
title = 'Refactoring, 2nd Edition',
creators = ['Martin Fowler', 'Kent Beck'],
date = datetime.date(2018, 11, 19),
type = <ResourceType.BOOK: 1>,
description = 'Improving the design of existing code',
language = 'EN',
subjects = ['computer programming', 'OOP'],
)
下面实现上面所示的格式:
from dataclasses import dataclass, field, fields
from typing import Optional, TypedDict
from enum import Enum, auto
from datetime import date
@dataclass
class Resource:
"""Media resource description."""
identifier: str # identifier 是唯一必传参数
title: str = '<untitled>' # title作为第一个带默认值的字段,使得下面所有的字段都必须提供默认值
creators: list[str] = field(default_factory=list)
date: Optional[date] = None # date的值可以是datetime.date或None
type: ResourceType = ResourceType.BOOK # 默认值为 ResourceType.BOOK
description: str = ''
language: str = ''
subjects: list[str] = field(default_factory=list)
def __repr__(self):
cls = self.__class__
cls_name = cls.__name__
indent = ' ' * 4
res = [f'{cls_name}('] # 开始构建输出字符串
for f in fields(cls): # 遍历类中所有字段
value = getattr(self, f.name)
res.append(f'{indent}{f.name} = {value!r},')
res.append(')')
return '\n'.join(res)
description = 'Improving the design of existing code'
book = Resource('978-0-13-475759-9', 'Refactoring, 2nd Edition', ['Martin Fowler', 'Kent Beck'],
date(2018, 11, 19),
ResourceType.BOOK, description, 'EN',
['computer programming', 'OOP'])
book
Resource(
identifier = '978-0-13-475759-9',
title = 'Refactoring, 2nd Edition',
creators = ['Martin Fowler', 'Kent Beck'],
date = datetime.date(2018, 11, 19),
type = <ResourceType.BOOK: 1>,
description = 'Improving the design of existing code',
language = 'EN',
subjects = ['computer programming', 'OOP'],
)
数据类导致的代码异味
无论你是通过自己编写所有代码来实现数据类,还是利用本章中描述的类构建器,都要注意它可能产生你设计中的问题。
《重构:改进现有规范的设计》,第二版提供了一个“代码异味”的目录——代码中的模式可能表明了重构的需要。标题为“数据类”的条目以这样开头:
这些类都有字段,为字段获取和设置方法,而没有其他内容。这样的类是哑(dump)数据持有者,并且经常被其他类过于详细地操作。
面向对象编程的主要思想是将行为和数据放在同一个代码单元中:一个类。如果一个类被广泛使用,但它自己没有重要的行为,那么处理它自己的实例的代码可能会在整个系统的方法和函数中分散(甚至重复)——这是维护麻烦的原因。
考虑到这一点,在有一些常见的场景中,拥有一个很少或没有行为的数据类是有意义的。
数据类作为脚手架
在这个场景中,数据类是一个类的初始的、简单的实现。随着时间的推移,类应该得到自己的方法,而不是依赖于其他类的方法来操作它的实例。脚手架是临时的;最终,你的自定义类可能会完全独立于你用来启动它的构建器。
Python也可用于快速解决问题和实验,然后就可以保留脚手架。
数据类作为中间表示
数据类可以用于构建即将导出为JSON或其他交换格式的记录,或者保存刚刚导入的数据,并跨越某些系统边界。Python的数据类构建器都提供了一个方法或函数来将实例转换为普通的dict,并且你总是可以调用dict作为用**
扩展的关键字参数。这样的dict非常接近于JSON的记录。
在这种情况下,数据类实例应该作为不可变的对象来处理——即使字段是可变的,但在它们处于此中间形式时,你也不应该更改它们。如果你这样做了,你就失去了将数据和行为紧密结合起来的关键好处。当导入/导出需要更改值时,你应该实现自己的构建器方法,而不是使用给定的asdict
方法或标准构造函数。
模式匹配类实例
类模式被设计为按类型和可选的属性来匹配类实例。类模式的主题可以是任何类实例,而不仅仅是数据类的实例。
类模式有三种变体:简单的、关键字和位置。我们将按这个顺序来研究它们。
简单类模式
我们已经看到了一个使用简单的类模式作为子模式的示例:
case [str(name), _, _, (float(lat), float(lon))]:
该模式匹配一个四项序列,其中第一个项必须是str的一个实例,而最后一个项必须是一个具有两个float实例的2个元组。
类模式的语法看起来像是一个构造函数调用。下面是匹配浮点值而不绑定变量的类模式:
match x:
case float():
do_something_with(x)
但可能会是你代码中的一个bug:
match x:
case float: # DANGER!!!
do_something_with(x)
在上面的例子中,case float:
匹配任何主题(subject),因为Python将float
看成变量,然后绑定到该主题。
float(x)
的简单模式语法是一种特殊情况,它只适用于9种内置类型:
bytes dict float frozenset int list set str tuple
在这些类中,看起来像构造函数参数的变量——例如,float(x)
中的x
——被绑定到整个主题实例或匹配子模式的主题部分,如我们前面看到的序列模式中的str(name)
所示:
case [str(name), _, _, (float(lat), float(lon))]:
如果该类不是这9个内置对象之一,那么类似参数的变量表示要与该类实例的属性进行匹配的模式。
关键词类模式
要了解如何使用关键字类模式,请考虑以下城市类和五个实例。
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
模式City(continent='Asia')
匹配continent
值等于'Asia'
的任何City
实例,而不考虑其他属性的值。
如果你想收集国家属性的值,你可以写:
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
属性。如果模式变量也被称为国家,这也适用:
match city:
case City(continent='Asia', country=country):
results.append(country)
关键字类模式非常具有可读性,并且可以用于任何具有公共实例属性的类,但它们有些冗长。
位置类模式在某些情况下更方便,但是它们需要主题类的明确支持,我们将在下面看到。
位置类模式
下面的函数将返回一个使用位置类模式的亚洲城市列表:
def match_asian_cities_pos():
results = []
for city in cities:
match city:
case City('Asia'):
results.append(city)
return results
模式City('Asia')
匹配第一个属性值为“Asia”的任何City实例,而不管其他属性的值如何。
如果你想收集国家属性的值,你可以写:
def match_asian_countries_pos():
results = []
for city in cities:
match city:
case City('Asia', _, country):
results.append(country)
return results
模式城市City('Asia', _, country)
匹配上面相同的亚洲城市,但是现在国家变量被绑定到实例的第三个属性。
使City或任何类使用位置模式工作的是存在一个名为__match_args__
的特殊类属性,这一章中的类构建器会自动创建它。这是城市类中的__match_args__
的值:
City.__match_args__
('continent', 'name', 'country')
如你所见,__match_args__
按照属性在位置模式中使用的顺序声明了属性的名称。