Smart-World Surf

יחידה 5: פונקציות מסדר גבוה וסגורים

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

ברוכים הבאים ליחידה "פונקציות מסדר גבוה וסגורים" בקורס "שפות תכנות" (20905). יחידה זו היא אבן יסוד בהבנת פרדיגמות תכנות פונקציונליות וכיצד מפרשים (Interpreters) מטפלים בפונקציות. נחקור כיצד פונקציות הופכות ל"אזרח סוג א'", את הרעיון העוצמתי של סגורים, וכיצד מנגנונים אלו ממומשים בליבת מפרש שפה.

פונקציות כאזרח סוג א'

בשפות תכנות רבות, פונקציות הן ישויות מיוחדות. אך בשפות פונקציונליות ובשפות מודרניות רבות, פונקציות נחשבות ל"אזרח סוג א'". מה זה אומר?

אזרח סוג א' (First-Class Citizen): ישות תכנותית הנחשבת לבעלת מעמד שווה לנתונים בסיסיים אחרים (כמו מספרים או מחרוזות). היא יכולה להיות מוקצה למשתנים, להיות מועברת כארגומנט לפונקציות אחרות, ומוחזרת כערך מפונקציות.

פונקציות מסדר גבוה (Higher-Order Functions - HOFs)

היכולת להתייחס לפונקציות כאזרח סוג א' מאפשרת את קיומן של פונקציות מסדר גבוה.

