قدرت اعجاب انگیز Decorator پایتون

decorator پایتون

تصور کنید ۳۰ تابع با منطق های پیچیده کسب و کار در برنامه خود نوشته اید.
در یک صبح شنبه بارانی، رئیس کنار میز شما می‌آید و می‌گوید:

“صبح شنبه بخیر! آن گزارشهای TPS را به خاطر داری؟ نیاز دارم تا داده های ورودی و خروجی در هر مرحله تولید گزارش را به صورت کامل ثبت کنید. برای اهداف حسابرسی با شرکت XYZ این اطلاعات باید آماده شوند. به آنها گفتم که می‌توانیم این کار را تا روز دوشنبه ارسال کنیم.”

این درخواست فشار خون شما را افزایش خواهد داد یا باعث آرامش شما خواهد شد. بستگی به ­این دارد که آیا شما از دکوریتورهای پایتون به درک عمیقی دست یافته‌­اید یا خیر. بدون دکوریتورها، ممکن است سه روز آینده را صرف اصلاح هر یک از این ۳۰ تابع کنید و با فراخوانی های دستی ثبت وقایع و گزارش­گیری آنها را درهم بریزید. لحظات سرگرم‌ کنند‌ه‌ ای است، اینطور نیست؟ با این حال، اگر دکوریتورهای پایتون را بشناسید، با آرامش به رئیس خود لبخند می‌زنید و می‌گویید:

“کاووس جان نگران نباش، امروز تا ساعت ۲ بعد از ظهر این کار را انجام خواهم داد.”

درست پس از آن، کدی را برای دکوریتور عمومی audit_log@ تایپ می‌­کنید (طول این کد تقریبا ۱۰ خط است) و به سرعت در جلوی تعریف تابع قرار می­‌دهید. بعد کد خود را ارسال می­‌کنید و یک فنجان قهوه دیگر می­‌خورید. لذت بخش است، نه؟

شاید در اینجا کمی بزرگنمایی می‌کنم. دکوریتورها می‌توانند قدرتمند و اعجاب انگیز باشند. در این مطلب تا جایی پیش می‌رویم که بگوییم درک دکوریتورهای پایتون، نقطه عطفی برای هر برنامه‌­نویس سختکوش پایتون است. درک صحیح دکوریتورها نیاز به درک کاملی از چندین مفهوم پیشرفته در زبان پایتون، از جمله ویژگی توابع کلاس اول (First Class Functions) دارند.

معتقدم که نتایج مثبت درک عمیق نحوه عملکرد دکوریتورها در پایتون، می­تواند بسیار زیاد باشد.


قدرت Decorator پایتون

Decorator پایتون به شما امکان گسترش و اصلاح رفتارهای قابل فراخوانی (مانند متدها و کلاس ها) بدون اصلاح دائمی پارامتر قابل فراخوانی را می‌­دهد. هر فعالیتی که به ­اندازه کافی عمومی ­باشد و بتوانید آن را به کلاس یا رفتارهای موجود اضافه کنید، یک مورد عالی برای استفاده در دکوریتور محسوب می­شود. کلمه انگلیسی Decorator معادل فارسی تزئین است و در این مطلب گاهی از کلمه تزئین به جای دکوریتور استفاده خواهیم کرد.

به زبان ساده دکوریتورهای پایتون امکان اضافه کردن رفتار جدید به اشیا را می‌دهند. این رفتارهای جدید در قالب بسته بندی‌های جدید شی خواهند بود.

کارهای تکراری که با استفاده از Decorator پایتون می‌توانید انجام دهید شامل موارد زیر است:

  • ثبت وقایع و گزارش­گیری
  • اجرای کنترل دسترسی و احراز هویت
  • تعیین زمان اجرای توابع
  • محدودیت های پراستفاده
  • Caching (دخیره سازی موقت اطلاعات) و …

