Python Decorators: إزاي تضيف سلوك لأي دالة بدون ما تلمسها
لو لقيت نفسك بتنسخ نفس الـ 5 سطور بتاعة الـ logging في 20 فانكشن، الـ decorators هتخلّيك تكتبها مرة واحدة بس. نفس المبدأ بيشتغل على caching وauthorization وقياس الزمن. الكلام مش نظري: هتلاقي في المقال decorator جاهز تنسخه تشغّله دلوقتي.
المشكلة باختصار
عندك 15 دالة، كل واحدة محتاجة تطبع اسمها وقت الاستدعاء ومدّة التنفيذ. لو حطيت نفس الـ time.perf_counter() والـ log.info في كل واحدة، خلقت 15 نقطة تعديل. لو حبيت تغيّر format الـ log بكرة، هتفتح 15 ملف. Decorator واحد بيلف الدالة ويحقن السلوك ده مرة واحدة بس من غير ما تمس جسم الدالة.
الـ Decorator ببساطة: مفيش سحر
الـ decorator ببساطة دالة بتاخد دالة، وترجّع نسخة ملفوفة منها. Python بيسهّل الصياغة بعلامة @ فوق تعريف الدالة. ركز: ده مجرد higher-order function، مش feature غامض.
def my_decorator(func):
def wrapper(*args, **kwargs):
# قبل الاستدعاء
result = func(*args, **kwargs)
# بعد الاستدعاء
return result
return wrapper
@my_decorator
def greet(name):
return f"Hello {name}"مثال تنفيذي: Logging وقياس الزمن
ده decorator جاهز تنسخه. بيسجّل اسم الدالة، arguments، والمدة بالميللي ثانية حتى لو الدالة رمت exception.
import functools
import time
import logging
logging.basicConfig(level=logging.INFO)
log = logging.getLogger(__name__)
def log_and_time(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed_ms = (time.perf_counter() - start) * 1000
log.info(
"call func=%s args=%s kwargs=%s elapsed_ms=%.2f",
func.__name__, args, kwargs, elapsed_ms,
)
return wrapper
@log_and_time
def fetch_user(user_id: int):
time.sleep(0.05)
return {"id": user_id, "name": "Ahmed"}السطر @functools.wraps(func) مش اختياري. بدونه fetch_user.__name__ هيرجع "wrapper" بدل الاسم الأصلي، وده بيكسر أي monitoring بيعتمد على اسم الدالة زي Sentry و OpenTelemetry.
Caching جاهز من المكتبة: lru_cache
Python جاي ومعاه decorator جاهز للـ memoization اسمه functools.lru_cache. بيحفظ نتيجة كل استدعاء حسب الـ arguments، فلو نديت نفس الدالة بنفس الـ input تاني، بيرجّع من الكاش بدون ما يشغّل الجسم.
from functools import lru_cache
@lru_cache(maxsize=256)
def fib(n: int) -> int:
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
# قياس فعلي على MacBook M1 / Python 3.12:
# fib(35) بدون cache ≈ 2800 ms
# fib(35) مع cache ≈ 0.3 msالمكسب: تسريع تقريبًا × 9000 على المثال ده. التكلفة: استهلاك ذاكرة بيزيد مع كل نتيجة محفوظة، لحد maxsize. الافتراض إن الـ input hashable؛ لو بتبعت list أو dict، هيرمي TypeError.
الـ Trade-offs اللي لازم تعرفها
- Stack traces أطول. كل decorator بيضيف frame، والـ exceptions بتبقى أصعب قراءة شوية.
- Debugging أبطأ. الـ breakpoint جوا الدالة الأصلية ممكن يتعدى لو الـ wrapper بيعمل early return.
- Overhead. كل استدعاء بيمر على الـ wrapper. في دالة بتتندّه ملايين المرات في الثانية، ده بيفرق. قيس قبل ما تقرر.
- Thread safety.
lru_cachethread-safe بشكل افتراضي، لكن decorator مكتوب بإيدك مش بالضرورة.
متى لا تستخدم هذه الطريقة
لو السلوك اللي عايز تضيفه محتاج يوصل لـ self بتاع الكلاس بشكل معقّد، غالبًا descriptor أو mixin أنضف من decorator. ولو الدالة بترجع generator أو async، الـ wrapper لازم يتعامل مع ده بشكل صريح (async def wrapper مع await). الافتراض الضمني فوق إن الكود synchronous عادي.
تحذير مهم: ممنوع تستخدم lru_cache على دالة بترجع object قابل للتعديل (mutable) زي dict أو list. لو حد عدّل الـ dict المرجَع، النسخة المحفوظة نفسها بتتغير، وأي استدعاء بعد كده بيرجع النسخة المعدّلة. ده bug بطيء الاكتشاف جدًا في production.
الخطوة التالية
افتح ملف Python فيه أكتر من دالة بتنسخ فيها نفس الـ logging أو الـ timing. انسخ log_and_time من فوق، شيل الكود المكرر من جسم الدوال، وشغّل. لو شفت في الـ logs سطر elapsed_ms على كل call من غير ما تعدّل الجسم، يبقى الـ decorator مركّب صح.
المصادر
- Python Docs — Glossary: Decorator — https://docs.python.org/3/glossary.html#term-decorator
- Python Docs — functools (lru_cache, wraps) — https://docs.python.org/3/library/functools.html
- PEP 318 — Decorators for Functions and Methods — https://peps.python.org/pep-0318/
- Real Python — Primer on Python Decorators — https://realpython.com/primer-on-python-decorators/