回文章列表
Python13 分鐘閱讀2026年9月1日

Python dataclass 入門:一行 decorator 免寫 __init__、__repr__、__eq__

#Python#dataclass#OOP#class#進階

文章目錄

如果你已經學過 class、也補完 `__init__`,應該已經很習慣這種寫法:一個類別開頭一串 self.x = x,欄位越多,那串就越長。手上那支「銷售資料整理腳本」目前還在用 dict 或 tuple 傳一筆訂單——沒有欄位名稱檢查,order['unit_price'] 打成 order['unitprice'] 也不會有人攔你,要等到程式跑到某一行才炸出 KeyError。前面用 collections 按商品、按分類統計銷量的那篇,靠的正是每筆訂單裡都有 product、category 這兩個欄位,但 dict 版沒人強迫這兩個 key 一定要存在。這篇要做的事很單純:把「一筆訂單」升級成一個像樣的類別,讓這些欄位變成寫在類別定義裡的白紙黑字,而且不用手刻六行 self.x = x。

先手刻一次,感受一下有多長

一筆訂單有六個欄位:訂單編號、商品名稱、分類、數量、單價、狀態。照上一篇教的方式,一個「寫好的」Order 類別長這樣:

class Order:
    def __init__(self, order_id, product, category, quantity, unit_price, status='pending'):
        self.order_id = order_id
        self.product = product
        self.category = category
        self.quantity = quantity
        self.unit_price = unit_price
        self.status = status

    def __repr__(self):
        return (f'Order(order_id={self.order_id!r}, product={self.product!r}, '
                f'category={self.category!r}, quantity={self.quantity!r}, '
                f'unit_price={self.unit_price!r}, status={self.status!r})')

    def __eq__(self, other):
        if not isinstance(other, Order):
            return NotImplemented
        return (self.order_id, self.product, self.category, self.quantity,
                self.unit_price, self.status) == \
               (other.order_id, other.product, other.category, other.quantity,
                other.unit_price, other.status)


o1 = Order('A001', '機械鍵盤', '3C', 2, 890)
o2 = Order('A001', '機械鍵盤', '3C', 2, 890)
print(o1)
print(o1 == o2)
Order(order_id='A001', product='機械鍵盤', category='3C', quantity=2, unit_price=890, status='pending')
True

這裡刻意多寫了兩個上一篇沒細講的方法:__repr__ 是為了讓 print(o1) 印出有意義的內容,不然預設就是上一篇提過的 <__main__.Order object at 0x...> 那種記憶體位址;__eq__ 是為了讓 o1 == o2 能照欄位值比較,而不是預設的「兩個物件是不是同一份記憶體」。光是六個欄位,就要寫六行 __init__、一行落落長的 __repr__、外加一段 __eq__,欄位再多幾個,這段程式碼會線性變長,而且每加一個欄位要記得三個地方都要改——漏改 __repr__ 或 __eq__ 其中一處,Python 不會提醒你,只會留下一個沒同步更新的 bug。

用 @dataclass 改寫:同樣的東西,六行搞定

dataclasses 是 Python 3.7 開始內建的標準函式庫,@dataclass 這個 decorator 專門處理「這個類別主要是拿來裝資料」這種場景。把上面那段整個換掉:

from dataclasses import dataclass


@dataclass
class Order:
    order_id: str
    product: str
    category: str
    quantity: int
    unit_price: float
    status: str = 'pending'


o1 = Order('A001', '機械鍵盤', '3C', 2, 890)
o2 = Order('A001', '機械鍵盤', '3C', 2, 890)
print(o1)
print(o1 == o2)
Order(order_id='A001', product='機械鍵盤', category='3C', quantity=2, unit_price=890, status='pending')
True

