Decorators
Function ที่แปลงร่าง function อื่น
หัวข้อที่มีชื่อว่า “Function ที่แปลงร่าง function อื่น”decorator คือ function ที่รับ function เป็น argument และ return function ตัวใหม่ออกมา
เนื่องจาก function เป็น first-class object ใน Python decorator จึงเป็นแค่ function ธรรมดาตัวหนึ่ง ไม่มี syntax พิเศษอะไรเลยจนกว่าเราจะเติม @
def my_decorator(func): def wrapper(*args, **kwargs): print("Before") result = func(*args, **kwargs) print("After") return result return wrapper
def say_hello(): print("Hello!")
say_hello = my_decorator(say_hello) # manual decorationsay_hello()# Before# Hello!# Aftersyntax @
หัวข้อที่มีชื่อว่า “syntax @”syntax @decorator เป็นรูปแบบย่อ จะใช้ decorator โดยอัตโนมัติทันทีหลังจากที่ function ถูกนิยามเสร็จ
สองแบบนี้เทียบเท่ากันทุกประการ:
@my_decoratordef say_hello(): print("Hello!")
# is identical to:def say_hello(): print("Hello!")say_hello = my_decorator(say_hello)รักษา metadata ไว้ด้วย functools.wraps
หัวข้อที่มีชื่อว่า “รักษา metadata ไว้ด้วย functools.wraps”ถ้าไม่ใช้ functools.wraps function ตัว wrapper จะไปแทนที่ __name__, __doc__ และ attribute อื่น ๆ ของ function ต้นฉบับ
เรื่องนี้ทำให้ introspection, logging และเครื่องมือทำเอกสารทำงานพัง
import functools
def my_decorator(func): @functools.wraps(func) # copies __name__, __doc__, __annotations__, etc. def wrapper(*args, **kwargs): print(f"Calling {func.__name__}") return func(*args, **kwargs) return wrapper
@my_decoratordef add(a: int, b: int) -> int: """Return the sum of a and b.""" return a + b
print(add.__name__) # add (not 'wrapper')print(add.__doc__) # Return the sum of a and b.ให้ใช้ @functools.wraps(func) กับ function wrapper ตัวในเสมอ ในทุก decorator ที่เราเขียน
Broken กับ fixed เทียบกัน — รวมถึง inspect.signature
หัวข้อที่มีชื่อว่า “Broken กับ fixed เทียบกัน — รวมถึง inspect.signature”functools.wraps ไม่ได้ก็อปแค่ __name__/__doc__ แต่ยังทำให้ inspect.signature รายงาน signature ที่แท้จริงของ function ที่ถูกห่อ แทนที่จะเป็น (*args, **kwargs) แบบทั่ว ๆ ไปของ wrapper เครื่องมือที่ generate API doc หรือ CLI help text จาก inspect.signature จะแสดง signature ผิดแบบเงียบ ๆ เมื่อขาด wraps:
import functoolsimport inspect
def broken_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper
def fixed_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper
@broken_decoratordef add(a: int, b: int) -> int: """Return the sum of a and b.""" return a + b
@fixed_decoratordef sub(a: int, b: int) -> int: """Return a minus b.""" return a - b
print(add.__name__, inspect.signature(add)) # wrapper (*args, **kwargs)print(sub.__name__, inspect.signature(sub)) # sub (a: int, b: int) -> intlogging decorator ที่ใช้งานจริง
หัวข้อที่มีชื่อว่า “logging decorator ที่ใช้งานจริง”import functoolsimport time
def timed(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start print(f"{func.__name__} finished in {elapsed:.6f}s") return result return wrapper
@timeddef compute(n: int) -> int: return sum(range(n))functools.lru_cache และ functools.cache
หัวข้อที่มีชื่อว่า “functools.lru_cache และ functools.cache”functools.cache (และรุ่นพี่ที่ปรับแต่งได้มากกว่าอย่าง functools.lru_cache) คือ decorator ที่ memoize ค่า return ของ function โดยใช้ argument เป็น key เมื่อเรียกซ้ำด้วย argument เดิม function จะข้าม body ไปเลย:
import functools
@functools.lru_cache(maxsize=32)def fib(n: int) -> int: if n < 2: return n return fib(n - 1) + fib(n - 2)
fib(20)print(fib.cache_info()) # CacheInfo(hits=18, misses=21, maxsize=32, currsize=21)สามจุดคมที่ควรรู้ก่อนใช้:
- argument ต้อง hashable cache key ถูกสร้างจาก argument ดังนั้น argument ที่เป็น
list(หรือ object ใดก็ตามที่ unhashable) จะ raiseTypeError: unhashable type: 'list'— ไม่ใช่แค่ข้าม cache ไปเฉย ๆ maxsize=None(หรือ@functools.cacheซึ่งก็คือlru_cache(maxsize=None)เป๊ะ ๆ) จะไม่มีการ evict เลย ทุกชุด argument ที่เคยเรียก function นี้จะถูก cache ไว้ตลอดอายุของ process — cache ที่โตไม่จำกัดคือ memory leak ถ้า input space ใหญ่หรือไม่มีขอบเขต@lru_cacheบน method จะ cacheselfไปด้วยselfเป็นส่วนหนึ่งของ cache key ดังนั้นทุก instance ที่เคยเรียก method นี้จะถูก cache เก็บ reference ไว้ให้มีชีวิตอยู่ — แม้ไม่มี code ส่วนอื่นอ้างถึงมันแล้วก็ตาม สิ่งนี้ขัดขวางไม่ให้ garbage collector เก็บ instance เหล่านั้นได้จนกว่า cache จะถูก clear หรือ class เองจะถูกลบ ทางแก้คือใช้functools.cached_propertyสำหรับ cache แบบต่อ instance หรือ cache function ระดับ module ที่รับข้อมูล hashable ธรรมดาแทนที่จะรับself
class Doubler: def __init__(self, factor: int) -> None: self.factor = factor
@functools.lru_cache(maxsize=None) def compute(self, x: int) -> int: return x * self.factor
d1 = Doubler(2)d1.compute(5)d2 = Doubler(3)d2.compute(5)print(d2.compute.cache_info())# CacheInfo(hits=0, misses=2, maxsize=None, currsize=2)# both d1 and d2 are now referenced by the cache, forever, until cache_clear()functools.partial
หัวข้อที่มีชื่อว่า “functools.partial”functools.partial ตรึงค่า argument บางตัวของ function ไว้ล่วงหน้า แล้ว return callable ตัวใหม่ที่ต้องการแค่ argument ที่เหลือ:
import functools
def power(base: float, exponent: float) -> float: return base ** exponent
square = functools.partial(power, exponent=2)cube = functools.partial(power, exponent=3)print(square(5)) # 25print(cube(2)) # 8partial เป็น object ธรรมดา (ไม่ใช่ function ที่ถูก decorate) มันเปิด .func และ .keywords ให้เราตรวจสอบได้ว่าอะไรถูกตรึงไว้บ้าง มันยังเป็นทางแก้มาตรฐานของ late-binding closure ในลูป ด้วย นั่นคือ partial(f, i=i) จะตรึงค่า i ปัจจุบันไว้ด้วยกลไกเดียวกับการใช้ default argument
Class decorator
หัวข้อที่มีชื่อว่า “Class decorator”decorator ไม่จำเป็นต้องห่อแค่ function เท่านั้น — มันห่อ class ก็ได้ โดยรับ class object เข้ามาแล้ว return class ออกไป (ปกติคือ class ตัวเดิมที่ถูกแก้ไขหรือขยายเพิ่ม):
def add_repr(cls): """Class decorator: attach a generic __repr__ built from __dict__.""" def __repr__(self): fields = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items()) return f"{cls.__name__}({fields})" cls.__repr__ = __repr__ return cls
@add_reprclass Point: def __init__(self, x, y): self.x = x self.y = y
print(Point(1, 2)) # Point(x=1, y=2)@dataclass คือ class decorator ที่พบบ่อยที่สุดใน standard library มันทำงานด้วยกลไกแบบนี้เป๊ะ ๆ คือแนบ __init__, __repr__ และ __eq__ เข้ากับ class ที่มันตกแต่ง
@property ก็แค่ decorator ตัวหนึ่ง
หัวข้อที่มีชื่อว่า “@property ก็แค่ decorator ตัวหนึ่ง”property ไม่ใช่ syntax พิเศษ — มันเป็น built-in class ตัวหนึ่ง และ @property ก็ตกแต่ง method ด้วยกลไกเดียวกับที่ decorator อื่น ๆ ตกแต่ง function นั่นคือแทนที่ชื่อด้วย object ตัวใหม่:
class Temperature: def __init__(self, celsius: float) -> None: self._celsius = celsius
@property def celsius(self) -> float: return self._celsius
@celsius.setter def celsius(self, value: float) -> None: self._celsius = value
t = Temperature(20.0)print(t.celsius) # 20.0 — looks like attribute access, runs a methodt.celsius = 25.0 # runs the setterprint(type(Temperature.celsius).__name__) # propertycelsius = property(_get_celsius, _set_celsius) คือสิ่งที่ @property / @celsius.setter ขยายออกมาเป๊ะ ๆ — กลไกเดียวกับ “decorator แทนที่ชื่อด้วย object ตัวใหม่” เหมือน decorator ทุกตัวในบทนี้ แค่ return property แทนที่จะ return function ที่ถูกห่อ
Decorator ที่รับ argument
หัวข้อที่มีชื่อว่า “Decorator ที่รับ argument”หากต้องการส่ง argument ให้ decorator ให้เพิ่มการซ้อนอีกหนึ่งชั้น นั่นคือ factory function ที่รับ argument เข้ามาแล้ว return decorator ตัวจริงออกไป
import functools
def retry(max_attempts: int = 3): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_attempts + 1): try: return func(*args, **kwargs) except Exception as exc: if attempt == max_attempts: raise print(f"Attempt {attempt} failed: {exc}. Retrying...") return wrapper return decorator
@retry(max_attempts=3)def fetch(url: str) -> str: # would call an HTTP client here return f"Response from {url}"@retry(max_attempts=3) จะเรียก retry(max_attempts=3) ก่อน ซึ่งจะ return decorator ออกมา
จากนั้น Python จึงนำ decorator ไปใช้กับ fetch ดังนั้น call chain ทั้งหมดคือ fetch = retry(3)(fetch)
นั่นคือการซ้อน function สามชั้น หนึ่งชั้นต่อวงเล็บเปิดหนึ่งคู่: retry (factory ถูกเรียกด้วย argument ของ decorator เอง), decorator (ถูกเรียกครั้งเดียว ด้วย function ที่ถูกตกแต่ง) และ wrapper (ถูกเรียกทุกครั้งที่ function ที่ถูกตกแต่งถูกเรียกใช้)
เดโมที่รันได้แบบเต็ม
หัวข้อที่มีชื่อว่า “เดโมที่รันได้แบบเต็ม”import functools
def log_calls(level: str = "INFO"): """Decorator factory: log calls at the given level.""" def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): arg_str = ", ".join( [repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()] ) print(f"[{level}] {func.__name__}({arg_str}) called") result = func(*args, **kwargs) print(f"[{level}] {func.__name__} returned {result!r}") return result return wrapper return decorator
@log_calls(level="DEBUG")def add(a: int, b: int) -> int: """Return the sum of a and b.""" return a + b
@log_calls()def greet(name: str, greeting: str = "Hello") -> str: return f"{greeting}, {name}!"
print("--- add ---")add(3, 4)
print("--- greet ---")greet("Alice")greet("Bob", greeting="Hi")
print("--- metadata preserved ---")print(add.__name__)print(add.__doc__)Loading Python runtime (first run only)…
เดโมเจาะลึก: wraps, lru_cache, partial, class decorator, property
หัวข้อที่มีชื่อว่า “เดโมเจาะลึก: wraps, lru_cache, partial, class decorator, property”playground ด้านล่างรันหัวข้อเจาะลึกทั้งหมดข้างบน รวมถึงสองจุดที่ล้มเหลวจริง ได้แก่ decorator ที่ขาด wraps จนสูญเสีย signature และ lru_cache ที่ raise TypeError เมื่อรับ argument ที่ unhashable:
import functoolsimport inspect
# --- functools.wraps: broken vs fixed ---def broken_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper
def fixed_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper
@broken_decoratordef add(a: int, b: int) -> int: """Return the sum of a and b.""" return a + b
@fixed_decoratordef sub(a: int, b: int) -> int: """Return a minus b.""" return a - b
print("--- functools.wraps ---")print("broken __name__:", add.__name__)print("broken __doc__:", add.__doc__)print("broken signature:", inspect.signature(add))print("fixed __name__:", sub.__name__)print("fixed __doc__:", sub.__doc__)print("fixed signature:", inspect.signature(sub))
# --- lru_cache / cache ---print("\n--- functools.lru_cache ---")
@functools.lru_cache(maxsize=32)def fib(n: int) -> int: if n < 2: return n return fib(n - 1) + fib(n - 2)
print("fib(20):", fib(20))print("cache_info:", fib.cache_info())
fib.cache_clear()print("after cache_clear:", fib.cache_info())
@functools.cachedef total(items): return sum(items)
try: total([1, 2, 3])except TypeError as e: print(type(e).__name__ + ":", e)
class Doubler: def __init__(self, factor: int) -> None: self.factor = factor
@functools.lru_cache(maxsize=None) def compute(self, x: int) -> int: return x * self.factor
d1 = Doubler(2)d1.compute(5)print("cache after d1.compute(5):", d1.compute.cache_info())d2 = Doubler(3)d2.compute(5)print("cache after d2.compute(5) — self is part of the key, entry #2 kept alive:", d2.compute.cache_info())
# --- functools.partial ---print("\n--- functools.partial ---")
def power(base: float, exponent: float) -> float: return base ** exponent
square = functools.partial(power, exponent=2)cube = functools.partial(power, exponent=3)print("square(5):", square(5))print("cube(2):", cube(2))print("square.func.__name__:", square.func.__name__)print("square.keywords:", square.keywords)
# --- class decorator ---print("\n--- class decorator ---")
def add_repr(cls): """Class decorator: attach a generic __repr__ built from __dict__.""" def __repr__(self): fields = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items()) return f"{cls.__name__}({fields})" cls.__repr__ = __repr__ return cls
@add_reprclass Point: def __init__(self, x, y): self.x = x self.y = y
print(Point(1, 2))
# --- @property is just a decorator ---print("\n--- @property is a decorator ---")
class Temperature: def __init__(self, celsius: float) -> None: self._celsius = celsius
@property def celsius(self) -> float: return self._celsius
@celsius.setter def celsius(self, value: float) -> None: self._celsius = value
t = Temperature(20.0)print("read:", t.celsius)t.celsius = 25.0print("after write:", t.celsius)print("Temperature.celsius is a:", type(Temperature.celsius).__name__)Loading Python runtime (first run only)…
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”การอ่าน @log_calls(level="DEBUG") ที่อยู่เหนือ def add(...) จากล่างขึ้นบน แสดงสามชั้นการเรียกที่ decorator แบบรับ argument จะมีเสมอ — หนึ่ง function call ต่อวงเล็บหนึ่งคู่ใน source:
flowchart TD A["retry(max_attempts=3)\n(the factory — called once, with the decorator's own args)"] --> B["decorator\n(called once, with the function being decorated)"] B --> C["wrapper\n(called every time fetch(...) is called)"] C --> D["func(*args, **kwargs)\n(the original fetch)"]
decorator ที่ซ้อนกัน (@a @b @c เหนือ def f()) จะถูก apply จากล่างขึ้นบน แต่จะ รัน จากนอกเข้าใน: c ห่อ f, b ห่อผลลัพธ์ของ c, a ห่อผลลัพธ์ของ b — ดังนั้นตอนเรียกใช้จริง code ของ wrapper ของ a จะรันก่อน ตามด้วย b แล้วจึง c แล้วสุดท้ายคือ f เอง
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
@functools.wraps(func) | __name__/__doc__/inspect.signature รายงาน function จริงทั้งหมด | ต้อง import และเขียนเพิ่มอีกหนึ่งบรรทัดต่อ decorator — ลืมง่าย |
@functools.lru_cache/@cache | ข้ามการคำนวณซ้ำได้เลยเมื่อเรียกด้วย argument เดิม | argument ต้อง hashable; การโตแบบไม่จำกัด (maxsize=None) คือ memory leak; การ cache method จะทำให้ทุก self มีชีวิตอยู่ตลอด |
functools.partial | ไม่ต้องเขียน function ใหม่เพื่อตรึง argument บางตัว | ผลลัพธ์เป็น object partial ไม่ใช่ function — __name__/__doc__ ไม่ถูกก็อปให้อัตโนมัติ |
| Class decorator | ใช้กลไก “ห่อแล้วแทนที่” แบบเดียวกับ function decorator นำไปใช้ซ้ำกับหลาย class ได้ | การแก้ cls in place (แทนที่จะ return class ใหม่) อาจทำให้ code ที่เก็บ reference ของ class เดิมไว้แปลกใจ |
@property | syntax หน้าตาเหมือน attribute แต่ validate, คำนวณ หรือ log ตอนอ่าน/เขียนได้ | ทุกการอ่าน/เขียนคือการเรียก method — อย่าใส่งานที่หนักหรือมี side effect ไว้หลังสิ่งที่หน้าตาเหมือน attribute ธรรมดา |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “decorator ที่ไม่มี
functools.wrapsก็ยังทำงานเหมือนเดิม แค่__name__ต่างไป” — ข้อเท็จจริง:inspect.signatureก็พังไปด้วย ดังนั้นอะไรก็ตามที่ introspect function (เครื่องมือสร้างเอกสาร, บาง framework ของ CLI/DI) จะเห็น(*args, **kwargs)แทนที่จะเห็น parameter จริง - “
lru_cache/cacheใส่กับ function ไหนก็ปลอดภัยเสมอ” — ข้อเท็จจริง: มันจะ raiseTypeErrorเมื่อ argument เป็น unhashable และเมื่อmaxsize=Noneมันจะไม่ลืมอะไรเลย ซึ่งเป็น memory leak สำหรับ function ที่ถูกเรียกด้วย input ที่หลากหลายหรือไม่มีขอบเขต — และถ้าใช้กับ method มันจะทำให้ทุก instance ที่เรียกมันมีชีวิตอยู่ตลอด - “
@propertyต้องอาศัย interpreter support พิเศษ” — ข้อเท็จจริง: มันเป็นแค่ decorator ธรรมดารอบ class ธรรมดาตัวหนึ่งคือproperty— กลไกเดียวกัน (แทนที่ชื่อด้วยค่า return ของ decorator) เหมือน decorator อื่น ๆ ทุกตัวในบทนี้ - “decorator มีไว้แค่สำหรับตัวอย่าง logging/timing” — ข้อเท็จจริง:
@dataclass,@functools.cache,@app.route(...)ใน web framework,@pytest.fixtureและ@propertyล้วนเป็น decorator ที่ทำงานเชิงโครงสร้างจริง ไม่ใช่แค่ห่อด้วย print statement - “decorator ที่ซ้อนกันจะรันตามลำดับที่ apply (บนลงล่าง)” — ข้อเท็จจริง: มัน apply จากล่างขึ้นบน (ตัวที่ใกล้ function ที่สุดก่อน) แต่ wrapper ที่ได้จะ รัน จากนอกเข้าในตอนเรียกใช้ — code ของ decorator ตัวบนสุดคือตัวแรกที่ทำงาน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- decorator ทุกตัวมี
@functools.wraps(func)อยู่เหนือwrapperตัวในหรือเปล่า? การขาดมันสังเกตยากตอน review และทำให้__name__,__doc__และinspect.signatureพังไป @lru_cacheหรือ@cacheถูกใช้กับ method (function ที่ parameter ตัวแรกคือself) หรือเปล่า? เช็คว่าตั้งใจให้ instance ถูก cache ไว้ตลอดไปจริงหรือไม่ — ถ้าไม่ นี่คือ memory leak- function ที่ถูก cache เคยได้รับ argument เป็น
list,dictหรือ unhashable อื่น ๆ หรือเปล่า? การเรียกนั้นจะ raiseTypeErrorที่จุดเรียกใช้ ไม่ใช่ตอน decorate - decorator ถูกใช้กับ class แทนที่จะเป็น function หรือเปล่า? ตรวจว่ามัน return class (หรือสิ่งทดแทนที่ใช้ได้) จริง — class decorator ที่ return
Noneจะทำลายชื่อ class ไปแบบเงียบ ๆ - สำหรับ decorator ที่รับ argument ให้นับวงเล็บ: มีการซ้อนสามชั้นจริงหรือไม่ (factory → decorator → wrapper) หรือผู้เขียนลืมไปหนึ่งชั้นแล้วส่ง function เข้า factory ตรง ๆ
🐛 หา bug ในโค้ดนี้
import functools
class ReportGenerator: def __init__(self, dataset_id: str) -> None: self.dataset_id = dataset_id
@functools.lru_cache(maxsize=None) def summary(self, rows: int) -> str: return f"{self.dataset_id}: summarized {rows} rows"
def build_report(dataset_id: str, rows: int) -> str: generator = ReportGenerator(dataset_id) return generator.summary(rows)
for i in range(100_000): build_report(f"dataset-{i}", rows=10)
print("done")เฉลย
@functools.lru_cache(maxsize=None) บน summary รวม self เข้าไปใน cache key ด้วย ทุกหนึ่งใน 100,000 instance ของ ReportGenerator ที่มีอายุสั้น ๆ ที่ถูกสร้างในลูปจะถูก cache เก็บไว้ตลอดไป — ไม่มีการ evict เลย จึงไม่มี instance ไหนถูก garbage collect ได้ และ cache จะโตไม่มีขอบเขตตลอดอายุของ process (ยืนยันแล้ว: ReportGenerator.summary.cache_info().currsize ขึ้นไปถึง 100000 หลังจบลูป และไม่มีวันลดลง) ทางแก้คือไม่ cache method ที่พก state เฉพาะ instance แบบนี้เลย — ให้ cache function ธรรมดาที่รับข้อมูล hashable แทน (functools.cache(lambda dataset_id, rows: f"{dataset_id}: summarized {rows} rows")) หรือใช้การ cache แบบต่อ instance สไตล์ functools.cached_property ถ้าค่านั้นควรมีอายุคู่กับ instance เดียว