พื้นฐาน

Decorator ใน Python: คืออะไร ทำงานอย่างไร และเขียนเองอย่างไร

อัปเดต: · Skillaria.Top

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

กฎสามข้อของเทมเพลต:

  1. *args, **kwargs wrapper รับอาร์กิวเมนต์ได้ทุกแบบและส่งต่อโดยไม่เปลี่ยนแปลง
  2. return result อย่าลืมคืนผลลัพธ์ ไม่เช่นนั้นฟังก์ชันที่ถูกตกแต่งจะคืน None เสมอ
  3. @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: от новичка до профи”

ไปที่คอร์ส