ข้ามไปยังเนื้อหา

Closures

Python อนุญาตให้เรานิยาม function ไว้ภายในอีก function หนึ่งได้ function ตัวในสามารถเข้าถึงตัวแปรใน scope ของ function ตัวนอก (enclosing) ได้

def outer():
message = "Hello from outer"
def inner():
print(message) # accesses the enclosing scope
inner()
outer() # Hello from outer

การหาชื่อทุกครั้งใน Python เป็นไปตามลำดับคงที่หนึ่งเดียว ย่อว่า LEGB:

  1. Local — ชื่อที่ถูก assign ภายใน function ปัจจุบัน
  2. Enclosing — ชื่อใน local scope ของ function ที่ครอบอยู่ (ตรงนี้แหละที่ closure อาศัยอยู่)
  3. Global — ชื่อที่ถูก assign ที่ระดับ module
  4. Built-in — ชื่อที่ Python มีให้เองอยู่แล้ว (len, print, range, …)

Python จะหยุดที่ scope แรกที่เจอชื่อนั้น:

x = "global x"
def legb_demo():
y = "enclosing y"
def inner():
z = "local z"
print("local:", z)
print("enclosing:", y) # not local to inner — found in legb_demo's scope
print("global:", x) # not local or enclosing — found at module level
print("built-in:", len) # not any of the above — found in builtins
inner()
legb_demo()

เมื่อ function ตัวใน อ้างถึงตัวแปรจาก enclosing scope ของมัน แล้วถูก return ออกไปสู่โลกภายนอก Python จะสร้าง closure ขึ้นมา — function ตัวในนั้น “ปิดครอบ” (close over) ตัวแปรไว้ และทำให้ตัวแปรนั้นยังมีชีวิตอยู่แม้ outer จะทำงานเสร็จไปแล้วก็ตาม

def make_greeter(greeting: str):
def greet(name: str) -> str:
return f"{greeting}, {name}!" # greeting is a free variable
return greet
hello = make_greeter("Hello")
hi = make_greeter("Hi")
print(hello("Alice")) # Hello, Alice!
print(hi("Bob")) # Hi, Bob!

greeting เป็น free variable ใน greet — มันไม่ได้ถูกนิยามภายใน greet และก็ไม่ใช่ global แต่อาศัยอยู่ใน local scope ของ make_greeter สูงขึ้นไปหนึ่งชั้น ซึ่งก็คือ “E” ใน LEGB closure จะรักษาค่าของ greeting ให้มีชีวิตอยู่ตราบเท่าที่ hello หรือ hi ยังคงอยู่

closure ไม่ใช่เวทมนตร์ — CPython implement มันด้วย cell object หนึ่งตัวต่อหนึ่ง free variable เก็บไว้ใน tuple __closure__ ของ function นั้น __code__.co_freevars บอกชื่อว่าตัวแปรไหนบ้างที่ cell พวกนี้เก็บอยู่:

def make_greeter(greeting):
def greet(name):
return f"{greeting}, {name}!"
return greet
hello = make_greeter("Hello")
print(hello.__closure__[0].cell_contents) # Hello
print(hello.__code__.co_freevars) # ('greeting',)

การเรียก make_greeter แต่ละครั้งจะสร้าง cell ใหม่เอี่ยม — hello และ hi จากตัวอย่างก่อนหน้ามี cell ที่เป็นอิสระจากกันโดยสิ้นเชิง นี่คือเหตุผลที่มันจำ greeting คนละคำกันได้

ภายใน nested function เราสามารถ อ่าน free variable ได้โดยไม่ต้องใช้ keyword พิเศษใด ๆ แต่ถ้าจะ กำหนดค่าใหม่ (reassign) ให้ตัวแปรนั้น เราต้องใช้ nonlocal — ไม่งั้น Python จะมองว่าชื่อนั้นเป็นตัวแปร local ตัวใหม่

def make_counter(start: int = 0):
count = start
def increment(step: int = 1) -> int:
nonlocal count # tells Python: reuse the enclosing 'count'
count += step
return count
def reset() -> None:
nonlocal count
count = start
return increment, reset
inc, rst = make_counter()
print(inc()) # 1
print(inc()) # 2
print(inc(5)) # 7
rst()
print(inc()) # 1

ถ้าไม่มี nonlocal count บรรทัด count += step จะทำให้เกิด UnboundLocalError เพราะ Python เห็นการ assign แล้วมองว่า count เป็นตัวแปร local ที่ยังไม่เคยถูกกำหนดค่ามาก่อน