輸出跟手刻版一模一樣,但這裡完全沒有寫 __init__、__repr__、__eq__——@dataclass 看到類別裡這幾行「欄位名稱: 型別」的宣告,就自動幫你生成這三個方法。order_id: str 這種寫法叫型別註解(type annotation),dataclass 靠它才知道這個類別有哪些欄位、欄位要怎麼排進 __init__ 的參數列。要注意的是這行註解不會強制檢查型別——就算你把 890 換成字串 '890',Python 一樣讓它跑,型別註解在這裡的用途純粹是「告訴 dataclass 這是一個欄位」,真正的型別檢查是另一個工具的事,之後這系列的下一篇會專門講。

status: str = 'pending' 這行也值得留意:等號後面接的是預設值,跟函式定義預設參數的寫法完全一樣,Order('A001', '機械鍵盤', '3C', 2, 890) 沒傳 status 時就自動補上 'pending'。

可變預設值的坑:list 不能直接當預設值

接下來想幫 Order 加一個 tags 欄位,讓客服可以標記「急件」「禮盒包裝」這類備註,一筆訂單可以掛好幾個標籤。直覺會寫成這樣:

from dataclasses import dataclass


@dataclass
class Order:
    order_id: str
    product: str
    category: str
    quantity: int
    unit_price: float
    status: str = 'pending'
    tags: list = []

跑起來直接被 Python 擋下來:

ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory

這跟函式參數那篇提過的 def add_student(name, scores=[]) 是同一個根源的坑——預設值只在定義的當下建立一次,之後每個沒傳 tags 的 Order 物件都會共用同一份 list,改了一個就等於改了全部。差別是:函式那邊 Python 不會攔你,add_student 照樣跑得起來、只是行為詭異;dataclass 這邊直接把路擋死,一定義就報錯,逼你在寫出那個共用 list 的 bug 之前先發現問題。

正確寫法是用 field() 搭配 default_factory:

from dataclasses import dataclass, field


@dataclass
class Order:
    order_id: str
    product: str
    category: str
    quantity: int
    unit_price: float
    status: str = 'pending'
    tags: list = field(default_factory=list)


o1 = Order('A001', '機械鍵盤', '3C', 2, 890)
o1.tags.append('急件')
o2 = Order('A002', '無線滑鼠', '3C', 1, 450)
print(o1)
print(o2)
print(o1.tags is o2.tags)
Order(order_id='A001', product='機械鍵盤', category='3C', quantity=2, unit_price=890, status='pending', tags=['急件'])
Order(order_id='A002', product='無線滑鼠', category='3C', quantity=1, unit_price=450, status='pending', tags=[])
False

default_factory 接的不是一個值,是一個零參數的函式——list 這個名稱本身就是「呼叫它會產生一個新空 list」的函式,field(default_factory=list) 的意思是「每次沒傳 tags 就呼叫一次 list(),各自拿到一份全新的空 list」。最後 o1.tags is o2.tags 印出 False,證明兩個物件的 tags 是各自獨立的兩份記憶體,不會互相汙染。

有預設值的欄位要放後面

再多加一個欄位時很容易撞到另一個規則。假設想把 status(有預設值)插在中間、order_id(沒預設值)之後:

from dataclasses import dataclass


@dataclass
class Order:
    status: str = 'pending'
    order_id: str
TypeError: non-default argument 'order_id' follows default argument 'status'

原因跟一般函式的參數順序規則完全一樣:__init__ 是照欄位宣告的順序生成參數列,如果 status='pending' 排在 order_id 前面,生出來的 __init__(self, status='pending', order_id) 本身就是不合法的函式簽名——Python 的函式規則是有預設值的參數後面不能接沒有預設值的參數。解法就是排序:所有沒有預設值的欄位寫在最前面,有預設值的欄位統一放在後面,前面 Order 那幾版程式碼把 order_id、product、category、quantity、unit_price 放前面,status、tags 放最後,就是照這個規則排的。

frozen 與 order:兩個常用參數

@dataclass() 這個 decorator 本身可以帶參數,調整生成出來的行為。最常用的兩個是 frozen 和 order。

