Nested Functions, Closures ו-nonlocal

מטרות השיעור
  • להגדיר פונקציה בתוך פונקציה ולהסביר מה היא רואה מה-Enclosing Scope
  • להסביר מה זה Closure — פונקציה שזוכרת משתנים גם אחרי שהפונקציה החיצונית הסתיימה
  • להבין למה שינוי משתנה חיצוני גורם ל-UnboundLocalError, ולתקן עם nonlocal
  • להבחין בין global ל-nonlocal

מה נלמד?

  • Nested Function — פונקציה שמוגדרת בתוך פונקציה.
  • Enclosing — ה-E של LEGB, מקרוב.
  • Closure — פונקציה ש"זוכרת" משתנים.
  • nonlocal — איך משנים משתנה של הפונקציה העוטפת.

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

  • פונקציית עזר פרטית: לפעמים פונקציה צריכה פונקציית עזר קטנה ששייכת רק לה. אין סיבה שהיא תהיה גלובלית ותתערבב עם שאר הקוד.
  • פונקציות שזוכרות מצב: בשיעור הקודם ראינו ש-make_multiplier(2) מחזירה פונקציה ש"זוכרת" את המספר 2. היום נבין איך זה עובד — ואיך בונים מונה, מצבר או מטמון בלי global.

הסבר מהבסיס

פונקציה בתוך פונקציה

אפשר להגדיר def בתוך def. הפונקציה הפנימית:

  • קיימת רק בתוך הפונקציה החיצונית — מבחוץ אי אפשר לקרוא לה.
  • רואה את המשתנים והפרמטרים של הפונקציה החיצונית.

Enclosing — ה-E של LEGB

בשיעור על Scope למדנו את סדר החיפוש L → E → G → B. כשפונקציה פנימית משתמשת בשם, Python מחפשת:

L  Local       → inside the inner function
E  Enclosing   → inside the outer function
G  Global      → top level of the file
B  Built-in    → print, len, ...

Closure — פונקציה שזוכרת

כשפונקציה חיצונית מחזירה את הפונקציה הפנימית, משהו מעניין קורה: הפונקציה החיצונית כבר הסתיימה, אבל הפנימית עדיין זוכרת את המשתנים שלה. הצירוף הזה — פונקציה + המשתנים שהיא זוכרת — נקרא Closure.

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

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

nonlocal

לקרוא משתנה מה-Enclosing — מותר תמיד. לשנות אותו (להשים לו ערך חדש) — אי אפשר בלי הכרזה, בדיוק כמו עם גלובלי. ההכרזה היא nonlocal name: “המשתנה הזה שייך לפונקציה העוטפת”.

הכרזההמשתנה שייך ל…
global xרמת הקובץ
nonlocal xהפונקציה העוטפת הקרובה

תחביר

def outer():
    count = 0                 # enclosing variable

    def inner():
        nonlocal count        # assignments go to outer's count
        count += 1
        return count

    return inner              # return the function itself (no parentheses)

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

פונקציית עזר פרטית. week-2/receipt.py:

def format_receipt(items):
    def line(name, price):
        return name.ljust(10) + str(price)

    lines = []
    for name, price in items:
        lines.append(line(name, price))
    return "\n".join(lines)


print(format_receipt([("Milk", 6), ("Bread", 9)]))

פלט:

Milk      6
Bread     9

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

שורההסבר
def line(name, price):פונקציה פנימית — קיימת רק בתוך format_receipt.
name.ljust(10)מרפד את השם ברווחים עד 10 תווים, כדי שהמחירים יעמדו בטור.
lines.append(line(name, price))הפונקציה החיצונית משתמשת בפונקציית העזר שלה.
"\n".join(lines)מחבר את כל השורות לטקסט אחד, עם ירידת שורה ביניהן.

דוגמה נוספת

Closure — פונקציה שזוכרת. week-2/greeter.py:

def make_greeter(greeting):
    def greet(name):
        return greeting + ", " + name
    return greet


hello = make_greeter("Hello")
shalom = make_greeter("Shalom")
print(hello("Dana"))
print(shalom("Avi"))

פלט:

Hello, Dana
Shalom, Avi

make_greeter הסתיימה כבר בשורה hello = ..., אבל hello עדיין זוכרת ש-greeting הוא "Hello". לכל פונקציה שנוצרה יש עותק משלה של המשתנה — shalom זוכרת "Shalom".

מונה עם nonlocal. week-2/counter.py:

def make_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    return increment


counter = make_counter()
print(counter(), counter(), counter())

other = make_counter()
print(other())

פלט:

1 2 3
1

כל מונה זוכר את ה-count שלו: other מתחיל מאפס, בלי קשר ל-counter. זה מצב פרטי — בלי משתנה גלובלי שכל הקוד יכול לשנות.

טעות נפוצה

הטעות: לשנות משתנה של הפונקציה העוטפת בלי nonlocal.

def make_counter():
    count = 0

    def increment():
        count += 1
        return count

    return increment