حال، چرا باید به کاربرد دکوریتورها در پایتون تسلط داشته باشید؟ بدون درک این کاربرد چیزهایی که در ابتدا گفتیم کاملا انتزاعی به نظر می‌رسد و فهمیدن این موضوع که دکوریتورها چقدر می‌توانند در کار روزانه یک توسعه ­دهنده پایتون سودمند باشند، دشوار خواهد بود.

قطعا، فکر کردن برای اولین بار در مورد دکوریتورها کمی دشوار است، اما ویژگی بسیار سودمندی است که اغلب در فریمورک های شخص ثالث و در کتابخانه های استاندارد پایتون با آن مواجه خواهید شد. در این مطلب نهایت تلاشم را خواهم کرد تا بصورت گام به گام شما را با دکوریتور ها آشنا کنم و تعدادی از ویژگی های اعجاب انگیز دکوریتورها را ببینیم.

قبل از بررسی این موضوع، اکنون لحظه ­ای عالی برای یادآوری در مورد ویژگی های توابع کلاس اول (یا توابع به عنوان شهروندان درجه اول) در پایتون است. مهمترین نکات اساسی “توابع کلاس اول” برای درک دکوریتورها عبارتند از:

• توابع اشیاء هستند. توابع را می‌توان درون متغیرها ذخیره کرد، یا توابع را به عنوان ورودی و خروجی به توابع دیگر داد. همه چیز در زبان برنامه نویسی پایتون از جنس شی است.

• توابع را می‌توان در داخل توابع دیگر قرار داد. تابع فرزند می‌تواند از وضعیت محلی تابع والد استفاده کند. (lexical closures)

خب، برای انجام ­این کار آماده هستید؟ پس شروع کنیم.


مبانی Decorator های پایتون

حالا، دکوریتورها واقعا چه چیزی هستند؟ دکوریتورها تابع را “تزئین” یا “بسته ­بندی” می­‌کنند و به شما اجازه می‌­دهند تا قبل و بعد از اجرای تابع بسته­‌ بندی شده، کد دلخواهی را اجرا کنید.

Decorator پایتون به شما امکان تعریف بلوک های قابل استفاده مجدد را می­‌دهد تا بتوانید رفتار سایر توابع را تغییر یا گسترش دهید. رفتار تابع فقط هنگام تزئین آن تغییر می‌­کند.

پیاده­ سازی دکوریتور ساده چگونه است؟ به بیان ساده، Decorator پایتون یک تابع قابل فراخوانی است که یک تابع قابل فراخوانی دیگری را به عنوان ورودی دریافت می‌کند و می‌تواند آن را به عنوان خروجی برگرداند.

تابع زیر دارای این ویژگی است و می‌­تواند ساده‌­ترین دکوریتور­ی باشد که می‌­توانید به صورت زیر بنویسید:

def null_decorator(func):
    return func

همانطور که مشاهده می­‌کنید null_decorator یک تابع قابل فراخوانی است، و مقدار قابل فراخوانی func که یک تابع است را به عنوان ورودی دریافت می‌­کند و آن تابع را بدون ایجاد تغییری در خروجی بر می‌گرداند.

اجازه دهید از آن برای تزئین (بسته‌­بندی یا Decorate) تابع دیگری استفاده کنیم.

def greet():
    return 'Hello!'

greet = null_decorator(greet)

>>> greet()
'Hello!'

در این مثال، تابع greet را تعریف کرده ­ام و بعد بلافاصله آن را از طریق تابع null_decorator تزئین کرده ­ام. می‌­دانم که ­این موضوع چندان مفید به نظر نمی‌­رسد. منظورم این است که دکوریتور تهی را به گونه­‌ای خاص طراحی کرده‌­ایم تا بی­‌فایده باشد، درست است؟ ­این مثال نحوه کار سینتکس Decorator پایتون برای حالت ویژه را توضیح خواهد داد.

به جای فراخوانی صریح null_decorator روی greet و جایگزینی شی greet، می­‌توانید از decorator@ پایتون برای تزئین مناسب ­تر و زیباتر تابع استفاده کنید.

@null_decorator
def greet():
    return 'Hello!'

>>> greet()
'Hello!'

