Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

函數與模組

深入 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 函數:閉包、裝飾器與命名空間的真相〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。