dataclass

发布时间 2023-04-11 09:49:17作者: LgRun

[数据类(dataclass)](Python 3.7+ 中的数据类 (指南) – 真正的 Python (realpython.com))


引入

数据类是通常主要包含数据的类,尽管实际上没有任何限制。它是使用新的装饰器创建的,@dataclass如下所示:

from dataclasses import dataclass


@dataclass
class DataClassCard:
    rank: str
    suit: str
    # python版本>=3.7


queen_of_hearts = DataClassCard("Q", "Hearts")
print(queen_of_hearts.rank)
print(queen_of_hearts)
print(queen_of_hearts == DataClassCard("Q", "Hearts"))
"""
Q
DataClassCard(rank='Q', suit='Hearts')
True
"""

与常见类进行比较:

class RegularCard:
    def __init__(self,rank,suit):
        self.rank=rank
        self.suit=suit

queen_of_hearts = RegularCard("Q", "Hearts")
print(queen_of_hearts.rank)
print(queen_of_hearts)
print(queen_of_hearts == RegularCard("Q", "Hearts"))
"""
True
Q
<__main__.RegularCard object at 0x0000019B33708850>
False
"""

普通类实现过程中相同参数的实例对象不相同;

默认情况下,dataclass类是实现了一个.__repr__()方法来提供字符串表示形式和一个可以执行基本对象比较的方法。

RegularCard优化:

class RegularCard:
    def __init__(self, rank, suit):
        self.rank = rank
        self.suit = suit

    def __repr__(self):
        return (f"{self.__class__.__name__} rank={self.rank!r},suit={self.suit!r}")

    def __eq__(self, other):
        if other.__class__ is not self.__class__:
            return NotImplementedError
        return (self.rank, self.suit) == (other.rank, other.suit)

数据类的替代项

对于简单的数据结构,常用的有元组字典.如:

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

它们可以满足当前的需求场景,但是会给程序员增加额外的工作:

  • 你选哦记住:变量queen_of_hearts_表示一张牌;
  • 需要牢记变量顺序;
  • 如果使用kwrags需要保证,key一致;

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

>>> queen_of_hearts_tuple[0]  # No named access
'Q'
>>> queen_of_hearts_dict['suit']  # Would be nicer with .suit
'Hearts'

更好的选择是命名元组(nametuple)。长期以来,它一直用于创建可读的小型数据结构。实际上,我们可以使用这样的方法重新创建上面的数据类示例:

from collections import namedtuple

NamedTupleCard = namedtuple('NamedTupleCard', ['rank', 'suit'])

这个定义将给出与我们的示例完全相同的输出:

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

那么,为什么还要为数据类而烦恼呢?首先,数据类具有比您目前看到的更多的功能。同时,具有一些不一定需要的其他功能。根据设计,a 是一个常规元组。这可以从比较中看出,例如:

>>> queen_of_hearts == ('Q', 'Hearts')
True

虽然这似乎是一件好事,但缺乏对自身类型的意识会导致微妙且难以发现的错误,特别是因为它也会愉快地比较两个不同的类:

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

这也有一些限制。例如,很难向 中的某些字段添加默认值。A 本质上也是不可变的。也就是说,一个的价值永远不会改变。在某些应用程序中,这是一个很棒的功能,但在其他设置中,拥有更大的灵活性会很好

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

数据类不会取代 的所有用法。例如,如果您需要数据结构的行为像元组,那么命名元组是一个很好的选择!

另一种选择,也是数据类的灵感来源之一,是 attrs 项目。使用 install (),您可以按如下方式编写卡类:

!pip install attrs
import attr

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

这可以以与前面的示例完全相同的方式使用。该项目很棒,并且确实支持数据类所没有的一些功能,包括转换器和验证器。此外,已经存在了一段时间,并且在Python 2.7以及Python 3.4及更高版本中得到支持。但是,由于它不是标准库的一部分,因此它确实会向项目添加外部依赖项。通过数据类,类似的功能将在任何地方可用。

数据类基础

例如,我们将创建一个类,该类将用名称以及纬度和经度来表示地理位置:Position

from dataclasses import dataclass

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

使它成为数据类的是类定义正上方的@dataclass装饰器。在该行下方,您只需列出数据类中所需的字段。用于字段的符号正在使用 Python 3.6 中称为变量注释的新功能。

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

pos=Position("Oslo",10.8,59.9)
print(pos)
print(pos.lat)
print(f'{pos.name} is at {pos.lat}°N, {pos.lon}°E')

还可以创建数据类,类似于创建命名元组的方式。以下内容(几乎)等同于上述定义:Position

from dataclasses import make_dataclass
#类型于创建命名元组
Position=make_dataclass("Position",['name',"lat",'lon'])
pos=Position("Oslo",10.8,59.9)
print(type(pos))

数据类是常规的 Python 类。唯一使它与众不同的是它具有基本的数据模型方法,,例如:

__init__\__repr__\__eq__.