frozen=True 讓物件建立之後不能再修改欄位,適合拿來做「這筆資料建好之後就不該被改動」的情境:

from dataclasses import dataclass


@dataclass(frozen=True)
class Point:
    x: int
    y: int


p = Point(1, 2)
p.x = 99
dataclasses.FrozenInstanceError: cannot assign to field 'x'

order=True 則是幫你生成 <、<=、>、>= 這幾個比較方法,讓物件可以直接排序——比較方式是把物件當成「欄位值排成的 tuple」,依欄位宣告順序一個個比下去,先比第一個欄位,相等才比下一個:

from dataclasses import dataclass


@dataclass(order=True)
class Score:
    value: int
    name: str


scores = [Score(88, '小明'), Score(72, '雅婷'), Score(95, '阿哲')]
print(sorted(scores))
[Score(value=72, name='雅婷'), Score(value=88, name='小明'), Score(value=95, name='阿哲')]

sorted() 能直接排 Score 物件的清單,靠的就是 order=True 生出來的 __lt__,先比 value,value 一樣才會接著比 name。這兩個參數可以一起開:@dataclass(frozen=True, order=True) 完全合法,一個物件既不能改、又能排序,例如拿來當 dict 的 key 或塞進 set 都很合適。要留意的是 order=True 比的是欄位宣告順序,不是「你這次想依哪個欄位排序」——如果排序依據是像小計這種算出來的值、而不是宣告的欄位本身,order=True 幫不上忙,得靠等一下最後那段的 key=lambda。

asdict()/astuple():轉回 dict 或 tuple

有時候需要把 dataclass 物件轉回普通的 dict 或 tuple,例如要塞進 json.dumps()(json 模組不認得 dataclass 物件)。dataclasses 模組提供現成的函式:

from dataclasses import dataclass, field, asdict, astuple


@dataclass
class Order:
    order_id: str
    product: str
    category: str
    quantity: int
    unit_price: float
    status: str = 'pending'
    tags: list = field(default_factory=list)


o = Order('A001', '機械鍵盤', '3C', 2, 890, tags=['急件'])
print(asdict(o))
print(astuple(o))
{'order_id': 'A001', 'product': '機械鍵盤', 'category': '3C', 'quantity': 2, 'unit_price': 890, 'status': 'pending', 'tags': ['急件']}
('A001', '機械鍵盤', '3C', 2, 890, 'pending', ['急件'])

asdict() 轉出來的 key 就是欄位名稱,astuple() 則是照欄位順序排成一個 tuple。兩個都會遞迴處理——如果欄位本身也是 dataclass 或裝了 dataclass 的 list,一樣會被轉開,不用自己再手動拆。

什麼時候不該用 dataclass

@dataclass 適合的場景很明確:這個類別主要工作就是裝資料,欄位之間沒有太複雜的邏輯。但遇到下面兩種情況,寫一般 class 反而更清楚:

  • 建立時需要複雜驗證邏輯。例如 unit_price 不能是負數、status 只能是幾個固定字串之一,這種驗證塞進 __init__ 很直覺,但 dataclass 生成的 __init__ 是自動的,硬要加驗證得另外定義 __post_init__ 方法,邏輯一多,跟手寫 __init__ 比起來並沒有比較省事。
  • 類別本身有大量自訂行為,例如前面 Product 那個有 restock()、sell() 這種會改動狀態、帶副作用的方法。dataclass 不是不能加方法(一樣可以在類別裡定義),但如果一個類別的重點是「一堆會計算、會修改自身狀態的行為」而不是「單純裝資料」,用一般 class 更能讓讀程式碼的人一眼看出這是個有行為的物件,而不是誤以為它只是個資料容器。

一個實用的判斷準則:這個類別的角色比較接近「一筆資料記錄」還是「一個會做事的物件」?前者交給 @dataclass,後者回頭寫完整的 class 加 __init__。

