Decorator در پایتون

اشتراک‌گذاری

یکی از امکانات جالب پایتون دکوریتورها (Decorators) هست؛ که به طور ساده و خلاصه یک تابع هست که در ورودی یک تابع دیگر را گرفته، روی آن اعمالی انجام می‌دهد و مقداری که باز می‌گرداند با نام همان تابع ذخیره می‌شود. اگر با FastAPI کار کرده باشید حتما با این کد آشنایید:

Python
from fastapi import FastAPI, 

app = FastAPI()

@app.get("/")
def hello_world():
    return {"Hello": "World"}

یا برای فلسک (Flask) داریم:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello_world():
    return "<p>Hello, World!</p>"

تو این مطلب می‌خوام بگم جریان این علامت @ چی هست و چه کارایی می‌تونید باهاش بکنید.

یک دکوریتور چطور کار می‌کنه

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

نمونه برنامه اول: ایجاد رابط کاربری متنی

فرض کنید برای یک رابط کاربری متنی (CLI) در برنامه‌تان می‌خواهید یک دیکشنری (Dictionary) با کلید نام تابع و مقدار تابع کنترل کننده (Handler) داشته باشید. تا بتوانید از آن به این شکل استفاده کنید:

all_commands = {}

...

while True:
    line = input("> ")
    cmd, *args = line.split()
    if cmd not in all_commands:
        print("Invalid Command")
        continue
    all_commands[cmd](*args)    # Call the function and send input words (except first) as argument

حالا کد بالا یک خط از کاربر می‌گیره و با توجه به اولین کلمه در اون خط اگر در کلیدهای دیکشنری بود تابعی که بهش داده بودیم رو روی مابقی کلمات اون خط صدا می‌زنه مثلا می‌تونیم توی دیکشنری این رو داشته باشیم:

all_commands["echo"] = print

اینطوری اگه یک نفر بنویسه echo hello world برنامه بهش hello world رو نمایش میده. حالا برای توابع بزرگ‌تر باید چیکار کنیم؟ فرض کنید می‌خواهیم یک دستور (تابع) به نام calc اضافه کنیم تا بتونه محاسبه‌های ساده رو انجام بده. این تابع به شکل زیر پیاده‌سازی میشه:

all_commands = {}

def calc(l=None, op=None, r=None):
    try:
        l, r = int(l), int(r)
    except:
        print("SyntaxError\n\n usage: calc 2 + 5\nspaces are important")
        return
    operators = {
        "+": lambda: l + r,
        "-": lambda: l - r,
        "*": lambda: l * r,
        "/": lambda: l / r,
        "//": lambda: l // r,
    }
    if op not in operators:
        print("operator '%s' is not supported" % op)
        return
    print(operators[op]())
    
  while True:
      line = input("> ")
      cmd, *args = line.split()

اگه یک دکوریتور مناسب داشتیم می‌تونستیم مثل کد پایین خیلی خوشگل و شیک این تابع رو هم به دستورات برنامه‌مون اضافه کنیم:

@command
def calc(l=None, op=None, r=None):
    ...

این همه مقدمه چیدم که اینجا بگم چطور بیاییم و برای این نمونه برنامه دکوریتوری به اسم command بسازیم که هر تابعی که خواستیم به دستورات اضافه کنیم قبلش اون رو بنویسیم و کار رو انجام بده:

all_commands = {}

def command(func: callable) -> callable:
    all_commands[func.__name__] = func
    return func

@command
def calc(l=None, op=None, r=None):
    ...

به همین سادگی! گفتم که دکوریتور یه تابعه! تابع command که به همراه راهنمای نوع ورودی و خروجی‌هاش (type hint) نوشتمش، میاد و یک شی صدازدنی (یطورایی همون تابع خودمون!) رو می‌گیره و همون رو بر می‌گردونه. تو پایتون مثلا زمانی که این دکوریتور رو واسه calc استفاده می‌کنیم تابع رو در پارامتر به command میده و چیزی که command بر می‌گردونه رو به اسم calc ذخیره می‌کنه. (یعنی اگه command چیزی بر نمی‌گردوند calc مقدار None می‌گرفت) توی تابع command اومدیم تابع‌ای رو که توی پارامتر میاد رو توی دیکشنری با اسمش ذخیره کردیم.

کد ما تا اینجای کار این شکلیه:

all_commands = {"echo": print}


def command(func: callable) -> callable:
    all_commands[func.__name__] = func
    return func


@command
def calc(l=None, op=None, r=None):
    try:
        l, r = int(l), int(r)
    except:
        print("SyntaxError\n\n usage: calc 2 + 5\nspaces are important")
        return
    operators = {
        "+": lambda: l + r,
        "-": lambda: l - r,
        "*": lambda: l * r,
        "/": lambda: l / r,
        "//": lambda: l // r,
    }
    if op not in operators:
        print("operator '%s' is not supported" % op)
        return
    print(operators[op]())


while True:
    line = input("> ")
    cmd, *args = line.split()
    if cmd not in all_commands:
        print("Invalid Command")
        continue
    all_commands[cmd](
        *args
    )  # Call the function and send input words (except first) as argument

دکوریتور های پیشرفته‌تر!

