geekdoc-python-zh/docs/realpython/python-data-classes.md

32 KiB
Raw Blame History

Python 3.7+中的数据类(指南)

原文:https://realpython.com/python-data-classes/

*立即观看**本教程有真实 Python 团队创建的相关视频课程。和书面教程一起看,加深理解: 在 Python 中使用数据类

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

虽然没有更多的代码要写,但是您已经可以看到样板文件之痛的迹象:ranksuit都重复了三次,只是为了初始化一个对象。此外,如果您尝试使用这个普通的类,您会注意到对象的表示不是非常具有描述性,并且由于某种原因,红心皇后与红心皇后不同:

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

在本教程中,您将确切了解数据类提供了哪些便利。除了漂亮的表示和比较,您还会看到:

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

我们将很快深入研究数据类的这些特性。然而,你可能会想你以前已经见过类似的东西了。

免费下载: 从 Python 技巧中获取一个示例章节:这本书用简单的例子向您展示了 Python 的最佳实践,您可以立即应用它来编写更漂亮的+Python 代码。

数据类的替代方案

对于简单的数据结构,你可能已经使用过tupledict。您可以用以下任何一种方式代表红心皇后牌:

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

它工作了。然而,作为一名程序员,这给你带来了很多责任:

  • 你需要记住的是queen_of_hearts_... 变量代表一张牌。
  • 对于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

虽然这看起来是件好事,但是缺乏对自身类型的了解可能会导致微妙且难以发现的错误,尤其是因为它还会乐于比较两个不同的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的所有用途。例如,如果您需要您的数据结构表现得像一个元组,那么命名元组是一个很好的选择!

另一个选择,也是数据类的灵感之一,是 attrs项目。安装了attrs(pip install attrs)后,可以编写如下的卡类:

import attr

@attr.s
class AttrsCard:
    rank = attr.ib()
    suit = attr.ib()

这可以以与前面的DataClassCardNamedTupleCard示例完全相同的方式使用。attrs项目很棒,支持一些数据类不支持的特性,包括转换器和验证器。此外,attrs已经存在了一段时间,在 Python 2.7 以及 Python 3.4 和更高版本中都得到了支持。然而,由于attrs不是标准库的一部分,它给你的项目增加了一个外部依赖项。通过数据类,类似的功能将随处可见。

除了tupledictnamedtupleattrs之外,还有很多其他类似的项目,包括 typing.NamedTuplenamedlistattrdictplumberfields 。虽然数据类是一个很好的新选择,但是仍然有旧的变体更适合的用例。例如,如果您需要与期望元组的特定 API 兼容,或者需要数据类中不支持的功能。

Remove ads

基本数据类别

让我们回到数据类。例如,我们将创建一个Position类,它将使用名称以及纬度和经度来表示地理位置:

from dataclasses import dataclass

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

使它成为数据类的是类定义上面的 @dataclass装饰符。在class Position:行下面,您只需简单地列出您想要包含在数据类中的字段。用于字段的:符号使用了 Python 3.6 中的一个新特性,叫做变量注释。我们将很快谈论更多关于这个符号以及为什么我们指定像strfloat这样的数据类型。

您只需要这几行代码。新类已经可以使用了:

>>> 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 这样的类型检查器。

Remove ads

添加方法

你已经知道数据类只是一个普通的类。这意味着您可以自由地将自己的方法添加到数据类中。作为一个例子,让我们沿着地球表面计算一个位置和另一个位置之间的距离。一种方法是使用哈弗辛公式:

The haversine formula

您可以向数据类添加一个.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

更灵活的数据类别

到目前为止,您已经看到了 data 类的一些基本特性:它为您提供了一些方便的方法,您仍然可以添加默认值和其他方法。现在您将了解一些更高级的特性,比如@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})或\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 中最常见的反模式之一:使用可变默认参数。问题是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)。您不能同时指定defaultdefault_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()函数检索元数据(以及关于字段的其他信息)(注意复数 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'

Remove ads

你需要代理吗?

回想一下,我们可以凭空创造卡片组:

>>> 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 对象有两种不同的字符串表示:

  • 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__()方法也是可能的,我们将违反它应该返回可以重新创建对象的代码的原则。实用性终究胜过纯粹性。下面的代码添加了一个更简洁的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说明符。这意味着我们明确地想要使用每个PlayingCardstr()表示。有了新的.__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()装饰器提供参数。支持以下参数:

  • 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 似乎认为皇后比 a 高…

事实证明,数据类比较对象就好像它们是其字段的元组一样。换句话说,皇后比 a 高,因为在字母表中'Q'排在'A'之后:

>>> ('A', '♠') > ('Q', '♡')
False

这对我们来说并不奏效。相反,我们需要定义某种使用RANKSSUITS顺序的排序索引。大概是这样的:

>>> 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可能也是一个好主意。

最后ace 很高:

>>> 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来做这个…

Remove ads

不可变数据类

您之前看到的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]

尽管ImmutableCardImmutableDeck都是不可变的,但是包含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')

Capitalcountry字段添加在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抱怨“非默认参数country跟在默认参数后面。”问题是我们新的country字段没有默认值,而lonlat字段有默认值。数据类将尝试用下面的签名编写一个.__init__()方法:

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

但是,这不是有效的 Python。如果一个参数有默认值,所有后续参数也必须有默认值。换句话说,如果基类中的字段有默认值,那么子类中添加的所有新字段也必须有默认值。

另一件需要注意的事情是字段在子类中是如何排序的。从基类开始,字段按照第一次定义的顺序排序。如果一个字段在子类中被重新定义,它的顺序不会改变。例如,如果将PositionCapital定义如下:

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中字段的顺序仍然是namelonlatcountry。但是lat的默认值会是40.0

>>> Capital('Madrid', country='Spain')
Capital(name='Madrid', lon=0.0, lat=40.0, country='Spain')

Remove ads

优化数据类

我要用几句关于的话来结束这个教程。插槽可以用来使类更快,使用更少的内存。数据类没有处理插槽的显式语法,但是创建插槽的正常方式也适用于数据类。(他们真的只是普通班!)

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__中的变量或属性可能无法定义。此外,插槽类可能没有默认值。

添加这些限制的好处是可以进行某些优化。例如,插槽类占用更少的内存,可以使用 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)

类似地,插槽类通常使用起来更快。以下示例使用标准库中的 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 talk Dataclasses:结束所有代码生成器的代码生成器非常值得一看。

如果您还没有 Python 3.7,那么 Python 3.6 还有一个数据类反向移植。现在,向前迈进,编写更少的代码!

立即观看**本教程有真实 Python 团队创建的相关视频课程。和书面教程一起看,加深理解: 在 Python 中使用数据类******