global คือคู่หูของ nonlocal สำหรับชื่อระดับ module: มันบอก Python ว่าการ assign ภายใน function ควร rebind ชื่อ global แทนที่จะสร้างตัวแปร local ใหม่ กฎ UnboundLocalError แบบเดียวกันก็ใช้ได้เมื่อไม่มี global — Python ตัดสินว่าชื่อหนึ่งเป็น local ของ function โดยสแกน body ทั้งหมดของ function หาการ assign ก่อนที่จะรันโค้ดสักบรรทัดเดียว ดังนั้นแม้แต่การอ่านชื่อนั้นก่อนถึงบรรทัดที่ assign ก็ยังพังอยู่ดี:

counter = 0
def increment_global():
global counter
counter += 1
increment_global()
increment_global()
print(counter) # 2
def broken_increment():
counter += 1 # counter is assigned somewhere in this function...
# ...so Python treats it as local for the *entire* function body
broken_increment()
# UnboundLocalError: cannot access local variable 'counter' where it is not associated with a value

global และ nonlocal แก้ปัญหาเดียวกัน (การ assign จะสร้าง local ที่บัง shadow ตัวแปรเดิมถ้าไม่มี keyword พวกนี้) แต่ต่างกันที่ระยะ scope — global จะไปถึง module scope โดยตรงเสมอ ข้ามทุก enclosing function scope ที่อยู่ระหว่างกลาง; nonlocal จะไปถึง enclosing function scope ที่ใกล้ที่สุด

การ reassign free variable ต้องใช้ nonlocal แต่การ mutate object ที่ closure ถืออ้างอิงอยู่แล้วไม่ต้องใช้ — นี่มักเป็นทางแก้ที่ง่ายกว่าเมื่อ inner function หลายตัวต้องแชร์ state ร่วมกัน:

def make_counter_mutable():
state = {"count": 0} # mutable container — no nonlocal needed
def increment(step: int = 1) -> int:
state["count"] += step # mutating the dict, not rebinding the name
return state["count"]
return increment, state
inc, state = make_counter_mutable()
print(inc()) # 1
print(inc(5)) # 6
print(state) # {'count': 6} — visible from outside too

state["count"] += step มองหา state ก่อน (การอ่าน free variable แบบ read-only ไม่ต้องใช้ nonlocal) แล้วค่อย mutate dict ที่มันชี้ไปหา วิธีนี้แลกการ reassign แบบชัดเจนของ nonlocal กับ container ที่แชร์กันได้ — มีประโยชน์เมื่อ inner function หลายตัวต้องสังเกตหรือ reset state เดียวกัน แต่ก็เปิดให้ code ใด ๆ ที่ถือ reference ไปยัง state เข้าไปแก้ไขได้โดยตรง ซึ่งเป็นสิ่งที่ cell แบบ private เต็มที่ของ nonlocal ไม่ยอมให้เกิดขึ้น

def make_validator(min_val: int, max_val: int):
"""Return a validator function for the given range."""
def validate(value: int) -> bool:
return min_val <= value <= max_val
validate.__doc__ = f"Return True if value is in [{min_val}, {max_val}]."
return validate
is_valid_age = make_validator(0, 120)
is_valid_score = make_validator(0, 100)
is_valid_port = make_validator(1, 65535)
tests = [
("age 25", is_valid_age(25)),
("age 200", is_valid_age(200)),
("score 95", is_valid_score(95)),
("score 101", is_valid_score(101)),
("port 8080", is_valid_port(8080)),
("port 0", is_valid_port(0)),
]
for label, result in tests:
print(f"{label}: {result}")

เดโมเจาะลึก: LEGB, global, mutable state, __closure__ และการแก้ปัญหา late-binding

หัวข้อที่มีชื่อว่า “เดโมเจาะลึก: LEGB, global, mutable state, __closure__ และการแก้ปัญหา late-binding”
import functools
# --- LEGB named ---
x = "global x"
def legb_demo():
y = "enclosing y"
def inner():
z = "local z"
print("local:", z)
print("enclosing:", y)
print("global:", x)
print("built-in:", len)
inner()
print("--- LEGB ---")
legb_demo()
# --- global keyword ---
counter = 0
def increment_global():
global counter
counter += 1
print("\n--- global ---")
increment_global()
increment_global()
print("counter:", counter)
def broken_increment():
counter += 1 # UnboundLocalError: assignment makes counter local here
try:
broken_increment()
except UnboundLocalError as e:
print(type(e).__name__ + ":", e)
# --- closing over a mutable instead of nonlocal ---
print("\n--- mutable cell instead of nonlocal ---")
def make_counter_mutable():
state = {"count": 0} # mutable container — no nonlocal needed
def increment(step=1):
state["count"] += step # mutating the dict, not rebinding the name
return state["count"]
return increment, state
inc, state = make_counter_mutable()
print(inc())
print(inc(5))
print("state dict itself:", state)
# --- __closure__ / cell contents ---
print("\n--- __closure__ and cell contents ---")
def make_greeter(greeting):
def greet(name):
return f"{greeting}, {name}!"
return greet
hello = make_greeter("Hello")
print(hello.__closure__[0].cell_contents)
print(hello.__code__.co_freevars)
# --- late binding + partial fix ---
print("\n--- late binding + functools.partial fix ---")
funcs_broken = [lambda: i for i in range(3)]
print("broken:", [f() for f in funcs_broken])
funcs_default_arg = [lambda i=i: i for i in range(3)]
print("default-arg fix:", [f() for f in funcs_default_arg])
def identity(i):
return i
funcs_partial = [functools.partial(identity, i) for i in range(3)]
print("partial fix:", [f() for f in funcs_partial])

