本文 AI 產出,尚未審核
Python – 函式式編程:Immutability 與 Pure Function 概念
簡介
在 Python 這門多範式語言中,函式式編程 (Functional Programming) 提供了一套避免副作用、提升程式可預測性的思考方式。兩個最核心的概念——不可變性 (immutability) 與 純函式 (pure function),不僅能讓程式更易於測試與除錯,還能在多執行緒、分散式系統等高併發情境下提升安全性。
對於剛踏入 Python 的新手或已具備基礎的開發者而言,了解這兩個概念並能在實務中正確運用,是邁向更乾淨、可維護程式碼的重要一步。本篇文章將以 簡單易懂的語言、實用範例,說明什麼是不可變物件與純函式、它們的好處、常見陷阱,以及在真實專案中如何落地。
核心概念
1️⃣ 什麼是不可變性 (Immutability)
在程式語言裡,資料結構大致可分為 可變 (mutable) 與 不可變 (immutable) 兩類。
- 可變物件:其內部狀態可以在建立後被改變,例如
list、dict、set。 - 不可變物件:一旦建立,其內容就不能被更改,例如
int、float、str、tuple、frozenset。
為什麼要偏好不可變物件?
- 不可變物件天然 thread‑safe,多執行緒同時讀取不會產生競爭條件。
- 在函式式編程中,資料的「傳遞」不會產生副作用,讓程式的行為更可預測。
範例 1:使用 tuple 替代 list
# 可變的 list
numbers = [1, 2, 3]
numbers.append(4) # 改變了原本的 list
print(numbers) # [1, 2, 3, 4]
# 不可變的 tuple
numbers_imm = (1, 2, 3)
# numbers_imm.append(4) # AttributeError: 'tuple' object has no attribute 'append'
new_numbers = numbers_imm + (4,) # 產生新 tuple,原本的 tuple 不變
print(new_numbers) # (1, 2, 3, 4)
重點:
tuple本身不會被改變,若需要「新增」元素,會回傳全新物件,而不是改寫原本的資料。
2️⃣ 什麼是純函式 (Pure Function)
純函式 必須同時滿足兩個條件:
- 相同的輸入永遠產生相同的輸出(沒有隨機性、時間或全域狀態的影響)。
- 不會產生副作用(不會修改參數、全域變數、檔案、資料庫等外部資源)。
純函式的好處包括:
- 易於測試:只要驗證輸入與輸出即可。
- 可組合:純函式之間可以自由組合,不必擔心相互干擾。
- 提升可併發性:因為不會改變共享狀態,天然適合平行執行。
範例 2:純函式 vs. 非純函式
# 非純函式:依賴全域變數,且修改了傳入的 list
total = 0
def accumulate(values):
global total
for v in values:
total += v # 改變全域狀態
return total
print(accumulate([1, 2, 3])) # 6
print(accumulate([1, 2])) # 9 ← 前一次的結果被保留下來,行為不一致
# 純函式:僅依賴輸入,回傳新值,不改變任何外部狀態
def pure_sum(values):
return sum(values) # 內建 sum 本身是純函式
print(pure_sum([1, 2, 3])) # 6
print(pure_sum([1, 2])) # 3 ← 每次呼叫都與輸入直接對應
3️⃣ 結合不可變性與純函式的寫法
在 Python 中,我們可以藉由 資料結構的不可變化,配合 純函式,打造「函式式」的程式碼風格。
範例 3:使用 namedtuple 與 map、filter
from collections import namedtuple
# 定義不可變的資料結構
Point = namedtuple('Point', ['x', 'y'])
points = (Point(1, 2), Point(3, 4), Point(-1, -5))
# 純函式:計算每個點到原點的距離
def distance(p: Point) -> float:
return (p.x ** 2 + p.y ** 2) ** 0.5
# 使用 map 產生新序列,原始 points 不會被改變
distances = tuple(map(distance, points))
print(distances) # (2.23606797749979, 5.0, 5.0990195135927845)
範例 4:利用 functools.reduce 實作純粹的累計
from functools import reduce
# 純函式:二元運算子
def add(a, b):
return a + b
numbers = (10, 20, 30)
# reduce 會把序列折疊成單一值,整個過程不會改變原始 numbers
total = reduce(add, numbers, 0) # 初始值 0
print(total) # 60
範例 5:使用 dataclasses 的 frozen=True 建立不可變物件
from dataclasses import dataclass
@dataclass(frozen=True)
class User:
id: int
name: str
alice = User(1, "Alice")
# alice.name = "Bob" # dataclasses.FrozenInstanceError: cannot assign to field 'name'
# 純函式:產生新 User,保留原本的 id
def rename(user: User, new_name: str) -> User:
return User(user.id, new_name)
bob = rename(alice, "Bob")
print(alice) # User(id=1, name='Alice')
print(bob) # User(id=1, name='Bob')
小結:以上範例展示了「不可變資料 + 純函式」的典型寫法;每一次的「變更」其實是產生新物件,而不是直接改寫舊物件。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
誤以為 list/dict 是不可變的 |
直接把可變容器傳給純函式,函式內部若不小心修改,會產生副作用。 | 使用 tuple、frozenset,或在函式內 先 copy (list(obj)、obj.copy()) 再操作。 |
| 全域變數的隱藏依賴 | 純函式若不小心讀取或寫入全域變數,會破壞可預測性。 | 把所有需要的資訊作為參數傳入,或使用 dependency injection。 |
| Mutable default arguments | def foo(arg=[]): 會共用同一個列表,導致意外狀態累積。 |
使用 None 作為預設值,並在函式內部建立新容器:if arg is None: arg = []。 |
| 忘記返回新物件 | 有時候寫了「改變」的程式碼卻忘了 return,結果是 None,呼叫端無法取得新值。 |
明確寫 return new_obj,或使用 pipeline(pipe)風格。 |
過度使用 deepcopy |
為了避免副作用而大量 deep copy,會帶來效能問題。 | 盡量選擇天生不可變的結構,或只在必要的層級 copy。 |
最佳實踐
- 盡量使用不可變容器:
tuple、frozenset、namedtuple、dataclass(frozen=True)。 - 讓函式保持純粹:只接受參數、只回傳值,避免 I/O、全域狀態。
- 函式組合:利用
map、filter、reduce、itertools等工具鏈接純函式,提升可讀性。 - 單元測試:純函式的測試只需要檢查輸入/輸出,寫測試時可省去 mock 之類的複雜設定。
- 文件說明:在函式 docstring 中標註「pure」或「immutable」以提醒使用者其行為特性。
實際應用場景
| 場景 | 為何適合使用不可變 + 純函式 | 示例 |
|---|---|---|
| 資料分析的管線 (ETL) | 每一步驟產生新資料表,避免前一步的變更影響後續步驟。 | df_clean = df.dropna().assign(new_col=lambda x: x['a']*2)(Pandas 雖非純函式,但可透過 copy() 保持不可變) |
| 多執行緒或協程 | 不可變資料不需要鎖 (lock),降低死結風險。 | 在 concurrent.futures.ThreadPoolExecutor 中傳遞 tuple、frozenset 給 worker。 |
| 函式式 API 設計 | 讓 API 呼叫者只關心輸入與輸出,方便組合與重用。 | def paginate(items: tuple, page: int, size: int) -> tuple: |
| 遊戲或模擬系統的狀態管理 | 使用 immutable state tree(類似 Redux)追蹤每一次狀態變更,方便 undo/redo。 | state = State(board=tuple(board), turn='X') → new_state = state.move(pos) |
| 金融或科學計算 | 結果必須可重現,純函式保證相同輸入得到相同結果。 | def price_option(s, k, t, r, sigma):(純函式) |
總結
- 不可變性 讓資料本身成為「只讀」的資源,天然支援多執行緒與函式式組合。
- 純函式 則保證「相同輸入 → 相同輸出」且不產生副作用,讓程式更易於測試、除錯與重構。
- 在 Python 中,我們可以透過
tuple、frozenset、namedtuple、dataclass(frozen=True)等工具,輕鬆建立不可變物件;再配合map、filter、reduce、itertools等高階函式,寫出 純粹且可組合 的程式碼。 - 謹記常見陷阱(可變容器、全域依賴、mutable default arguments),並遵循最佳實踐(使用不可變容器、保持函式純粹、寫清楚的 docstring),即可在日常開發或大型系統中,享受到函式式編程帶來的 可預測性、可維護性與高併發安全。
挑戰自己:在下個小專案中,嘗試把資料流改寫為「不可變 → 純函式」的方式,你會發現程式碼的可讀性與測試成本都有明顯提升。祝你玩得開心,寫出更乾淨的 Python 程式!