Python 3.7+ 的数据类(指南)

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

by Geir Arne Hjelle May 15, 2018

目录

  • 数据类的各种选择

  • 基础的数据类

    • 默认值
    • 类型提示
    • 添加方法
  • 更灵活的数据类

    • 高级默认值
    • 你需要表示形式?
    • 比较卡牌
  • 不可变数据类型

  • 继承

  • 优化数据类

  • 结语&拓展阅读

一项 new and exciting feature coming in Python 3.7 (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 重复了3次。更重要的是,如果你用这种普通的类,那么它的字符串表示形式描述性很弱,出于某种原因一个红心皇后居然跟另一个不一样:

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

在本篇教程中,你将学习数据类到底有什么便利之处。为了更进一步做到优雅的字符串表示和(对象)比较,你会学到:

  • 如何给数据类的字段增加默认值
  • 数据类如何实现对象排序
  • 如何表示出不可变数据
  • 数据类怎么继承

我们很快就会深入了解那些数据类的特性。然而,你可能想到自己之前就看过类似的东西。

Free Download: Get a sample chapter from Python Tricks: The Book that shows you Python’s best practices with simple examples you can apply instantly to write more beautiful + Pythonic code.

数据类的各种选择

你可能已经使用a tuple or a dict 作为简单的数据结构。你可能用下面的方式之一来表示红心皇后卡牌:

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

能用。然而,给你这个程序员带来了很多任务:

  • 你需要记住 queen_of_hearts_... variable (变量)代表一张卡牌

  • 对于 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

乍一看还不错,但如果意识不到它本来的类型,可能会导致难察觉又难修复的bug,尤其是它还乐于把两个完全不同的 namedtuple 拿来比较:

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

namedtuple 也有一些限制。例如, namedtuple 里很难给字段添加默认值,而且天生 immutable (不可变)。这就是说,一个 namedtuple 的值永远不可变。在有的程序里,这是个很棒的特性,但在其他情况下,灵活一点才好。

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

数据类不可能完全替代 namedtuple。例如,如果你就希望数据结构像元组那样,那namedtuple 就是很好的选择!

另一个选择,也是 inspirations for data classes (数据类的灵感来源)之一,就是 attrs projectattrs项目)。安装好 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 并不是标准库的一部分,它确实会给你的项目增加额外的 dependency (依赖项)。而通过数据类,在哪儿都能用这些相似的功能。

除了 tupledictnamedtuple, 和 attrs之外, 还有 many other similar projects (许多其他相似项目),包括 typing.NamedTuplenamedlistattrdictplumber, 和 fields 。虽然数据类是很好的选择,仍然有一些情况采用其他(数据类的)变体更好。例如,你需要兼容一个特定的接收元组的API或者需要某种数据类不支持的功能。

基础的数据类

让我们回到数据类。作为示例,我们创建一个 Position 类来代表地理位置,有名字和经纬度:

from dataclasses import dataclass

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

正是这个类在定义时写在头顶上的 @dataclass decorator (装饰器)使得它变成了数据类。 在 class Position: 这行下面,你只是简单列举了想要的字段。字段里使用的 : 符号是 Python 3.6 里被称作 variable annotations (变量标注)的新特性。 我们 soon (马上)就会更多地讨论这个符号以及为什么我们要把数据类型指定为 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 class 。唯一把它区分开的事就是它已经帮你实现了诸如 .__init__().__repr__(), 和 .__eq__() 这些基本的 data model methods (数据模型方法)。

默认值

你可以很容易地给数据类的字段添加默认值:

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)

Later (之后)你会学到 default_factory,提供了设置更复杂默认值的方式。

类型提示

到目前为止,我们还没有过多强调数据类天然地支持类型提示这一事实。你可能已经注意到了我们在定义字段时使用了类型提示: name: str 说明 name 应该是一个 text string (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 is and will always be a dynamically typed language。为了确切地捕获到类型错误,你可以在源码里运行 Mypy 这样的类型检查器。

添加方法

你已经知道了数据类其实就是常规类。这就说明你可以自由地往里面加入自己的方法。例如,让我们计算两个位置之间沿着地球表面经过的距离。一种方式是使用 the haversine formula (半正矢公式):

d = 2 r a r c s i n s i n 2 1 2 ( ϕ 2 − ϕ 1 ) + c o s ϕ 1 c o s ϕ 2 s i n 2 1 2 ( λ 2 − λ 1 ) d = 2r arcsin \sqrt { sin^2 \frac {1} {2} (\phi_2 - \phi_1) + cos \phi_1 cos \phi_2 sin^2 \frac {1} {2}(\lambda_2 - \lambda_1) } d=2rarcsinsin221(ϕ2ϕ1)+cosϕ1cosϕ2sin221(λ2λ1)

你可以在数据类里加入一个 .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

更灵活的数据类

目前为止,你已经见识了很多数据类的基本特征:它提供了方便的方法,你也可以往里面添加默认值和别的方法。现在你将继续学习一些高级特征,像是给 @dataclass 装饰器和 field() 函数加参数。结合它们,数据类的创建会更加可控。

让我们继续回到本教程一开始的卡牌游戏例子,并在此过程中添加一个包含一副牌的类:

from dataclasses import dataclass
from typing import List

@dataclass
class PlayingCard:
    rank: str
    suit: str

@dataclass
class Deck:
    cards: List[PlayingCard]

只含2张牌的一副牌可以这样创建:

>>> 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张牌的 regular (French) deck (常规牌组)那将是很方便的。 首先,指定不同的点数和花色。然后,加入创建PlayingCard 对象 listmake_french_deck() 函数:

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]

