Closures
Nested function
หัวข้อที่มีชื่อว่า “Nested function”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 outerLEGB: Python หาชื่อตามลำดับอย่างไร
หัวข้อที่มีชื่อว่า “LEGB: Python หาชื่อตามลำดับอย่างไร”การหาชื่อทุกครั้งใน Python เป็นไปตามลำดับคงที่หนึ่งเดียว ย่อว่า LEGB:
- Local — ชื่อที่ถูก assign ภายใน function ปัจจุบัน
- Enclosing — ชื่อใน local scope ของ function ที่ครอบอยู่ (ตรงนี้แหละที่ closure อาศัยอยู่)
- Global — ชื่อที่ถูก assign ที่ระดับ module
- 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()Free variable และ closure
หัวข้อที่มีชื่อว่า “Free variable และ closure”เมื่อ 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__ และเนื้อหาใน cell
หัวข้อที่มีชื่อว่า “__closure__ และเนื้อหาใน cell”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) # Helloprint(hello.__code__.co_freevars) # ('greeting',)การเรียก make_greeter แต่ละครั้งจะสร้าง cell ใหม่เอี่ยม — hello และ hi จากตัวอย่างก่อนหน้ามี cell ที่เป็นอิสระจากกันโดยสิ้นเชิง นี่คือเหตุผลที่มันจำ greeting คนละคำกันได้
keyword nonlocal
หัวข้อที่มีชื่อว่า “keyword nonlocal”ภายใน 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()) # 1print(inc()) # 2print(inc(5)) # 7rst()print(inc()) # 1ถ้าไม่มี nonlocal count บรรทัด count += step จะทำให้เกิด UnboundLocalError เพราะ Python เห็นการ assign แล้วมองว่า count เป็นตัวแปร local ที่ยังไม่เคยถูกกำหนดค่ามาก่อน
global: กฎเดียวกัน แต่ไกลออกไปอีกหนึ่ง scope
หัวข้อที่มีชื่อว่า “global: กฎเดียวกัน แต่ไกลออกไปอีกหนึ่ง scope”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 valueglobal และ nonlocal แก้ปัญหาเดียวกัน (การ assign จะสร้าง local ที่บัง shadow ตัวแปรเดิมถ้าไม่มี keyword พวกนี้) แต่ต่างกันที่ระยะ scope — global จะไปถึง module scope โดยตรงเสมอ ข้ามทุก enclosing function scope ที่อยู่ระหว่างกลาง; nonlocal จะไปถึง enclosing function scope ที่ใกล้ที่สุด
ปิดครอบตัวแปร mutable แทนการใช้ nonlocal
หัวข้อที่มีชื่อว่า “ปิดครอบตัวแปร mutable แทนการใช้ nonlocal”การ 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()) # 1print(inc(5)) # 6print(state) # {'count': 6} — visible from outside toostate["count"] += step มองหา state ก่อน (การอ่าน free variable แบบ read-only ไม่ต้องใช้ nonlocal) แล้วค่อย mutate dict ที่มันชี้ไปหา วิธีนี้แลกการ reassign แบบชัดเจนของ nonlocal กับ container ที่แชร์กันได้ — มีประโยชน์เมื่อ inner function หลายตัวต้องสังเกตหรือ reset state เดียวกัน แต่ก็เปิดให้ code ใด ๆ ที่ถือ reference ไปยัง state เข้าไปแก้ไขได้โดยตรง ซึ่งเป็นสิ่งที่ cell แบบ private เต็มที่ของ nonlocal ไม่ยอมให้เกิดขึ้น
Factory function — closure ที่ใช้งานจริง
หัวข้อที่มีชื่อว่า “Factory function — closure ที่ใช้งานจริง”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}")Loading Python runtime (first run only)…
เดโมเจาะลึก: 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])Loading Python runtime (first run only)…
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”การเรียก 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 ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
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เป็นแค่วิธีที่สั้นที่สุดในการสาธิตมัน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- 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 ที่แชร์กัน