• בלוג
  • מה ההבדל בין for, loop ו doseq ב Clojure?

מה ההבדל בין for, loop ו doseq ב Clojure?

15/04/2020

פקודות הלולאה loop, for ו deseq של Clojure מצליחות בקלות לבלבל מתכנתים שלא מגיעים מרקע של תכנות פונקציונאלי, ואפילו כמה מתכנתים פונקציונאליים עשויים לעקם את הפרצוף כשקוראים את התיעוד עליהן. בואו ננסה לעשות קצת סדר ולראות מה בדיוק ההבדל בין הפקודות ומתי נשתמש בכל אחת.

1. הפקודה loop

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

(defn factorial [x]
  (cond (< x 2) 1
        :else (*' x (factorial (dec x)))))

עצרת של 1 היא אחד, ולכל מספר גדול יותר n עצרת של n היא פשוט n כפול עצרת של n-1. הקוד קלוז'ר הזה עובד אבל גרוע. השימוש בשם הפונקציה כדי להפעיל רקורסיה אומר שאנחנו לא מקבלים את אופטימיזציית מחיקת זנב הרקורסיה, ולכן יש מגבלה על עומק הקריאה הרקורסיבית. בקלוז'ר שלי הצלחתי לחשב עצרת של 3,000 אבל עצרת של 4,000 כבר נכשלה עם שגיאת StackOverflowError.

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

הקוד הבא לדוגמא אינו קלוז'ר תקני:

(defn factorial [x]
  (cond (< x 2) 1
        :else (* x (recur (dec x)))))

בשביל להפוך את הקוד לכזה שיכול להשתמש ב recur אני חייב לוותר על הכפול ולהחזיר ממש את מה שהפונקציה factorial תחזיר. הקוד הבא הוא קלוז'ר תקני למרות שהוא ממש לא מחשב עצרת:

(defn factorial [x]
  (cond (< x 2) 1
        :else (recur (dec x))))

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

(defn factorial [x n]
  (cond (< x 2) n
        :else (recur (dec x) (* n x))))

וזה עובד ממש טוב! אבל עכשיו אנחנו צריכים להפעיל את הפונקציה עם הפרמטר השני:

(factorial 4 1)

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

(defn _factorial [x n]
  (cond (< x 2) n
        :else (recur (dec x) (* n x))))

(defn factorial [x]
  (_factorial x 1))

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

כך נראה קוד זהה עם loop:

(defn factorial [x]
  (loop [x x
         n 1]
    (cond (< x 2) n
          :else (recur (dec x) (* n x)))))

שימו לב איך חיברנו את שתי הפונקציות כדי לקבל בדיוק את אותו קוד אבל בתוך הגדרה אחת. ה loop מתפקד כמו הגדרה של פונקציה פנימית.

2. הפקודה for

לעומת המילה loop הפקודה for של clojure מייצגת לולאה ברמה גבוהה יותר. התפקיד שלה הוא לא לעזור לנו לבנות פונקציות רקורסיביות, אלא היא בעצמה פונקציה רקורסיבית שתפקידה לבצע פעולת List Comprehension או בעברית יצירת רשימה מתוך רשימה אחרת.

זה מאוד שונה מ for של שפות כמו Python גם מבחינת המטרה וגם מבחינת אופן הפעולה. ה for לא "עושה משהו X פעמים", אלא "מייצר רשימה מרשימה אחרת".

קוד? קוד. יש לכם רשימה של המספרים מ-1 עד 10:

(def numbers (range 10))

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

(def squares (for [x numbers] (* x x)))

והתוצאה היא:

 (0 1 4 9 16 25 36 49 64 81) 

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

וכן, אפשר להעביר יותר מצמד (משתנה-רשימה) אחד. נסו את זה ותראו מה קורה.

3. הפקודה doseq

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

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

הנה דוגמא פשוטה לשימוש ב doseq

(doseq [x (range 10)] (prn x))

דוגמא נוספת יכולה להיות הכנסה לבסיס נתונים. הפונקציה jdbc/insert! מכניסה פריט חדש לבסיס נתונים:

(jdbc/insert! db-spec :fruit {:name "Pear" :appearance "green" :cost 99})

כך שהיינו יכולים לשלב את זה עם doseq כדי להכניס מספר מוצרים:

(doseq [fruit ["apple" "pear" "banana"]] (jdbc/insert! db-spec :fruit {:name fruit :appearance "green" :cost 99}))

(למרות שבחיים האמיתיים עדיף להשתמש ב jdbc/insert-multi! במקרה כזה).

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