深入 Python 函數:閉包、裝飾器與命名空間的真相
為什麼函數會「記得」上次的值?拆開一等物件、LEGB 規則與閉包的運作機制
一個看似簡單的問題:為什麼這個函數「記得」上次的值?
你已經會寫函數、會 import,也知道把重複的程式碼包成 def 是好習慣。現在來看一段會讓很多人困惑的程式碼:
def make_counter():
count = 0
def step():
nonlocal count
count += 1
return count
return step
c = make_counter()
print(c(), c(), c()) # 1 2 3
make_counter() 早就 return 結束了,它的區域變數 count 照理說應該隨著函數結束被銷毀。但 step 卻一次次「記得」上次的 count,而且每呼叫一次就 +1。更弔詭的是,如果你再寫一個 d = make_counter(),d 的計數從 1 重新開始,完全不受 c 影響。
這不是魔術,而是 Python 函數背後一整套名稱解析(name resolution)與閉包(closure)機制在運作。入門篇教你「函數是把程式碼裝起來的盒子」;這篇要拆開盒子,看看函數其實是 Python 裡的一等公民物件(first-class object),以及由此延伸出來的閉包、裝飾器與模組系統的真實面貌。讀完之後,你不只會「用」函數,而是能解釋它為什麼這樣運作。

