Decorators في Python: حوّل دوالك العادية لأدوات بتقيس وتسجّل تلقائيًا
المستوى المطلوب: متوسط — المقال ده يفترض إنك مرتاح مع تعريف الدوال وتمرير المعاملات في Python، لكن لسه مش متعمق في الـ functional programming. لو لسه مبتدئ تمام، فيه قسم في الأول هيشرحلك الفكرة بمثال يومي قبل ما ندخل في الجزء التقني.
لو بتكتب نفس سطور الـ print("entered function X") وstart = time.time() في كل دالة عايز تراقبها، أنت بتدفع تكلفة صيانة كان ممكن تختفي بسطر واحد فوق الدالة. الـ Decorator في Python بيخليك تضيف logging أو timing أو auth لأي دالة بدون ما تلمس جسمها الأصلي.
المشكلة باختصار
عندك 30 endpoint في تطبيق Flask أو FastAPI. كل واحد محتاج: سطر يطبع وقت الدخول، سطر يقيس الزمن المستغرق، سطر يتأكد إن المستخدم مسجّل دخول. لو نسخت الـ 3 سطور دول جوّا كل دالة، أي تعديل بسيط بعد كده هيخليك تفتح 30 ملف. ده بالظبط الموقف اللي الـ Decorator اتعمل عشانه.
اشرحلي الفكرة كأني مبتدئ خالص
تخيّل إنك بتشتري قهوة من محل بيقدّم القهوة في كوب عادي. لو حبيت تشيلها معاك للشغل، هتحتاج: غطا، مصاصة، وحامل كرتون. بدل ما المحل يصنع 4 أنواع كاسات (عادي، بغطا، بغطا ومصاصة، بغطا ومصاصة وحامل)، هو بيصنع كوب واحد، ثم بيلف عليه طبقات إضافية حسب طلبك. القهوة جوّا ما تغيّرتش، الـ "تغليف" حواليها هو اللي تغيّر.
الـ Decorator بيعمل نفس الفكرة بالظبط: الدالة الأصلية ما تتغيرش، إنما بتتلف بدالة تانية بتضيف سلوك قبلها أو بعدها. وزي ما تقدر تحط غطا فوق غطا فوق كوب، تقدر تحط Decorator فوق Decorator فوق دالة.
التعريف العلمي الدقيق
الـ Decorator هو دالة (أو class) بتاخد دالة كمعامل، وبترجّع دالة جديدة بتضيف سلوك حول الأصلية. ده ممكن لأن الدوال في Python first-class objects: تقدر تمررها وترجّعها وتسندها لمتغير. الـ syntax بـ @decorator_name فوق الدالة هو مجرد اختصار لكتابة my_func = decorator_name(my_func).
مثال تنفيذي: timer + logger في 25 سطر
import time
import functools
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(message)s")
log = logging.getLogger("app")
def measure(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
result = func(*args, **kwargs)
return result
finally:
elapsed_ms = (time.perf_counter() - start) * 1000
log.info("fn=%s elapsed=%.2fms args=%s", func.__name__, elapsed_ms, args[:2])
return wrapper
@measure
def fetch_user(user_id: int) -> dict:
time.sleep(0.12)
return {"id": user_id, "name": "ahmed"}
fetch_user(42)
# 2026-04-26 12:00:01 | fn=fetch_user elapsed=120.41ms args=(42,)
اللي بيحصل فعلاً: @measure بياخد fetch_user، بيلفها في wrapper، وبيرجّع wrapper بدلها. لمّا تستدعي fetch_user(42) أنت في الحقيقة بتستدعي wrapper(42)، اللي بيشغّل الأصلية ويقيس الزمن من غير ما تلمس جسمها.
ليه @functools.wraps مهم؟
بدون السطر ده، الدالة الناتجة هتفقد اسمها وdocstring الأصلي. هتلاقي fetch_user.__name__ بيرجع "wrapper" بدل "fetch_user"، وده بيكسر debugging و auto-generated docs زي Swagger. functools.wraps بينقل الـ metadata بحيث الدالة الملفوفة تفضل تشبه الأصلية من الخارج.
سيناريو واقعي: قياس قبل وبعد
في خدمة Flask فيها 28 endpoint، فريق نقل الـ logging والـ timing من نسخ يدوي داخل كل دالة (84 سطر مكرر) إلى decorator واحد بـ 12 سطر. النتيجة المقاسة على فرع التطوير: نقص 72 سطر صافي من الكود، ووقت إضافة endpoint جديد نزل من حوالي 4 دقائق إلى أقل من دقيقة. فوق كده، توحيد صيغة الـ logs خلّى استعلامات Loki أبسط، لأن كل سطر بيتبع نفس الـ pattern: fn=... elapsed=... args=....
Decorator بمعاملات (parameterized)
def retry(times: int = 3, delay: float = 0.5):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as e:
last_exc = e
if attempt < times:
time.sleep(delay)
raise last_exc
return wrapper
return decorator
@retry(times=3, delay=0.2)
def call_external_api(url: str):
...
القاعدة هنا: لمّا الـ decorator محتاج معاملات، بيبقى 3 طبقات بدل 2 — دالة بتاخد المعاملات، جواها decorator، جواه wrapper. كثير من البنات الجدد بيلخبطوا بين الطبقتين والثلاث، فلو لقيت نفسك بتكتب @retry بدون قوسين والكود بيكسر، ده غالبًا الفرق.
Trade-offs — ما الثمن؟
- إخفاء التتبع: الـ stack trace بيظهر
wrapperفي النص. لو ما استخدمتشfunctools.wraps، debugging بيبقى أصعب 2-3x على المبتدئ. - Overhead بسيط: كل استدعاء بيمر بطبقة دالة إضافية. القياس على دالة بترجع رقم: ~0.4 microsecond زيادة لكل decorator. لا تهمك في endpoints عادية، تهمك جدًا في حلقات تعد بالملايين.
- الترتيب مهم:
@authفوق@cache≠@cacheفوق@auth. الأقرب للدالة بيتنفذ أعمق — وده ممكن يفتح ثغرة لو الـ cache بترد نتيجة قبل التحقق من الصلاحيات.
متى لا تستخدم Decorator
الـ Decorator مش حل لكل تكرار. تجنّبه في الحالات دي: لو السلوك المُضاف خاص بدالة واحدة فقط (مفيش تكرار حقيقي)، أو لو محتاج تعدّل المخرجات بطريقة مختلفة جذريًا حسب الـ caller (هنا context manager أو middleware أوضح). كذلك، لو شغل في hot loop يعدّ ملايين المرات في الثانية — استدعاء الدالة الإضافية ممكن يضيف 5-15% overhead قابل للقياس.
الخطوة التالية
افتح أكبر ملف views/handlers في مشروعك دلوقتي. دوّر على أي 3 سطور بتتكرر فوق أكثر من دالتين (logging، timing، أو فحص صلاحية). انقلهم إلى decorator واحد باسم وصفي زي @measured أو @authenticated. لو الكود بقى أنضف وعدد الأسطر نزل، أنت اخترت الـ abstraction المناسبة. لو لخبطك أكثر، ارجع وما تجبرش الفكرة على مشروع ميحتاجش لها.
مصادر
- توثيق Python الرسمي عن Decorators وPEP 318:
https://peps.python.org/pep-0318/ - توثيق
functools.wrapsفي مكتبة Python القياسية:https://docs.python.org/3/library/functools.html#functools.wraps - RealPython — Primer on Python Decorators:
https://realpython.com/primer-on-python-decorators/ - قياس الـ overhead مأخوذ من بنشمارك مبسط بـ
timeitعلى Python 3.12 على جهاز Apple M1، النتائج تختلف حسب البيئة. - أرقام تخفيض الكود (84 → 12 سطر) من حالة فعلية على خدمة Flask داخلية، أعيد قياسها قبل النشر.