默认值

#默认值
@dataclass
class Position:
    name:str
    lon:float=0.0
    lat:float=0.0

print(Position("Null Island"))
print(Position("Greenwich",lat=51.8))
print(Position("Vancouver",lat=49.3,lon=-123.1))
"""
Position(name='Null Island', lon=0.0, lat=0.0)
Position(name='Greenwich', lon=0.0, lat=51.8)
Position(name='Vancouver', lon=-123.1, lat=49.3)
"""

类型提示

实际上,在定义数据类中的字段时,必须添加某种类型提示。如果没有类型提示,该字段不是数据类的一部分。

但是,如果不想向数据类添加显式类型,请使用typing.Any

from typing import Any


@dataclass
class WithoutExplicitTypes:
    name: Any
    value: Any = 42


虽然在使用数据类式需要以某种形式添加类型提示,但这些类型不会再运行时强制执行。一下代码运行没有任何问题:

print(Position(3.14,"pi day",2018))

这就是在python中键入通常的工作方式:Python是并且永远在是一种动态类型语言。为了捕获实际的类型错误,可以在源代码上运行像Mypy这样的类型检查器。

添加方法

您已经知道数据类只是一个常规类。这意味着您可以自由地将自己的方法添加到数据类中。

例如:计算沿地球表面的一个位置与另一个位置之间的距离。一种方法是使用哈弗正弦公式

image-20230203133900862

@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)
print(oslo.distance_to(vancouver))

更灵活的数据类

到目前为止,您已经看到了 data 类的一些基本功能:它为您提供了一些方便的方法,并且您仍然可以添加默认值和其他方法。现在,您将了解一些更高级的功能,例如装饰器和函数的参数。它们共同为您提供了在创建数据类时的更多控制权。

让我们回到您在本教程开头看到的扑克牌示例,并在我们使用它时添加一个包含一副牌的类:

可以像这样创建仅包含两张牌的简单套牌:

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])
print(two_cards)

高级默认值

创建一个由 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]
print(make_french_deck())

为了好玩,四种不同的花色使用它们的 Unicode 符号指定。

注意:上面,我们直接在源代码中使用了 Unicode 字形。我们可以这样做,因为Python默认支持用UTF-8编写源代码。有关如何在系统上输入这些内容,请参阅此页面有关 Unicode 输入的内容。

为了简化以后的牌牌比较,等级和花色也按通常的顺序列出。

理论上,您现在可以使用此函数为 指定默认值:Deck.cards

@dataclass
class Deck:  # Will NOT Work
    cards: List[PlayingCard] = make_french_deck()


"""
ValueError: mutable default <class 'list'> for field cards 
is not allowed: use default_factory

"""

别这样!这引入了 Python 中最常见的反模式之一:使用可变的默认参数。问题是 的所有实例都将使用相同的列表对象作为属性的默认值。这意味着,例如,如果从一张卡中删除一张卡,那么它也将从所有其他实例中消失。实际上,数据类试图阻止您这样做,上面的代码将引发ValueError

相反,数据类中使用default_factory来处理可变的默认值

@dataclass
class Deck:
    cards: List[PlayingCard] = field(default_factory=make_french_deck)


print(Deck())

"""
Deck(cards=[PlayingCard(rank='2', suit='♣'), PlayingCard(rank='3', suit='♣'), PlayingCard(
rank='4', suit='♣'), PlayingCard(rank='5', suit='♣'), PlayingCard(rank='6', suit='♣'), 
PlayingCard(rank='7', suit='♣'), PlayingCard(rank='8', suit='♣'), PlayingCard(rank='9', 
suit='♣'), PlayingCard(rank='10', suit='♣'), PlayingCard(rank='J', suit='♣'), PlayingCard(
rank='Q', suit='♣'), PlayingCard(rank='K', suit='♣'), PlayingCard(rank='A', suit='♣'), 
PlayingCard(rank='2', suit='♢'), PlayingCard(rank='3', suit='♢'), PlayingCard(rank='4', 
suit='♢'), PlayingCard(rank='5', suit='♢'), PlayingCard(rank='6', suit='♢'), PlayingCard(
rank='7', suit='♢'), PlayingCard(rank='8', suit='♢'), PlayingCard(rank='9', suit='♢'), 
PlayingCard(rank='10', suit='♢'), PlayingCard(rank='J', suit='♢'), PlayingCard(rank='Q', 
suit='♢'), PlayingCard(rank='K', suit='♢'), PlayingCard(rank='A', suit='♢'), PlayingCard(
rank='2', suit='♡'), PlayingCard(rank='3', suit='♡'), PlayingCard(rank='4', suit='♡'), 
PlayingCard(rank='5', suit='♡'), PlayingCard(rank='6', suit='♡'), PlayingCard(rank='7', 
suit='♡'), PlayingCard(rank='8', suit='♡'), PlayingCard(rank='9', suit='♡'), PlayingCard(
rank='10', suit='♡'), PlayingCard(rank='J', suit='♡'), PlayingCard(rank='Q', suit='♡'), 
PlayingCard(rank='K', suit='♡'), PlayingCard(rank='A', suit='♡'), PlayingCard(rank='2', 
suit='♠'), PlayingCard(rank='3', suit='♠'), PlayingCard(rank='4', suit='♠'), PlayingCard(
rank='5', suit='♠'), PlayingCard(rank='6', suit='♠'), PlayingCard(rank='7', suit='♠'), 
PlayingCard(rank='8', suit='♠'), PlayingCard(rank='9', suit='♠'), PlayingCard(rank='10', 
suit='♠'), PlayingCard(rank='J', suit='♠'), PlayingCard(rank='Q', suit='♠'), PlayingCard(
rank='K', suit='♠'), PlayingCard(rank='A', suit='♠')])

"""