نمی‌دونم متوجه فرق دکوریتور ما با اون مثال های اولی که آوردم شدید یا نه، برای دکوریتور هایی که تو مثال های flask و fastapi داشتیم مثلا می‌نوشتیم @app.get("/") اما تو دکوریتور خودمون فقط داریم می‌نویسیم @command

جریان اینه که بعضی مواقع تو دکوریتور نیاز داریم که پارامتر های دیگه‌ای هم بگیریم، مثلا فرض کنید تو برنامه بالایی می‌خوایی بگیم برای اضافه کردن یه دستور به دیکشنری‌مون می‌خواییم یه اسم دلخواه و شاید متفاوت با اسم خود تابع اضافه کنیم. (اول این رو می‌گم و بعد میرسیم به این که چطور تابعی بسازیم که در جاهای مختلف هم اسم خود تابع رو بذاره و هم اسم دلخواه)
مثلا چنین چیزی می‌خواییم:

@command("for")
def for_function(start=None, end=None):
    try:
        start, end = int(start), int(end)
    except:
        print("SyntaxError\n\n usage: for 2 5\nspaces are important")

    for i in range(start, end):
        print(i)

مثلا خواستیم دستور for رو اضافه کنیم ولی چون از کلمه کلیدی های پایتونه نمی‌تونیم.

برای چنین مواقعی تابعی که برای command تعریف می‌کنیم وقتی صدا زده میشه یه تابع دیگه بر می‌گردونه و پایتون تابع هدف رو به تابع برگردونده شده میده. برای همین تابع command اینطوری میشه:

def command(name: str) -> callable:
    def decorator(func:callable):
        all_commands[name] = func
        return func
    return decorator

جریان اینه که خود تابع command یه رشته (string) در پارامتر به عنوان اسم دستور می‌گیره و از اونجا که تو پایتون میشه همینطور تابع تو دل تابع نوشت! یه تابع تعریف می‌کنه که تابعی که میگیره رو به کلید name تو دیکشنری ذخیره کنه و خود تابع رو مثل قبل برگردونه. تابع command هم وقتی صدا زده میشه تابعی که تو دل خودشه رو بر می‌گردونه.

دکوریتور ترکیبی

همونطور که قول دادم! اینجا دکوریتوری می‌نویسم که هر دو حالت بشه ازش استفاده کرد. البته روش‌های مختلفی هست ولی این یک مدلشه:

def command(f: str) -> callable:
    def decorator(func: callable):
        all_commands[name] = func
        return func

    if isinstance(f, str):
        name = f  # `f` is a string so it is the name
        return decorator
    else:
        # `f` is not a string so may be it's a callable
        name = f.__name__
        # this code may raise AttributeError,
        # but this is a simple example, isn't?!
        return decorator(f)

تو این کد چک می‌کنیم ببینیم پارامتری که به command اومده از چه جنسیه، اگه رشته بود پس اسم دستور هست و اگر نه خود تابع دستور هست. در هر دو صورت اسم دستور با متغیر name تعیین میشه، وقتی رشته بود name برابر میشه با رشته داده شده و خود تابع داخلیه فرستاده میشه؛ چون توی کد این تابع به شکل @command("example") صدا زده شده، پس باید یه تابع تحویل پایتون بده که پایتون تابع هدف رو به اون بفرسته. در غیر این صورت احتمالا پارامتره یه تابعه (چون این دکوریتور رو برای خودمون می‌نویسیم زیاد پیچیده‌ش نکردم که همه چیز رو چک کنه!) پس به صورت @command صدا زده شده و یه تابع بهش داده شده، مثل قبل اسم تابع رو تو name ذخیره کنه و خودش تابع توی دلش رو روی این تابعی که گرفته صدا بزنه

نمونه برنامه دوم: استفاده از @property در پایتون

یادش بخیر، اون زمان ها که من C# کد می‌زدم یه چیز باحالی داشت به نام property بعدا که اومدم سراغ پایتون دنبالش گشتم و دیدم پایتون هم یه چیزی مشابه‌اش داره ولی از دکوریتور براش استفاده می‌کنه. جریان چیه حالا!

این property تو کلاس‌ها هست و یک‌جور فیلده که موقع گرفتن مقدار یا ذخیره مقدار توش در واقع یه تابع صدا زده میشه. قبلا در مورد dataclass گفتم، اینجا برای این مثال ازش استفاده می‌کنم تا مجبور نباشم تابع __init__ رو بنویسم، دوست داشتید اون مطلبم رو هم بخونید.

from dataclasses import dataclass
from datetime import date, datetime

@dataclass
class Person:
    name: str
    birth_date: date

    @property
    def age(self):
        return (date.today() - self.birth_date).days // 365

حالا اگه یه نمونه از این کلاس بسازیم می‌تونیم فیلد area رو مثل بقیه فیلد ها بگیریم ولی هر بار این فیلد با توجه به دو مقدار ارتفاع و عرض محاسبه میشه:

me = Person("Behnam", date(2000, 9, 29))
print(me.age)

حالا اگه بخواییم این property(خصوصیت) مقدار هم بگیره می‌تونیم براش یه setter مشخص کنیم:

from dataclasses import dataclass
from datetime import date, timedelta


@dataclass
class Person:
    name: str
    birth_date: date
    
    @property
    def age(self):
        return (date.today() - self.birth_date).days // 365
        
    @age.setter
    def age(self):
        self.birth_date = date.today() + timedelta(value*365)
اشتراک‌گذاری