Decorator ใน Python: คืออะไร ทำงานอย่างไร และเขียนเองอย่างไร
Decorator ใน Python คือฟังก์ชันที่รับฟังก์ชันหนึ่งเข้าไป และคืนฟังก์ชันใหม่ ซึ่งมักเป็นฟังก์ชันห่อหุ้ม (wrapper) ที่เพิ่มพฤติกรรมก่อนหรือหลังการเรียก เช่น การบันทึกล็อก การจับเวลา การแคช หรือการตรวจสิทธิ์ การเขียน @decorator เหนือ def เป็นทางลัดของ func = decorator(func) และเพื่อให้ wrapper คงชื่อและ docstring ของฟังก์ชันเดิม ให้ใช้ functools.wraps
Decorator ใน Python คือฟังก์ชันที่รับฟังก์ชันหนึ่งเข้าไป และคืนฟังก์ชันใหม่ออกมา ซึ่งส่วนใหญ่เป็นฟังก์ชันห่อหุ้ม (wrapper) ที่ครอบฟังก์ชันเดิมไว้ วิธีนี้ทำให้เพิ่มพฤติกรรม เช่น การบันทึกล็อก การแคช หรือการลองใหม่เมื่อเกิดข้อผิดพลาด ได้โดยไม่ต้องแก้โค้ดของฟังก์ชันนั้น decorator เขียนด้วยการใส่ @ชื่อ เหนือการประกาศฟังก์ชัน และพบได้ทั่วไปใน Flask, FastAPI, pytest และไลบรารีมาตรฐาน
decorator คืออะไร อธิบายอย่างง่าย
ลองนึกถึงของขวัญที่ห่อด้วยกระดาษห่อ ตัวของขวัญ (ฟังก์ชัน) ยังเหมือนเดิม แต่มีชั้นห่อด้านนอกที่ทำงานก่อนเป็นอันดับแรก decorator ก็เป็นการห่อแบบนี้ เมื่อมีการเรียกฟังก์ชัน โค้ดของ wrapper จะทำงานก่อน จากนั้นเรียกฟังก์ชันเดิม และสามารถจัดการกับผลลัพธ์ได้อีกครั้ง
สิ่งนี้เป็นไปได้เพราะในภาษา Python ฟังก์ชันคือออบเจกต์ธรรมดา สามารถส่งเป็นอาร์กิวเมนต์ให้ฟังก์ชันอื่น คืนค่าจากฟังก์ชัน และประกาศไว้ภายในฟังก์ชันได้ ทั้งสามคุณสมบัตินี้คือรากฐานของ decorator
decorator ทำงานอย่างไร: @ คือไวยากรณ์ทางลัด
ลองดู decorator ที่พิมพ์ข้อความก่อนและหลังการเรียกฟังก์ชัน:
def log_calls(func):
def wrapper():
print(f"กำลังเรียก {func.__name__}")
result = func()
print(f"{func.__name__} ทำงานเสร็จ")
return result
return wrapper
@log_calls
def hello():
print("สวัสดี!")
hello()
กำลังเรียก hello
สวัสดี!
hello ทำงานเสร็จ
การเขียน @log_calls เหนือ def เทียบเท่ากับการเขียนคำสั่งนี้หลังการประกาศ:
def hello():
print("สวัสดี!")
hello = log_calls(hello)
ตอนนี้ชื่อ hello ชี้ไปที่ wrapper ส่วนฟังก์ชันเดิมอยู่ในปิดล้อม (closure) เพราะ wrapper จำตัวแปร func จากฟังก์ชันภายนอกได้ ประเด็นสำคัญคือ decorator ถูกนำไปใช้เพียงครั้งเดียว ตอนที่คำสั่ง def ทำงาน ซึ่งก็คือตอนที่โมดูลถูก import แต่ wrapper จะทำงานทุกครั้งที่มีการเรียก
closure และ decorator
closure คือฟังก์ชันซ้อนที่จำตัวแปรของฟังก์ชันภายนอกไว้แม้ฟังก์ชันภายนอกจะทำงานจบไปแล้ว หากไม่มี closure decorator ก็จะไม่มี เพราะ wrapper ได้เข้าถึง func ผ่านกลไกนี้ นอกจากนี้ closure ยังเก็บสถานะได้ เช่น ตัวนับจำนวนการเรียก:
def count_calls(func):
calls = 0
def wrapper(*args, **kwargs):
nonlocal calls
calls += 1
print(f"{func.__name__}: เรียกครั้งที่ {calls}")
return func(*args, **kwargs)
return wrapper
วิธีเขียน decorator ใน Python: เทมเพลตมาตรฐาน
ตัวอย่างแรกมีข้อจำกัดสองข้อ คือ wrapper ไม่รับอาร์กิวเมนต์ และ "กลืน" ชื่อของฟังก์ชันเดิม เทมเพลตที่ใช้เป็นจุดเริ่มต้นของ decorator ทุกตัวได้ดังนี้:
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# โค้ดก่อนเรียกฟังก์ชัน
result = func(*args, **kwargs)
# โค้ดหลังเรียกฟังก์ชัน
return result
return wrapper
กฎสามข้อของเทมเพลต:
*args, **kwargswrapper รับอาร์กิวเมนต์ได้ทุกแบบและส่งต่อโดยไม่เปลี่ยนแปลงreturn resultอย่าลืมคืนผลลัพธ์ ไม่เช่นนั้นฟังก์ชันที่ถูกตกแต่งจะคืนNoneเสมอ@functools.wraps(func)คัดลอกข้อมูลเมตาของฟังก์ชันเดิมไปยัง wrapper
ตัวอย่างของ decorator ที่จับเวลาการทำงาน:
import functools
import time
def timer(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__} ใช้เวลา {elapsed:.3f} วินาที")
return result
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
slow_sum(10_000_000) # slow_sum ใช้เวลา 0.142 วินาที
functools.wraps ใช้ทำอะไร
หากไม่ใช้ wraps ฟังก์ชันที่ถูกตกแต่งจะถูกแทนที่ชื่อและ docstring และอื่น ๆ ทำให้ภายนอกเห็นเป็น wrapper:
def plain(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@plain
def add(a, b):
"""บวกเลขสองตัว"""
return a + b
print(add.__name__, add.__doc__) # wrapper None
เมื่อใช้ @functools.wraps(func) wrapper จะได้ __name__ __doc__ __module__ __qualname__ และ __annotations__ ของฟังก์ชันเดิม และเก็บการอ้างอิงถึงต้นฉบับไว้ในแอตทริบิวต์ __wrapped__ สิ่งนี้สำคัญต่อการดีบัก traceback คำสั่ง help() เฟรมเวิร์กที่ลงทะเบียนฟังก์ชันด้วยชื่อ และ inspect.signature ที่แสดงลายเซ็นจริง
decorator ที่มีพารามิเตอร์ (มีอาร์กิวเมนต์)
บางครั้ง decorator ต้องการการตั้งค่า เช่น @retry(times=3) หรือ @app.route("/") วิธีคือเพิ่มชั้นการซ้อนอีกหนึ่งชั้น ฟังก์ชันชั้นนอกรับพารามิเตอร์และคืน decorator จริงออกมา โครงสร้างแบบนี้เรียกว่าโรงงานของ decorator (decorator factory)
import functools
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def ping():
print("ping")
ping() # ping, ping, ping — สามบรรทัด
ลำดับการทำงานคือ repeat(times=3) คืน decorator ซึ่งถูกนำไปใช้กับ ping และคืน wrapper กล่าวคือ @repeat(times=3) เท่ากับ ping = repeat(times=3)(ping)
ตัวอย่างที่นำไปใช้ได้จริงคือการลองใหม่เมื่อเกิดข้อผิดพลาด:
import functools
import time
def retry(times=3, delay=1.0, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions:
if attempt == times:
raise
time.sleep(delay)
return wrapper
return decorator
@retry(times=5, delay=0.5, exceptions=(ConnectionError,))
def fetch_rate():
...
decorator หลายตัวบนฟังก์ชันเดียว
สามารถวาง decorator ซ้อนกันได้ การนำไปใช้จะเรียงจากล่างขึ้นบน คือตัวที่อยู่ใกล้ def ที่สุดจะถูกนำไปใช้ก่อน
@timer
@log_calls
def report():
...
# เท่ากับ:
report = timer(log_calls(report))
ตอนเรียกใช้ decorator ชั้นนอก (timer) จะทำงานก่อน ดังนั้นมันจะจับเวลารวมทั้งการทำงานของ log_calls ด้วย ลำดับมีความสำคัญ เช่น decorator ที่ตรวจสิทธิ์และ decorator ที่แคชผลลัพธ์ จะให้ผลต่างกันเมื่อสลับตำแหน่ง
decorator มาตรฐานและที่มีมาในตัวของ Python
มี decorator ที่มีประโยชน์หลายตัวอยู่แล้วในภาษาและไลบรารีมาตรฐาน:
| Decorator | หน้าที่ |
|---|---|
@property | เปลี่ยนเมธอดให้เป็นแอตทริบิวต์อ่านอย่างเดียว เมื่อใช้คู่กับ @x.setter จะกลายเป็นแอตทริบิวต์ที่ควบคุมการกำหนดค่าได้ |
@staticmethod | เมธอดของคลาสที่ไม่มี self เป็นฟังก์ชันธรรมดาที่อยู่ในเนมสเปซของคลาส |
@classmethod | เมธอดรับคลาส cls แทนอินสแตนซ์ ใช้เขียนตัวสร้างทางเลือก |
@functools.cache, @functools.lru_cache | แคชผลลัพธ์ตามอาร์กิวเมนต์ |
@functools.wraps | คัดลอกข้อมูลเมตาของฟังก์ชันไปยัง wrapper |
@dataclasses.dataclass | สร้าง __init__ __repr__ __eq__ ให้คลาส |
@contextlib.contextmanager | เปลี่ยน generator ให้เป็น context manager สำหรับ with |
การแคช: functools.cache และ lru_cache
decorator สำหรับแคชจะจำผลลัพธ์ของอาร์กิวเมนต์แต่ละชุด และเมื่อเรียกซ้ำจะคืนค่าที่เก็บไว้โดยไม่ต้องคำนวณใหม่ ตัวอย่างคลาสสิกคือการคำนวณลำดับฟีโบนัชชีแบบเรียกซ้ำ ซึ่งหากไม่มีแคชจะใช้เวลาเพิ่มขึ้นแบบทวีคูณ:
import functools
@functools.cache
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(100)) # 354224848179261915075
functools.cache (มีตั้งแต่ Python 3.9) เก็บผลลัพธ์ทั้งหมดโดยไม่จำกัดขนาด ส่วน @functools.lru_cache(maxsize=128) เก็บเฉพาะรายการล่าสุดและลบรายการเก่าออก ซึ่งปลอดภัยกว่าสำหรับบริการที่ทำงานต่อเนื่องนาน ๆ ดูสถิติได้ด้วย fib.cache_info() และล้างแคชด้วย fib.cache_clear() อาร์กิวเมนต์ของฟังก์ชันที่แคชต้องแฮชได้ การส่งลิสต์เข้าไปจะเกิด TypeError: unhashable type: 'list' และควรแคชเฉพาะฟังก์ชันบริสุทธิ์ (pure function) คือฟังก์ชันที่ให้ผลเดิมเสมอเมื่ออาร์กิวเมนต์เดิม และไม่มีผลข้างเคียง
property ทำอะไร
@property ทำให้เรียกเมธอดเหมือนแอตทริบิวต์ ไม่ต้องใส่วงเล็บ และควบคุมการกำหนดค่าได้เมื่อจำเป็น:
class Account:
def __init__(self, balance):
self._balance = balance
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("ยอดคงเหลือต้องไม่ติดลบ")
self._balance = value
acc = Account(100)
print(acc.balance) # 100
acc.balance = -5 # ValueError
staticmethod และ classmethod
class Money:
def __init__(self, kopecks):
self.kopecks = kopecks
@classmethod
def from_rubles(cls, rubles):
return cls(round(rubles * 100))
@staticmethod
def is_valid(value):
return value >= 0
m = Money.from_rubles(12.5)
print(m.kopecks, Money.is_valid(-1)) # 1250 False
@classmethod ทำให้เมธอดทำงานกับตัวคลาสเอง ไม่ใช่อินสแตนซ์ ตัวอย่างนี้ from_rubles จะสร้างอ็อบเจกต์ของคลาสย่อยได้อย่างถูกต้องด้วย
decorator ของคลาส และคลาสที่เป็น decorator
decorator สามารถนำไปใช้กับคลาสได้ โดยรับคลาสเข้าไปและคืนคลาส (เดิมหรือที่ปรับแล้ว) ตัวอย่าง @dataclass ก็ทำงานแบบนี้ ตัวอย่างง่าย ๆ คือรีจิสทรีของคลาส:
registry = {}
def register(cls):
registry[cls.__name__] = cls
return cls
@register
class CsvExporter:
pass
print(registry) # {'CsvExporter': <class '__main__.CsvExporter'>}
ในทางกลับกัน คลาสก็สามารถทำหน้าที่เป็น decorator ได้ โดยออบเจกต์ใดก็ตามที่มีเมธอด __call__ ใช้ได้ และการเก็บสถานะทำได้สะดวกผ่านแอตทริบิวต์ของอินสแตนซ์:
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.calls = 0
def __call__(self, *args, **kwargs):
self.calls += 1
return self.func(*args, **kwargs)
@CountCalls
def tick():
pass
tick(); tick()
print(tick.calls) # 2
decorator สำหรับฟังก์ชันแบบ async
สำหรับฟังก์ชันที่ประกาศด้วย async def wrapper ต้องเป็นคอรูทีนด้วยและต้องรอผลลัพธ์ด้วย await ไม่เช่นนั้นจะได้ออบเจกต์คอรูทีนที่ยังไม่ได้ทำงานกลับมา:
import functools
def log_async(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
print(f"เริ่ม {func.__name__}")
result = await func(*args, **kwargs)
print(f"เสร็จ {func.__name__}")
return result
return wrapper
ทำไมต้องใช้ decorator ใน Python
- ตรรกะที่ใช้ร่วมกัน เช่น การบันทึกล็อก การวัดค่า และการจับเวลา โดยไม่ต้องคัดลอกโค้ดไปทุกฟังก์ชัน
- การแคช ด้วย
@functools.cacheสำหรับการคำนวณที่แพงและเป็นฟังก์ชันบริสุทธิ์ - การตรวจสอบ เช่น สิทธิ์การเข้าถึง การตรวจสอบอาร์กิวเมนต์ และการยืนยันตัวตนในตัวจัดการของเว็บ
- การลงทะเบียน เช่น เส้นทางใน Flask และ FastAPI (
@app.get("/")) และ fixture ของ pytest - ความน่าเชื่อถือ เช่น การลองใหม่ การกำหนดเวลาหมดอายุ และการจัดการข้อผิดพลาด
ข้อดีหลักคือการแยกความรับผิดชอบ ฟังก์ชันทำหน้าที่ของตัวเอง ส่วนพฤติกรรมเสริมถูกเพิ่มด้วยบรรทัดเดียว และลบออกได้ง่ายเช่นกัน
ข้อผิดพลาดที่พบบ่อย
- wrapper ไม่คืนผลลัพธ์ ฟังก์ชันหลังการตกแต่งจะคืน
None - ไม่มี
*args, **kwargsจะเกิดTypeErrorเมื่อเรียกด้วยอาร์กิวเมนต์ - ลืม
wrapsทำให้__name__และ docstring ผิด และ log สับสน - ลืมวงเล็บของโรงงาน เขียน
@repeatแทน@repeat(3)ทำให้ฟังก์ชันไปอยู่ในพารามิเตอร์times - โค้ดหนักในตัว decorator ทำงานตอน import โมดูล ไม่ใช่ตอนเรียกใช้
สรุป
Decorator ใน Python คือฟังก์ชันระดับสูง (higher-order function) ที่รับฟังก์ชันและคืน wrapper ส่วน @ เป็นเพียงรูปแบบย่อของการกำหนดค่า เริ่มจากเทมเพลตที่มี *args, **kwargs return result และ functools.wraps หากต้องการการตั้งค่า ให้เพิ่มชั้นของโรงงาน ฟังก์ชัน closure และ annotation ที่ decorator ต้องพึ่งพาถูกสอนแบบลงมือทำในคอร์สเรื่อง Python
คำถามที่พบบ่อย
ถอด decorator ออกจากฟังก์ชันได้หรือไม่?
ถ้า decorator ใช้ functools.wraps ต้นฉบับจะอยู่ที่แอตทริบิวต์ __wrapped__ เรียก func.__wrapped__(...) ได้โดยไม่ผ่าน wrapper มิฉะนั้นต้องลบบรรทัดที่มี @ ออกจากโค้ด
โค้ดของ decorator ทำงานเมื่อใด?
ตัวของ decorator ทำงานครั้งเดียวตอนประกาศฟังก์ชัน ซึ่งมักเป็นตอนที่โมดูลถูก import ส่วนโค้ดภายใน wrapper ทำงานทุกครั้งที่เรียกฟังก์ชันที่ถูกตกแต่ง
decorator ใช้กับเมธอดของคลาสได้หรือไม่?
ได้ decorator ทั่วไปที่ใช้ *args, **kwargs ทำงานกับเมธอดได้ โดย self จะเป็นสมาชิกตัวแรกของ args
decorator ต่างจากการสืบทอด (inheritance) อย่างไร?
การสืบทอดเปลี่ยนพฤติกรรมของคลาสผ่านลำดับชั้น ส่วน decorator ห่อฟังก์ชันหรือคลาสจากภายนอก และสามารถนำมาผสมกันได้หลายตัวโดยไม่ต้องแก้โค้ดเดิม
หัวข้อนี้สอนอย่างละเอียดพร้อมฝึกปฏิบัติในคอร์ส “Python 3.15: от новичка до профи”
ไปที่คอร์ส