原文链接:https://realpython.com/python-data-classes/
by Geir Arne Hjelle May 15, 2018
目录
数据类的各种选择
基础的数据类
- 默认值
- 类型提示
- 添加方法
更灵活的数据类
- 高级默认值
- 你需要表示形式?
- 比较卡牌
不可变数据类型
继承
优化数据类
结语&拓展阅读
一项 new and exciting feature coming in Python 3.7 (Python 3.7的令人兴奋的新特性)就是数据类。
数据类是专门用来存储数据的类,虽然其实并没有严格的限制。它是用新的 @dataclass
装饰器创建的,像这样:
from dataclasses import dataclass
@dataclass
class DataClassCard:
rank: str
suit: str
**注意:**这段代码以及本教程里的其他代码都得在Python 3.7+运行。
一个具备基本功能的数据类就这样实现了。例如,你可以直接实例化、打印、以及比较数据类的实例:
>>> queen_of_hearts = DataClassCard('Q', 'Hearts')
>>> queen_of_hearts.rank
'Q'
>>> queen_of_hearts
DataClassCard(rank='Q', suit='Hearts')
>>> queen_of_hearts == DataClassCard('Q', 'Hearts')
True
如果用常规的类来对比,一个最简化的常规类应该长这样:
class RegularCard:
def __init__(self, rank, suit):
self.rank = rank
self.suit = suit
虽然也不是特别多的代码,但你已经能看出“样板代码痛苦”的端倪了:为了完成初始化对象的简单任务, rank
和 suit
重复了3次。更重要的是,如果你用这种普通的类,那么它的字符串表示形式描述性很弱,出于某种原因一个红心皇后居然跟另一个不一样:
>>> queen_of_hearts = RegularCard('Q', 'Hearts')
>>> queen_of_hearts.rank
'Q'
>>> queen_of_hearts
<__main__.RegularCard object at 0x7fb6eee35d30>
>>> queen_of_hearts == RegularCard('Q', 'Hearts')
False
看起来数据类在幕后真的帮了我们很多。默认情况下,数据类实现的 .__repr__()
方法提供了优美的字符串表示,而 .__eq__()
方法也做到了基础的对象比较。至于上面那个模拟数据类的 RegularCard
类,你也得加上这些方法:
class RegularCard
def __init__(self, rank, suit):
self.rank = rank
self.suit = suit
def __repr__(self):
return (f'{self.__class__.__name__}'
f'(rank={self.rank!r}, suit={self.suit!r})')
def __eq__(self, other):
if other.__class__ is not self.__class__:
return NotImplemented
return (self.rank, self.suit) == (other.rank, other.suit)
在本篇教程中,你将学习数据类到底有什么便利之处。为了更进一步做到优雅的字符串表示和(对象)比较,你会学到:
- 如何给数据类的字段增加默认值
- 数据类如何实现对象排序
- 如何表示出不可变数据
- 数据类怎么继承
我们很快就会深入了解那些数据类的特性。然而,你可能想到自己之前就看过类似的东西。
Free Download: Get a sample chapter from Python Tricks: The Book that shows you Python’s best practices with simple examples you can apply instantly to write more beautiful + Pythonic code.
数据类的各种选择
你可能已经使用a tuple
or a dict
作为简单的数据结构。你可能用下面的方式之一来表示红心皇后卡牌:
>>> queen_of_hearts_tuple = ('Q', 'Hearts')
>>> queen_of_hearts_dict = {'rank': 'Q', 'suit': 'Hearts'}
能用。然而,给你这个程序员带来了很多任务:
-
你需要记住
queen_of_hearts_...
variable (变量)代表一张卡牌 -
对于
tuple
的版本,你需要记住属性的顺序。如果写成了('Spades', 'A')
就会让你的程序混乱,而且很有可能缺少一个容易理解的错误提示。 -
如果你用了
dict
版本,你必须确保属性名永远都是一致的。比如{'value': 'A', 'suit': 'Spades'}
就不会按预想的起效。
更多不理想的方面体现在:
>>> queen_of_hearts_tuple[0] # No named access
'Q'
>>> queen_of_hearts_dict['suit'] # Would be nicer with .suit
'Hearts'
一个好点的选择是使用 namedtuple
。人们已经用了它很长时间来创建可读性强又小巧的数据结构。事实上,我们可以用 namedtuple
重新创建这个数据类:
from collections import namedtuple
NamedTupleCard = namedtuple('NamedTupleCard', ['rank', 'suit'])
NamedTupleCard
和我们的 DataClassCard
输出一致:
>>> queen_of_hearts = NamedTupleCard('Q', 'Hearts')
>>> queen_of_hearts.rank
'Q'
>>> queen_of_hearts
NamedTupleCard(rank='Q', suit='Hearts')
>>> queen_of_hearts == NamedTupleCard('Q', 'Hearts')
True
所以干嘛还操心数据类呢?首先,数据类还有很多你没看到的特性。同时, namedtuple
有一些并不需要的特性。 namedtuple
被有意设计成一个常规的元组。可以通过比较看出来,例如:
>>> queen_of_hearts == ('Q', 'Hearts')
True
乍一看还不错,但如果意识不到它本来的类型,可能会导致难察觉又难修复的bug,尤其是它还乐于把两个完全不同的 namedtuple
拿来比较:
>>> Person = namedtuple('Person', ['first_initial', 'last_name']
>>> ace_of_spades = NamedTupleCard('A', 'Spades')
>>> ace_of_spades == Person('A', 'Spades')
True
namedtuple
也有一些限制。例如, namedtuple
里很难给字段添加默认值,而且天生 immutable (不可变)。这就是说,一个 namedtuple
的值永远不可变。在有的程序里,这是个很棒的特性,但在其他情况下,灵活一点才好。
>>> card = NamedTupleCard('7', 'Diamonds')
>>> card.rank = '9'
AttributeError: can't set attribute
数据类不可能完全替代 namedtuple
。例如,如果你就希望数据结构像元组那样,那namedtuple
就是很好的选择!
另一个选择,也是 inspirations for data classes (数据类的灵感来源)之一,就是 attrs
project (attrs
项目)。安装好 attrs
后(pip install attrs
)你可以像这样编写数据卡牌:
import attr
@attr.s
class AttrsCard:
rank = attr.ib()
suit = attr.ib()
先前的 DataClassCard
和 NamedTupleCard
示例放在这里一样能起作用。attrs
项目很好,也支持一些数据类不支持的特性,包括转换器和验证器。此外, attrs
已经有一段时间了,支持 Python 2.7 以及 Python 3.4+。然而,attrs
并不是标准库的一部分,它确实会给你的项目增加额外的 dependency (依赖项)。而通过数据类,在哪儿都能用这些相似的功能。
除了 tuple
, dict
, namedtuple
, 和 attrs
之外, 还有 many other similar projects (许多其他相似项目),包括 typing.NamedTuple
, namedlist
, attrdict
, plumber
, 和 fields
。虽然数据类是很好的选择,仍然有一些情况采用其他(数据类的)变体更好。例如,你需要兼容一个特定的接收元组的API或者需要某种数据类不支持的功能。
基础的数据类
让我们回到数据类。作为示例,我们创建一个 Position
类来代表地理位置,有名字和经纬度:
from dataclasses import dataclass
@dataclass
class Position:
name: str
lon: float
lat: float
正是这个类在定义时写在头顶上的 @dataclass
decorator (装饰器)使得它变成了数据类。 在 class Position:
这行下面,你只是简单列举了想要的字段。字段里使用的 :
符号是 Python 3.6 里被称作 variable annotations (变量标注)的新特性。 我们 soon (马上)就会更多地讨论这个符号以及为什么我们要把数据类型指定为 str
和 float
。
你就只需要写(上面)那么几行,新的类就能用了:
>>> pos = Position('Oslo', 10.8, 59.9)
>>> print(pos)
Position(name='Oslo', lon=10.8, lat=59.9)
>>> pos.lat
59.9
>>> print(f'{pos.name} is at {pos.lat}°N, {pos.lon}°E')
Oslo is at 59.9°N, 10.8°E
你也可以用创建命名元组差不多的方式创建数据类。下面这段就跟上面创建的 Position
(几乎)等价:
from dataclasses import make_dataclass
Position = make_dataclass('Position', ['name', 'lat', 'lon'])
一个数据类是常规的 Python class 。唯一把它区分开的事就是它已经帮你实现了诸如 .__init__()
, .__repr__()
, 和 .__eq__()
这些基本的 data model methods (数据模型方法)。
默认值
你可以很容易地给数据类的字段添加默认值:
from dataclasses import dataclass
@dataclass
class Position:
name: str
lon: float = 0.0
lat: float = 0.0
这就跟你在一个常规类的 .__init__()
里制定了默认值是一样的:
>>> Position('Null Island')
Position(name='Null Island', lon=0.0, lat=0.0)
>>> Position('Greenwich', lat=51.8)
Position(name='Greenwich', lon=0.0, lat=51.8)
>>> Position('Vancouver', -123.1, 49.3)
Position(name='Vancouver', lon=-123.1, lat=49.3)
Later (之后)你会学到 default_factory
,提供了设置更复杂默认值的方式。
类型提示
到目前为止,我们还没有过多强调数据类天然地支持类型提示这一事实。你可能已经注意到了我们在定义字段时使用了类型提示: name: str
说明 name
应该是一个 text string (str
类)。
事实上,在数据类里定义字段时,类型提示是强制添加的。如果没有类型提示,字段就不是数据类的一部分。然而,如果你不想给数据类加入显式的类型提示,就用 typing.Any
:
from dataclasses import dataclass
from typing import Any
@dataclass
class WithoutExplicitTypes:
name: Any
value: Any = 42
虽然你在使用数据类时需要加入某种形式的类型提示,但运行时却不是强制性的。下面的代码就能正常运行:
>>> Position(3.14, 'pi day', 2018)
Position(name=3.14, lon='pi day', lat=2018)
这是Python里的类型通常的运作方式: Python is and will always be a dynamically typed language。为了确切地捕获到类型错误,你可以在源码里运行 Mypy 这样的类型检查器。
添加方法
你已经知道了数据类其实就是常规类。这就说明你可以自由地往里面加入自己的方法。例如,让我们计算两个位置之间沿着地球表面经过的距离。一种方式是使用 the haversine formula (半正矢公式):
d = 2 r a r c s i n s i n 2 1 2 ( ϕ 2 − ϕ 1 ) + c o s ϕ 1 c o s ϕ 2 s i n 2 1 2 ( λ 2 − λ 1 ) d = 2r arcsin \sqrt { sin^2 \frac {1} {2} (\phi_2 - \phi_1) + cos \phi_1 cos \phi_2 sin^2 \frac {1} {2}(\lambda_2 - \lambda_1) } d=2rarcsinsin221(ϕ2−ϕ1)+cosϕ1cosϕ2sin221(λ2−λ1)
你可以在数据类里加入一个 .distance_to()
方法,就跟常规类一样:
from dataclasses import dataclass
from math import asin, cos, radians, sin, sqrt
@dataclass
class Position:
name: str
lon: float = 0.0
lat: float = 0.0
def distance_to(self, other):
r = 6371 # Earth radius in kilometers
lam_1, lam_2 = radians(self.lon), radians(other.lon)
phi_1, phi_2 = radians(self.lat), radians(other.lat)
h = (sin((phi_2 - phi_1) / 2)**2
+ cos(phi_1) * cos(phi_2) * sin((lam_2 - lam_1) / 2)**2)
return 2 * r * asin(sqrt(h))
它会跟你预期的一样起作用:
>>> oslo = Position('Oslo', 10.8, 59.9)
>>> vancouver = Position('Vancouver', -123.1, 49.3)
>>> oslo.distance_to(vancouver)
7181.7841229421165
更灵活的数据类
目前为止,你已经见识了很多数据类的基本特征:它提供了方便的方法,你也可以往里面添加默认值和别的方法。现在你将继续学习一些高级特征,像是给 @dataclass
装饰器和 field()
函数加参数。结合它们,数据类的创建会更加可控。
让我们继续回到本教程一开始的卡牌游戏例子,并在此过程中添加一个包含一副牌的类:
from dataclasses import dataclass
from typing import List
@dataclass
class PlayingCard:
rank: str
suit: str
@dataclass
class Deck:
cards: List[PlayingCard]
只含2张牌的一副牌可以这样创建:
>>> queen_of_hearts = PlayingCard('Q', 'Hearts')
>>> ace_of_spades = PlayingCard('A', 'Spades')
>>> two_cards = Deck([queen_of_hearts, ace_of_spades])
Deck(cards=[PlayingCard(rank='Q', suit='Hearts'),
PlayingCard(rank='A', suit='Spades')])
高级默认值
比如说你想给 Deck
默认值。打个比方,如果 Deck()
创建一副含52张牌的 regular (French) deck (常规牌组)那将是很方便的。 首先,指定不同的点数和花色。然后,加入创建PlayingCard
对象 list 的 make_french_deck()
函数:
RANKS = '2 3 4 5 6 7 8 9 10 J Q K A'.split()
SUITS = '♣ ♢ ♡ ♠'.split()
def make_french_deck():
return [PlayingCard(r, s) for s in SUITS for r in RANKS]
为了有趣,4种花色用它们的 Unicode symbols (Unicode码)来指定。
**注意:**在上面,我们直接在源码里使用像
♠
的 Unicode 字形。我们之所以可以这么做是因为 Python supports writing source code in UTF-8 by default(默认情况下Python支持使用UTF-8编写源码)。参考 this page on Unicode input (这篇关于Unicode输入的页面)来了解如何在你的系统输入这些。 你也可以使用\N
命名字符转义(如\N{BLACK SPADE SUIT}
)或\u
Unicode 转义(如\u2660
)来输入花色的Unicode符号。
为了方便后续比较卡牌大小,点数和花色也按常规顺序排列。
>>> make_french_deck()
[PlayingCard(rank='2', suit='♣'), PlayingCard(rank='3', suit='♣'), ...
PlayingCard(rank='K', suit='♠'), PlayingCard(rank='A', suit='♠')]
理论上讲,你可以使用这个函数来为 Deck.cards
指定默认值:
from dataclasses import dataclass
from typing import List
@dataclass
class Deck: # Will NOT work
cards: List[PlayingCard] = make_french_deck()
别真这么做!这是Python里最常见的“反模式”之一: using mutable default arguments(使用可变的默认参数)。问题就在于所有的 Deck
对象都会使用同样的列表对象作为 .cards
属性的默认值。这就是说,如果某张牌被从一个 Deck
里移除了,那么它也会从所有别的 Deck
实例中移除。事实上,数据类会试着 prevent you from doing this (防止你这么做),上面的代码会引发 ValueError
。
相反,数据类使用 default_factory
来处理可变默认值。要使用 default_factory
(以及许多其他数据类的炫酷特性),你需要使用 field()
指定符:
from dataclasses import dataclass, field
from typing import List
@dataclass
class Deck:
cards: List[PlayingCard] = field(default_factory=make_french_deck)
default_factory
的参数可以是任何无参数的可调用对象。现在就很容易创建一副完整的牌组了:
>>> Deck()
Deck(cards=[PlayingCard(rank='2', suit='♣'), PlayingCard(rank='3', suit='♣'), ...
PlayingCard(rank='K', suit='♠'), PlayingCard(rank='A', suit='♠')])
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
例子中,你可以指定经纬度应该以度为单位传入:
from dataclasses import dataclass, field
@dataclass
class Position:
name: str
lon: float = field(default=0.0, metadata={'unit': 'degrees'})
lat: float = field(default=0.0, metadata={'unit': 'degrees'})
元数据(以及其他关于一个字段的信息)能通过 fields()
函数获取(注意fields是复数,有s):
>>> from dataclasses import fields
>>> fields(Position)
(Field(name='name',type=<class 'str'>,...,metadata={}),
Field(name='lon',type=<class 'float'>,...,metadata={'unit': 'degrees'}),
Field(name='lat',type=<class 'float'>,...,metadata={'unit': 'degrees'}))
>>> lat_unit = fields(Position)[2].metadata['unit']
>>> lat_unit
'degrees'
你需要表示形式?
回想一下我们可以凭空创建一副扑克牌:
>>> Deck()
Deck(cards=[PlayingCard(rank='2', suit='♣'), PlayingCard(rank='3', suit='♣'), ...
PlayingCard(rank='K', suit='♠'), PlayingCard(rank='A', suit='♠')])
这里 Deck
的表示形式清晰、可读性强,也很冗长。上面的输出中我删了52张牌中的48张。在一个80行的显示中,仅仅是打印 Deck
就占了22行!让我们加入更简洁的表示。通常来讲,一个Python对象有 two different string representations(2种不同的字符串表示):
repr(obj)
是obj.__repr__()
定义的,应该返回一个开发者友好型的obj
表示。最好就是重新创建obj
的代码。数据类能做到这点。str(obj)
是obj.__str__()
定义的,应该返回一个用户友好型的obj
表示。 数据类不会实现一个.__str__()
方法,所以Python回退到调用.__repr__()
方法。
让我们给 PlayingCard
实现一个用户友好型的表示:
from dataclasses import dataclass
@dataclass
class PlayingCard:
rank: str
suit: str
def __str__(self):
return f'{self.suit}{self.rank}'
卡牌看起来好多了,但一副牌还是很冗长:
>>> ace_of_spades = PlayingCard('A', '♠')
>>> ace_of_spades
PlayingCard(rank='A', suit='♠')
>>> print(ace_of_spades)
♠A
>>> print(Deck())
Deck(cards=[PlayingCard(rank='2', suit='♣'), PlayingCard(rank='3', suit='♣'), ...
PlayingCard(rank='K', suit='♠'), PlayingCard(rank='A', suit='♠')])
为了展示可以自定义 .__repr__()
方法,我们将违反(repr)应该返回能够重新创建本对象代码的原则。毕竟 Practicality beats purity (实用性胜过纯粹性)。下面的代码增加了更简明的 Deck
表示形式:
from dataclasses import dataclass, field
from typing import List
@dataclass
class Deck:
cards: List[PlayingCard] = field(default_factory=make_french_deck)
def __repr__(self):
cards = ', '.join(f'{c!s}' for c in self.cards)
return f'{self.__class__.__name__}({cards})'
注意格式化字符串 {c!s}
里的 !s
符号。意思是我们显式地表明要使用每张 PlayingCard
的 str()
表示方式。有了新的 .__repr__()
, Deck
的表示方式看起来直观多了:
>>> Deck()
Deck(♣2, ♣3, ♣4, ♣5, ♣6, ♣7, ♣8, ♣9, ♣10, ♣J, ♣Q, ♣K, ♣A,
♢2, ♢3, ♢4, ♢5, ♢6, ♢7, ♢8, ♢9, ♢10, ♢J, ♢Q, ♢K, ♢A,
♡2, ♡3, ♡4, ♡5, ♡6, ♡7, ♡8, ♡9, ♡10, ♡J, ♡Q, ♡K, ♡A,
♠2, ♠3, ♠4, ♠5, ♠6, ♠7, ♠8, ♠9, ♠10, ♠J, ♠Q, ♠K, ♠A)
这是个更好的表示一副牌的方式。然而,也有代价。你不能再通过执行这段表示方式来重新创建对象了。通常, 最好用 .__str__()
实现这种表示方式。
比较卡牌
在很多卡牌游戏里,卡牌会互相比较。比如经典的扑克牌游戏,点数最大的牌获胜。就目前的实现来说, PlayingCard
类并不支持这种比较:
>>> queen_of_hearts = PlayingCard('Q', '♡')
>>> ace_of_spades = PlayingCard('A', '♠')
>>> ace_of_spades > queen_of_hearts
TypeError: '>' not supported between instances of 'Card' and 'Card'
然而,这(看起来)很容易纠正:
from dataclasses import dataclass
@dataclass(order=True)
class PlayingCard:
rank: str
suit: str
def __str__(self):
return f'{self.suit}{self.rank}'
@dataclass
装饰器有两种形式。目前为止你已经看过简单形式了, @dataclass
在指定时并没有任何括号和参数。然而,你也可以往 @dataclass()
装饰器的括号里传入参数。支持下列这些参数:
init
:是否添加.__init__()
方法?(默认True
。)repr
:是否添加.__repr__()
方法?(默认True
。)eq
:是否添加.__eq__()
方法?(默认True
。)order
:是否添加排序方法?(默认False
。)unsafe_hash
:强制添加一个.__hash__()
方法?(默认False
。)frozen
:如果是True
,给字段赋值就会报错。(默认False
。)
看 the original PEP 可以获取到参数的更多信息。在设定 order=True
之后, PlayingCard
的实例就可以比较了:
>>> queen_of_hearts = PlayingCard('Q', '♡')
>>> ace_of_spades = PlayingCard('A', '♠')
>>> ace_of_spades > queen_of_hearts
False
只不过这两张牌怎么比的呢?你还没指定该怎么排序呢,出于某些原因 Python 看起来认定了 Queen 比 Ace 要高级…
原来数据类在比较对象时,会把它们的字段排列成元组。换句话说,Queen 比 Ace 要高级是因为字母表里 'Q'
在 'A'
后面:
>>> ('A', '♠') > ('Q', '♡')
False
这对我们来说不太行得通。相反,我们需要定义某种专门用来排序的索引,得用上 RANKS
和 SUITS
里的顺序的。看起来像这样:
>>> RANKS = '2 3 4 5 6 7 8 9 10 J Q K A'.split()
>>> SUITS = '♣ ♢ ♡ ♠'.split()
>>> card = PlayingCard('Q', '♡')
>>> RANKS.index(card.rank) * len(SUITS) + SUITS.index(card.suit)
42
为了让 PlayingCard
能在比较时使用排序索引,我们需要在类里面加一个 .sort_index
字段。然而,这个字段应该是根据 .rank
和 .suit
自动算出来的。这就正好是 special method (特殊方法) .__post_init__()
的作用。允许在常规的 .__init__()
方法被调用之后进行一些特殊处理:
from dataclasses import dataclass, field
RANKS = '2 3 4 5 6 7 8 9 10 J Q K A'.split()
SUITS = '♣ ♢ ♡ ♠'.split()
@dataclass(order=True)
class PlayingCard:
sort_index: int = field(init=False, repr=False)
rank: str
suit: str
def __post_init__(self):
self.sort_index = (RANKS.index(self.rank) * len(SUITS)
+ SUITS.index(self.suit))
def __str__(self):
return f'{self.suit}{self.rank}'
注意 .sort_index
作为类的第一个字段被加入。这样,比较在一开始使用 .sort_index
字段时就完成了,只有在它们相等时才会比较别的字段。使用 field()
,你必须明确规定 .sort_index
不被囊括在 .__init__()
方法的参数里(因为它是由 .rank
和 .suit
字段算出来的)。为了避免用户被这个实现细节搞晕,将 .sort_index
从类的 repr
里移除大概也是个好主意。
终于,aces要高级点了:
>>> queen_of_hearts = PlayingCard('Q', '♡')
>>> ace_of_spades = PlayingCard('A', '♠')
>>> ace_of_spades > queen_of_hearts
True
现在你可以轻松创建一副排好序的牌:
>>> Deck(sorted(make_french_deck()))
Deck(♣2, ♢2, ♡2, ♠2, ♣3, ♢3, ♡3, ♠3, ♣4, ♢4, ♡4, ♠4, ♣5,
♢5, ♡5, ♠5, ♣6, ♢6, ♡6, ♠6, ♣7, ♢7, ♡7, ♠7, ♣8, ♢8,
♡8, ♠8, ♣9, ♢9, ♡9, ♠9, ♣10, ♢10, ♡10, ♠10, ♣J, ♢J, ♡J,
♠J, ♣Q, ♢Q, ♡Q, ♠Q, ♣K, ♢K, ♡K, ♠K, ♣A, ♢A, ♡A, ♠A)
或者,如果你不关心 sorting (排序),这儿有如何随机抽取十张牌:
>>> from random import sample
>>> Deck(sample(make_french_deck(), k=10))
Deck(♢2, ♡A, ♢10, ♣2, ♢3, ♠3, ♢A, ♠8, ♠9, ♠2)
当然,你不需要 order=True
来做到这一点…
不可变的数据类
在你之前看到的 namedtuple
里,其中一个特性就是 immutable (不可变)。这意味着,它的字段的值永远不会改变。这对许多类型的数据类来说都是很好的主意! 在你创建数据类的时候设置 frozen=True
可以让一个数据类不可变。例如, you saw earlier (你之前看到的)Position
类, 下面是一个不可变的例子:
from dataclasses import dataclass
@dataclass(frozen=True)
class Position:
name: str
lon: float = 0.0
lat: float = 0.0
在一个冻结数据类里,你创建好后就不能再给字段指定值了:
>>> pos = Position('Oslo', 10.8, 59.9)
>>> pos.name
'Oslo'
>>> pos.name = 'Stockholm'
dataclasses.FrozenInstanceError: cannot assign to field 'name'
注意如果你的类里包含可变字段, 那么它们可能发生改变。这对 Python 里所有嵌套数据都适用(看 this video for further info(这个视频获取更多信息)):
from dataclasses import dataclass
from typing import List
@dataclass(frozen=True)
class ImmutableCard:
rank: str
suit: str
@dataclass(frozen=True)
class ImmutableDeck:
cards: List[ImmutableCard]
即使 ImmutableCard
和 ImmutableDeck
都不可变,保存 cards
的列表却是可变的。因此你还是可以改变一副牌里的牌:
>>> queen_of_hearts = ImmutableCard('Q', '♡')
>>> ace_of_spades = ImmutableCard('A', '♠')
>>> deck = ImmutableDeck([queen_of_hearts, ace_of_spades])
>>> deck
ImmutableDeck(cards=[ImmutableCard(rank='Q', suit='♡'), ImmutableCard(rank='A', suit='♠')])
>>> deck.cards[0] = ImmutableCard('7', '♢')
>>> deck
ImmutableDeck(cards=[ImmutableCard(rank='7', suit='♢'), ImmutableCard(rank='A', suit='♠')])
要避免这点,确保一个不可变数据类的所有字段使用的都是不可变类型(但记住类型在运行时不是强制性的)。 ImmutableDeck
应该用元组而不是列表实现。
继承
你也可以相当随意地为数据类创建 subclass (子类)。作为示例,我们将用 country
字段来拓展 Position
示例并用它来记录首都:
from dataclasses import dataclass
@dataclass
class Position:
name: str
lon: float
lat: float
@dataclass
class Capital(Position):
country: str
在这个简单的例子中,一切都顺利地运行:
>>> Capital('Oslo', 10.8, 59.9, 'Norway')
Capital(name='Oslo', lon=10.8, lat=59.9, country='Norway')
Capital
的 country
字段在 Position
的三个初始字段后面加入。如果基类里的任何一个字段有默认值,事情就会变得复杂起来:
from dataclasses import dataclass
@dataclass
class Position:
name: str
lon: float = 0.0
lat: float = 0.0
@dataclass
class Capital(Position):
country: str # Does NOT work
这段代码会立即崩溃然后报 TypeError
,显示 “non-default argument ‘country’ follows default argument.” 这个问题是我们的新 country
字段没有默认值,然而 lon
和 lat
字段有默认值。数据类试着写一个具有如下签名的 .__init__()
方法:
def __init__(name: str, lon: float = 0.0, lat: float = 0.0, country: str):
...
然而,这在 Python 里是无效的。 If a parameter has a default value, all following parameters must also have a default value (如果一个参数有默认值,所有后续的参数都得有默认值)。换句话说,如果一个基类里的字段有默认值,那么所有在子类里添加的新字段都也得有默认值。
另一个要注意的事情是子类里的字段是如何排序的。从基类开始,字段由它们最初定义的顺序排序。如果一个字段在子类中被重定义了,它的顺序不会变。例如,如果你像下面这样定义 Position
和 Capital
:
from dataclasses import dataclass
@dataclass
class Position:
name: str
lon: float = 0.0
lat: float = 0.0
@dataclass
class Capital(Position):
country: str = 'Unknown'
lat: float = 40.0
Capital
里字段的顺序还是 name
, lon
, lat
, country
。然而, lat
的默认值成了 40.0
。
>>> Capital('Madrid', country='Spain')
Capital(name='Madrid', lon=0.0, lat=40.0, country='Spain')
优化数据类
我会用一些关于 slots 的讨论来结束本教程。 Slots 可以用来使类更快、占用更少内存。数据类没有明确的语法来处理slots,但普通的创建slots的方式对数据类也适用(它们真的只是常规类!)
from dataclasses import dataclass
@dataclass
class SimplePosition:
name: str
lon: float
lat: float
@dataclass
class SlotPosition:
__slots__ = ['name', 'lon', 'lat']
name: str
lon: float
lat: float
重要的是, slots 是在类中使用 .__slots__
列举变量后定义出来的。没出现在 .__slots__
里的变量和属性可能不会被定义。此外一个slots类可能没有默认值。
加入这种限制的好处是能完成特定优化。例如,slots 类占据更少内存,可以用 Pympler 测量:
>>> from pympler import asizeof
>>> simple = SimplePosition('London', -0.1, 51.5)
>>> slot = SlotPosition('Madrid', -3.7, 40.4)
>>> asizeof.asizesof(simple, slot)
(440, 248)
类似地,slots 类通常来说运行起来更快。下面的例子使用标准库里的 timeit 测量了访问 slots 数据类属性和常规数据类属性的速度。
>>> from timeit import timeit
>>> timeit('slot.name', setup="slot=SlotPosition('Oslo', 10.8, 59.9)", globals=globals())
0.05882283499886398
>>> timeit('simple.name', setup="simple=SimplePosition('Oslo', 10.8, 59.9)", globals=globals())
0.09207444800267695
在这个特定例子中,slot 类快了差不多35%。
结语&拓展阅读
数据类是 Python 3.7 的新特性之一。有了数据类,你不用非得写“样板代码”来让对象进行恰当的初始化、表示和比较。
你已经看到了如何定义自己的数据类,以及:
- 如何给数据类的字段增加默认值
- 数据类如何实现对象排序
- 如何表示出不可变数据
- 数据类怎么继承
如果你想深入了解数据类,看看 PEP 557 和原始 GitHub repo 里的讨论。
此外, Raymond Hettinger 的 PyCon 2018 演讲 Dataclasses: The code generator to end all code generators 也值得一看。
如果你还没有 Python 3.7,这还有一个 data classes backport for Python 3.6。现在,继续前进,写更少的代码吧!