Python 3.7的@dataclass装饰器-数据类(data class)

Python 3.7引入了一项新功能,即数据类(data class)。数据类通常主要包含数据,尽管实际上没有严格的限制。它使用新的@dataclass装饰器创建,如下所示:

from dataclasses import dataclass

@dataclass
class DataClassCard:
    rank: str
    suit: str

数据类已经实现了基本功能。例如,您可以直接创建、打印和比较数据类的实例:

>>> 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三次。此外,如果您尝试使用这个普通类,您会发现对象的表示形式不太具描述性,而且奇怪的是,一个红心皇后与另一个红心皇后不相等:

>>> 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)

在本教程中,您将了解数据类提供的各种便利功能,除了漂亮的表示和比较之外,还包括:

- 如何为数据类字段添加默认值
- 数据类如何允许对象排序
- 如何表示不可变数据
- 数据类如何处理继承

我们将在后续内容中深入介绍数据类的这些特性。

数据类的替代方案


对于简单的数据结构,您可能已经使用过元组或字典。您可以使用以下任一方式表示红心皇后牌:

>>> queen_of_hearts_tuple = ('Q', 'Hearts')
>>> queen_of_hearts_dict = {'rank': 'Q', 'suit': 'Hearts'}

它们可以工作。然而,作为程序员,您需要承担很多责任:

1、您需要记住`queen_of_hearts_...`变量代表一张牌。
2、对于元组版本,您需要记住属性的顺序。写成`('Spades', 'A')`将会破坏您的程序,但可能不会给出易于理解的错误消息。
3、如果使用字典版本,您必须确保属性名称是一致的。例如,`{'value': 'A', 'suit': 'Spades'}`将不会按预期工作。

此外,使用这些结构不是理想的:

>>> queen_of_hearts_tuple[0]  # 无命名访问
'Q'
>>> queen_of_hearts_dict['suit']  # 使用`.suit`会更好
'Hearts'

更好的替代方案是`namedtuple`。长期以来,它一直被用来创建易读的小型数据结构。事实上,我们可以使用`namedtuple`来重现上面的`DataClassCard`示例,如下所示:

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

虽然这看起来似乎很好,但是它不了解自己的类型可能会导致微妙且难以发现的错误,特别是它也会愉快地比较两个不同的`namedtuple`类:

>>> Person = namedtuple('Person', ['first_initial', 'last_name'])
>>> ace_of_spades = NamedTupleCard('A', 'Spades')
>>> ace_of_spades == Person('A', 'Spades')
True

`namedtuple`还带有一些限制。例如,很难为`namedtuple`的某些字段添加默认值。`namedtuple`本质上是不可变的,也就是说,`namedtuple`的值永远不会改变。在某些应用中,这是一个很棒的特性,但在其他场景中,希望拥有更多灵活性:

>>> card = NamedTupleCard('7', 'Diamonds')
>>> card.rank = '9'
AttributeError: can't set attribute

数据类并不会取代所有使用`namedtuple`的场景。例如,如果您需要数据结构表现得像一个元组,那么`namedtuple`是一个很好的替代选择!

另一个替代方案,也是数据类的灵感来源之一,是`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`不是标准库的一部分,因此它会为您的项目添加外部依赖项。通过数据类,类似的功能将在任何地方都可用。

除了元组、字典、`namedtuple`和`attrs`,还有许多其他类似的项目,包括`typing.NamedTuple`、`namedlist`、`attrdict`、`plumber`和`fields`。虽然数据类是一个很好的新选择,但在某些情况下,旧的替代方案仍然更合适。例如,如果您需要与特定 API 兼容的元组,或者需要数据类不支持的功能。

数据类基本

让我们回到数据类。例如,我们将创建一个名为Position的类,用于表示地理位置,包括名称、经度和纬度:

from dataclasses import dataclass

@dataclass
class Position:
    name: str
    lon: float
    lat: float

将`@dataclass`装饰器放在类定义的上方,这使得它成为一个数据类。在`class Position:`行下面,您只需列出您想在数据类中拥有的字段。使用的冒号标注表示这些字段是使用Python 3.6中的一种称为变量注释的新特性。我们很快将更详细地讨论这种标注以及为什么要指定数据类型,例如`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类。唯一区别于普通类的是它自动为您实现了基本的数据模型方法,如`.__init__()`、`.__repr__()`和`.__eq__()`。

默认值
向数据类的字段添加默认值非常简单:

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)

稍后您将了解到`default_factory`,它提供了一种提供更复杂默认值的方式。

类型提示
到目前为止,我们并未过多强调数据类支持原生的类型提示这一事实。您可能已经注意到我们使用类型提示定义了字段:`name: str`表示`name`应为文本字符串(`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始终是一种动态类型的语言。要实际捕获类型错误,可以在源代码上运行像Mypy这样的类型检查器。

添加方法
您已经了解到数据类只是一种普通的类。这意味着您可以自由地向数据类中添加自己的方法。例如,让我们计算两个位置在地球表面上的距离,可以使用haversine公式:

您可以像使用普通类一样向数据类添加.distance_to()方法:

# haversine formula
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  # 地球半径(单位:千米)
        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]

您可以像这样创建一个只包含两张牌的简单纸牌组:

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张扑克牌。首先,定义不同的等级和花色。然后,添加一个名为make_french_deck()的函数,用于创建PlayingCard实例的列表:

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]