การเรียก make_greeter แต่ละครั้งจัดสรร cell ของ greeting ขึ้นมาใหม่ — closure คือ function ที่ return กลับมาบวกกับ reference ไปยัง cell ตัวนั้นโดยเฉพาะ ไม่ใช่สำเนาของค่า:

flowchart LR
  subgraph call1["make_greeter(\"Hello\")"]
    cellA["cell: greeting = 'Hello'"]
  end
  subgraph call2["make_greeter(\"Hi\")"]
    cellB["cell: greeting = 'Hi'"]
  end
  hello["hello.__closure__[0]"] --> cellA
  hi["hi.__closure__[0]"] --> cellB
การเรียก make_greeter แต่ละครั้งสร้าง cell อิสระ — hello กับ hi ไม่เคยแชร์ state กัน
สิ่งที่ได้ประโยชน์ต้นทุน
nonlocalการ reassign ตัวแปรใน enclosing scope ที่ชัดเจน อ่านแล้วรู้เลยว่าตั้งใจทำอะไรต้องประกาศก่อนการ assign ครั้งแรกใน function ลืมแล้วจะได้ UnboundLocalError ไม่ใช่คำตอบผิดแบบเงียบ ๆ
ปิดครอบตัวแปร mutable (dict/list)ไม่ต้องใช้ nonlocal ก็แชร์ state ข้าม inner function หลายตัวได้state เป็น object ธรรมดาที่ code ใด ๆ ที่ถือ reference ไปหามันก็แก้จากภายนอกได้เหมือนกัน — ไม่มีความเป็นส่วนตัวแบบที่ cell ของ nonlocal ให้
globalให้ function อัปเดต state ระดับ module จริง ๆ ได้ผูก function เข้ากับ global ตัวใดตัวหนึ่งของ module นั้นโดยเฉพาะ ทำให้ทดสอบแยกส่วนและทำความเข้าใจจาก module อื่นยากขึ้น
default-argument late-binding fix (lambda i=i: ...)เพิ่มแค่ =i เข้าไป ไม่ต้อง import อะไรเพิ่มลืมง่าย และอ่านแล้วแปลก ๆ สำหรับคนที่ไม่คุ้นกับ idiom นี้
functools.partial late-binding fixชัดเจนและอธิบายตัวเองได้ — freeze argument ด้วยชื่อต้อง import functools เพิ่ม และต้องใช้ named function แทน lambda แบบ inline
  • “closure จับค่าที่ตัวแปรมีอยู่ตอนที่ function ถูกนิยาม” — ความจริง: มันจับ ตัวแปรตัวมันเอง (cell) — ถ้าค่าของตัวแปรนั้นเปลี่ยนไปทีหลัง closure ทุกตัวที่ครอบมันอยู่จะเห็นค่าใหม่ทั้งหมด นี่คือสาเหตุที่กับดัก late-binding ในลูปเกิดขึ้นได้
  • “การอ่านตัวแปรใน enclosing scope ต้องใช้ nonlocal เหมือนตอนเขียน” — ความจริง: nonlocal จำเป็นเฉพาะตอน reassign free variable เท่านั้น การอ่านมัน หรือ mutate object ที่มันชี้ไปหา (state["count"] += 1 บน dict) ไม่ต้องประกาศอะไรเลย
  • “UnboundLocalError แปลว่าตัวแปรนั้นไม่เคยถูกกำหนดค่าที่ไหนเลย” — ความจริง: มันแปลว่า Python ตัดสินแล้วว่าชื่อนี้เป็น local ของ function นี้ (เพราะมันถูก assign อยู่ที่ไหนสักแห่งใน body ของ function) และการรันครั้งนั้นไปเจอการอ่านก่อนที่การ assign แบบ local จะรัน — นี่คืออาการพังที่แท้จริงของการลืม global/nonlocal
  • “closure สองตัวที่สร้างจาก factory function เดียวกันแชร์ state กัน” — ความจริง: การเรียก factory แต่ละครั้ง (make_greeter("Hello") กับ make_greeter("Hi")) สร้าง cell ที่เป็นอิสระของตัวเอง — closure จากการเรียกคนละครั้งจะไม่มีวันเห็นตัวแปรของกันและกัน เว้นแต่จะแชร์ mutable object กันโดยตั้งใจ
  • “บั๊ก late-binding ในลูปเกิดกับ lambda เท่านั้น” — ความจริง: nested function ใด ๆ ที่ถูกนิยามในลูปแล้วจับตัวแปรของลูปไว้ก็มีบั๊กเดียวกันนี้ทั้งนั้น lambda เป็นแค่วิธีที่สั้นที่สุดในการสาธิตมัน
  • nested function assign ตัวแปรจาก enclosing scope โดยไม่มี nonlocal (หรือ assign global ของ module โดยไม่มี global) ไหม? ถ้าใช่ มันอาจเป็นบั๊ก (UnboundLocalError รอเกิดอยู่) หรือคนเขียนตั้งใจให้เป็นตัวแปร local ใหม่จริง ๆ — ต้องเช็กว่าเป็นแบบไหน
  • มี function หรือ lambda ถูกสร้างในลูป จับตัวแปรของลูปไว้ แล้วถูกเรียกใช้ หลังจาก ลูปจบแล้วหรือเปล่า? เช็กหากับดัก late-binding — ทุกตัวจะเห็นค่าสุดท้ายของตัวแปรลูป
  • มี state แบบ mutable (dict หรือ list) ที่ถูกปิดครอบโดย inner function หลายตัวแทนการใช้ nonlocal ไหม? ยืนยันว่าการแชร์นั้นตั้งใจจริง และไม่มี code ภายนอกที่จะมาทำให้มันเสียหายโดยไม่คาดคิด
  • function อ่านชื่อที่ถูก assign ทีหลังใน body เดียวกัน โดยคาดหวังว่าจะเห็นค่า global หรือ enclosing ก่อนไหม? Python ตัดสินว่าชื่อไหนเป็น “local” ของ function ทั้งก้อนล่วงหน้า — สิ่งนี้ทำให้เกิด UnboundLocalError ไม่ใช่การ fallback ไปหา outer scope
  • มีการใช้ global ในจุดที่จริง ๆ แล้วส่งค่าผ่าน parameter และ return value ของ function แทนได้ไหม? การใช้ global มากเกินไปทำให้ function ทดสอบยากและทำความเข้าใจยากโดยไม่ต้องอ่านทั้ง module

