Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

函數與模組

深入 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)mapfiltersortedkey 參數都是高階函數的應用:

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 內建(printlenrange…)

當程式碼讀取一個名稱時,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_counternonlocal 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

doubletriple 是用同一個工廠生出來的,但各自封存了不同的 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') 先被呼叫、回傳 decoratordecorator 才去裝飾函數。這叫帶參數的裝飾器(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 變數,要改外層得用 nonlocalglobal
  • 閉包是「函數 + 它捕捉的 enclosing 變數」,捕捉的是變數本身(參照)而非值,因此有延遲綁定陷阱,可用 i=i 預設參數凍結值。
  • 裝飾器是接收函數、回傳新函數的高階函數,靠閉包運作;記得用 functools.wraps 保留原函數 metadata;帶參數的裝飾器是三層巢狀。
  • 模組是檔案層級的命名空間import 會把模組執行並快取進 sys.modulesif __name__ == "__main__" 讓檔案能同時當函式庫與腳本。

深入探討(研究所視角)

閉包與 CPython 的實作機制。 在 CPython 中,被閉包捕捉的變數並非存在一般的區域變數槽(fast local),而是被提升為 cell 變數。編譯器在編譯期就靜態分析出哪些變數會被內層函數引用,把它們標記為 cellvars(在外層函數視角)與 freevars(在內層函數視角)。執行時這些變數被裝進 cell 物件——一個只有單一 cell_contents 欄位的容器。內外層函數共享同一個 cell 的參照,這正是「修改外層變數會被內層看到」的物理基礎。你可以透過 func.__code__.co_cellvarsfunc.__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,模組層級的惰性載入)。這些都建立在本文「函數是物件、命名空間分層」的核心觀念之上。

AI 共讀助教正在陪你讀:深入 Python 函數:閉包、裝飾器與命名空間的真相
嗨!我是這篇文章的共讀助教,只根據〈深入 Python 函數:閉包、裝飾器與命名空間的真相〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。