Decorators — להוסיף התנהגות לפונקציה בלי לגעת בה

מטרות השיעור
  • להסביר מה זה Decorator ואיך הוא בנוי מ-Higher-Order Function ומ-Closure
  • לכתוב Decorator עם פונקציית wrapper ולהחיל אותו עם @
  • לכתוב wrapper שמתאים לכל פונקציה בעזרת *args ו-**kwargs
  • לזהות את שתי הטעויות הנפוצות — wrapper בלי return, ו-return wrapper() עם סוגריים

מה נלמד?

  • מה זה Decorator (מעטר) ולמה הוא שימושי.
  • איך בונים Decorator: פונקציה שמקבלת פונקציה ומחזירה wrapper.
  • התחביר @.
  • wrapper שמתאים לכל פונקציה, עם *args ו-**kwargs.

למה צריך את זה?

רוצים להדפיס הודעה בכל פעם שקוראים לפונקציה — לצורכי דיבאג:

def add(a, b):
    print("Calling add")
    return a + b


def multiply(a, b):
    print("Calling multiply")
    return a * b

הבעיות:

  • אותה שורה מועתקת לכל פונקציה — שוב הכפילות ש-DRY אוסר.
  • הלוגיקה מתערבבת עם קוד עזר שאין לו קשר אליה.
  • כדי להסיר את ההדפסות, צריך לעבור על כל הפונקציות ולמחוק.

Decorator מאפשר לכתוב את ההתנהגות הנוספת פעם אחת, ולהלביש אותה על כל פונקציה בשורה אחת.

הסבר מהבסיס

ממה Decorator בנוי

Decorator משלב שני דברים שלמדנו בשיעורים הקודמים:

  • Higher-Order Function — הוא מקבל פונקציה ומחזיר פונקציה.
  • Closure — הפונקציה שהוא מחזיר זוכרת את הפונקציה המקורית.

המבנה תמיד אותו מבנה:

  1. ה-Decorator מקבל את הפונקציה המקורית (func).
  2. בתוכו מגדירים פונקציה חדשה — wrapper (עטיפה) — שעושה משהו נוסף, וקוראת ל-func.
  3. ה-Decorator מחזיר את wrapper — בלי סוגריים.

התחביר @

@log_call
def add(a, b):
    return a + b

זה בדיוק אותו דבר כמו לכתוב אחרי ההגדרה:

add = log_call(add)

כלומר: השם add מצביע עכשיו על ה-wrapper, וה-wrapper קורא ל-add המקורית.

אנלוגיה: עטיפת מתנה

המתנה (הפונקציה) לא משתנה. עוטפים אותה בנייר (wrapper) — ומי שמקבל אותה פוגש קודם את העטיפה, ורק אחר כך את המתנה. אפשר לעטוף כל מתנה באותו סוג נייר, ואפשר להוריד את העטיפה בלי לפגוע במתנה.

תחביר

def my_decorator(func):              # 1. receive the original function
    def wrapper(*args, **kwargs):    # 2. a new function that wraps it
        # ... something before ...
        result = func(*args, **kwargs)
        # ... something after ...
        return result                # return the original result
    return wrapper                   # 3. return the wrapper (no parentheses)


@my_decorator                        # same as: my_function = my_decorator(my_function)
def my_function():
    ...

דוגמת קוד ראשונה

week-2/log_call.py:

def log_call(func):
    def wrapper(a, b):
        print("Calling", func.__name__)
        return func(a, b)
    return wrapper


@log_call
def add(a, b):
    return a + b


@log_call
def multiply(a, b):
    return a * b


print(add(2, 3))
print(multiply(4, 5))

פלט:

Calling add
5
Calling multiply
20

פירוק הקוד שורה אחר שורה

