本文 AI 產出,尚未審核
Python:型別提示與靜態分析 – dataclass + Type Hint
簡介
在 Python 3.6 之後,型別提示(type hint) 逐漸成為程式碼可讀性與維護性的核心工具。配合靜態分析工具(如 mypy、pyright)使用,能在執行前即捕捉到許多潛在錯誤,讓開發者在編寫程式時就能得到即時的型別檢查回饋。
另一方面,dataclass(自 Python 3.7 正式納入標準庫)提供了一種簡潔的方式來宣告「只用來儲存資料」的類別。它會自動產生 __init__、__repr__、__eq__ 等魔法方法,減少樣板程式碼。
將 dataclass 與 型別提示 結合,能同時獲得:
- 清晰的資料結構說明(型別提示)
- 自動產生的建構子與比較功能(
dataclass) - 靜態分析工具的完整支援(如未符合型別會直接報錯)
對於專案規模從小型腳本到大型服務端程式,都能提升開發效率與程式品質。本篇文章將從核心概念說明、實作範例、常見陷阱與最佳實踐,最後帶出實務應用場景,幫助你在日常開發中善用 dataclass + type hint。
核心概念
1. dataclass 的基本語法
from dataclasses import dataclass
@dataclass
class Point:
x: float # 透過型別提示宣告欄位型別
y: float
@dataclass會根據欄位自動產生__init__(self, x: float, y: float)、__repr__、__eq__等。- 欄位的型別提示(
x: float)不會在執行時強制轉型,僅供型別檢查工具與 IDE 使用。
重點:即使不寫
__init__,dataclass仍會根據提示自動產生,讓程式碼更簡潔。
2. 預設值與 field()
from dataclasses import dataclass, field
from typing import List
@dataclass
class Todo:
title: str
completed: bool = False # 預設值
tags: List[str] = field(default_factory=list) # 使用 factory 產生可變預設值
- 不可變的預設值(如
False、0、"")直接寫在欄位後即可。 - 可變的預設值(如 list、dict)必須使用
field(default_factory=…),避免所有實例共享同一個物件。
3. 型別提示的進階寫法
3.1 使用 typing 模組的泛型
from dataclasses import dataclass
from typing import Generic, TypeVar, List
T = TypeVar('T')
@dataclass
class Box(Generic[T]):
content: T
history: List[T] = field(default_factory=list)
Box[int]、Box[str]等都能在靜態分析時得到正確的型別推斷。Generic讓dataclass也能支援 型別參數化,適合實作容器、結果封裝等結構。
3.2 Optional 與 Union
from dataclasses import dataclass
from typing import Optional, Union
@dataclass
class User:
id: int
name: str
email: Optional[str] = None # 允許 None
role: Union[str, int] = "guest" # 可接受字串或整數
Optional[X]等價於Union[X, None],提醒開發者此欄位可能是None。Union讓欄位接受多種型別,靜態分析會檢查是否符合其中任一型別。
4. frozen=True:讓資料類別成為不可變物件
@dataclass(frozen=True)
class ImmutablePoint:
x: float
y: float
frozen=True會在__setattr__加入保護,防止在建立後修改屬性。- 這在 資料傳遞(data transfer objects, DTO) 或 快取鍵 時非常有用,因為不可變物件天然可作為字典 key。
5. 結合靜態分析工具
# 安裝 mypy
pip install mypy
# 執行型別檢查
mypy my_project/
在上述範例中,如果 Todo.tags 被指定為非 list,mypy 會直接報錯:
error: Incompatible type for "tags" (expected "List[str]", got "int")
這樣的即時回饋可大幅降低執行時錯誤的機會。
程式碼範例
以下提供 5 個實務導向的範例,說明 dataclass + type hint 在不同情境下的寫法與技巧。每段程式碼都附有說明註解。
範例 1:簡易的 API 回傳模型
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class ArticleResponse:
id: int
title: str
author: str
tags: List[str]
summary: Optional[str] = None
- 這個模型可直接作為 FastAPI、Flask 等框架的回傳型別。
Optional[str]告訴前端「summary 可能不存在」,IDE 會自動提示None判斷。
範例 2:使用 default_factory 的可變欄位
from dataclasses import dataclass, field
from typing import Dict
@dataclass
class Counter:
counts: Dict[str, int] = field(default_factory=dict)
def inc(self, key: str, amount: int = 1) -> None:
"""將指定 key 的計數加上 amount。"""
self.counts[key] = self.counts.get(key, 0) + amount
- 每個
Counter實例都有自己的countsdict,避免共用同一個空字典。 inc方法的參數也使用型別提示,靜態分析會檢查key必須是str。
範例 3:不可變的設定物件(適合作為全域設定)
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
host: str
port: int
debug: bool = False
Config建立後不可變,確保程式在執行期間不會意外改變設定值。- 可直接作為函式參數傳遞,且可安全作為字典的 key:
cfg = Config(host="localhost", port=8000)
cache = {cfg: "已初始化"}
範例 4:泛型容器 – Result 包裝成功或失敗資訊
from dataclasses import dataclass
from typing import Generic, TypeVar, Optional
T = TypeVar('T')
E = TypeVar('E')
@dataclass
class Result(Generic[T, E]):
value: Optional[T] = None
error: Optional[E] = None
@property
def is_ok(self) -> bool:
return self.error is None
@property
def is_err(self) -> bool:
return self.error is not None
- 使用方式:
def divide(a: float, b: float) -> Result[float, str]:
if b == 0:
return Result(error="除以零")
return Result(value=a / b)
res = divide(10, 2)
if res.is_ok:
print("結果 =", res.value)
else:
print("錯誤 :", res.error)
- 靜態分析會確保
value與error的型別分別符合float與str。
範例 5:結合 FastAPI 的請求與回傳模型
from fastapi import FastAPI
from pydantic import BaseModel
from dataclasses import dataclass
from typing import List
app = FastAPI()
# Pydantic 用於驗證請求資料
class CreateUserReq(BaseModel):
username: str
age: int
# 回傳使用 dataclass
@dataclass
class UserDTO:
id: int
username: str
age: int
tags: List[str]
# 模擬資料庫
_FAKE_DB: dict[int, UserDTO] = {}
_NEXT_ID = 1
@app.post("/users/", response_model=UserDTO)
def create_user(req: CreateUserReq):
global _NEXT_ID
user = UserDTO(id=_NEXT_ID, username=req.username, age=req.age, tags=[])
_FAKE_DB[_NEXT_ID] = user
_NEXT_ID += 1
return user
- 重點:雖然 FastAPI 主要使用 Pydantic,但回傳時可以直接使用
dataclass,FastAPI 會自動把它轉換成 JSON。 - 這樣的寫法讓資料層(
UserDTO)保持簡潔,同時仍享有型別提示與自動生成的__repr__。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 可變預設值共享 | 使用 tags: List[str] = [] 會讓所有實例共享同一個列表。 |
改用 field(default_factory=list)。 |
| 型別提示僅供靜態分析 | 直接把錯誤型別傳入建構子不會拋出例外,只會在 mypy 報錯。 |
在開發流程中加入 mypy CI,確保每次提交都有型別檢查。 |
frozen=True 與可變欄位衝突 |
即使 frozen=True,若欄位本身是可變物件(如 list),仍可在外部修改。 |
使用 typing.Tuple 取代 list,或自行在 __post_init__ 內作深拷貝。 |
__post_init__ 中的型別檢查 |
有時需要在建構後做更嚴格的檢查,但忘記加上 @dataclass 的 init=False 會導致參數不匹配。 |
若需要自訂初始化邏輯,使用 def __post_init__(self): 並在其中執行驗證。 |
混用 dataclass 與 pydantic |
兩者都會自動產生 __init__,若同時繼承會產生衝突。 |
只在資料層使用 dataclass,在 API 層使用 Pydantic,或使用 pydantic.dataclasses.dataclass 取代標準 dataclass。 |
最佳實踐
- 盡量在類別層面完整描述型別:所有欄位都加上提示,讓 IDE 能提供自動完成與錯誤提示。
- 使用
field()管理預設值:尤其是可變物件,避免不易察覺的共享問題。 - 結合 CI/CD 執行型別檢查:例如在 GitHub Actions 中加入
mypy --strict,確保每次合併前通過。 - 在不可變資料結構上使用
frozen=True:提升程式的可預測性與雜湊相容性(可作為 dict key)。 - 將
dataclass作為「純資料」的 DTO:業務邏輯應寫在服務層或領域模型中,保持dataclass的簡潔與專注。
實際應用場景
| 場景 | 為何適合使用 dataclass + type hint |
|---|---|
| API 回傳/請求模型 | 直接對外暴露的資料結構,型別提示讓前端與測試人員清楚欄位型別。 |
| 設定檔與環境變數 | frozen=True 的設定類別保證在程式執行期間不會被意外改變。 |
| 快取鍵(Cache Key) | 不可變的 dataclass 可直接作為 dict 或 lru_cache 的鍵,避免自行實作 __hash__。 |
| 工作流(Workflow)或排程任務資料 | 使用 default_factory 管理可變欄位,確保每個任務都有獨立的狀態。 |
| 領域驅動設計(DDD)中的值物件(Value Object) | 值物件本質上是不可變且只關心其屬性,正好符合 @dataclass(frozen=True) 的特性。 |
總結
dataclass為 Python 提供了「少寫樣板,多寫業務」的便利;配合 型別提示,能在編寫階段即捕捉錯誤、提升可讀性。- 正確使用
field(default_factory=…)、frozen=True、以及typing系列(List、Optional、Generic等)可以讓資料類別在各種情境下都保持安全且易於維護。 - 結合靜態分析工具(
mypy、pyright)與 CI 流程,讓型別檢查成為開發的必備步驟,從根本減少執行時錯誤。 - 無論是 API、設定、快取或是領域模型,
dataclass+ type hint 都是「清晰、可靠且高效」的解決方案。
把今天學到的概念套用到自己的專案中,從 小範例 開始逐步改寫,讓程式碼在可讀性與安全性上都有顯著提升吧!祝你寫程式愉快 🚀