🐛 หา bug ในโค้ดนี้

def make_click_handlers(labels):
handlers = []
for i, label in enumerate(labels):
def handler():
print(f"Button {i}: {label} clicked")
handlers.append(handler)
return handlers
buttons = make_click_handlers(["Save", "Cancel", "Delete"])
for click in buttons:
click()
เฉลย

handler closure ทุกตัวจับ ตัวแปร `i` และ `label` ไว้ ไม่ใช่ค่าที่ตัวแปรถืออยู่ตอนลูปรอบนั้น พอถึงเวลาที่ `click()` ถูกเรียกจริง ๆ — หลังจากลูป `for` ใน `make_click_handlers` จบไปแล้ว — `i` และ `label` ต่างก็ถือค่าสุดท้ายของการวนลูปทั้งคู่ ตรวจสอบแล้วบน Python 3.14: ทั้งสามครั้งพิมพ์ `Button 2: Delete clicked` เหมือนกันหมด ไม่มี `Button 0` หรือ `Button 1` เลย ทางแก้คือ freeze ค่าปัจจุบันไว้ตอน definition time ไม่ว่าจะด้วย default argument (`def handler(i=i, label=label): …`) หรือ `functools.partial(handler_body, i, label)` — ทั้งสองวิธีประเมินค่า `i`/`label` ทันที ครั้งเดียวต่อรอบลูป แทนที่จะไปมองหามันทีหลังผ่าน closure cell ที่แชร์กัน

'closure' ใน Python คือ:
โค้ดนี้พิมพ์อะไร? x = "global" def outer(): x = "enclosing" def inner(): print(x) inner() outer()
keyword ใดที่ต้องใช้เพื่อกำหนดค่าใหม่ (reassign) ให้ free variable จาก enclosing scope ภายใน nested function?
โค้ดนี้พิมพ์อะไร? counter = 0 def bump(): counter += 1 return counter try: bump() except UnboundLocalError: print("caught it")
ทำไม [lambda: i for i in range(3)] ถึงให้ผลลัพธ์ [2, 2, 2] เมื่อถูกเรียก?
โค้ดนี้พิมพ์อะไร? def make_counter(): state = {"n": 0} def bump(): state["n"] += 1 return state["n"] return bump, state bump, state = make_counter() bump() bump() print(state["n"])