פונקציות מסדר גבוה (Higher-Order Functions): פונקציות שמקבלות פונקציות אחרות כארגומנטים, או מחזירות פונקציות כערך, או שתיהן יחד.
  • העברת פונקציות כארגומנטים: דוגמאות קלאסיות הן פונקציות כמו map, filter, fold (או reduce) בשפות כמו Scheme/Racket. פונקציות אלו מקבלות פונקציה אחרת ומיישמות אותה על איברים בקולקציה. לדוגמה, (map (lambda (x) (* x x)) '(1 2 3)).
  • החזרת פונקציות כערך: פונקציה יכולה לייצר ולהחזיר פונקציה חדשה. זהו מנגנון בסיסי ליצירת סגורים, כפי שנראה בהמשך.

סגורים (Closures)

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

סגור (Closure): פונקציה יחד עם הסביבה הלקסיקלית שבה היא הוגדרה. הסגור "זוכר" את המשתנים החופשיים (free variables) שהיו נגישים בסביבת ההגדרה שלו, גם לאחר שסביבה זו כבר לא פעילה.

לכידת סביבה (Environment Capture)

הליבה של סגור היא מנגנון לכידת הסביבה.

סביבה לקסיקלית (Lexical Environment): אוסף של קישורי שמות-ערכים (משתנים ופונקציות) שהיו זמינים בנקודה מסוימת בקוד בעת הגדרת הפונקציה.
לכידת סביבה (Environment Capture): התהליך שבו פונקציה "שומרת" עותק או מצביע לסביבה הלקסיקלית שבה היא הוגדרה. כאשר הפונקציה נקראת מאוחר יותר, היא משתמשת בסביבה הלכודה כדי לפתור שמות של משתנים חופשיים.

דוגמה קלאסית: פונקציה שמייצרת פונקציות אחרות:

(define (make-adder n)
  (lambda (x)
    (+ x n)))

(define add-5 (make-adder 5))
(define add-10 (make-adder 10))

(add-5 3)   ; => 8
(add-10 3)  ; => 13

כאן, הפונקציות add-5 ו-add-10 הן סגורים. כל אחת מהן לוכדת ערך שונה של n מהסביבה שבה נוצרה, ומשתמשת בו בעת הקריאה.

מימוש סגורים במפרש

הבנת הסגורים מתחזקת כאשר מבינים כיצד הם ממומשים ברמת המפרש (כפי שנלמד בקורס דרך קבצי interp.scm ו-environments.scm).

סביבת הגדרה

הסביבה (Environment) שבה פונקציה נוצרה (הוגדרה). כאשר המפרש נתקל בהגדרת פונקציה (למשל, ביטוי lambda), הוא יוצר אובייקט סגור שכולל מצביע לסביבה זו.

סביבת קריאה

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

אובייקט סגור (Closure Object)

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

כאשר פונקציה המהווה סגור נקראת, המפרש מבצע את הפעולות הבאות:

  1. יוצר סביבת קריאה חדשה.
  2. מאכלס את סביבת הקריאה בקישורי הפרמטרים שהועברו לפונקציה.
  3. משרשר את סביבת הקריאה החדשה לסביבת ההגדרה הלכודה של הסגור. כלומר, סביבת ההגדרה הלכודה הופכת להיות "ההורה" של סביבת הקריאה.
  4. מבצע את גוף הפונקציה בתוך סביבת הקריאה המשורשרת.

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

הבחנה קריטית: סביבת הגדרה מול סביבת קריאה בסגורים: הבנה עמוקה של סגורים דורשת להבחין בין הסביבה שבה פונקציה *מוגדרת* לבין הסביבה שבה היא *נקראת*. סגור תמיד "זוכר" את הסביבה שבה הוא נוצר, ומשתמש בה כדי לפתור שמות של משתנים חופשיים (משתנים שאינם פרמטרים של הפונקציה ואינם מוגדרים בתוכה). זוהי נקודה קריטית לבחינה, שכן טעות נפוצה היא לבלבל בין סביבת ההגדרה לסביבת הקריאה, מה שמוביל להבנה שגויה של התנהגות קוד המשתמש בסגורים.

שאלות לדיון

  • הסבירו מדוע פונקציות מסדר גבוה וסגורים נחשבים לעמודי תווך בתכנות פונקציונלי. תנו דוגמאות לשימושים מעשיים.
  • כיצד מפרש (כמו זה שנבנה בקורס) מטפל בהגדרת פונקציה שמחזירה פונקציה אחרת? מהו מבנה הנתונים הפנימי שנוצר?
  • הסבירו את ההבדל בין משתנה גלובלי למשתנה חופשי בסגור. מתי מפרש יחפש ערך של משתנה בסביבה הגלובלית ומתי בסביבה הלכודה של סגור?
  • מהם היתרונות והחסרונות של שימוש נרחב בסגורים מבחינת ניהול זיכרון וביצועים?

נקודות לתשובת מודל

  • חשיבות: HOFs מאפשרות הפשטה ושימוש חוזר בקוד (דפוסי איטרציה, טרנספורמציה). סגורים מאפשרים יצירת פונקציות "ממוקדות מצב" (stateful functions) ללא שימוש באובייקטים מורכבים, בניית DSLs, קארינג, דקורטורים.
  • טיפול במפרש: בעת הגדרת פונקציה פנימית (למשל, lambda בתוך make-adder), המפרש יוצר אובייקט סגור. אובייקט זה מכיל את קוד ה-lambda ואת מצביע לסביבה הלקסיקלית הנוכחית (זו שבה make-adder נקראה ו-n הוגדר). כאשר הסגור נקרא, סביבת הקריאה שלו מקושרת לסביבה הלכודה.
  • משתנה גלובלי מול חופשי: משתנה גלובלי מוגדר בסביבה העליונה ביותר של התוכנית. משתנה חופשי בסגור הוא משתנה שאינו פרמטר של הסגור ואינו מוגדר בתוכו, אך הוא היה קיים בסביבת ההגדרה של הסגור. המפרש יחפש תחילה בסביבת הקריאה, אחר כך בסביבה הלכודה של הסגור, ורק לבסוף בסביבות ה"הוריות" עד לסביבה הגלובלית.
  • יתרונות/חסרונות: יתרונות: גמישות, מודולריות, קוד קצר וקריא, הימנעות מ-side effects לא רצויים. חסרונות: עלול להוביל לצריכת זיכרון גבוהה יותר אם הסביבות הלכודות גדולות ואינן משוחררות בזמן (garbage collection), פוטנציאל לבלבול למתכנתים לא מנוסים.
מצאתם טעות או שחסר משהו?
→ הקודמת
סביבות וטווח הכרה (Lexical Scope)
הבאה ←
מצב וזיכרון: מפרשים מבוססי Store