如果你已經學過 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=[])
Falsedefault_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: strTypeError: 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 = 99dataclasses.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=['急件']) 小計 1780subtotal() 是一個普通方法,跟 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 這種型別註解並不會真的檢查型別,這系列的下一篇會接著處理這件事。