משתני קונטקסט בסקאלה

18/10/2023

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

(let [agent (ssh-agent {})]
  (let [session (session agent "host-ip" {:strict-host-key-checking :no})]
    (with-connection session
      (let [channel (ssh-sftp session)]
        (with-channel-connection channel
          (sftp channel {} :cd "/remote/path")
          (sftp channel {} :put "/some/file" "filename"))))))

בבלוק מעתיקים קבצים לשרת ואנחנו רואים שצריך להעביר את המשתנה channel כל פעם שקוראים ל sftp. זה הגיוני כי אנחנו בשפה פונקציונאלית והסטייט נשמר מחוץ לפונקציות, אבל יכול להיות מעייף בכתיבה.

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

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

def readUsers(config: ServerConfig): Unit =
  println(s"read users from ${config.ip}")

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

def readUsers(using config: ServerConfig): Unit =
  println(s"read users from ${config.ip}")

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

@main def main(): Unit =
  given config: ServerConfig(ip = "12.13.14.15", port = 8080)
  readUsers

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

def readUsers(using config: ServerConfig): Unit =
  println(s"read users from ${config.ip}")

def addUser(username: String, password: String)(using config: ServerConfig): Unit =
  println(s"add user ${username} to ${config.ip}")

def wrapper(using ServerConfig): Unit =
  readUsers

@main def main(): Unit =
  given config: ServerConfig(ip = "12.13.14.15", port = 8080)

  readUsers
  addUser("foo", "bar")
  wrapper

בזכות הגדרת ה given בתחילת ה main, כל הפונקציות שצריכות משתנה סביבה מסוג ServerConfig יקבלו את אוביקט הקונפיגורציה שיצרתי. יותר מזה, הפוקנציה wrapper יכולה לקרוא ל addUsers בלי שאף אחד מהן תצטרך לקבל או להעביר את משתנה הקונפיגורציה ופשוט כל פונקציה שצריכה לגשת אליו מקבלת אותו אוטומטית ובצורה מובלעת.

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

(def ^:dynamic *config* {:ip "default-ip"})

(defn read-users []
  (println (str "read users from " (:ip *config*))))

;; Usage
(let [custom-config {:ip "custom-ip"}]
  (binding [*config* custom-config]
    (read-users)))

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