counter = make_counter()
counter()
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

למה? בגלל ההשמה count += 1, Python מחליטה ש-count הוא משתנה מקומי של increment — ואז מנסה לקרוא מקומי שעוד אין לו ערך. זו בדיוק אותה שגיאה שראינו עם גלובליים בשיעור על Scope.

התיקון: nonlocal count בתחילת increment.

תרגול

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

תרגיל 1: פונקציית עזר פנימית

כתבו פונקציה format_receipt(items) שמקבלת רשימה של זוגות (שם, מחיר) ומחזירה טקסט של קבלה. בתוכה הגדירו פונקציה פנימית line(name, price) שמרפדת את השם ל-10 תווים (ljust).

print(format_receipt([("Milk", 6), ("Bread", 9)]))

פלט צפוי:

Milk      6
Bread     9
הצג פתרון
def format_receipt(items):
    def line(name, price):
        return name.ljust(10) + str(price)

    lines = []
    for name, price in items:
        lines.append(line(name, price))
    return "\n".join(lines)


print(format_receipt([("Milk", 6), ("Bread", 9)]))
Milk      6
Bread     9

line קיימת רק בתוך format_receipt — היא פונקציית עזר פרטית, ולא מתערבבת עם שאר הקוד.

תרגיל 2: Closure

כתבו make_greeter(greeting) שמחזירה פונקציה שמקבלת שם ומחזירה ברכה.

hello = make_greeter("Hello")
shalom = make_greeter("Shalom")
print(hello("Dana"))
print(shalom("Avi"))

פלט צפוי:

Hello, Dana
Shalom, Avi
הצג פתרון
def make_greeter(greeting):
    def greet(name):
        return greeting + ", " + name
    return greet


hello = make_greeter("Hello")
shalom = make_greeter("Shalom")
print(hello("Dana"))
print(shalom("Avi"))
Hello, Dana
Shalom, Avi

make_greeter כבר הסתיימה, אבל כל פונקציה שהיא החזירה זוכרת את ה-greeting שלה. זה Closure.

תרגיל 3: חזו את הפלט

def outer():
    message = "outer"

    def show():
        print("show:", message)

    def change():
        nonlocal message
        message = "changed"

    show()
    change()
    show()

outer()
הצג פתרון
def outer():
    message = "outer"

    def show():
        print("show:", message)

    def change():
        nonlocal message
        message = "changed"

    show()
    change()
    show()


outer()
show: outer
show: changed

change משתמשת ב-nonlocal, ולכן ההשמה משנה את ה-message של outer — היא לא יוצרת משתנה מקומי חדש.

תרגיל 4: מונה עם צעד

כתבו make_counter(step) — כל קריאה למונה מגדילה אותו ב-step ומחזירה את הערך החדש. צרו שני מונים ובדקו שהם לא משפיעים זה על זה.

by_two = make_counter(2)
by_ten = make_counter(10)
print(by_two(), by_two())
print(by_ten())
print(by_two())

פלט צפוי:

2 4
10
6
הצג פתרון
def make_counter(step):
    count = 0

    def increment():
        nonlocal count
        count += step
        return count

    return increment


by_two = make_counter(2)
by_ten = make_counter(10)
print(by_two(), by_two())
print(by_ten())
print(by_two())
2 4
10
6
  • step רק נקרא בתוך increment, ולכן לא צריך עבורו הכרזה.
  • count משתנה, ולכן צריך nonlocal count.

תרגיל 5: בלי nonlocal

def make_counter():
    count = 0
    def increment():
        count += 1
        return count
    return increment

counter = make_counter()
counter()
  1. איזו שגיאה תתקבל, ולמה?
  2. מה ההבדל בין global ל-nonlocal?
הצג פתרון
  1. UnboundLocalError. בגלל ההשמה count += 1, Python מחליטה ש-count הוא משתנה מקומי של increment — ואין לו ערך כשמנסים להגדיל אותו. התיקון: nonlocal count בתחילת increment.
  2. global — המשתנה שייך לרמת הקובץ. nonlocal — המשתנה שייך לפונקציה העוטפת הקרובה. Closure עם nonlocal נותן מצב פרטי לכל מונה, בלי משתנה גלובלי שכל הקוד יכול לשנות.

סיכום

  • Nested Function — פונקציה בתוך פונקציה. קיימת רק בפנים, ורואה את המשתנים של העוטפת (Enclosing).
  • Closure — פונקציה פנימית שהוחזרה, וזוכרת את המשתנים של הפונקציה שיצרה אותה. לכל אחת עותק משלה.
  • לקרוא משתנה מה-Enclosing — מותר. לשנות אותו — רק עם nonlocal, אחרת UnboundLocalError.
  • global — משתנה ברמת הקובץ. nonlocal — משתנה בפונקציה העוטפת.
  • Closure נותן מצב פרטי בלי משתנה גלובלי. ועדיין — כשאפשר, עדיף פרמטר נכנס ו-return יוצא.

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