Python 3.7 新特性 Data Class

Python 3.7 introduced a new way that helping for defining the data structure in a more convenient way: data class

April 18, 2018 - 10 minute read -
python

在 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 的方式,虽然也没有写太多的代码,但是我们已经可以看出一些它相对于数据类的缺点: 仅为了简单的初始化类,ranksuit 就已经重复出现了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()

它提供了与 namedtupledata 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=Falserepr=False, 把该字段隐藏了起来,并且不需要初始化,因为它是由后两个字段ranksuit 计算得来的。

另一个关键的特殊方法是 __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]

即使 ImmutableCardImmutableDeck 是不可变的, 但是卡片列表却不是:

>>> 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 在默认参数后面,因为 lonlat 是有默认值的。

.__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 我们将在不需要刻板的初始化类,以及它的表示和对象间的比较。

参考: