משחקים עם קלוז'ר: פיתרון AoC 2021 Day 2

05/06/2022

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

1. מה אנחנו בונים

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

forward 5
down 5
forward 8
up 3
down 8
forward 2

בחלק הראשון הפקודה forward X אומרת לזוז X צעדים קדימה, הפקודה down למטה והפקודה up למעלה. המשימה היא לגלות איפה תהיה הצוללת בסוף המסלול בעזרת שני ערכים - עומק ומיקום אופקי, ולהדפיס את מכפלתם.

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

  1. פקודת down מעלה את ה aim
  2. פקודת up מורידה את ה aim
  3. פקודת forward זזה קדימה וגם מוסיפה את aim כפול הפרמטר שקיבלה לעומק.

2. פיתרון בקלוז'ר

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

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

(defn part1 [position line]
  (let [[command value-str] (str/split line #"\W")
        value (Integer/parseInt value-str)]
    (case command
      "forward" (update position :horizontal + value)
      "up"      (update position :depth + (* -1 value))
      "down"    (update position :depth + value))))

ובחלק השני היא תהיה:

(defn part2 [position line]
  (let [[command value-str] (str/split line #"\W")
        value (Integer/parseInt value-str)]
    (case command
      "forward" (-> position
                    (update :horizontal + value)
                    (update :depth + (* (:aim position) value)))
      "up"      (update position :aim + (* -1 value))
      "down"    (update position :aim + value))))

הלולאה הראשית שקוראת את הקלט ומריצה את ה reduce היא:

(let [input (->> "input.txt"
                 (slurp)
                 (str/split-lines))
      pos1 (reduce part1 {:horizontal 0 :depth 0} input)
      pos2 (reduce part2 {:horizontal 0 :depth 0 :aim 0} input)]
  (println (apply * ((juxt :horizontal :depth) pos1)))
  (println (apply * ((juxt :horizontal :depth) pos2))))

הפקודה שחוזרת על עצמה הכי הרבה כאן היא update, לדוגמה:

"forward" (update position :horizontal + value)

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

פקודה מעניינת נוספת כאן היא פקודת ההדפסה בסוף:

(println (apply * ((juxt :horizontal :depth) pos1)))

בגלל ש pos1 הוא מילון, הפקודה juxt לוקחת ממנו את הערכים של :horizontal ו depth ומחזירה מערך עם שניהם, ואז * צריכה לכפול את שני האיברים במערך. אבל בגלל ש * מצפה לקבל שני פרמטרים ופה היא מקבלת מערך, אנחנו משתמשים ב apply שהוא כמו כוכבית של פייתון - ותפקידו להפוך מערך של שני איברים לשני פרמטרים נפרדים לפונקציה.

3. פיתרון ישן בפייתון

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

pos = 0
depth = 0
aim = 0

with open('day2.txt') as f:
    for line in f:
        op, count = line.split()
        match [op, count]:
            case ["forward", n]:
               pos += int(n)
               depth += (aim * int(n))
            case ["down", n]:
                aim += int(n)
            case ["up", n]:
                aim -= int(n)

print(pos * depth)

זה פיתרון יותר קצר שכולל גם את הלולאה וגם את פונקציית הטיפול, הכל באותו מקום. שני הדברים שהופכים את קוד הפייתון לקליל יותר הם ההמרה למספר (הפונקציה int יותר קצרה מ Integer/parseInt) וה Pattern Matching שבינתיים לא מצאתי מנגנון מקביל לו ב Clojure.