قرار دادن خط null_decorator@ در مقابل تعریف تابع، همان چیزی است که به عنوان اولین تعریف تابع ارائه کردیم و بعد از طریق دکوریتور تابع را اجرا کردیم. استفاده از decorator@ فقط فرم دگرگون­ یافته و میانبری برای الگوی پرکاربرد Decorator پایتون است.

توجه داشته باشید که استفاده از decorator@ تابع را بلافاصله در زمان تعریف تزئین می­‌کند. این سبب دسترسی دشوار به نسخه اصلی تزئین نشده می‌شود. بنابراین ممکن است برای حفظ امکان فراخوانی تابع تزئین نشده، گزینه­ های مختلفی را برای تزئین دستی انتخاب کنید (در ادامه در رابطه با این ویژگی توضیحات بیشتری خواهیم داد).


Decorator ها رفتار توابع را اصلاح می‌کنند

حالا که کمی بیشتر ­با سینتکس دکوریتور آشنا شدید، اجازه دهید دکوریتور دیگری بنویسیم که در واقع کاری را انجام می‌دهد و رفتار تابع تزئین شده را اصلاح می­‌کند. در اینجا دکوریتور کمی پیچیده­‌تری وجود دارد که نتیجه تابع تزئین شده را به حروف بزرگ تبدیل می­‌کند.

def uppercase(func):
    def wrapper():
        original_result = func()
        modified_result = original_result.upper()
        return modified_result
    return wrapper

این دکوریتور بجای برگرداندن ورودی تابع مانند دکوریتور تهی، تابع جدیدی را on-the-fly تعریف می‌کند و از آن برای بسته­‌بندی تابع ورودی استفاده می­‌کند تا تابع، رفتار خود را در زمان فراخوانی تغییر دهد. تابع wrapper به تابع ورودی تزئین نشده دسترسی دارد و می‌تواند قطعه کدی را قبل و بعد از فراخوانی تابع ورودی اجرا کند. (به لحاظ فنی، اصلا نیازی به فراخوانی تابع ورودی ندارد)

به این توجه داشته باشید که چرا تابع تزئین شده اجرا نشده است. در واقع، فراخوانی تابع ورودی در این مرحله منطقی نخواهد بود. می­خواهیم وقتی که دکوریتور در نهایت فراخوانی می‌­شود، بتواند رفتار تابع ورودی خود را اصلاح کند.

ممکن است بخواهید یکی دو دقیقه در مورد آن فکر کنید. می­‌دانم که ­این مسئله چقدر می­‌تواند دشوار به نظر برسد، اما قول می­دهم که با کمک یکدیگر این مسئله را حل خواهیم کرد. وقت آن رسیده که در عمل Decorator پایتون که حروف را به حروف بزرگ تبدیل می‌کند را ببینیم. اگر با استفاده از آن تابع اصلی greet را تزئین کنید، چه چیزی رخ خواهد داد؟

@uppercase
def greet():
    return 'Hello!'

>>> greet()
'HELLO!'

امیدوارم این همان نتیجه ­ای که انتظار آن را داشتید، باشد. اجازه دهید نگاهی دقیق ­تر به آنچه که در اینجا اتفاق افتاده، بیاندازیم.

برخلاف null_decorator، دکوریتور upercase@ هنگامی که تابع را تزئین می‌­کند، شیء متفاوتی از تابع را بر می­‌گرداند.

>>> greet
<function greet at 0x10e9f0950>

>>> null_decorator(greet)
<function greet at 0x10e9f0950>

>>> uppercase(greet)
<function uppercase.<locals>.wrapper at 0x76da02f28>

در اینجا نکته ی کوچکی وجود دارد، Decorator پایتون باید این کار را انجام دهد تا در هنگام فراخوانی نهایی، رفتار تابع تزئین شده را اصلاح کند. دکوریتور upercase@ خود نیز تابع است. تنها روش برای اثرگذاری بر “رفتار آتی” تابع ورودی که آن را تزئین می­‌کند، جایگزینی (یا بسته­‌بندی) تابع ورودی است.