שורההסבר
def log_call(func):ה-Decorator מקבל את הפונקציה המקורית.
def wrapper(a, b):העטיפה — מקבלת את אותם פרמטרים כמו המקורית.
print("Calling", func.__name__)ההתנהגות הנוספת. __name__ הוא השם של הפונקציה.
return func(a, b)קוראים למקורית, ומחזירים את התוצאה שלה.
return wrapperה-Decorator מחזיר את העטיפה — בלי סוגריים.
@log_calladd = log_call(add). מעכשיו add(2, 3) מפעיל את ה-wrapper.

דוגמה נוספת

wrapper שמתאים לכל פונקציה. ה-wrapper הקודם עובד רק עם פונקציות שמקבלות בדיוק a, b. עם *args ו-**kwargs הוא מקבל כל פרמטר ומעביר אותו הלאה. week-2/log_any.py:

def log_call(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__, "with", args, kwargs)
        return func(*args, **kwargs)
    return wrapper


@log_call
def greet(name, greeting="Hello"):
    return greeting + ", " + name


@log_call
def total(*prices):
    return sum(prices)


print(greet("Dana", greeting="Hi"))
print(total(10, 20, 30))

פלט:

Calling greet with ('Dana',) {'greeting': 'Hi'}
Hi, Dana
Calling total with (10, 20, 30) {}
60

דוגמה שימושית: מדידת זמן. week-2/timed.py:

import time


