Python 3.7+ 的數據類(指南)

原文鏈接:https://realpython.com/python-data-classes/
by
May 15, 2018
目錄
數據類的各種選擇
基礎的數據類
默認值
類型提示
添加方法
更靈活的數據類
高級默認值
你需要表示形式?
比較卡牌
不可變數據類型
繼承
優(yōu)化數據類
結語&拓展閱讀
一項
(Python 3.7的令人興奮的新特性)就是數據類。數據類是專門用來存儲數據的類,雖然其實并沒有嚴格的限制。它是用新的 @dataclass
裝飾器創(chuàng)建的,像這樣:
注意:這段代碼以及本教程里的其他代碼都得在Python 3.7+運行。
一個具備基本功能的數據類就這樣實現了。例如,你可以直接實例化、打印、以及比較數據類的實例:
如果用常規(guī)的類來對比,一個最簡化的常規(guī)類應該長這樣:
雖然也不是特別多的代碼,但你已經能看出“樣板代碼痛苦”的端倪了:為了完成初始化對象的簡單任務, rank
和 suit
?重復了3次。更重要的是,如果你用這種普通的類,那么它的字符串表示形式描述性很弱,出于某種原因一個紅心皇后居然跟另一個不一樣:
看起來數據類在幕后真的幫了我們很多。默認情況下,數據類實現的 .__repr__()
方法提供了優(yōu)美的字符串表示,而 .__eq__()
方法也做到了基礎的對象比較。至于上面那個模擬數據類的 RegularCard
類,你也得加上這些方法:
在本篇教程中,你將學習數據類到底有什么便利之處。為了更進一步做到優(yōu)雅的字符串表示和(對象)比較,你會學到:
如何給數據類的字段增加默認值
數據類如何實現對象排序
如何表示出不可變數據
數據類怎么繼承
我們很快就會深入了解那些數據類的特性。然而,你可能想到自己之前就看過類似的東西。
Free Download: that shows you Python’s best practices with simple examples you can apply instantly to write more beautiful + Pythonic code.
數據類的各種選擇
你可能已經使用
作為簡單的數據結構。你可能用下面的方式之一來表示紅心皇后卡牌:
能用。然而,給你這個程序員帶來了很多任務:
你需要記住
queen_of_hearts_...
(變量)代表一張卡牌對于
tuple
的版本,你需要記住屬性的順序。如果寫成了('Spades', 'A')
就會讓你的程序混亂,而且很有可能缺少一個容易理解的錯誤提示。如果你用了
dict
版本,你必須確保屬性名永遠都是一致的。比如{'value': 'A', 'suit': 'Spades'}
就不會按預想的起效。
更多不理想的方面體現在:
一個好點的選擇是使用 namedtuple
重新創(chuàng)建這個數據類:
NamedTupleCard
和我們的 DataClassCard
輸出一致:
所以干嘛還操心數據類呢?首先,數據類還有很多你沒看到的特性。同時, namedtuple
有一些并不需要的特性。 namedtuple
被有意設計成一個常規(guī)的元組??梢酝ㄟ^比較看出來,例如:
乍一看還不錯,但如果意識不到它本來的類型,可能會導致難察覺又難修復的bug,尤其是它還樂于把兩個完全不同的 namedtuple
拿來比較:
namedtuple
也有一些限制。例如, namedtuple
里很難給字段添加默認值,而且天生 (不可變)。這就是說,一個 namedtuple
的值永遠不可變。在有的程序里,這是個很棒的特性,但在其他情況下,靈活一點才好。
數據類不可能完全替代 namedtuple
。例如,如果你就希望數據結構像元組那樣,那namedtuple
就是很好的選擇!
另一個選擇,也是 attrs
項目)。安裝好 attrs
后(pip install attrs
)你可以像這樣編寫數據卡牌:
先前的 DataClassCard
和 NamedTupleCard
示例放在這里一樣能起作用。attrs
項目很好,也支持一些數據類不支持的特性,包括轉換器和驗證器。此外, attrs
已經有一段時間了,支持 Python 2.7 以及 Python 3.4+。然而,attrs
并不是標準庫的一部分,它確實會給你的項目增加額外的 (依賴項)。而通過數據類,在哪兒都能用這些相似的功能。
除了 tuple
, dict
, namedtuple
, 和 attrs
之外, 還有 (許多其他相似項目),包括 , , , , 和 。雖然數據類是很好的選擇,仍然有一些情況采用其他(數據類的)變體更好。例如,你需要兼容一個特定的接收元組的API或者需要某種數據類不支持的功能。
基礎的數據類
讓我們回到數據類。作為示例,我們創(chuàng)建一個 Position
類來代表地理位置,有名字和經緯度:
正是這個類在定義時寫在頭頂上的 class Position:
這行下面,你只是簡單列舉了想要的字段。字段里使用的 ?:
符號是 Python 3.6 里被稱作 (變量標注)的新特性。 我們 (馬上)就會更多地討論這個符號以及為什么我們要把數據類型指定為 str
和 float
。
你就只需要寫(上面)那么幾行,新的類就能用了:
你也可以用創(chuàng)建命名元組差不多的方式創(chuàng)建數據類。下面這段就跟上面創(chuàng)建的 Position
(幾乎)等價:
一個數據類是常規(guī)的 .__init__()
, ?.__repr__()
, 和 .__eq__()
這些基本的 (數據模型方法)。
默認值
你可以很容易地給數據類的字段添加默認值:
這就跟你在一個常規(guī)類的 .__init__()
里制定了默認值是一樣的:
default_factory
,提供了設置更復雜默認值的方式。
類型提示
到目前為止,我們還沒有過多強調數據類天然地支持類型提示這一事實。你可能已經注意到了我們在定義字段時使用了類型提示: name: str
說明 name
應該是一個 (str
類)。
事實上,在數據類里定義字段時,類型提示是強制添加的。如果沒有類型提示,字段就不是數據類的一部分。然而,如果你不想給數據類加入顯式的類型提示,就用 typing.Any
:
雖然你在使用數據類時需要加入某種形式的類型提示,但運行時卻不是強制性的。下面的代碼就能正常運行:
這是Python里的類型通常的運作方式:
。為了確切地捕獲到類型錯誤,你可以在源碼里運行 這樣的類型檢查器。添加方法
你已經知道了數據類其實就是常規(guī)類。這就說明你可以自由地往里面加入自己的方法。例如,讓我們計算兩個位置之間沿著地球表面經過的距離。一種方式是使用
(半正矢公式):你可以在數據類里加入一個 .distance_to()
方法,就跟常規(guī)類一樣:
它會跟你預期的一樣起作用:
更靈活的數據類
目前為止,你已經見識了很多數據類的基本特征:它提供了方便的方法,你也可以往里面添加默認值和別的方法?,F在你將繼續(xù)學習一些高級特征,像是給 @dataclass
裝飾器和 field()
函數加參數。結合它們,數據類的創(chuàng)建會更加可控。
讓我們繼續(xù)回到本教程一開始的卡牌游戲例子,并在此過程中添加一個包含一副牌的類:
只含2張牌的一副牌可以這樣創(chuàng)建:
高級默認值
比如說你想給 Deck
默認值。打個比方,如果 Deck()
創(chuàng)建一副含52張牌的 (常規(guī)牌組)那將是很方便的。 首先,指定不同的點數和花色。然后,加入創(chuàng)建PlayingCard
對象 的 make_french_deck()
函數:
為了有趣,4種花色用它們的
(Unicode碼)來指定。注意:在上面,我們直接在源碼里使用像
?
的 Unicode 字形。我們之所以可以這么做是因為 (默認情況下Python支持使用UTF-8編寫源碼)。參考 (這篇關于Unicode輸入的頁面)來了解如何在你的系統(tǒng)輸入這些。 你也可以使用\N
命名字符轉義(如\N{BLACK SPADE SUIT}
)或\u
Unicode 轉義(如\u2660
)來輸入花色的Unicode符號。
為了方便后續(xù)比較卡牌大小,點數和花色也按常規(guī)順序排列。
理論上講,你可以使用這個函數來為 Deck.cards
指定默認值:
別真這么做!這是Python里最常見的“反模式”之一: Deck
對象都會使用同樣的列表對象作為 .cards
屬性的默認值。這就是說,如果某張牌被從一個 Deck
里移除了,那么它也會從所有別的 Deck
實例中移除。事實上,數據類會試著 (防止你這么做),上面的代碼會引發(fā) ValueError
。
相反,數據類使用 default_factory
來處理可變默認值。要使用 default_factory
(以及許多其他數據類的炫酷特性),你需要使用 field()
指定符:
default_factory
的參數可以是任何無參數的可調用對象?,F在就很容易創(chuàng)建一副完整的牌組了:
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)
。你不能同時指定 default
和 default_factory
。
metadata
參數不是給數據類自己用的,而是讓你(或第三方包)能給字段附上信息。在 Position
例子中,你可以指定經緯度應該以度為單位傳入:
元數據(以及其他關于一個字段的信息)能通過 fields()
函數獲?。ㄗ⒁鈌ields是復數,有s):
你需要表示形式?
回想一下我們可以憑空創(chuàng)建一副撲克牌:
這里 Deck
的表示形式清晰、可讀性強,也很冗長。上面的輸出中我刪了52張牌中的48張。在一個80行的顯示中,僅僅是打印 Deck
就占了22行!讓我們加入更簡潔的表示。通常來講,一個Python對象有 (2種不同的字符串表示):
repr(obj)
是obj.__repr__()
定義的,應該返回一個開發(fā)者友好型的obj
表示。最好就是重新創(chuàng)建obj
的代碼。數據類能做到這點。str(obj)
是obj.__str__()
定義的,應該返回一個用戶友好型的obj
表示。 數據類不會實現一個.__str__()
方法,所以Python回退到調用.__repr__()
方法。
讓我們給 PlayingCard
實現一個用戶友好型的表示:
卡牌看起來好多了,但一副牌還是很冗長:
為了展示可以自定義 .__repr__()
方法,我們將違反(repr)應該返回能夠重新創(chuàng)建本對象代碼的原則。畢竟 (實用性勝過純粹性)。下面的代碼增加了更簡明的 Deck
表示形式:
注意格式化字符串 {c!s}
里的 !s
符號。意思是我們顯式地表明要使用每張 PlayingCard
的 str()
表示方式。有了新的 .__repr__()
, Deck
的表示方式看起來直觀多了:
這是個更好的表示一副牌的方式。然而,也有代價。你不能再通過執(zhí)行這段表示方式來重新創(chuàng)建對象了。通常, 最好用 .__str__()
實現這種表示方式。
比較卡牌
在很多卡牌游戲里,卡牌會互相比較。比如經典的撲克牌游戲,點數最大的牌獲勝。就目前的實現來說, PlayingCard
類并不支持這種比較:
然而,這(看起來)很容易糾正:
@dataclass
裝飾器有兩種形式。目前為止你已經看過簡單形式了, @dataclass
在指定時并沒有任何括號和參數。然而,你也可以往 @dataclass()
裝飾器的括號里傳入參數。支持下列這些參數:
init
:是否添加 ?.__init__()
方法?(默認True
。)repr
:是否添加.__repr__()
方法?(默認True
。)eq
:是否添加 ?.__eq__()
方法?(默認True
。)order
:是否添加排序方法?(默認False
。)unsafe_hash
:強制添加一個.__hash__()
方法?(默認False
。)frozen
:如果是True
,給字段賦值就會報錯。(默認False
。)
看 order=True
之后, PlayingCard
的實例就可以比較了:
只不過這兩張牌怎么比的呢?你還沒指定該怎么排序呢,出于某些原因 Python 看起來認定了 Queen 比 Ace 要高級…
原來數據類在比較對象時,會把它們的字段排列成元組。換句話說,Queen 比 Ace 要高級是因為字母表里 'Q'
在 'A'
后面:
這對我們來說不太行得通。相反,我們需要定義某種專門用來排序的索引,得用上 RANKS
和 SUITS
里的順序的??雌饋硐襁@樣:
為了讓 PlayingCard
能在比較時使用排序索引,我們需要在類里面加一個 .sort_index
字段。然而,這個字段應該是根據 .rank
和 .suit
自動算出來的。這就正好是 (特殊方法) .__post_init__()
的作用。允許在常規(guī)的 .__init__()
方法被調用之后進行一些特殊處理:
注意 .sort_index
作為類的第一個字段被加入。這樣,比較在一開始使用 .sort_index
字段時就完成了,只有在它們相等時才會比較別的字段。使用 field()
,你必須明確規(guī)定 .sort_index
不被囊括在 .__init__()
方法的參數里(因為它是由 .rank
和 .suit
字段算出來的)。為了避免用戶被這個實現細節(jié)搞暈,將 .sort_index
從類的 repr
里移除大概也是個好主意。
終于,aces要高級點了:
現在你可以輕松創(chuàng)建一副排好序的牌:
或者,如果你不關心
(排序),這兒有如何隨機抽取十張牌:
當然,你不需要 order=True
來做到這一點…
不可變的數據類
在你之前看到的 namedtuple
里,其中一個特性就是 (不可變)。這意味著,它的字段的值永遠不會改變。這對許多類型的數據類來說都是很好的主意! 在你創(chuàng)建數據類的時候設置 frozen=True
可以讓一個數據類不可變。例如, (你之前看到的)Position
類, 下面是一個不可變的例子:
在一個凍結數據類里,你創(chuàng)建好后就不能再給字段指定值了:
注意如果你的類里包含可變字段, 那么它們可能發(fā)生改變。這對 Python 里所有嵌套數據都適用(看
(這個視頻獲取更多信息)):
即使 ImmutableCard
和 ImmutableDeck
都不可變,保存 cards
的列表卻是可變的。因此你還是可以改變一副牌里的牌:
要避免這點,確保一個不可變數據類的所有字段使用的都是不可變類型(但記住類型在運行時不是強制性的)。 ImmutableDeck
應該用元組而不是列表實現。
繼承
你也可以相當隨意地為數據類創(chuàng)建 country
字段來拓展 Position
示例并用它來記錄首都:
在這個簡單的例子中,一切都順利地運行:
Capital
的 country
字段在 Position
的三個初始字段后面加入。如果基類里的任何一個字段有默認值,事情就會變得復雜起來:
這段代碼會立即崩潰然后報 TypeError
,顯示 “non-default argument ‘country’ follows default argument.” ?這個問題是我們的新 country
字段沒有默認值,然而 lon
和 lat
字段有默認值。數據類試著寫一個具有如下簽名的 .__init__()
方法:
然而,這在 Python 里是無效的。
(如果一個參數有默認值,所有后續(xù)的參數都得有默認值)。換句話說,如果一個基類里的字段有默認值,那么所有在子類里添加的新字段都也得有默認值。另一個要注意的事情是子類里的字段是如何排序的。從基類開始,字段由它們最初定義的順序排序。如果一個字段在子類中被重定義了,它的順序不會變。例如,如果你像下面這樣定義 Position
和 Capital
:
Capital
里字段的順序還是 name
, lon
, lat
, country
。然而, lat
的默認值成了 40.0
。
優(yōu)化數據類
我會用一些關于
重要的是, slots 是在類中使用 .__slots__
列舉變量后定義出來的。沒出現在 .__slots__
里的變量和屬性可能不會被定義。此外一個slots類可能沒有默認值。
加入這種限制的好處是能完成特定優(yōu)化。例如,slots 類占據更少內存,可以用
測量:
類似地,slots 類通常來說運行起來更快。下面的例子使用標準庫里的
測量了訪問 slots 數據類屬性和常規(guī)數據類屬性的速度。
在這個特定例子中,slot 類快了差不多35%。
結語&拓展閱讀
數據類是 Python 3.7 的新特性之一。有了數據類,你不用非得寫“樣板代碼”來讓對象進行恰當的初始化、表示和比較。
你已經看到了如何定義自己的數據類,以及:
如何給數據類的字段增加默認值
數據類如何實現對象排序
如何表示出不可變數據
數據類怎么繼承
如果你想深入了解數據類,看看
和原始 里的討論。此外, Raymond Hettinger 的 PyCon 2018 演講
也值得一看。如果你還沒有 Python 3.7,這還有一個
?,F在,繼續(xù)前進,寫更少的代碼吧!