Python 3.7+ 的数据类(指南)
夜是夜莺的夜
编辑于 2023年08月14日 19:58
收录于文集
共6篇

原文链接: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的令人兴奋的新特性)就是数据类。

数据类是专门用来存储数据的类,虽然其实并没有严格的限制。它是用新的 装饰器创建的,像这样:

代码块
Python
自动换行
复制代码
from dataclasses import dataclass

@dataclass
class DataClassCard:
   rank: str
   suit: str
复制成功

注意:这段代码以及本教程里的其他代码都得在Python 3.7+运行。

一个具备基本功能的数据类就这样实现了。例如,你可以直接实例化、打印、以及比较数据类的实例:

代码块
Python
自动换行
复制代码
>>> 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
复制成功

如果用常规的类来对比,一个最简化的常规类应该长这样:

代码块
Python
自动换行
复制代码
class RegularCard:
   def __init__(self, rank, suit):
       self.rank = rank
       self.suit = suit
复制成功

虽然也不是特别多的代码,但你已经能看出“样板代码痛苦”的端倪了:为了完成初始化对象的简单任务, 和  重复了3次。更重要的是,如果你用这种普通的类,那么它的字符串表示形式描述性很弱,出于某种原因一个红心皇后居然跟另一个不一样:

代码块
Python
自动换行
复制代码
>>> 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
复制成功

看起来数据类在幕后真的帮了我们很多。默认情况下,数据类实现的 方法提供了优美的字符串表示,而 方法也做到了基础的对象比较。至于上面那个模拟数据类的 类,你也得加上这些方法:

代码块
Python
自动换行
复制代码
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 作为简单的数据结构。你可能用下面的方式之一来表示红心皇后卡牌:

代码块
Python
自动换行
复制代码
>>> queen_of_hearts_tuple = ('Q', 'Hearts')
>>> queen_of_hearts_dict = {'rank': 'Q', 'suit': 'Hearts'}
复制成功

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

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

  • 对于 tuple 的版本,你需要记住属性的顺序。如果写成了 ('Spades&#​39;, 'A&#​39;) 就会让你的程序混乱,而且很有可能缺少一个容易理解的错误提示。

  • 如果你用了 dict 版本,你必须确保属性名永远都是一致的。比如 {'value&#​39;: 'A&#​39;, 'suit&#​39;: 'Spades&#​39;} 就不会按预想的起效。

更多不理想的方面体现在:

代码块
Python
自动换行
复制代码
>>> queen_of_hearts_tuple[0]  # No named access
'Q'
>>> queen_of_hearts_dict['suit']  # Would be nicer with .suit
'Hearts'
复制成功

一个好点的选择是使用 namedtuple。人们已经用了它很长时间来创建可读性强又小巧的数据结构。事实上,我们可以用 重新创建这个数据类:

代码块
Python
自动换行
复制代码
from collections import namedtuple

NamedTupleCard = namedtuple('NamedTupleCard', ['rank', 'suit'])
复制成功

和我们的 输出一致:

代码块
Python
自动换行
复制代码
>>> 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
复制成功

所以干嘛还操心数据类呢?首先,数据类还有很多你没看到的特性。同时, 有一些并不需要的特性。 被有意设计成一个常规的元组。可以通过比较看出来,例如:

代码块
Python
自动换行
复制代码
>>> queen_of_hearts == ('Q', 'Hearts')
True
复制成功

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

代码块
Python
自动换行
复制代码
>>> Person = namedtuple('Person', ['first_initial', 'last_name']
>>> ace_of_spades = NamedTupleCard('A', 'Spades')
>>> ace_of_spades == Person('A', 'Spades')
True
复制成功

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

代码块
Python
自动换行
复制代码
>>> card = NamedTupleCard('7', 'Diamonds')
>>> card.rank = '9'
AttributeError: can't set attribute
复制成功

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

另一个选择,也是 inspirations for data classes (数据类的灵感来源)之一,就是 attrs project (项目)。安装好 后()你可以像这样编写数据卡牌:

代码块
Python
自动换行
复制代码
import attr

@attr.s
class AttrsCard:
   rank = attr.ib()
   suit = attr.ib()
复制成功

先前的 和 示例放在这里一样能起作用。 项目很好,也支持一些数据类不支持的特性,包括转换器和验证器。此外, 已经有一段时间了,支持 Python 2.7 以及 Python 3.4+。然而, 并不是标准库的一部分,它确实会给你的项目增加额外的 dependency (依赖项)。而通过数据类,在哪儿都能用这些相似的功能。

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

基础的数据类

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

代码块
Python
自动换行
复制代码
from dataclasses import dataclass

@dataclass
class Position:
   name: str
   lon: float
   lat: float
复制成功

正是这个类在定义时写在头顶上的 @dataclass decorator (装饰器)使得它变成了数据类。 在 这行下面,你只是简单列举了想要的字段。字段里使用的   符号是 Python 3.6 里被称作 variable annotations (变量标注)的新特性。 我们 soon (马上)就会更多地讨论这个符号以及为什么我们要把数据类型指定为 和 。

你就只需要写(上面)那么几行,新的类就能用了:

代码块
Python
自动换行
复制代码
>>> 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
复制成功

你也可以用创建命名元组差不多的方式创建数据类。下面这段就跟上面创建的 (几乎)等价:

代码块
Python
自动换行
复制代码
from dataclasses import make_dataclass

Position = make_dataclass('Position', ['name', 'lat', 'lon'])
复制成功

一个数据类是常规的 Python class 。唯一把它区分开的事就是它已经帮你实现了诸如 ,  , 和 这些基本的 data model methods (数据模型方法)。

默认值

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

代码块
Python
自动换行
复制代码
from dataclasses import dataclass

@dataclass
class Position:
   name: str
   lon: float = 0.0
   lat: float = 0.0
复制成功

这就跟你在一个常规类的 里制定了默认值是一样的:

代码块
Python
自动换行
复制代码
>>> 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 (之后)你会学到 ,提供了设置更复杂默认值的方式。

类型提示

到目前为止,我们还没有过多强调数据类天然地支持类型提示这一事实。你可能已经注意到了我们在定义字段时使用了类型提示: 说明 应该是一个 text string ( 类)。

事实上,在数据类里定义字段时,类型提示是强制添加的。如果没有类型提示,字段就不是数据类的一部分。然而,如果你不想给数据类加入显式的类型提示,就用 :

代码块
Python
自动换行
复制代码
from dataclasses import dataclass
from typing import Any

@dataclass
class WithoutExplicitTypes:
   name: Any
   value: Any = 42
复制成功

虽然你在使用数据类时需要加入某种形式的类型提示,但运行时却不是强制性的。下面的代码就能正常运行:

代码块
Python
自动换行
复制代码
>>> 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%20%3D%202r%20arcsin%20%5Csqrt%20%7B%20%20%20%20%20sin%5E2%20%5Cfrac%20%7B1%7D%20%7B2%7D%20(%5Cphi_2%20-%20%5Cphi_1)%20%2B%20%20%20%20%20%20cos%20%5Cphi_1%20cos%20%5Cphi_2%20sin%5E2%20%5Cfrac%20%7B1%7D%20%7B2%7D(%5Clambda_2%20-%20%5Clambda_1)%20%7D

你可以在数据类里加入一个 方法,就跟常规类一样:

代码块
Python
自动换行
复制代码
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))
复制成功

它会跟你预期的一样起作用:

代码块
Python
自动换行
复制代码
>>> oslo = Position('Oslo', 10.8, 59.9)
>>> vancouver = Position('Vancouver', -123.1, 49.3)
>>> oslo.distance_to(vancouver)
7181.7841229421165
复制成功

更灵活的数据类

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

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

代码块
Python
自动换行
复制代码
from dataclasses import dataclass
from typing import List

@dataclass
class PlayingCard:
   rank: str
   suit: str

@dataclass
class Deck:
   cards: List[PlayingCard]
复制成功

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

代码块
Python
自动换行
复制代码
>>> 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')])
复制成功

高级默认值

比如说你想给 默认值。打个比方,如果 创建一副含52张牌的 regular (French) deck (常规牌组)那将是很方便的。 首先,指定不同的点数和花色。然后,加入创建 对象 list 的 函数:

代码块
Python
自动换行
复制代码
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输入的页面)来了解如何在你的系统输入这些。 你也可以使用 命名字符转义(如 )或 Unicode 转义(如 )来输入花色的Unicode符号。

为了方便后续比较卡牌大小,点数和花色也按常规顺序排列。

代码块
Python
自动换行
复制代码
>>> make_french_deck()
[PlayingCard(rank='2', suit='♣'), PlayingCard(rank='3', suit='♣'), ...
PlayingCard(rank='K', suit='♠'), PlayingCard(rank='A', suit='♠')]
复制成功

理论上讲,你可以使用这个函数来为 指定默认值:

代码块
Python
自动换行
复制代码
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(使用可变的默认参数)。问题就在于所有的 对象都会使用同样的列表对象作为 属性的默认值。这就是说,如果某张牌被从一个 里移除了,那么它也会从所有别的 实例中移除。事实上,数据类会试着 prevent you from doing this (防止你这么做),上面的代码会引发 。

相反,数据类使用 来处理可变默认值。要使用 (以及许多其他数据类的炫酷特性),你需要使用 指定符:

代码块
Python
自动换行
复制代码
from dataclasses import dataclass, field
from typing import List

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

的参数可以是任何无参数的可调用对象。现在就很容易创建一副完整的牌组了:

代码块
Python
自动换行
复制代码
>>> Deck()
Deck(cards=[PlayingCard(rank='2', suit='♣'), PlayingCard(rank='3', suit='♣'), ...
           PlayingCard(rank='K', suit='♠'), PlayingCard(rank='A', suit='♠')])
复制成功

指定符用来单独定制数据类的每个字段。你一会儿会看到更多的例子。这有一些 支持的参数供参考:

  • default:字段默认值

  • default_factory:返回字段初始值的函数

  • init:在 .__init__() 方法里用这个字段?(默认 True)

  • repr:在对象的 repr 里用这个字段?(默认 True)

  • compare:在比较里用这个字段?(默认 True)

  • hash:在计算 hash() 时用这个字段?(默认跟 compare 一样)

  • metadata:有关字段的信息映射

在这个 例子中,你知道了到如何通过写 来添加简单的默认值。然而,如果你还想自定义字段,比如在 里把它藏起来,你就需要用 参数: 。你不能同时指定 和 。

参数不是给数据类自己用的,而是让你(或第三方包)能给字段附上信息。在 例子中,你可以指定经纬度应该以度为单位传入:

代码块
Python
自动换行
复制代码
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):

代码块
Python
自动换行
复制代码
>>> 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'
复制成功

你需要表示形式?

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

代码块
Python
自动换行
复制代码
>>> Deck()
Deck(cards=[PlayingCard(rank='2', suit='♣'), PlayingCard(rank='3', suit='♣'), ...
           PlayingCard(rank='K', suit='♠'), PlayingCard(rank='A', suit='♠')])
复制成功

这里 的表示形式清晰、可读性强,也很冗长。上面的输出中我删了52张牌中的48张。在一个80行的显示中,仅仅是打印 就占了22行!让我们加入更简洁的表示。通常来讲,一个Python对象有 two different string representations(2种不同的字符串表示):

  • repr(obj) 是 obj.__repr__() 定义的,应该返回一个开发者友好型的 obj 表示。最好就是重新创建 obj 的代码。数据类能做到这点。

  • str(obj) 是 obj.__str__() 定义的,应该返回一个用户友好型的 obj 表示。 数据类不会实现一个 .__str__() 方法,所以Python回退到调用 .__repr__() 方法。

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

代码块
Python
自动换行
复制代码
from dataclasses import dataclass

@dataclass
class PlayingCard:
   rank: str
   suit: str

   def __str__(self):
       return f'{self.suit}{self.rank}'
复制成功

卡牌看起来好多了,但一副牌还是很冗长:

代码块
Python
自动换行
复制代码
>>> 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)应该返回能够重新创建本对象代码的原则。毕竟 Practicality beats purity (实用性胜过纯粹性)。下面的代码增加了更简明的 表示形式:

代码块
Python
自动换行
复制代码
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})'
复制成功

注意格式化字符串 里的 符号。意思是我们显式地表明要使用每张 的 表示方式。有了新的 , 的表示方式看起来直观多了:

代码块
Python
自动换行
复制代码
>>> 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)
复制成功

这是个更好的表示一副牌的方式。然而,也有代价。你不能再通过执行这段表示方式来重新创建对象了。通常, 最好用 实现这种表示方式。

比较卡牌

在很多卡牌游戏里,卡牌会互相比较。比如经典的扑克牌游戏,点数最大的牌获胜。就目前的实现来说, 类并不支持这种比较:

代码块
Python
自动换行
复制代码
>>> 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'
复制成功

然而,这(看起来)很容易纠正:

代码块
Python
自动换行
复制代码
from dataclasses import dataclass

@dataclass(order=True)
class PlayingCard:
   rank: str
   suit: str

   def __str__(self):
       return f'{self.suit}{self.rank}'
复制成功

装饰器有两种形式。目前为止你已经看过简单形式了, 在指定时并没有任何括号和参数。然而,你也可以往 装饰器的括号里传入参数。支持下列这些参数:

  • init:是否添加  .__init__() 方法?(默认 True。)

  • repr:是否添加 .__repr__() 方法?(默认 True。)

  • eq:是否添加  .__eq__() 方法?(默认 True。)

  • order:是否添加排序方法?(默认 False。)

  • unsafe_hash:强制添加一个 .__hash__() 方法?(默认 False 。)

  • frozen:如果是 True ,给字段赋值就会报错。(默认 False。)

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

代码块
Python
自动换行
复制代码
>>> queen_of_hearts = PlayingCard('Q', '♡')
>>> ace_of_spades = PlayingCard('A', '♠')
>>> ace_of_spades > queen_of_hearts
False
复制成功

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

原来数据类在比较对象时,会把它们的字段排列成元组。换句话说,Queen 比 Ace 要高级是因为字母表里 在 后面:

代码块
Python
自动换行
复制代码
>>> ('A', '♠') > ('Q', '♡')
False
复制成功

这对我们来说不太行得通。相反,我们需要定义某种专门用来排序的索引,得用上 和 里的顺序的。看起来像这样:

代码块
Python
自动换行
复制代码
>>> 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
复制成功

为了让 能在比较时使用排序索引,我们需要在类里面加一个 字段。然而,这个字段应该是根据 和 自动算出来的。这就正好是 special method (特殊方法) 的作用。允许在常规的 方法被调用之后进行一些特殊处理:

代码块
Python
自动换行
复制代码
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}'
复制成功

注意 作为类的第一个字段被加入。这样,比较在一开始使用 字段时就完成了,只有在它们相等时才会比较别的字段。使用 ,你必须明确规定 不被囊括在 方法的参数里(因为它是由 和 字段算出来的)。为了避免用户被这个实现细节搞晕,将 从类的 里移除大概也是个好主意。

终于,aces要高级点了:

代码块
Python
自动换行
复制代码
>>> queen_of_hearts = PlayingCard('Q', '♡')
>>> ace_of_spades = PlayingCard('A', '♠')
>>> ace_of_spades > queen_of_hearts
True
复制成功

现在你可以轻松创建一副排好序的牌:

代码块
Python
自动换行
复制代码
>>> 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 (排序),这儿有如何随机抽取十张牌:

代码块
Python
自动换行
复制代码
>>> from random import sample
>>> Deck(sample(make_french_deck(), k=10))
Deck(♢2, ♡A, ♢10, ♣2, ♢3, ♠3, ♢A, ♠8, ♠9, ♠2)
复制成功

当然,你不需要 来做到这一点…

不可变的数据类

在你之前看到的 里,其中一个特性就是 immutable (不可变)。这意味着,它的字段的值永远不会改变。这对许多类型的数据类来说都是很好的主意! 在你创建数据类的时候设置 可以让一个数据类不可变。例如, you saw earlier (你之前看到的) 类, 下面是一个不可变的例子:

代码块
Python
自动换行
复制代码
from dataclasses import dataclass

@dataclass(frozen=True)
class Position:
   name: str
   lon: float = 0.0
   lat: float = 0.0
复制成功

在一个冻结数据类里,你创建好后就不能再给字段指定值了:

代码块
Python
自动换行
复制代码
>>> 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(这个视频获取更多信息)):

代码块
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]
复制成功

即使 和 都不可变,保存 的列表却是可变的。因此你还是可以改变一副牌里的牌:

代码块
Python
自动换行
复制代码
>>> 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='♠')])
复制成功

要避免这点,确保一个不可变数据类的所有字段使用的都是不可变类型(但记住类型在运行时不是强制性的)。 应该用元组而不是列表实现。

继承

你也可以相当随意地为数据类创建 subclass (子类)。作为示例,我们将用 字段来拓展 示例并用它来记录首都:

代码块
Python
自动换行
复制代码
from dataclasses import dataclass

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

@dataclass
class Capital(Position):
   country: str
复制成功

在这个简单的例子中,一切都顺利地运行:

代码块
Python
自动换行
复制代码
>>> Capital('Oslo', 10.8, 59.9, 'Norway')
Capital(name='Oslo', lon=10.8, lat=59.9, country='Norway')
复制成功

的 字段在 的三个初始字段后面加入。如果基类里的任何一个字段有默认值,事情就会变得复杂起来:

代码块
Python
自动换行
复制代码
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
复制成功

这段代码会立即崩溃然后报 ,显示 “non-default argument ‘country’ follows default argument.”  这个问题是我们的新 字段没有默认值,然而 和 字段有默认值。数据类试着写一个具有如下签名的 方法:

代码块
Python
自动换行
复制代码
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 (如果一个参数有默认值,所有后续的参数都得有默认值)。换句话说,如果一个基类里的字段有默认值,那么所有在子类里添加的新字段都也得有默认值。

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

代码块
Python
自动换行
复制代码
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
复制成功

里字段的顺序还是 , , , 。然而, 的默认值成了 。

代码块
Python
自动换行
复制代码
>>> Capital('Madrid', country='Spain')
Capital(name='Madrid', lon=0.0, lat=40.0, country='Spain')
复制成功

优化数据类

我会用一些关于 slots 的讨论来结束本教程。 Slots 可以用来使类更快、占用更少内存。数据类没有明确的语法来处理slots,但普通的创建slots的方式对数据类也适用(它们真的只是常规类!)

代码块
Python
自动换行
复制代码
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 类占据更少内存,可以用 Pympler 测量:

代码块
Python
自动换行
复制代码
>>> 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 数据类属性和常规数据类属性的速度。

代码块
Python
自动换行
复制代码
>>> 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。现在,继续前进,写更少的代码吧!