为了有趣,4种花色用它们的 Unicode symbols (Unicode码)来指定。

**注意:**在上面,我们直接在源码里使用像 的 Unicode 字形。我们之所以可以这么做是因为 Python supports writing source code in UTF-8 by default(默认情况下Python支持使用UTF-8编写源码)。参考 this page on Unicode input (这篇关于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里最常见的“反模式”之一: using mutable default arguments(使用可变的默认参数)。问题就在于所有的 Deck 对象都会使用同样的列表对象作为 .cards 属性的默认值。这就是说,如果某张牌被从一个 Deck 里移除了,那么它也会从所有别的 Deck 实例中移除。事实上,数据类会试着 prevent you from doing this (防止你这么做),上面的代码会引发 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() 函数获取(注意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'

你需要表示形式?

回想一下我们可以凭空创建一副扑克牌:

>>> 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对象有 two different string representations(2种不同的字符串表示):

  • 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__() 方法,我们将违反(repr)应该返回能够重新创建本对象代码的原则。毕竟 Practicality beats purity (实用性胜过纯粹性)。下面的代码增加了更简明的 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 装饰器有两种形式。目前为止你已经看过简单形式了, @dataclass 在指定时并没有任何括号和参数。然而,你也可以往 @dataclass() 装饰器的括号里传入参数。支持下列这些参数:

  • init:是否添加 .__init__() 方法?(默认 True。)
  • repr:是否添加 .__repr__() 方法?(默认 True。)
  • eq:是否添加 .__eq__() 方法?(默认 True。)
  • order:是否添加排序方法?(默认 False。)
  • unsafe_hash:强制添加一个 .__hash__() 方法?(默认 False 。)
  • frozen:如果是 True ,给字段赋值就会报错。(默认 False。)

the original PEP 可以获取到参数的更多信息。在设定 order=True 之后, PlayingCard 的实例就可以比较了:

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

只不过这两张牌怎么比的呢?你还没指定该怎么排序呢,出于某些原因 Python 看起来认定了 Queen 比 Ace 要高级…

原来数据类在比较对象时,会把它们的字段排列成元组。换句话说,Queen 比 Ace 要高级是因为字母表里 '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 自动算出来的。这就正好是 special method (特殊方法) .__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 字段算出来的)。为了避免用户被这个实现细节搞晕,将 .sort_index 从类的 repr 里移除大概也是个好主意。

终于,aces要高级点了:

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

或者,如果你不关心 sorting (排序),这儿有如何随机抽取十张牌:

>>> 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 里,其中一个特性就是 immutable (不可变)。这意味着,它的字段的值永远不会改变。这对许多类型的数据类来说都是很好的主意! 在你创建数据类的时候设置 frozen=True 可以让一个数据类不可变。例如, you saw earlier (你之前看到的)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 里所有嵌套数据都适用(看 this video for further info(这个视频获取更多信息)):

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

继承

你也可以相当随意地为数据类创建 subclass (子类)。作为示例,我们将用 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 ,显示 “non-default argument ‘country’ follows default argument.” 这个问题是我们的新 country 字段没有默认值,然而 lonlat 字段有默认值。数据类试着写一个具有如下签名的 .__init__() 方法:

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

然而,这在 Python 里是无效的。 If a parameter has a default value, all following parameters must also have a default value (如果一个参数有默认值,所有后续的参数都得有默认值)。换句话说,如果一个基类里的字段有默认值,那么所有在子类里添加的新字段都也得有默认值。

另一个要注意的事情是子类里的字段是如何排序的。从基类开始,字段由它们最初定义的顺序排序。如果一个字段在子类中被重定义了,它的顺序不会变。例如,如果你像下面这样定义 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')

优化数据类

我会用一些关于 slots 的讨论来结束本教程。 Slots 可以用来使类更快、占用更少内存。数据类没有明确的语法来处理slots,但普通的创建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__ 列举变量后定义出来的。没出现在 .__slots__ 里的变量和属性可能不会被定义。此外一个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)

类似地,slots 类通常来说运行起来更快。下面的例子使用标准库里的 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 演讲 Dataclasses: The code generator to end all code generators 也值得一看。

如果你还没有 Python 3.7,这还有一个 data classes backport for Python 3.6。现在,继续前进,写更少的代码吧!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值