پایتونمتوسط

دکوریتورها در پایتون، قدم‌به‌قدم

دکوریتورها جادویی به نظر می‌رسند، اما فقط توابعی هستند که توابع دیگر را می‌پوشانند. دکوریتورهای زمان‌سنجی و تلاش مجدد را از صفر بسازید.

تابعی که داخل دو لایهٔ دکوریتور، یعنی retry و timed، پوشانده شده است
در این صفحه

احتمالاً کدی شبیه این را در Flask، FastAPI یا Django دیده‌اید:

Python
@app.route("/")
def home():
    return "Hello!"

آن خطی که با @ شروع می‌شود یک دکوریتور (decorator) است. دکوریتورها به شما اجازه می‌دهند بدون تغییر کدِ یک تابع، رفتاری به آن اضافه کنید؛ مثل لاگ گرفتن، زمان‌سنجی، کش کردن یا کنترل دسترسی. شاید جادویی به نظر برسند، اما از سه ایدهٔ ساده ساخته شده‌اند. بیایید یکی‌یکی سراغشان برویم.

ایدهٔ ۱: توابع شیء هستند#

در پایتون، توابع هم مثل هر مقدار دیگری هستند. می‌توانید آن‌ها را به متغیرها نسبت دهید و به توابع دیگر بفرستید:

Python
def greet(name):
    return f"Hello, {name}!"

say_hello = greet          # no parentheses: we're not calling it
print(say_hello("Ada"))    # Hello, Ada!

ایدهٔ ۲: می‌توان تابع را داخل تابع تعریف کرد#

Python
def outer():
    def inner():
        return "I'm inside!"
    return inner           # return the function itself

fn = outer()
print(fn())                # I'm inside!

ایدهٔ ۳: توابع داخلی محیط اطرافشان را به خاطر می‌سپارند#

یک تابع داخلی می‌تواند از متغیرهای تابعی که آن را ساخته استفاده کند، حتی بعد از اینکه آن تابع بیرونی تمام شده باشد. به این کلوژر (closure) می‌گویند:

Python
def make_multiplier(factor):
    def multiply(x):
        return x * factor  # "remembers" factor
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5), triple(5))  # 10 15

کنار هم گذاشتن: اولین دکوریتور شما#

دکوریتور تابعی است که یک تابع می‌گیرد و یک تابع جدید برمی‌گرداند؛ معمولاً تابعی که تابع اصلی را با رفتاری اضافه می‌پوشاند:

Python
def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

def greet(name):
    return f"Hello, {name}!"

greet = shout(greet)       # wrap it manually
print(greet("Ada"))        # HELLO, ADA!

سینتکس @ فقط میان‌بُری برای همان خطِ greet = shout(greet) است:

Python
@shout
def greet(name):
    return f"Hello, {name}!"

print(greet("Ada"))        # HELLO, ADA!
⁧⁦@shout⁩ خلاصهٔ ⁦greet = shout(greet)⁩ است⁩ greet⁧تابع اصلی⁩ shout(func)⁧دکوریتور⁩ wrapper⁧⁦greet()⁩ را صدا می‌زند⁩⁧و بعد ⁦.upper()⁩⁩ ⁧ورودی⁩⁧برمی‌گرداند⁩ ⁧حالا نام ⁦greet⁩ به ⁦wrapper⁩ اشاره می‌کند؛ پس ⁦greet("Ada")⁩ به ترتیب ⁦wrapper⁩، ⁦greet⁩ اصلی و ⁦.upper()⁩ را اجرا می‌کند⁩
خط @ واقعاً چه می‌کند: دکوریتور تابع شما را می‌گیرد و جایگزینی برایش برمی‌گرداند

*args و **kwargs به wrapper اجازه می‌دهند هر آرگومانی را بپذیرد و مستقیم پاس بدهد، پس دکوریتور روی توابعی با هر امضایی کار می‌کند.

functools.wraps را فراموش نکنید#

دکوریتور ما یک مشکل ظریف دارد: تابعِ پوشانده‌شده هویتش را از دست داده است.

Python
print(greet.__name__)      # wrapper

این مشکل دیباگ کردن، ابزارهای مستندسازی و بعضی فریم‌ورک‌ها را خراب می‌کند. با functools.wraps درستش کنید؛ این تابع نام، docstring و دیگر اطلاعات تابع اصلی را روی wrapper کپی می‌کند:

Python
from functools import wraps

def shout(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

عادتش کنید

@wraps(func) را به هر دکوریتوری که می‌نویسید اضافه کنید. فقط یک خط هزینه دارد و جلوی باگ‌های گیج‌کننده را در آینده می‌گیرد.

یک دکوریتور کاربردی: زمان‌سنجی#

بیایید چیزی مفید بسازیم: دکوریتوری که گزارش می‌دهد یک تابع چقدر طول می‌کشد.

Python
import time
from functools import wraps

def timed(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start
            print(f"{func.__name__} took {elapsed:.3f}s")
    return wrapper

@timed
def build_report(rows):
    return sum(i * i for i in range(rows))

build_report(5_000_000)    # build_report took 0.312s

try/finally مطمئن می‌شود که زمان حتی اگر تابع خطا بدهد چاپ شود.

دکوریتورهایی که آرگومان می‌گیرند#

اگر بخواهید @retry(times=3) بنویسید چه؟ آن‌وقت به یک لایهٔ دیگر نیاز دارید: تابعی که آرگومان‌ها را می‌گیرد و یک دکوریتور برمی‌گرداند.

Python
import time
from functools import wraps

def retry(times=3, delay=1.0, exceptions=(Exception,)):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as error:
                    if attempt == times:
                        raise
                    print(f"Attempt {attempt} failed: {error}. Retrying in {delay}s…")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(times=5, delay=0.5, exceptions=(ConnectionError,))
def fetch_prices():
    ...

@retry(times=5) اول retry(...) را صدا می‌زند که decorator را برمی‌گرداند و بعد آن روی fetch_prices اعمال می‌شود. سه لایه پیچیده به نظر می‌رسد، اما هر کدام فقط یک وظیفه دارد:

لایه دریافت می‌کند برمی‌گرداند
retry تنظیمات (times، delay…) decorator
decorator تابعی که باید پوشانده شود wrapper
wrapper آرگومان‌های هر فراخوانی نتیجهٔ تابع

دکوریتورهای داخلی که باید بشناسید#

پایتون چند دکوریتور عالیِ آماده دارد. مثلاً لازم نیست کش را خودتان بنویسید، چون functools.cache (پایتون ۳.۹ به بعد) این کار را برایتان انجام می‌دهد:

Python
from functools import cache

@cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(100))   # 354224848179261915075, instantly

بدون @cache، این بازگشتِ ساده بیش از ۱۰ به توان ۲۰ فراخوانی تابع انجام می‌داد. با آن، هر مقدار دقیقاً یک بار محاسبه می‌شود. اگر لازم است مصرف حافظه را محدود کنید، از functools.lru_cache(maxsize=…) استفاده کنید.

دکوریتورهای داخلیِ دیگری که ارزش شناختن دارند:

  • @property یک متد را به شکل یک ویژگیِ فقط‌خواندنی در اختیار می‌گذارد.
  • @staticmethod و @classmethod متدهایی تعریف می‌کنند که به نمونه (instance) نیاز ندارند.
  • @dataclass متدهای __init__، __repr__ و __eq__ را برایتان می‌سازد.
  • @contextlib.contextmanager یک generator را به context manager برای دستور with تبدیل می‌کند.

جمع‌بندی#

  • توابع شیء هستند: می‌توان آن‌ها را جابه‌جا کرد، برگرداند و داخل توابع دیگر تعریف کرد.
  • دکوریتور یک تابع می‌گیرد و نسخهٔ پوشانده‌شدهٔ آن را برمی‌گرداند؛ @deco خلاصهٔ func = deco(func) است.
  • برای پشتیبانی از هر امضایی از *args, **kwargs استفاده کنید و همیشه @functools.wraps را به کار ببرید.
  • دکوریتورهایی که آرگومان می‌گیرند یک لایهٔ اضافه لازم دارند: تابعی که دکوریتور را برمی‌گرداند.
  • پیش از نوشتن دکوریتور خودتان، سراغ نمونه‌های داخلی مثل @cache، @property و @dataclass بروید.

چالش: یک دکوریتور @log_calls بنویسید که هر بار تابع صدا زده می‌شود، نام و آرگومان‌هایش را چاپ کند. بعد آن را همراه با @timed روی هم بگذارید و ببینید به چه ترتیبی اجرا می‌شوند.