به همین دلیل، تابع uppercase تابع دیگری را که بعدا می­توان فراخوانی کرد، را تعریف کرده و برمی­‌گرداند، تابع ورودی اصلی را اجرا کرده و نتیجه آن را اصلاح می­‌کند.

دکوریتورها رفتار تابع قابل فراخوانی را از طریق بستار بسته­ ساز (lexical closure) اصلاح می­‌کنند، پس نباید تابع اصلی را به صورت دائم اصلاح کنند. تابع قابل فراخوانی اصلی به صورت دائمی اصلاح نمی‌­شود بلکه فقط رفتار آن فقط هنگام فراخوانی تغییر می‌کند.

این کار به شما امکان اضافه کردن بلوک های ساختاری قابل استفاده مجدد، مانند ثبت وقایع و گزارش­ گیری و سایر ابزارهای دقیق، به توابع و کلاسهای موجود را می­‌دهد. این کار دکوریتورها را به چنان ویژگی قدرتمندی در پایتون تبدیل می­کند که اغلب در کتابخانه استاندارد و کتابخانه‌های شخص ثالث مورد استفاده قرار می­گیرد.


یک وقفه کوتاه برای درک بهتر Decorator های پایتون

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

لطفا عجله نکنید و نگران فهمیدن جواب مسئله نباشید. تکه کدهای بررسی شده را به صورت جداگانه در یک مفسر پایتون اجرا کنید و نتیجه ی آن را مشاهده کنید تا بهتر درک کنید. می‌­دانم که می­‌توانید این کار را انجام دهید!


استفاده از چند Decorator روی یک تابع

شاید تعجب ­آور نباشد که می­‌توانید بیش از یک Decorator پایتون را روی تابع اعمال کنید. این کار اثرات آنها را انباشته می­‌کند و این همان چیزی است که دکوریتورها را به اندازه بلوک های چندبخشی قابل استفاده مجدد سودمند می­‌کند.

مثالی در این مورد بررسی خواهیم کرد. دو دکوریتور زیر، رشته خروجی تابع تزئین شده با برچسبهای HTML را بسته­ بندی می‌کنند. با نگاهی بر نحوه قرارگیری تو در توی تگ ها، می­‌توان فهمید که Decorator پایتون از چه فرآیندی ­برای اعمال چندین دکوریتور استفاده می‌­کند.

def strong(func):
    def wrapper():
        return '<strong>' + func() + '</strong>'
    return wrapper


def emphasis(func):
    def wrapper():
        return '<em>' + func() + '</em>'
    return wrapper

حال اجازه دهید این دو دکوریتور را انتخاب کنیم و آنها را در تابع greet خود اعمال کنیم. می­‌توانید از syntax@ معمولی برای آن استفاده کنید و چندین دکوریتور را در بالای یک تابع واحد انباشته کنید.

@strong
@emphasis
def greet():
    return 'Hello!'

در صورت اجرای تابع تزیین شده، انتظار دارید کدام ورودی را ببینید؟ آیا دکوریتور emphasis@ ابتدا تگ <em> خود را اضافه خواهد کرد، یا strong@ اولویت دارد؟ هنگامی که تابع تزیین شده را فراخوانی می­‌کنید، چه اتفاقی رخ خواهد داد؟

>>> greet()
'<strong><em>Hello!</em></strong>'

این نتیجه به وضوح نشان می‌­دهد که دکوریتورهای چند گانه چگونه از پایین به بالا عمل کرده‌اند. ابتدا تابع ورودی با دکوریتور emphasis@ بسته ­بندی شد و بعد تابع تزئین شده حاصل با دکوریتور strong@ دوباره بسته­ بندی شد.

برای اینکه رفتار پایین به بالا در حافظه تان باقی بماند میخواهم طریقه اجرای دکوریتورهای چندگانه را در هسته پایتون توضیح دهم. دکوریتورهای پایتون با استفاده از پشته پیاده سازی می‌شود و ما این رفتار را decorator stacking می‌نامیم. اولین پشته را در پایین ایجاد می­‌کنیم و بعد بلوک های کد جدید را از بالا به آن اضافه می­‌کنیم تا اجرای برنامه راه خود را از پایین به سمت بالا پیش ببرد.

اگر مثال بالا را تجزیه کنید و از syntax@ برای اعمال به دکوریتورها خودداری کنید، زنجیره فراخوانی های مربوط به تابع دکوریتور به ­این شکل خواهد بود:

decorated_greet = strong(emphasis(greet))

همانطور که می‌­بینید ابتدا دکوریتور emphasis اعمال می­‌شود و بعد تابع بسته ­بندی شده حاصل دوباره با دکوریتور strong بسته ­بندی می‌­شود.

این همچنین به این معنی است که در نهایت سطوح عمیق پشته­ سازی دکوریتورها بر میزان کارایی اثر برنامه خواهند گذاشت، چون مدام فراخوانی های توابع تو در تو انجام می‌دهند. در عمل، معمولا انجام این کار مشکل نخواهد بود، اما اگر کارایی کد نوشته شده برایتان مهم است باید مرتبه زمانی عملیات decoration را در نظر بگیرید.


 Decoration توابعی که آرگومان‌های گوناگون می‌پذیرند

تاکنون همه نمونه­ ها فقط تابع ساده greet که هیچ آرگومانی ندارد را تزئین کرده ­اند. تا این لحظه، دکوریتورهایی که در این مطلب دیدید لازم نیست که با آرگومان های ارسال به تابع ورودی سر و کار داشته باشند. اگر یکی از این دکوریتورها را به تابعی که آرگومان هایی را می‌گیرد اعمال کنید، کارکرد درستی نخواهد داشت.

چگونه تابعی که آرگومانهای دلخواه می­‌گیرد را تزئین می­‌کنند؟

اینجاست که به ویژگی args* و kwargs** پایتون برای مقابله با تعداد متغیرهای آرگومان احتیاج خواهیم داشت. Decorator پایتون زیر به اسم پراکسی از این مزیت استفاده می­‌کند.

def proxy(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

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

  • با استفاده از عملگرهای * و ** در ورودی تابع wrapper، همه آرگومان های ورودی را جمع ­­آوری می‌­کند و آن‌ها را به صورت شی (args و kwargs) ذخیره می­‌کند.
  • سپس تابع wrapper آرگومان های جمع آوری شده توسط عملکرد * و ** را بازگشایی می‌کند (argument unpacking) و آن را به تابع ورودی ارسال می‌کند.

امیدوارم ایده اصلی مسئله را متوجه شده باشید هرچند می‌دانم کمی نیاز به تمرین با کدها دارید تا ایده اصلی را به خوبی متوجه شوید.

اجازه دهید روش گذاشته شده با دکوریتور پراکسی را در مثالی عملی تر بسط دهیم. در اینجا دکوریتور trace را خواهیم ساخت که آرگومان های ورودی تابع و نتایج حاصل از آن را در طی زمان اجرا ثبت می­کند.

def trace(func):
    def wrapper(*args, **kwargs):
        print(f'TRACE: calling {func.__name__}() '
            f'with {args}, {kwargs}')

        original_result = func(*args, **kwargs)

        print(f'TRACE: {func.__name__}() '
            f'returned {original_result!r}')

        return original_result
    return wrapper

تزئین تابع با تابع trace و بعد فراخوانی آن، آرگومان های منتقل شده به تابع و مقدار برگشتی آن را چاپ خواهد کرد.

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

@trace
def say(name, line):
    return f'{name}: {line}'

>>> say('Jane', 'Hello, World')
'TRACE: calling say() with ("Jane", "Hello, World"), {}' 'TRACE: say() returned "Jane: Hello, World"'
'Jane: Hello, World'

در رابطه با خطایابی صحبت کردیم، مواردی وجود دارند که هنگام خطایابی دکوریتورها باید به خاطر داشته باشید.


مهم‌ترین نکته: نوشتن Decorator های قابل دیباگ!

هنگام استفاده از دکوریتور، در واقع تنها کاری که انجام می­‌دهید جایگزین کردن یک تابع با تابع دیگر است. یک نکته منفی این فرآیند این است که برخی از metadata های تابع اصلی تزئین شده مخفی و جایگزین می‌شوند! این یکی از پنهان‌ترین عملکردهای Decorator پایتون است که ممکن است درنظر گرفته نشود.

برای مثال، نام تابع اصلی، داک‌استرینگ آن و لیست پارامترهای تابع پس از decorate تغییر خواهند کرد! به قطعه کد زیر توجه کنید.

def greet():
    """Return a friendly greeting."""
    return 'Hello!'

decorated_greet = uppercase(greet)

در صورت دسترسی به هریک از metadata های تابع greet پس از decorate شدن، metadata مربوط به دکوریتور را مشاهده خواهید کرد که تغییر کرده‌اند.

>>> greet.__name__
'greet'
>>> greet.__doc__
'Return a friendly greeting.'

>>> decorated_greet.__name__
'wrapper'
>>> decorated_greet.__doc__
None

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

می­‌توانید از دستور functools.wraps در دکوریتورهای خود استفاده کنید تا metadata های مفقود شده را از تابع تزئین نشده به تابع تزئین شده کپی کنید. مثالی را باهم بررسی می‌کنیم.

import functools
def uppercase(func):
    @functools.wraps(func)
    def wrapper():
        return func().upper()
    return wrapper

استفاده از دستور functools.wraps در دکوریتور باعث می‌شود metadata های مختلف هنگام decorate شدن تابع تغییری نکند و مقادیری مانند نام تابع و داک‌استرینگ تابع به صورت دست نخورده باقی بماند. حقه ی جالبیست نه؟

@uppercase
def greet():
    """Return a friendly greeting."""
    return 'Hello!'

>>> greet.__name__
'greet'
>>> greet.__doc__
'Return a friendly greeting.'

به عنوان بهترین روش، توصیه می‌­کنم که در تمام دکوریتورهایی که می­‌نویسید، از دستور functools.wraps استفاده کنید. این کار زیاد طول نمی­‌کشد ولی باعث می‌شود شما و بقیه از دردسرهای خطایابی در دکورتیورها در امان بمانید.

تبریک می­گویم، شما به انتهای این مطلب در رابطه با دکوریتورهای پایتون رسیدید و احتمالا درباره دکوریتورهای موجود در پایتون چیزهای زیادی آموخته ­اید. کارتان عالی بود!


نکات اصلی Decorator های پایتون

  • دکوریتورها، بلوک های کد قابل استفاده مجدد هستند که می‌­توانید برای اصلاح رفتار یک تابع از آن استفاده کنید بدون اینکه نیاز به تغییر دائمی تابع باشد.
  • دستور decorator@ تنها شکل کوتاه ­نویسی شده برای فراخوانی دکوریتور روی تابع ورودی است. دکوریتورهای متعدد روی تابع واحد از پایین به بالا اعمال می­شوند (decorator stacking را به خاطر داشته باشید).
  • به عنوان بهترین شیوه خطایابی، از دستور کمک کننده functools.wraps روی دکوریتورهای خود استفاده کنید تا metadata های بیشتری را از تابع قابل فراخوانی تزئین نشده (undecorated) به تابع قابل فراخوانی تزئین شده (decorated) منتقل کنید.
  • دکوریتورها درست مانند هر ابزار دیگری در جعبه ابزار توسعه نرم ­افزار، نوشدارو یا علاج قطعی نیستند و نباید از آنها بیش از حد استفاده کرد. متعادل­ سازی نیازها بر حسب “اولویت انجام کارها” با هدف “گرفتار نشدن در آشفتگی وحشتناک و غیرقابل نگهداری کد منبع” بسیار اهمیت دارد.
محمد بابازاده
متخصص DevOps و توسعه دهنده Python