为了有趣,这里使用它们的Unicode符号来表示四种不同的花色。

注意:上面我们在源代码中直接使用了Unicode符号(如♠)。我们可以这样做是因为Python默认支持使用UTF-8编写源代码。请参考Unicode输入页面,了解如何在您的系统上输入这些字符。您还可以使用\N命名字符转义(例如\N{BLACK SPADE SUIT})或\uUnicode转义(例如\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:  # 不会起作用
    cards: List[PlayingCard] = make_french_deck()


不要这样做!这引入了Python中最常见的反模式之一:使用可变的默认参数。问题在于所有的Deck实例都将使用相同的列表对象作为.cards属性的默认值。这意味着如果从一个Deck中移除一张牌,它也会从所有其他Deck实例中消失。事实上,数据类试图阻止你这样做,上面的代码会引发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()函数(注意复数形式)检索字段的metadata(以及关于字段的其他信息):

>>> 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的表示方式是明确且可读的,但它也非常冗长。上面的输出中,我删除了Deck中的52张牌中的48张牌。在80列的显示器上,仅打印完整的Deck就占用了22行!让我们添加一种更简洁的表示方式。通常情况下,Python对象有两种不同的字符串表示方式:

  • 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}'


现在的卡片看起来好多了,但是 Deck 仍然一如既往地冗长:

>>> 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__()方法,我们将违背它应该返回能够重新创建对象的代码的原则。实用性毕竟胜过纯粹性。下面的代码添加了一个更简洁的 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)

这是Deck 的更好表示。 然而,这是有代价的。 您不再能够通过执行其表示来重新创建套牌。 通常,您最好使用 .__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。)

有关每个参数的详细信息,请参阅原始的PEP文档。将order=True设置后,PlayingCard的实例可以进行比较:

>>> queen_of_hearts = PlayingCard('Q', '♡')
>>> ace_of_spades = PlayingCard('A', '♠')
>>> ace_of_spades > queen_of_hearts
False


那么这两张卡是如何比较的呢?你并没有指定排序规则,而且由于某种原因,Python似乎认为女王(Queen)比A要高...

事实证明,数据类将对象比较如同它们是字段元组。换句话说,女王比A高是因为在字母表中,'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`自动计算得出。这正是特殊方法`__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`字段计算的)。为了避免让用户对这个实现细节感到困惑,最好还是从类的`repr`中删除`sort_index`。

最后,`A`是最大的。

>>> 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)

或者,如果您不关心排序,这是如何随机抽取10张牌的方法:

>>> 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的一个定义特点是它是不可变的。也就是说,它的字段的值在创建后不能更改。对于许多类型的数据类来说,这是一个很好的想法!要创建一个不可变的数据类,可以在创建时设置`frozen=True`。例如,以下是您之前看到的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中的所有嵌套数据结构都成立:

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`应该使用元组而不是列表来实现。

继承


您可以自由地对数据类进行子类化。例如,我们将使用一个`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  # 不起作用

此代码会立即导致TypeError,并抱怨“非默认参数'country'跟在默认参数之后”。问题是我们的新`country`字段没有默认值,而`lon`和`lat`字段有默认值。数据类将尝试生成以下签名的`__init__()`方法:

def __init__(name: str, lon: float = 0.0, lat: float = 0.0, country: str):
    ...

然而,这是无效的Python语法。如果一个参数有默认值,则后面的所有参数也必须有默认值。换句话说,如果基类中的字段有默认值,则在子类中添加的所有新字段也必须有默认值。

另一个需要注意的事项是子类中字段的排序顺序。从基类开始,字段按照它们首次定义的顺序排序。如果一个字段在子类中重新定义,它的顺序不会改变。例如,如果您按照以下方式定义`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')

优化数据类


在本教程的结尾,我将对 Slotes 进行一些说明。Slotes 可以用于使类的速度更快,使用的内存更少。数据类没有专门用于处理Slotes 的语法,但是创建Slotes的常规方法对于数据类也同样适用(它们实际上就是普通的类!)。

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__`来定义Slotes,并在其中列出类中的变量。不在`.__slots__`中的变量或属性可能无法定义。此外,Slotes类可能没有默认值。

添加这些限制的好处是可以进行某些优化。例如,使用Pympler可以测量插Slotes类占用的内存更少:

from pympler import asizeof

simple = SimplePosition('London', -0.1, 51.5)
slot = SlotPosition('Madrid', -3.7, 40.4)
asizeof.asizesof(simple, slot)

类似地,使用标准库中的`timeit`,可以测量插槽数据类和常规数据类的属性访问速度:

from timeit import timeit

timeit('slot.name', setup="slot=SlotPosition('Oslo', 10.8, 59.9)", globals=globals())
timeit('simple.name', setup="simple=SimplePosition('Oslo', 10.8, 59.9)", globals=globals())

在这个特定的例子中,插槽类的速度大约快了35%。

结论和进一步阅读


数据类是Python 3.7的新特性之一。通过使用数据类,您无需编写样板代码即可获得适当的初始化、表示和比较功能。

您已经学会了如何定义自己的数据类,并且了解了以下内容:

  • 如何为数据类字段添加默认值
  • 如何自定义数据类对象的排序
  • 如何使用不可变数据类
  • 数据类的继承原理

你可能感兴趣的:(python)