PEP 557
dataclass
可以視為可變、而且可設定預設值的namedtuple
你的 Python 程式碼常用 tuple
或 NamedTuple
嗎?我相信你也會想試試功能更強且更有彈性的 dataclass
!
Python 在 3.7 版本後,內建函式庫新增了 dataclass
,開發者為了儲存資料而設計新類別(Class)來當作資料容器時,dataclass
會幫你預先寫好操作資料常用的功能,讓你節省力氣、少寫好幾行程式,也讓程式的更可讀、更好維護!
在這篇文章,我將分享 dataclass
幫你解決了什麼痛點、為什麼它值得你用用看,並且教學 dataclass
的基礎用法以及實用技巧,幫助你提升在 Python 儲存與操作資料的工作效率。
目錄
基礎教學
假設我們想要在 Python 儲存「員工」相關的資料,自定義類別會這樣寫:
class Employee:
"""Class that contains basic information about an employee."""
def __init__(self, name: str, job: str, salary: int = 0) -> None:
self.name = name
self.job = job
self.salary = salary
hao = Employee("Hao Huang", "Data Analyst", 22_000)
print(hao)
## 執行結果:
## $ python3 test.py
## <__main__.Employee object at 0x10ccf8fd0>
不過就是要儲存姓名、職業、與薪水三項屬性(Attribute),有沒有覺得用自定義類別寫起來稍嫌囉唆呢?dataclass
提供的 語法糖 馬上讓程式碼簡潔一些,用 @dataclass
這項裝飾器(Decorator)來快速定義資料容器類別,以下程式碼範例與上面的「員工」類別儲存資料完全相同:
from dataclasses import dataclass
@dataclass
class Employee:
"""Class that contains basic information about an employee."""
name: str
job: str
salary: int = 0
hao = Employee("Hao Huang", "Data Analyst", 22_000)
print(hao)
wilson = Employee("Wilson", "Software Engineer")
print(wilson)
## 執行結果:
## $ python3 test.py
## Employee(name='Hao Huang', job='Data Analyst', salary=22000)
## Employee(name='Wilson', job='Software Engineer', salary=0)
馬上可以看出用 dataclass
讓你少打多少字了吧!並且因為 dataclass
幫我們實作了 __repr__()
,print()
的結果也比用自定義類別漂亮了許多,這還只是 dataclass
眾多好用功能的冰山一角而已。
(延伸閱讀:你有注意到程式碼裡面數字可以用底線寫成 22_000
嗎?如果不熟悉這種底線寫法,請參考筆者的 Python 底線用法教學)
上方範例中的 salary: int = 0
語法包含了型別檢查及預設值兩項要點
: int
的語法表示資料的型別檢查= 0
的語法代表預設數值- 此範例也只有此項資訊有設定預設值,如果你想幫姓名資訊也增加預設值,同理可以寫成
name: str = ""
。
- 此範例也只有此項資訊有設定預設值,如果你想幫姓名資訊也增加預設值,同理可以寫成
除了可以設定型別檢查與預設值以外,dataclass
還可以為屬性設定加上更多豐富功能:使用 field()
來達成。
field()
:客製化資料屬性
field()
是用來有彈性地客製化 dataclass
裡的各個屬性(Attribute)資料。field()
能客製化的內容很多,筆者好豪在此介紹我自己常用的其中三項:
init=False
設定 field(init=True)
,代表此屬性會被包含在創造 dataclass
實例(Instance)所傳入的引數之一;反之,設定 init=False
後使用者不能在創造實例時傳入此引數。
筆者認為「資料紀錄時間」是使用此功能的好範例,「資料紀錄時間」當然應該是創造實例當下的系統時間、這可不能讓使用者自己亂輸入:
(此範例不小心先用到 default_factory
參數了,這在下方文章會介紹)
from dataclasses import dataclass, field # 記得要 import field
import datetime
@dataclass
class Employee:
"""Class that contains basic information about an employee."""
name: str
job: str
salary: int = 0
record_time: datetime.datetime = \
field(init=False, default_factory=datetime.datetime.now) # 資料紀錄時間
# 創造實例的時候引數 *不可以* 包含 record_time,不然會出現 error
hao = Employee("Hao Huang", "Data Analyst", 22_000)
print(hao)
## 執行結果:
## $ python3 test.py
## Employee(name='Hao Huang', job='Data Analyst', salary=22000,
## record_time=datetime.datetime(2022, 4, 3, 22, 48, 8, 124693))
repr=False
前面提過 dataclass
自動幫我們實作 __repr__()
函式,print()
因此才可以呈現出較有意義的內容,但也有某些情況我們不希望部分屬性被 print
出來,這可以透過設定 field(repr=False)
來達成。以下範例我們設定了年齡(age)屬性,但是不希望此屬性被 print
出來:
@dataclass
class Employee:
"""Class that contains basic information about an employee."""
name: str
age: int = field(repr=False)
job: str
salary: int = 0
hao = Employee("Hao Huang", 18, "Data Analyst", 22_000)
print(hao)
## 執行結果:
## $ python3 test.py
## Employee(name='Hao Huang', job='Data Analyst', salary=22000)
## ^ 年齡是小秘密,沒有被 print 出來 d(`・∀・)b
default
與 default_factory
default
的功能是屬性的預設值,也根據前面範例所學,以下兩個寫法的屬性預設值設定是一模一樣的:
age: int = 0
age: int = field(default=0)
既然以上兩行一樣,那為什麼還需要 default
參數呢?它是當你除了預設值之外、還需要為屬性客製化其他功能時會用到的。例如:如果我希望年齡屬性有預設值、卻又不希望它被 print
出來,就可以寫成:age: int = field(default=0, repr=False)
筆者更希望大家認識的是:default_factory
參數,它必須是一個(不需任何引數的)可呼叫物件(Callable)。「可變物件引數預設值」就是需要用到 default_factory
的超重要場景,例如要用 list
紀錄員工會的所有技能、並且此屬性要是一個空的 list
當作預設值,可以這樣寫:
@dataclass
class Employee:
"""Class that contains basic information about an employee."""
name: str
job: str
salary: int = 0
skillset: list[str] = field(default_factory=list)
hao = Employee("Hao Huang", "Data Analyst", 22_000)
print(hao)
hao.skillset.append("Python")
hao.skillset.append("C++")
print(hao)
## 執行結果:
## $ python3 test.py
## Employee(name='Hao Huang', job='Data Analyst',
## salary=22000, skillset=[])
## Employee(name='Hao Huang', job='Data Analyst',
## salary=22000, skillset=['Python', 'C++'])
設定 list
這種可變物件(mutable)當作預設值時,千萬不能寫成 skillset: list[str] = []
,如果讀者還不清楚它背後會出現什麼重大錯誤,請務必一讀筆者好豪探討此問題的文章:《Python 函式的可變物件預設引數陷阱》。
(小提醒:default
與 default_factory
兩個參數只能擇一使用,不能同時在 field()
出現)
dataclass
解決的痛點
上方文章介紹了 dataclass
的基本使用方法,在這個小節,我將繼續介紹幾個 dataclass
為 Python 工程師解決的寫程式痛點,讓你更了解 dataclass
的威力。
__init__()
初始化資料更簡潔
相信各位在前面的範例就已經看出來了,即使不要求資料容器有什麼複雜的資料操作,光只是需要 __init__()
初始化與儲存資料,dataclass
需要寫的程式顯然比自定義類別簡潔得多。
# 自定義類別
class T:
def __init__(self, a: int, b: float, c: str):
self.a = a
self.b = b
self.c = c
# 用 dataclass 省麻煩!
@dataclass
class T:
a: int
b: float
c: str
預寫好常用功能
當 Python 工程師在寫一個新的類別(Class)的時候,可以很粗略地分成兩種目的:
- 行為導向:例如為網頁「按鈕」設計類別,會需要為點擊、長按、拖曳等行為定義函式
- 資料導向:例如為「學生」設計類別,需要儲存學校、班級、考試成績等資料
行為導向的類別可能的用途無遠弗屆,但是資料導向的類別用途相對單純、就是要儲存資料並操作的容器,常用的功能不會差太多,dataclass
預先幫你定義好這些常用功能,工程師就不用花時間自己寫了!
當你創建 dataclass
實例的時候,它可以預先幫你實作好的函式(“dunder” method)包括:
__eq__()
__lt__()
__repr__()
__hash__()
- 還有更多函式,請見 PEP 557
就算你超級勤奮,不使用 dataclass
、而是在自定義類別寫好這些所有函式,要增減或修改屬性也很花時間,明明修改內容不大,卻要同時改很多地方、來回檢查,也是相當消耗心力的,還不如把這些資料相關的基本功能交給 dataclass
幫我們煩惱。
加減屬性比較簡單
直接使用以下自定義類別的範例介紹,如果需要改程式碼、額外加一個屬性,除了 __init__
,還要改 __str__
跟 __repr__
兩個函式,要是有更多函式呢?就要加超多次不是嗎?!
def Employee:
def __init__(self, name: str, job: str):
self.name = name
self.job = job
def __str__(self) -> str:
return f"{self.name}, {self.job}"
def __repr__(self) -> str:
return f"Person({self.name}, {self.job})"
...
啊不就改到吐血 _(´ཀ`」 ∠)_
採用 dataclass
的話,增減屬性的時候,至少 __repr__()
或 __eq__()
這些常用函式 dataclass
會幫我們同步增減屬性,工程師自己不需要費心改動程式碼,減少了不少繁瑣的工作。
__post_init__()
__post_init__()
是個有趣的功能,此函式會在所有屬性在實例被初始化之後立刻接著被呼叫,使用方法是在 dataclass
內自己定義一個名為 __post_init__()
的函式,它最常被用的場景是需要取決於初始的其他屬性來產生新資料的時候。例如,一開始員工資料只會收到姓名與職稱,我們想要收完資料之後產生像是「資料科學家:好豪」這種頭銜資訊,可以像以下這樣寫:
@dataclass
class Employee:
"""Class that contains basic information about an employee."""
name: str
job: str
salary: int = 0
def __post_init__(self):
self.title = f"{self.job}: {self.name}"
hao = Employee("Hao Huang", "Data Analyst", 22_000)
print(hao) # 在 __post_init__() 加入的屬性不會被印出來
print(hao.title)
## 執行結果:
## $ python3 test.py
## Employee(name='Hao Huang', job='Data Analyst', salary=22000)
## Data Analyst: Hao Huang
除了上述的這些功能以外,dataclass
還可以用裝飾器參數再擴充功能,接下來的文章將進一步介紹裝飾器參數解決了什麼寫程式的痛點。
進階:好用的裝飾器參數設定
@dataclass
裝飾器裡,有很多參數可以設定不同功能,語法會像是 @dataclass(init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False, match_args=True, kw_only=False, slots=False)
,筆者從中選出幾個自己常用的參數,與你分享。
frozen
使用 @dataclass(frozen=True)
之後,所有對 dataclass
屬性賦值(Assign)的行為都會出現程式錯誤(Exception),也就是要你的資料「結凍」起來、不可被修改。
@dataclass(frozen=True)
class Employee:
"""Class that contains basic information about an employee."""
name: str
job: str
salary: int = 0
hao = Employee("Hao Huang", "Data Analyst", 22_000)
hao.job = "Engineer" # 嘗試賦值
## 執行結果:
## $ python3 test.py
## 程式出現錯誤:dataclasses.FrozenInstanceError: cannot assign to field 'job'
如果你想「結凍」不可被修改的屬性資料只有一、兩個變數,筆者更推薦你使用 Python @property
語法,詳情請繼續閱讀 好豪的 Property 教學文章。
order
設定這個參數讓你的 dataclass
可以彼此比較大小、並排序。
使用 @dataclass(order=True)
後,dataclass
會自動幫你實作 __lt__()
、__le__()
、__gt__()
、以及 __ge__()
,有了這四個函式,就可以為資料排序了,dataclass
預設比較大小的方式是「按照屬性順序、一個個比較」,例如以下範例,dataclass
會先比較 a
屬性的大小、比不出高下的話再比較 b
屬性:
@dataclass(order=True)
class T:
a: int
b: int
c: int
t1 = T(1, 2, 3)
t2 = T(1, 0, 20)
print(t1 > t2) # __gt__()
print(t1 == t2) # __eq__()
## 執行結果:
## $ python3 test.py
## True
## False
slots
如果你的 Python 程式非常重視效能,請務必試試
slots
在類別內部,自訂的屬性資料 Python 實際上是用 dict
儲存,可以用 __dict__
查看:
@dataclass
class T:
a: int
b: int
c: int
t1 = T(1, 2, 3)
print(t1.__dict__)
## 執行結果:
## $ python3 test.py
## {'a': 1, 'b': 2, 'c': 3}
dict
是可以在程式執行階段(runtime)增減內容元素的,所以需要的記憶體自然較高。《Python 神乎其技》 書中建議,如果你不需要在程式執行階段增減物件屬性,就適合使用 slots
,它會事先定義物件屬性需要多少記憶體空間,執行時就可以讓佔用的記憶體「瘦身」、並且加快存取速度。通常 slots
的存取所需時間會比 dict
減少至少 20%!
dataclass
裡使用 slots
的方法,是在 dataclass
另外實作 __slots__
:
@dataclass()
class T:
# slots 內列出物件要擁有的屬性
__slots__ = ['a', 'b', 'c']
a: int
b: int
c: int
t1 = T(1, 2, 3)
print(t1.__slots__)
print(t1.b) # 取得資料的方法依舊
## 執行結果:
## $ python3 test.py
## ['a', 'b', 'c']
## 2
補充:如果你的 Python 是 3.10 以上版本,slots
設定就更方便了,你只要裝飾器參數設定 @dataclass(slots=True)
,dataclass
會自動幫你實作 __slots__
!
kw_only
(此為 Python 3.10 以上的功能)
@dataclass(kw_only=True)
要求使用者創造實例時,只能用關鍵字引數(Keyword Argument),不能用位置引數(Positional Argument)。
- 不能寫:
Employee("Hao Huang", "Data Analyst", 22_000)
- 只能寫:
Employee(name="Hao Huang", job="Data Analyst", salary=22_000)
結語
dataclass
讓你用定義類別的語法快速定義出資料容器,它的威力除了本文介紹的眾多方便功能以外,你也能像一般類別一樣運用它,像是繼承、DocString、或者自定義函式等等。
在文章的最後,或許有經驗的讀者會想到,有些情況的資料容器只需要用 dict
或 NamedTuple
等等資料結構就夠好用了,確實如此!dataclass
當然不是在所有場景都是最佳作法,筆者在此簡單條列我所知道的 dataclass
可能比其他資料結構好用的場景:
dict
:如果同類型屬性在程式碼中反覆出現,用dict
容易出錯- 你可能在某塊程式碼用
{"name": "Wilson", "job": "Data Scientist"}
紀錄資料 - 寫到程式碼的後半段則用
{"Name": "Wilson", "Job": "Data Scientist"}
- 你有看出來上面兩行哪裡不一樣而出錯了嗎?
- 你可能在某塊程式碼用
dict
也需要經過額外處理才能設定預設值- 延伸閱讀:Python dict 預設值技巧教學
NamedTuple
:這個結構是不可變(immutable)的,因此NamedTuple
物件沒辦法在程式運作過程增加屬性,只能靠重新創建一個NamedTuple
的實例來達成Pydantic
:如果你是 FastAPI 的愛用者,肯定知道Pydantic
的強大。然而,Pydantic
的主要功能是 Parsing,例如解析從網頁 API 收到的資料,而它在 Runtime 檢查型別的機制會使它消耗計算資源較大、運作較慢
比起 dataclass
,也有另外一派 Python 工程師喜歡使用 attrs 套件,它比 dataclass
更強的功能包括資料驗證器、強制資料型別轉換等等,如果你需要這些功能,推薦你延伸學習 attrs 套件。
參考資料:
- 《Python 神乎其技》
- Udemy 線上課程: Python Deep Dive – OOP (Slots 教學)
- 官方文件 PEP 557
- mCoding Youtube 頻道的 dataclass 與 attrs 介紹
- Real Python: dataclass tutorial
還想知道更多 Python 相關技巧嗎?這篇文章教學的 dataclass
是筆者從 《Python 神乎其技》 書中學到的,有興趣的話請繼續閱讀好豪幫你整理好的《Python 神乎其技》免費教學文章,學會更多 Pythonic Code!
如果這篇文章有幫助到你,歡迎追蹤好豪的 Facebook 粉絲專頁 與 Threads 帳號,我會持續分享 Python 技巧給大家;也歡迎點選下方按鈕將本文加入書籤、或者分享給更多正在學 Python 的朋友。