在 Python 3.7 版本中增加了数据类(data class)这一新功能, 类似于其他语言中的 struct
它就是一个使用装饰器创建的类:
from dataclasses import dataclass
@dataclass
class DataClassCard:
rank: str
suit: str
它帮助我们实现了,非常方便的使用 class 定义数据结构,但相对于常规的类,它默认封装了一些对于数据结构来说,友好的特性, 下面我们就一一介绍这些特性
>>> 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
上面的 data class 样例, 如果用普通类表示,应该是这样的:
class RegularCard
def __init__(self, rank, suit):
self.rank = rank
self.suit = suit
使用普通 class 的方式,虽然也没有写太多的代码,但是我们已经可以看出一些它相对于数据类的缺点:
仅为了简单的初始化类,rank
和 suit
就已经重复出现了3次,另外,实例对象的表示不完全是基于描述性的(缺少.__repr__()
方法):
>>> queen_of_hearts = RegularCard('Q', 'Hearts')
>>> queen_of_hearts.rank
'Q'
>>> queen_of_hearts
<__main__.RegularCard object at 0x7fb6eee35d30>
>>> # bacause of leaking developer-friendly __repr__ method
>>> queen_of_hearts == RegularCard('Q', 'Hearts')
False
>>> # does not support comparing, because of leaking the __eq__ method
data class 很好的帮助我们提供了这样的场景下,开箱即用的功能。
默认 data class 提供了 .__repr__()
和 .__eq__()
方法,实现了友好的输出和基本的对象间比较功能。
为了帮助我们更好的理解 data class 提供的方便之处,该文章中我们将覆盖:
- 在 data class 中如何给字段 (fields) 添加默认值
- data class 实例间的排序和比较
- 在 data class 中如何表示可变数据
- data class 的继承
在正式介绍 data class 之前我们看一下,当前我们在定义数据结构时,可以代替 data class 的做法。
Alternatives
在 python 还没有引入 data class 的情况下,为表示一个简单的数据结构,你也许在用元组或字典 或许是 namedtuple.
总的上来说,相对与 data class,元组或字典不是很友好, namedtuple
,提供了更接近 data class 的功能,它支持比较操作,但也有一些局限:
例如:
- 在 namedtuple 中给字段添加默认值不是很方便
- namedtuple 对象是不可变的,字段的值不可修改,在一些场景下这很好,但不够灵活
data class 并不是指在替换 namedtuple,在你仍然需要数据结构为 tuple 的场景下,namedtuple,仍然是比较好的选择。
取代 data class 的另一个选择是 attrs 项目,也是 data class 的灵感来源之一, 安装 attrs
之后,我们可以这样写:
import attr
@attr.s
class AttrsCard:
rank = attr.ib()
suit = attr.ib()
它提供了与 namedtuple
和 data class
一摸一样的功能,甚至还有一些额外的特性(converters
/validators
),但是相对于 data class
, 他不是标准库的一部分,我们仍然需要安装这个包。
当然,还有很多其他的一些同类代替:
Data Classes 的基础上手
- 创建 data class
from dataclasses import dataclass
@dataclass
class Position:
name: str
lon: float
lat: float
添加装饰器 @dataclass
, 表示我们创建的是一个 data class.
紧接着 class Position:
的下一行,开始列举我们的字段(fields).
字段名称后的 :
符号,是 python 3.6 新添加的称之为 变量注释 的功能
>>> 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
也可以这样创建 Data Class
from dataclasses import make_dataclass
Position = make_dataclass('Position', ['name', 'lat', 'lon'])
与我们创建 namedtulpe 的方法相同。
NOTES:
data class,就是一个常规的 class, 唯一不同的是,它添加了基本的数据模型方法,像 .__init__()
, .__repr__()
, 和 .__eq__()
。
默认值
from dataclasses import dataclass
@dataclass
class Position:
name: str
lon: float = 0.0
lat: float = 0.0
和我们在常规类中,使用 __init__()
指定默认值类似。
类型暗示(Hints)
在 data class 中,我们需要给每一个字段添加变量注释(typing),字段可以是任何类型(typing.Any
)。没有指定类型暗示的字段不会作为数据类的一部分
。
from dataclasses import dataclass
from typing import Any
@dataclass
class WithoutExplicitTypes:
name: Any
value: Any = 42
需要注意的是,在 python 中,尽管我们定义了变量注释,但是在真正运行的时候,我们仍然可以传递任何类型的变脸,而解释器并不会抛出任何异常(当然你的逻辑代码可能会),这也是 python 语言的特性,永远作为动态类型的语言, 想要在你的代码中运行类型检查,可以使用类似 Mypy 这样的工具
例如,在下面的例子中,我们虽然定义了参数 lon
的变量注释为 float
, 但是在运行时,我们可以传递 string
,而并不会报什么错误。
>>> Position(3.14, 'pi day', 2018)
Position(name=3.14, lon='pi day', lat=2018)
添加方法
和普通类完全相同,我们也可以为 data class 添加方法
Data Classe 高级使用
下面我们将介绍 data class 的高级用法,包括有:
- 装饰器
@dataclass
的参数 field()
函数
from dataclasses import dataclass
from typing import List
@dataclass
class PlayingCard:
rank: str
suit: str
@dataclass
class Deck:
cards: List[PlayingCard]
创建一个只有两张卡片的牌叠
>>> 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')])
高级默认值
假设我们想要给牌叠添加一个默认值,例如一副完整的52张牌(不含大王和小王)。
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]
理论上来说,我们可以使用这个函数作为 Deck.cards
的默认值
from dataclasses import dataclass
from typing import List
@dataclass
class Deck: # Will NOT work
cards: List[PlayingCard] = make_french_deck()
需要注意的是,我们最好避免该实现方法,因为它是 Python 中最常见的反模式:使用可变默认值作为参数,这会导致 Deck
的所有实例都是使用一个相同的列表对象作为cards
属性的默认值。这意味着,如果我们从其中的一个 Deck
实例的 cards
属性中移除了一个卡片,那这个卡片就会在所有的 Deck
实例中都不存在。
实际上,data class 会阻止我们这样做,上面的代码会抛出一个 ValueError
.
相反的,data class 使用一个叫做 default_factory
的东西来创建可变的默认值。使用 default_factory
(或是data class 中其他有趣的特性),我们需要使用 field()
函数:
from dataclasses import dataclass, field
from typing import List
@dataclass
class Deck:
cards: List[PlayingCard] = field(default_factory=make_french_deck)
参数 default_factory
可以为, 任何没有参数的可调用对象(如果有参数可以使用 functools.partial
去掉).
field()
函数可以用来自定义 data class 中每个的字段,field 的其他一些参数可以参考:
- default: Default value of the field
- default_factory: Function that returns the initial value of the field
- init: Use field in .init() method? (Default is True.)
- repr: Use field in repr of the object? (Default is True.)
- compare: Include the field in comparisons? (Default is True.)
- hash: Include the field when calculating hash()? (Default is to use the same as for compare.)
- metadata: A mapping with information about the field
data class 实例间的比较
还是用我们之前的例子:
from dataclasses import dataclass
@dataclass(order=True)
class PlayingCard:
rank: str
suit: str
def __str__(self):
return f'{self.suit}{self.rank}'
在我们创建 data class 的时候,我们只要为装饰器,指定参数 order=True
, 我们的 data class 就支持排序和比较了
>>> queen_of_hearts = PlayingCard('Q', '♡')
>>> ace_of_spades = PlayingCard('A', '♠')
>>> ace_of_spades > queen_of_hearts
False
data class 支持的其他的参数:
- init: Add .init() method? (Default is True.)
- repr: Add .repr() method? (Default is True.)
- eq: Add .eq() method? (Default is True.)
- order: Add ordering methods? (Default is False.)
- unsafe_hash: Force the addition of a .hash() method? (Default is False.)
- frozen: If True, assigning to fields raise an exception. (Default is False.)
需要注意的是,上面的比较,却不是按照我们预想的那样排序的,它是按照字母顺序排的,下面实现我们的牌面排序:
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}'
我们给 PalyingCard
添加了一个 sort_index
字段,并且指定了 init=False
和 repr=False
, 把该字段隐藏了起来,并且不需要初始化,因为它是由后两个字段rank
和 suit
计算得来的。
另一个关键的特殊方法是 __post_init__()
, 它允许我们在.__init__()
方法被调用后,处理一些特殊流程操作。就像它的字面含义: post
after init
.
查看我们排序后的牌叠
>>> Deck(sorted(make_french_deck()))
也可以参看随机排序的牌叠(当然也可以也不用指定参数order=True
)
>>> from random import sample
>>> Deck(sample(make_french_deck(), k=10))
不可变的 data class
前面我们看到的 namedtuple
的一个特性,便是不可变的.对于 data class 来说,它也可以使 data class 不可变,在我们创建data class 的时候,指定 frozen=True
, 这样我们创建的 data class 就是不可变的。
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'
需要注意的是,如果我们的 data class 中仍然包含着可变的字段,它的字段将仍然可以被改变,这也是 python 中所有嵌套的数据结构的特性.
from dataclasses import dataclass
from typing import List
@dataclass(frozen=True)
class ImmutableCard:
rank: str
suit: str
@dataclass(frozen=True)
class ImmutableDeck:
cards: List[PlayingCard]
即使 ImmutableCard
和 ImmutableDeck
是不可变的, 但是卡片列表却不是:
>>> 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
应该用 tuple 而不是 list.
data class 的继承
与常规类相似,我们也可以创建 data class 的子类:
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')
如果我们的基类中的任何字段存在默认值
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
错误,原因是因为 country
在默认参数后面,因为 lon
和 lat
是有默认值的。
.__init__()
方法可以理解为:
def __init__(name: str, lon: float = 0.0, lat: float = 0.0, country: str):
...
也就是说,如果我们的基类中的字段幽默值,那我们在子类中新添加的字段也必须包含默认值。
另外一个我们需要注意的是,即时我们在子类中重新覆盖了基类的某个字段值,我们所有字段,在 .__init__()
中的顺序仍然为覆盖前的顺序
优化 data class
使用 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
>>> from pympler import asizeof
>>> simple = SimplePosition('London', -0.1, 51.5)
>>> slot = SlotPosition('Madrid', -3.7, 40.4)
>>> asizeof.asizesof(simple, slot)
(440, 248)
>>> from timeit import timeit
>>> timeit('slot.name', setup="from position import SlotPosition; slot=SlotPosition('Oslo', 10.8, 59.9)")
0.05882283499886398
>>> timeit('simple.name', setup="from position import SimplePosition; simple=SimplePosition('Oslo', 10.8, 59.9)")
0.09207444800267695
总结
Data class 是 Python 3.7 的一个新特性,使用 data class 我们将在不需要刻板的初始化类,以及它的表示和对象间的比较。
参考: