Python dataclass 教學:輕鬆定義資料類別

by 好豪

dataclass 可以視為可變、而且可設定預設值的 namedtuple

PEP 557

你的 Python 程式碼常用 tupleNamedTuple 嗎?我相信你也會想試試功能更強且更有彈性的 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 眾多好用功能的冰山一角而已。

上方範例中的 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 

defaultdefault_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 函式的可變物件預設引數陷阱》

(小提醒:defaultdefault_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)的時候,可以很粗略地分成兩種目的:

  1. 行為導向:例如為網頁「按鈕」設計類別,會需要為點擊、長按、拖曳等行為定義函式
  2. 資料導向:例如為「學生」設計類別,需要儲存學校、班級、考試成績等資料

行為導向的類別可能的用途無遠弗屆,但是資料導向的類別用途相對單純、就是要儲存資料並操作的容器,常用的功能不會差太多,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'

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、或者自定義函式等等。

在文章的最後,或許有經驗的讀者會想到,有些情況的資料容器只需要用 dictNamedTuple 等等資料結構就夠好用了,確實如此!dataclass 當然不是在所有場景都是最佳作法,筆者在此簡單條列我所知道的 dataclass 可能比其他資料結構好用的場景:

  • dict:如果同類型屬性在程式碼中反覆出現,用 dict 容易出錯
    • 你可能在某塊程式碼用 {"name": "Wilson", "job": "Data Scientist"} 紀錄資料
    • 寫到程式碼的後半段則用 {"Name": "Wilson", "Job": "Data Scientist"}
    • 你有看出來上面兩行哪裡不一樣而出錯了嗎?
  • dict 也需要經過額外處理才能設定預設值
  • NamedTuple:這個結構是不可變(immutable)的,因此 NamedTuple 物件沒辦法在程式運作過程增加屬性,只能靠重新創建一個 NamedTuple 的實例來達成
  • Pydantic:如果你是 FastAPI 的愛用者,肯定知道 Pydantic 的強大。然而,Pydantic 的主要功能是 Parsing,例如解析從網頁 API 收到的資料,而它在 Runtime 檢查型別的機制會使它消耗計算資源較大、運作較慢

比起 dataclass,也有另外一派 Python 工程師喜歡使用 attrs 套件,它比 dataclass 更強的功能包括資料驗證器、強制資料型別轉換等等,如果你需要這些功能,推薦你延伸學習 attrs 套件。


參考資料:


還想知道更多 Python 相關技巧嗎?這篇文章教學的 dataclass 是筆者從 《Python 神乎其技》 書中學到的,有興趣的話請繼續閱讀好豪幫你整理好的《Python 神乎其技》免費教學文章,學會更多 Pythonic Code!

如果這篇文章有幫助到你,歡迎追蹤好豪的 Facebook 粉絲專頁,我會持續分享 Python 技巧給大家;也歡迎點選下方按鈕將本文加入書籤、或者分享給更多正在學 Python 的朋友。

推薦閱讀