def timed(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        seconds = round(time.time() - start, 2)
        print(func.__name__, "took", seconds, "seconds")
        return result
    return wrapper


@timed
def slow_sum(n):
    time.sleep(0.5)
    return sum(range(n))


print(slow_sum(1000))

פלט:

slow_sum took 0.5 seconds
499500

(הזמן המדויק משתנה מעט בין הרצות.) את אותו @timed אפשר להלביש על כל פונקציה בתוכנית — בלי לשנות שורה אחת בתוכה.

טעות נפוצה

שתי טעויות שקורות כמעט לכל מי שכותב Decorator ראשון:

def log_call(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        func(*args, **kwargs)
    return wrapper()
  1. אין return בתוך wrapper. הפונקציה המקורית רצה, אבל התוצאה שלה הולכת לאיבוד — והפונקציה המעוטרת תמיד מחזירה None. התיקון: return func(*args, **kwargs).
  2. return wrapper() עם סוגריים. זה קורא ל-wrapper כבר בזמן ההגדרה, ומחזיר את התוצאה שלו במקום את הפונקציה. התיקון: return wrapper.

הכלל: ה-wrapper מחזיר את התוצאה, וה-Decorator מחזיר את ה-wrapper.

תרגול

התרגילים מבוססים רק על מה שנלמד בשיעור הזה ובשיעורים שלפניו. נסו לבד, ורק אז פתחו את הפתרון.

תרגיל 1: Decorator ראשון

כתבו Decorator בשם log_call שמדפיס Calling <name> לפני כל קריאה לפונקציה, והחילו אותו עם @ על add ועל multiply.

print(add(2, 3))
print(multiply(4, 5))

פלט צפוי:

Calling add
5
Calling multiply
20
הצג פתרון
def log_call(func):
    def wrapper(a, b):
        print("Calling", func.__name__)
        return func(a, b)
    return wrapper


@log_call
def add(a, b):
    return a + b


@log_call
def multiply(a, b):
    return a * b


print(add(2, 3))
print(multiply(4, 5))
Calling add
5
Calling multiply
20

@log_call מעל def add שקול ל-add = log_call(add). מעכשיו add מצביע על ה-wrapper.

תרגיל 2: wrapper שמתאים לכל פונקציה

ה-wrapper הקודם עובד רק עם פונקציות שמקבלות בדיוק שני פרמטרים. שכתבו את log_call כך שיעבוד עם כל פונקציה.

@log_call
def greet(name, greeting="Hello"):
    return greeting + ", " + name

@log_call
def total(*prices):
    return sum(prices)

print(greet("Dana", greeting="Hi"))
print(total(10, 20, 30))

פלט צפוי:

Calling greet
Hi, Dana
Calling total
60
הצג פתרון
def log_call(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        return func(*args, **kwargs)
    return wrapper


@log_call
def greet(name, greeting="Hello"):
    return greeting + ", " + name


@log_call
def total(*prices):
    return sum(prices)


print(greet("Dana", greeting="Hi"))
print(total(10, 20, 30))
Calling greet
Hi, Dana
Calling total
60

*args ו-**kwargs אוספים את כל הארגומנטים, ו-func(*args, **kwargs) מעביר אותם הלאה כמו שהם.

תרגיל 3: בדיקת קלט

כתבו Decorator בשם positive_only לפונקציות שמקבלות מספר אחד: אם המספר שלילי, הוא זורק ValueError ולא קורא לפונקציה בכלל. ההודעה כוללת את שם הפונקציה (func.__name__) ואת המספר שהתקבל, כמו בפלט הצפוי.

@positive_only
def square_root(n):
    return n ** 0.5

print(square_root(16))
try:
    square_root(-4)
except ValueError as error:
    print("error:", error)

פלט צפוי:

4.0
error: square_root needs a positive number, got -4
הצג פתרון
def positive_only(func):
    def wrapper(n):
        if n < 0:
            raise ValueError(func.__name__ + " needs a positive number, got " + str(n))
        return func(n)
    return wrapper


@positive_only
def square_root(n):
    return n ** 0.5


print(square_root(16))
try:
    square_root(-4)
except ValueError as error:
    print("error:", error)
4.0
error: square_root needs a positive number, got -4

הבדיקה קורית לפני הקריאה ל-func — אם הקלט לא תקין, הפונקציה המקורית לא רצה בכלל.

תרגיל 4: ספירת קריאות

כתבו Decorator בשם count_calls, שסופר כמה פעמים קראו לפונקציה. בכל קריאה, לפני שהפונקציה רצה, הוא מדפיס את שם הפונקציה ואת מספר הקריאה — כמו בפלט הצפוי.

@count_calls
def say_hi():
    print("Hi")

say_hi()
say_hi()

פלט צפוי:

say_hi call #1
Hi
say_hi call #2
Hi
הצג פתרון
def count_calls(func):
    count = 0

    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        print(func.__name__, "call #" + str(count))
        return func(*args, **kwargs)

    return wrapper


@count_calls
def say_hi():
    print("Hi")


say_hi()
say_hi()
say_hi call #1
Hi
say_hi call #2
Hi

count שייך ל-count_calls, וה-wrapper זוכר אותו (Closure). כי הוא משנה את count, צריך nonlocal.

תרגיל 5: שתי טעויות

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

ה-Decorator אמור להחזיר את התוצאה של הפונקציה באותיות גדולות. מצאו שתי טעויות ותקנו.

@shout
def greet(name):
    return "hello " + name

print(greet("dana"))

פלט צפוי:

HELLO DANA
הצג פתרון
  1. return wrapper() עם סוגריים — קורא ל-wrapper כבר בזמן ההגדרה, במקום להחזיר אותו. צריך return wrapper.
  2. ה-wrapper לא מחזיר כלום — result.upper() יוצר מחרוזת חדשה, אבל היא לא מוחזרת. צריך return result.upper().
def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper


@shout
def greet(name):
    return "hello " + name


print(greet("dana"))
HELLO DANA

הכלל: ה-wrapper מחזיר את התוצאה, וה-Decorator מחזיר את ה-wrapper.

סיכום

  • Decorator = פונקציה שמקבלת פונקציה, עוטפת אותה ב-wrapper עם התנהגות נוספת, ומחזירה את ה-wrapper.
  • הוא בנוי מ-Higher-Order Function ומ-Closure — שני הנושאים של השיעורים הקודמים.
  • @decorator מעל def = func = decorator(func).
  • עם *args ו-**kwargs, ה-wrapper מתאים לכל פונקציה.
  • ה-wrapper מחזיר את התוצאה (return func(...)), וה-Decorator מחזיר את ה-wrapper (return wrapper, בלי סוגריים).

דוקומנטציה רשמית