Python 3.7+ 的数据类(指南)

本文介绍了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。现在,继续前进,写更少的代码吧!

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值