Python 3.7+ 的数据类(指南)

原文链接:https://realpython.com/python-data-classes/
by
May 15, 2018
目录
数据类的各种选择
基础的数据类
默认值
类型提示
添加方法
更灵活的数据类
高级默认值
你需要表示形式?
比较卡牌
不可变数据类型
继承
优化数据类
结语&拓展阅读
一项
(Python 3.7的令人兴奋的新特性)就是数据类。数据类是专门用来存储数据的类,虽然其实并没有严格的限制。它是用新的 @dataclass
装饰器创建的,像这样:
注意:这段代码以及本教程里的其他代码都得在Python 3.7+运行。
一个具备基本功能的数据类就这样实现了。例如,你可以直接实例化、打印、以及比较数据类的实例:
如果用常规的类来对比,一个最简化的常规类应该长这样:
虽然也不是特别多的代码,但你已经能看出“样板代码痛苦”的端倪了:为了完成初始化对象的简单任务, rank
和 suit
重复了3次。更重要的是,如果你用这种普通的类,那么它的字符串表示形式描述性很弱,出于某种原因一个红心皇后居然跟另一个不一样:
看起来数据类在幕后真的帮了我们很多。默认情况下,数据类实现的 .__repr__()
方法提供了优美的字符串表示,而 .__eq__()
方法也做到了基础的对象比较。至于上面那个模拟数据类的 RegularCard
类,你也得加上这些方法:
在本篇教程中,你将学习数据类到底有什么便利之处。为了更进一步做到优雅的字符串表示和(对象)比较,你会学到:
如何给数据类的字段增加默认值
数据类如何实现对象排序
如何表示出不可变数据
数据类怎么继承
我们很快就会深入了解那些数据类的特性。然而,你可能想到自己之前就看过类似的东西。
Free Download: that shows you Python’s best practices with simple examples you can apply instantly to write more beautiful + Pythonic code.
数据类的各种选择
你可能已经使用
作为简单的数据结构。你可能用下面的方式之一来表示红心皇后卡牌:
能用。然而,给你这个程序员带来了很多任务:
你需要记住
queen_of_hearts_...
(变量)代表一张卡牌对于
tuple
的版本,你需要记住属性的顺序。如果写成了('Spades', 'A')
就会让你的程序混乱,而且很有可能缺少一个容易理解的错误提示。如果你用了
dict
版本,你必须确保属性名永远都是一致的。比如{'value': 'A', 'suit': 'Spades'}
就不会按预想的起效。
更多不理想的方面体现在:
一个好点的选择是使用 namedtuple
重新创建这个数据类:
NamedTupleCard
和我们的 DataClassCard
输出一致:
所以干嘛还操心数据类呢?首先,数据类还有很多你没看到的特性。同时, namedtuple
有一些并不需要的特性。 namedtuple
被有意设计成一个常规的元组。可以通过比较看出来,例如:
乍一看还不错,但如果意识不到它本来的类型,可能会导致难察觉又难修复的bug,尤其是它还乐于把两个完全不同的 namedtuple
拿来比较:
namedtuple
也有一些限制。例如, namedtuple
里很难给字段添加默认值,而且天生 (不可变)。这就是说,一个 namedtuple
的值永远不可变。在有的程序里,这是个很棒的特性,但在其他情况下,灵活一点才好。
数据类不可能完全替代 namedtuple
。例如,如果你就希望数据结构像元组那样,那namedtuple
就是很好的选择!
另一个选择,也是 attrs
项目)。安装好 attrs
后(pip install attrs
)你可以像这样编写数据卡牌:
先前的 DataClassCard
和 NamedTupleCard
示例放在这里一样能起作用。attrs
项目很好,也支持一些数据类不支持的特性,包括转换器和验证器。此外, attrs
已经有一段时间了,支持 Python 2.7 以及 Python 3.4+。然而,attrs
并不是标准库的一部分,它确实会给你的项目增加额外的 (依赖项)。而通过数据类,在哪儿都能用这些相似的功能。
除了 tuple
, dict
, namedtuple
, 和 attrs
之外, 还有 (许多其他相似项目),包括 , , , , 和 。虽然数据类是很好的选择,仍然有一些情况采用其他(数据类的)变体更好。例如,你需要兼容一个特定的接收元组的API或者需要某种数据类不支持的功能。
基础的数据类
让我们回到数据类。作为示例,我们创建一个 Position
类来代表地理位置,有名字和经纬度:
正是这个类在定义时写在头顶上的 class Position:
这行下面,你只是简单列举了想要的字段。字段里使用的 :
符号是 Python 3.6 里被称作 (变量标注)的新特性。 我们 (马上)就会更多地讨论这个符号以及为什么我们要把数据类型指定为 str
和 float
。
你就只需要写(上面)那么几行,新的类就能用了:
你也可以用创建命名元组差不多的方式创建数据类。下面这段就跟上面创建的 Position
(几乎)等价:
一个数据类是常规的 .__init__()
, .__repr__()
, 和 .__eq__()
这些基本的 (数据模型方法)。
默认值
你可以很容易地给数据类的字段添加默认值:
这就跟你在一个常规类的 .__init__()
里制定了默认值是一样的:
default_factory
,提供了设置更复杂默认值的方式。
类型提示
到目前为止,我们还没有过多强调数据类天然地支持类型提示这一事实。你可能已经注意到了我们在定义字段时使用了类型提示: name: str
说明 name
应该是一个 (str
类)。
事实上,在数据类里定义字段时,类型提示是强制添加的。如果没有类型提示,字段就不是数据类的一部分。然而,如果你不想给数据类加入显式的类型提示,就用 typing.Any
:
虽然你在使用数据类时需要加入某种形式的类型提示,但运行时却不是强制性的。下面的代码就能正常运行:
这是Python里的类型通常的运作方式:
。为了确切地捕获到类型错误,你可以在源码里运行 这样的类型检查器。添加方法
你已经知道了数据类其实就是常规类。这就说明你可以自由地往里面加入自己的方法。例如,让我们计算两个位置之间沿着地球表面经过的距离。一种方式是使用
(半正矢公式):你可以在数据类里加入一个 .distance_to()
方法,就跟常规类一样:
它会跟你预期的一样起作用:
更灵活的数据类
目前为止,你已经见识了很多数据类的基本特征:它提供了方便的方法,你也可以往里面添加默认值和别的方法。现在你将继续学习一些高级特征,像是给 @dataclass
装饰器和 field()
函数加参数。结合它们,数据类的创建会更加可控。
让我们继续回到本教程一开始的卡牌游戏例子,并在此过程中添加一个包含一副牌的类:
只含2张牌的一副牌可以这样创建:
高级默认值
比如说你想给 Deck
默认值。打个比方,如果 Deck()
创建一副含52张牌的 (常规牌组)那将是很方便的。 首先,指定不同的点数和花色。然后,加入创建PlayingCard
对象 的 make_french_deck()
函数:
为了有趣,4种花色用它们的
(Unicode码)来指定。注意:在上面,我们直接在源码里使用像
♠
的 Unicode 字形。我们之所以可以这么做是因为 (默认情况下Python支持使用UTF-8编写源码)。参考 (这篇关于Unicode输入的页面)来了解如何在你的系统输入这些。 你也可以使用\N
命名字符转义(如\N{BLACK SPADE SUIT}
)或\u
Unicode 转义(如\u2660
)来输入花色的Unicode符号。
为了方便后续比较卡牌大小,点数和花色也按常规顺序排列。
理论上讲,你可以使用这个函数来为 Deck.cards
指定默认值:
别真这么做!这是Python里最常见的“反模式”之一: Deck
对象都会使用同样的列表对象作为 .cards
属性的默认值。这就是说,如果某张牌被从一个 Deck
里移除了,那么它也会从所有别的 Deck
实例中移除。事实上,数据类会试着 (防止你这么做),上面的代码会引发 ValueError
。
相反,数据类使用 default_factory
来处理可变默认值。要使用 default_factory
(以及许多其他数据类的炫酷特性),你需要使用 field()
指定符:
default_factory
的参数可以是任何无参数的可调用对象。现在就很容易创建一副完整的牌组了:
field()
指定符用来单独定制数据类的每个字段。你一会儿会看到更多的例子。这有一些 field()
支持的参数供参考:
default
:字段默认值default_factory
:返回字段初始值的函数init
:在.__init__()
方法里用这个字段?(默认True
)repr
:在对象的repr
里用这个字段?(默认True
)compare
:在比较里用这个字段?(默认True
)hash
:在计算hash()
时用这个字段?(默认跟compare
一样)metadata
:有关字段的信息映射
在这个 Position
例子中,你知道了到如何通过写 lat: float = 0.0
来添加简单的默认值。然而,如果你还想自定义字段,比如在 repr
里把它藏起来,你就需要用 default
参数: lat: float = field(default=0.0, repr=False)
。你不能同时指定 default
和 default_factory
。
metadata
参数不是给数据类自己用的,而是让你(或第三方包)能给字段附上信息。在 Position
例子中,你可以指定经纬度应该以度为单位传入:
元数据(以及其他关于一个字段的信息)能通过 fields()
函数获取(注意fields是复数,有s):
你需要表示形式?
回想一下我们可以凭空创建一副扑克牌:
这里 Deck
的表示形式清晰、可读性强,也很冗长。上面的输出中我删了52张牌中的48张。在一个80行的显示中,仅仅是打印 Deck
就占了22行!让我们加入更简洁的表示。通常来讲,一个Python对象有 (2种不同的字符串表示):
repr(obj)
是obj.__repr__()
定义的,应该返回一个开发者友好型的obj
表示。最好就是重新创建obj
的代码。数据类能做到这点。str(obj)
是obj.__str__()
定义的,应该返回一个用户友好型的obj
表示。 数据类不会实现一个.__str__()
方法,所以Python回退到调用.__repr__()
方法。
让我们给 PlayingCard
实现一个用户友好型的表示:
卡牌看起来好多了,但一副牌还是很冗长:
为了展示可以自定义 .__repr__()
方法,我们将违反(repr)应该返回能够重新创建本对象代码的原则。毕竟 (实用性胜过纯粹性)。下面的代码增加了更简明的 Deck
表示形式:
注意格式化字符串 {c!s}
里的 !s
符号。意思是我们显式地表明要使用每张 PlayingCard
的 str()
表示方式。有了新的 .__repr__()
, Deck
的表示方式看起来直观多了:
这是个更好的表示一副牌的方式。然而,也有代价。你不能再通过执行这段表示方式来重新创建对象了。通常, 最好用 .__str__()
实现这种表示方式。
比较卡牌
在很多卡牌游戏里,卡牌会互相比较。比如经典的扑克牌游戏,点数最大的牌获胜。就目前的实现来说, PlayingCard
类并不支持这种比较:
然而,这(看起来)很容易纠正:
@dataclass
装饰器有两种形式。目前为止你已经看过简单形式了, @dataclass
在指定时并没有任何括号和参数。然而,你也可以往 @dataclass()
装饰器的括号里传入参数。支持下列这些参数:
init
:是否添加.__init__()
方法?(默认True
。)repr
:是否添加.__repr__()
方法?(默认True
。)eq
:是否添加.__eq__()
方法?(默认True
。)order
:是否添加排序方法?(默认False
。)unsafe_hash
:强制添加一个.__hash__()
方法?(默认False
。)frozen
:如果是True
,给字段赋值就会报错。(默认False
。)
看 order=True
之后, PlayingCard
的实例就可以比较了:
只不过这两张牌怎么比的呢?你还没指定该怎么排序呢,出于某些原因 Python 看起来认定了 Queen 比 Ace 要高级…
原来数据类在比较对象时,会把它们的字段排列成元组。换句话说,Queen 比 Ace 要高级是因为字母表里 'Q'
在 'A'
后面:
这对我们来说不太行得通。相反,我们需要定义某种专门用来排序的索引,得用上 RANKS
和 SUITS
里的顺序的。看起来像这样:
为了让 PlayingCard
能在比较时使用排序索引,我们需要在类里面加一个 .sort_index
字段。然而,这个字段应该是根据 .rank
和 .suit
自动算出来的。这就正好是 (特殊方法) .__post_init__()
的作用。允许在常规的 .__init__()
方法被调用之后进行一些特殊处理:
注意 .sort_index
作为类的第一个字段被加入。这样,比较在一开始使用 .sort_index
字段时就完成了,只有在它们相等时才会比较别的字段。使用 field()
,你必须明确规定 .sort_index
不被囊括在 .__init__()
方法的参数里(因为它是由 .rank
和 .suit
字段算出来的)。为了避免用户被这个实现细节搞晕,将 .sort_index
从类的 repr
里移除大概也是个好主意。
终于,aces要高级点了:
现在你可以轻松创建一副排好序的牌:
或者,如果你不关心
(排序),这儿有如何随机抽取十张牌:
当然,你不需要 order=True
来做到这一点…
不可变的数据类
在你之前看到的 namedtuple
里,其中一个特性就是 (不可变)。这意味着,它的字段的值永远不会改变。这对许多类型的数据类来说都是很好的主意! 在你创建数据类的时候设置 frozen=True
可以让一个数据类不可变。例如, (你之前看到的)Position
类, 下面是一个不可变的例子:
在一个冻结数据类里,你创建好后就不能再给字段指定值了:
注意如果你的类里包含可变字段, 那么它们可能发生改变。这对 Python 里所有嵌套数据都适用(看
(这个视频获取更多信息)):
即使 ImmutableCard
和 ImmutableDeck
都不可变,保存 cards
的列表却是可变的。因此你还是可以改变一副牌里的牌:
要避免这点,确保一个不可变数据类的所有字段使用的都是不可变类型(但记住类型在运行时不是强制性的)。 ImmutableDeck
应该用元组而不是列表实现。
继承
你也可以相当随意地为数据类创建 country
字段来拓展 Position
示例并用它来记录首都:
在这个简单的例子中,一切都顺利地运行:
Capital
的 country
字段在 Position
的三个初始字段后面加入。如果基类里的任何一个字段有默认值,事情就会变得复杂起来:
这段代码会立即崩溃然后报 TypeError
,显示 “non-default argument ‘country’ follows default argument.” 这个问题是我们的新 country
字段没有默认值,然而 lon
和 lat
字段有默认值。数据类试着写一个具有如下签名的 .__init__()
方法:
然而,这在 Python 里是无效的。
(如果一个参数有默认值,所有后续的参数都得有默认值)。换句话说,如果一个基类里的字段有默认值,那么所有在子类里添加的新字段都也得有默认值。另一个要注意的事情是子类里的字段是如何排序的。从基类开始,字段由它们最初定义的顺序排序。如果一个字段在子类中被重定义了,它的顺序不会变。例如,如果你像下面这样定义 Position
和 Capital
:
Capital
里字段的顺序还是 name
, lon
, lat
, country
。然而, lat
的默认值成了 40.0
。
优化数据类
我会用一些关于
的讨论来结束本教程。 Slots 可以用来使类更快、占用更少内存。数据类没有明确的语法来处理slots,但普通的创建slots的方式对数据类也适用(它们真的只是常规类!)
重要的是, slots 是在类中使用 .__slots__
列举变量后定义出来的。没出现在 .__slots__
里的变量和属性可能不会被定义。此外一个slots类可能没有默认值。
加入这种限制的好处是能完成特定优化。例如,slots 类占据更少内存,可以用
测量:
类似地,slots 类通常来说运行起来更快。下面的例子使用标准库里的
测量了访问 slots 数据类属性和常规数据类属性的速度。
在这个特定例子中,slot 类快了差不多35%。
结语&拓展阅读
数据类是 Python 3.7 的新特性之一。有了数据类,你不用非得写“样板代码”来让对象进行恰当的初始化、表示和比较。
你已经看到了如何定义自己的数据类,以及:
如何给数据类的字段增加默认值
数据类如何实现对象排序
如何表示出不可变数据
数据类怎么继承
如果你想深入了解数据类,看看
和原始 里的讨论。此外, Raymond Hettinger 的 PyCon 2018 演讲
也值得一看。如果你还没有 Python 3.7,这还有一个
。现在,继续前进,写更少的代码吧!