field():说明符用于单独自定义数据类的每个字段;

  • default:字段的默认值
    • default_factor:返回字段初始化值得函数
    • init在方法中使用字段,默认值为True
    • repr:使用对象得字段,默认为True
    • compare:在比较中包含该字段;
    • hash:计算时包含字段;
    • metadata:包含有关字段信息得映射

该参数不由数据类本身使用,但可供您(或第三方包)将信息附加到字段。例如,在示例中,您可以指定纬度和经度应以度为单位:

from dataclasses import fields


@dataclass
class Position:
    name: str
    lon: float = field(default=0.0, metadata={"unit": "degrees"})
    lat: float = field(default=0.0, metadata={"unit": "degrees"})


# 元数据检索
print(fields(Position))
lat_unit = fields(Position)[2].metadata["unit"]
print(lat_unit)

数据类的字符串表示

虽然Deck() 的这种表示是明确且可读的,但它也非常冗长。我已经删除了上面输出中一副牌中 52 张牌中的 48 张。在 80 列显示屏上,仅打印完整内容就占用 22 行!让我们添加一个更简洁的表示形式。通常,Python 对象有两种不同的字符串表示形式:

  • repr(obj):obj.__repr__(),应该返回一个对开发者友好的对象表示

    如果可能,这应该是可以重新创建的代码,数据类执此操作:

  • str(obj):obj.__str__().数据类不实现此方法,因此Python将回退到该方法:obj.__str__().__repr__()

让我们实现一个用户友好的表示:PlayingCard

@dataclass
class PlayingCard:
    rank: str
    suit: str

    def __str__(self):
        return f"{self.suit}{self.rank}"


@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}"


print(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'
@dataclass(order=True)
class PlayingCard:
    rank: str
    suit: str

    def __str__(self):
        return f"{self.suit}{self.rank}"

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

@dataclass装饰由无参和有参两种形式;

支持的参数由:

  • init:添加初始化方法?默认是True
  • repr:添加__repr_方法?默认是True
  • eq:添加__eq__方法?默认为True
  • order:添加顺序?默认是False
  • unsafe_hash:强制增加a.__hash__()方法?默认是False
  • frozen:如果为True,指定fields时抛出异常,默认为False

有关每个参数的详细信息,请参阅原始 PEP

不过,这两张卡是如何比较的?您没有指定应该如何进行排序,出于某种原因,Python 似乎认为女王高于王牌......

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

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

这对我们并不真正有用。相反,我们需要定义某种排序索引,该索引使用RANKSUITS的顺序。

>>> 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.sit.__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}'


queen_of_hearts = PlayingCard('Q', '♡')
ace_of_spades = PlayingCard('A', '♠')
print(ace_of_spades > queen_of_hearts)#True
print(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

"""

不可变数据类

若要使数据类不可变,请在创建数据类使进行设置:frozen=True

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'

但请注意,如果数据类包含可变字段,这些字段仍可能更改。

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]

尽管两者都是不可变的,当列表不是

>>> 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='♠')])

若要避免这种情况,请确保不可变数据类的所有字段都使用不可变类型(但请记住,类型不会在运行时强制执行)。应该使用元组而不是列表来实现。

继承

继承数据类。例如:我们将用一个字段扩展我们的示例,并使用它来记录大写字母:Position``country

# 继承
@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

优化数据类

Slots可用于使类更快并使用更少的内存。

数据类没有用于处理slots的显示语法,当创建slots的常规方法也用适用于数据类。

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


@dataclass
class SlotPosition:
    __slots__ = ["name", 'lon', 'lat']
    name: str
    lon: float
    lat: float
   

本质上,slots是使用列表来定义类上得变量的。变量或属性可能不存在或未定义。此外,SlotPosition可能没有默认值。

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

同样,SlotPosition通常使用起来更快。以下示例使用标准库中的 timeit 测量对槽数据类和常规数据类的属性访问速度

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

在此特定示例中,插槽类的速度提高了约 35%。


参考:Python 3.7+ 中的数据类 (指南) – 真正的 Python (realpython.com)