函數是物件:first-class function
在 Python 裡,函數和整數、字串、串列一樣,都是物件。這句話不是比喻,是字面意義上的真。物件能做的事,函數全都能做:
def square(x):
return x * x
f = square # 把函數指派給變數(不加括號!)
print(f(5)) # 25
print(type(square)) # <class 'function'>
print(square.__name__) # 'square'
funcs = [square, abs, len] # 函數放進串列
print([g(-4) for g in funcs[:2]]) # [16, 4]
關鍵差別:square 是「函數物件本身」,square() 才是「呼叫它」。漏掉這個區分,是初學者最常見的踩雷點。
函數既然是物件,就能當作參數傳入別的函數,也能當作回傳值傳出。能接收或回傳函數的函數,稱為高階函數(higher-order function)。map、filter、sorted 的 key 參數都是高階函數的應用:
words = ["banana", "kiwi", "watermelon", "fig"]
# key 收一個函數,決定排序依據
print(sorted(words, key=len)) # ['kiwi', 'fig', 'banana', 'watermelon']
print(sorted(words, key=lambda w: w[-1])) # 依最後一個字母排序
lambda 是匿名函數,等價於一個沒有名字、只有一條 expression 的 def。它適合用在「只用一次、邏輯極短」的場合,但若邏輯稍長,請務必用具名 def——可讀性遠勝於把複雜邏輯擠進一行 lambda。
名稱解析的真相:LEGB 規則
回到開頭的謎題。要理解閉包,得先理解 Python 怎麼決定「這個變數名字到底指向誰」。Python 查找名稱遵循 LEGB 規則,由內而外四層:
| 層級 | 全名 | 範圍 |
|---|---|---|
| L | Local | 當前函數內部 |
| E | Enclosing | 外層(包圍的)函數 |
| G | Global | 模組層級 |
| B | Built-in | 內建(print、len、range…) |
當程式碼讀取一個名稱時,Python 依 L → E → G → B 的順序往外找,第一個找到的就停。理解這個順序,許多「奇怪」的行為瞬間就合理了:
x = "global"
def outer():
x = "enclosing"
def inner():
print(x) # 找不到 Local x,往 Enclosing 找 → "enclosing"
inner()
outer()
但賦值(assignment)會打破這個查找:在函數內對一個名稱賦值,Python 預設會把它當成新的 Local 變數,這正是經典陷阱的根源:
count = 0
def bump():
count = count + 1 # UnboundLocalError!
bump()
為什麼出錯?因為函數內有 count = ... 賦值,Python 在編譯期就把 count 標記為 Local;於是 count + 1 右邊讀到的是「尚未賦值的 Local count」,而不是外層的 global。要明確告訴 Python「我要用外層那個」,得用 global(指向模組層)或 nonlocal(指向 enclosing 層)關鍵字——這就是開頭 make_counter 用 nonlocal count 的原因。
閉包:函數帶著它的環境一起走
現在可以正式定義閉包了。閉包 = 函數 + 它被定義時所捕捉的 enclosing 變數。當內層函數引用了外層函數的區域變數,且這個內層函數被回傳到外面繼續存活,Python 不會丟掉那些被引用的變數,而是把它們「綁」在回傳出去的函數物件上。
你可以親眼驗證這件事:
def make_multiplier(factor):
def multiply(n):
return n * factor # 捕捉 enclosing 的 factor
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10)) # 20
print(triple(10)) # 30
# 被捕捉的變數存放在 __closure__ 裡
print(double.__closure__[0].cell_contents) # 2
print(triple.__closure__[0].cell_contents) # 3
double 和 triple 是用同一個工廠生出來的,但各自封存了不同的 factor。__closure__ 屬性裡的 cell 物件就是 Python 保存這些變數的容器——這證明閉包不是「複製值」,而是真實保留了對變數的參照。
動手算一下:閉包的延遲綁定陷阱
閉包捕捉的是「變數本身」而非「當下的值」,這會製造一個非常有名的 bug。猜猜以下輸出:
funcs = []
for i in range(3):
funcs.append(lambda: i)
print([f() for f in funcs]) # 你猜是 [0, 1, 2] 嗎?
答案是 [2, 2, 2]。所有 lambda 捕捉的都是同一個 i;迴圈結束時 i 停在 2,於是三個函數讀到的都是 2。這叫延遲綁定(late binding):閉包在「被呼叫時」才去讀 i 的值,而不是在「被定義時」。
修法是用預設參數的方式,在定義當下就把值「凍結」進去:
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2]
i=i 讓每個 lambda 在定義時就把當下的 i 存成自己的預設參數。這個陷阱在事件處理、回呼註冊(callback)的場合特別常見,務必記住。
裝飾器:把閉包用到極致
理解了「函數是物件」與「閉包」之後,裝飾器(decorator)就水到渠成了。裝飾器本質上就是一個「接收函數、回傳新函數」的高階函數,常被用來在不修改原函數程式碼的前提下,加上額外行為(記錄、計時、權限檢查、快取)。
import functools, time
def timer(func):
@functools.wraps(func) # 保留原函數的名稱與 docstring
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} 花了 {elapsed:.4f} 秒")
return result
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
slow_sum(1_000_000) # slow_sum 花了 0.0xx 秒
@timer 這行語法糖,等價於 slow_sum = timer(slow_sum)。wrapper 是個閉包,捕捉了外層的 func;它用 *args, **kwargs 接收任意參數,讓裝飾器能套用在任何函數上。
functools.wraps 是個容易被忽略卻很重要的細節:沒有它,被裝飾後的函數 __name__ 會變成 'wrapper'、docstring 也消失,除錯與文件工具都會被搞亂。它本身也是一個裝飾器,把原函數的 metadata 複製到 wrapper 上。
Uedu 後端到處都是這個模式。回想 CLAUDE.md 裡權限檢查的寫法:
def require_permission(permission_type='view'):
def decorator(f):
@functools.wraps(f)
def decorated_function(*args, **kwargs):
# 檢查權限...
return f(*args, **kwargs)
return decorated_function
return decorator
@require_permission('create')
def get_generation_progress(task_id):
...
注意這裡多了一層:require_permission('create') 先被呼叫、回傳 decorator,decorator 才去裝飾函數。這叫帶參數的裝飾器(decorator factory)——三層巢狀函數,最外層收裝飾器參數、中層收被裝飾函數、最內層收呼叫參數。能讀懂這三層,你對閉包的掌握就相當扎實了。
從函數到模組:命名空間的延伸
函數把名稱封裝進區域作用域;模組(module)則把名稱封裝進檔案層級的命名空間。每個 .py 檔就是一個模組,import 的本質是「執行那個檔案、把它的名稱裝進一個 module 物件,再把這個物件綁到當前命名空間」。
理解這點,就能解釋為什麼 import 同一個模組很多次,模組程式碼其實只執行一次:
import sys
import math
print('math' in sys.modules) # True,模組被快取在 sys.modules
Python 把載入過的模組存進 sys.modules 字典;之後再 import,直接從快取拿,不重新執行。這也是為什麼模組頂層適合放「只該做一次」的初始化。
不同 import 寫法的差別,其實全在於「把哪些名稱綁進當前命名空間」:
import math # 綁入 'math',用 math.pi
from math import pi # 綁入 'pi',直接用 pi
from math import pi as π # 綁入 'π'(改名)
from math import * # 把 math 所有公開名稱全綁入(不建議,污染命名空間)
from module import * 之所以被勸退,正是因為它會把一堆名稱倒進你的命名空間,可能無聲覆蓋掉你既有的變數,違反「明確優於隱晦」。
看一個例子:if __name__ == "__main__" 到底在做什麼
你一定看過這行,但它跟模組命名空間有直接關係。每個模組都有一個內建變數 __name__:
- 當檔案被直接執行(
python myfile.py)時,__name__是"__main__"。 - 當檔案被 import 時,
__name__是它的模組名(如"myfile")。
# calc.py
def add(a, b):
return a + b
if __name__ == "__main__":
print("直接執行時才跑這段測試")
print(add(2, 3))
這個慣用法讓同一個檔案能既當函式庫被 import、又能當腳本直接跑:被 import 時只暴露 add 函數而不執行測試碼,直接執行時才跑下面的測試。這是 Python 模組設計的核心慣例,幾乎每個正式專案都會用到。
把函數、模組、套件(package,含 __init__.py 的目錄)串起來,你會發現它們是同一個概念在不同尺度的展現:用命名空間把名稱分層隔離,避免衝突、促進重用。從一個函數的 Local 作用域,到一個模組的全域命名空間,再到一個套件的階層,Python 用同一套「命名空間」哲學貫穿始終。
重點回顧
- 函數是一等物件:能指派給變數、放進容器、當參數傳入、當回傳值傳出。
square是物件本身,square()才是呼叫。 - LEGB 規則決定名稱解析順序(Local → Enclosing → Global → Built-in);函數內的賦值預設建立 Local 變數,要改外層得用
nonlocal或global。 - 閉包是「函數 + 它捕捉的 enclosing 變數」,捕捉的是變數本身(參照)而非值,因此有延遲綁定陷阱,可用
i=i預設參數凍結值。 - 裝飾器是接收函數、回傳新函數的高階函數,靠閉包運作;記得用
functools.wraps保留原函數 metadata;帶參數的裝飾器是三層巢狀。 - 模組是檔案層級的命名空間,
import會把模組執行並快取進sys.modules;if __name__ == "__main__"讓檔案能同時當函式庫與腳本。
深入探討(研究所視角)
閉包與 CPython 的實作機制。 在 CPython 中,被閉包捕捉的變數並非存在一般的區域變數槽(fast local),而是被提升為 cell 變數。編譯器在編譯期就靜態分析出哪些變數會被內層函數引用,把它們標記為 cellvars(在外層函數視角)與 freevars(在內層函數視角)。執行時這些變數被裝進 cell 物件——一個只有單一 cell_contents 欄位的容器。內外層函數共享同一個 cell 的參照,這正是「修改外層變數會被內層看到」的物理基礎。你可以透過 func.__code__.co_cellvars 與 func.__code__.co_freevars 觀察這個分類。這也解釋了為何閉包捕捉的是「綁定(binding)」而非「值」:cell 是個間接層(indirection),名稱指向 cell,cell 才指向真正的值。
與其他語言的對照:詞法作用域 vs 動態作用域。 Python 採詞法作用域(lexical / static scoping)——名稱在哪裡被定義,就決定它能看見哪些外層變數,與「在哪裡被呼叫」無關。這與少數採動態作用域(dynamic scoping)的語言(如早期 Lisp、部分 shell)形成對比,後者的名稱解析取決於呼叫時的執行堆疊。詞法作用域讓程式行為可在閱讀原始碼時靜態推斷,這是現代語言的主流選擇,也是閉包能被可靠分析的前提。
函數作為閉包是物件導向的對偶。 有一句經典觀察:「物件是帶著函數的資料,閉包是帶著資料的函數。」一個保有狀態的閉包(如 make_counter)與一個只有單一方法的物件,在表達能力上是等價的——兩者都把「狀態」與「操作」綁在一起。從 lambda calculus 的角度,閉包是函數式程式設計處理狀態的基本手段;而裝飾器、functools.partial(部分套用,partial application)、柯里化(currying)都是這個基礎上的衍生技術。理解這層對偶,能幫你在「該用類別還是該用閉包」之間做出更有依據的設計判斷:需要多個相關方法與複雜狀態時用類別,只需單一行為加少量狀態時閉包往往更輕巧。
延伸閱讀方向。 若想再深入,可探索:functools.lru_cache(用閉包與字典實作記憶化 memoization,並理解其 $O(1)$ 攤還查找)、描述器協定(descriptor protocol,方法其實是透過它綁定 self)、以及 module 的 __getattr__(PEP 562,模組層級的惰性載入)。這些都建立在本文「函數是物件、命名空間分層」的核心觀念之上。