完整小成品:訂單清單依小計排出前三名

把這篇的重點串起來:定義一個帶 default_factory 的 Order dataclass,順便加一個 subtotal() 方法算「數量 × 單價」——小計是算出來的,不是存起來的欄位,這樣 quantity 或 unit_price 改了,小計永遠跟著對,不會有「兩個欄位沒同步更新」的問題。建一份訂單清單,用 sorted() 依小計排序印出前三名:

from dataclasses import dataclass, field


@dataclass
class Order:
    order_id: str
    product: str
    category: str
    quantity: int
    unit_price: float
    status: str = 'pending'
    tags: list = field(default_factory=list)

    def subtotal(self) -> float:
        return self.quantity * self.unit_price


orders = [
    Order('A001', '機械鍵盤', '3C', 2, 890, tags=['急件']),
    Order('A002', '27吋螢幕', '3C', 1, 3560, status='shipped'),
    Order('A003', '滑鼠墊', '文具', 3, 89),
    Order('A004', '無線喇叭', '3C', 2, 1250, status='shipped'),
    Order('A005', '延長線', '3C', 1, 350),
]

top3 = sorted(orders, key=lambda o: o.subtotal(), reverse=True)[:3]

print('小計前三名訂單:')
for o in top3:
    print(o, '小計', o.subtotal())
小計前三名訂單:
Order(order_id='A002', product='27吋螢幕', category='3C', quantity=1, unit_price=3560, status='shipped', tags=[]) 小計 3560
Order(order_id='A004', product='無線喇叭', category='3C', quantity=2, unit_price=1250, status='shipped', tags=[]) 小計 2500
Order(order_id='A001', product='機械鍵盤', category='3C', quantity=2, unit_price=890, status='pending', tags=['急件']) 小計 1780

subtotal() 是一個普通方法,跟 dataclass 自動生成的 __init__、__repr__、__eq__ 完全不衝突——dataclass 只負責生成那三個特殊方法,其他方法照樣自己寫。sorted(orders, key=lambda o: o.subtotal(), reverse=True) 這行不需要 order=True,因為排序依據是「呼叫 subtotal() 算出來的值」,不是物件本身欄位宣告順序的逐一比較;key 直接指定「拿什麼值來排」,跟前面 order=True 那種「整個物件依欄位順序比大小」是兩條不同的路。印出來的每一筆都是完整、可讀的 Order(...),不用再另外寫一個函式把物件格式化成字串——這正是這篇一開始想解決的事:一行 decorator,換掉一長串手刻的 self.x = x 加上另外兩個特殊方法。

小結

@dataclass 幫你用欄位的型別註解自動生成 __init__、__repr__、__eq__,省掉的不只是打字量,更是「加一個欄位要記得同步改三個地方」的維護負擔。這篇踩過的兩個規則值得記牢:可變的預設值(list、dict 這類)不能直接寫在等號後面,要用 field(default_factory=...);有預設值的欄位必須排在沒有預設值的欄位後面。frozen=True、order=True 是最常用的兩個開關(order=True 比的是欄位宣告順序,跟依算出來的值排序是兩回事),asdict()/astuple() 則是跟外部系統(例如要轉成 JSON)介接時的橋樑。

銷售資料整理腳本裡那個裸 dict 版的訂單,換成這篇的 Order dataclass 之後,product、category 這些欄位變成寫在類別定義裡的白紙黑字,打錯字會直接在建立物件時被 Python 擋下來,不用再等到程式跑到某一行才發現。下一步是幫這些欄位加上更嚴謹的型別檢查——目前 order_id: str 這種型別註解並不會真的檢查型別,這系列的下一篇會接著處理這件事。

相關課程

Python 基礎入門

從零開始學習 Python 程式語言,掌握變數、流程控制、函式與模組化設計,建立扎實的程式設計基礎。

查看課程內容