שגיאות בתור ערכים בפייתון?

29/01/2024

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

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

# Define a function that returns a union of a User and an error
def get_user(user_id: str) -> User | Exception:
    rows = users.find(user_id=user_id)
    if len(rows) == 0:
        return Exception("user not found")
    return rows[0]

def rename_user(user_id: str, name: str) -> User | Exception:
    # Consume the function
    user = get_user(user_id)
    if isinstance(user, Exception):
        return user
    user.name = name
    return user

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

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

נשווה את זה לסקאלה (או שפות אחרות שמאפשרות שגיאה-בתור-ערך):

case class User(id: Long, name: String)

val users: List[User] = List(
  User(1, "glassgrieving"),
  User(2, "wrensponge"),
  User(3, "horizonhow"))

def getUserIndex(userId: Long): Option[Int] =
  users.indexWhere(p => p.id == userId) match
    case i if i >= 0 => Some(i)
    case _ => None

def renameUser(userId: Long, newName: String): Option[List[User]] =
  for {
    index <- getUserIndex(userId)
  } yield users.updated(index, User(userId, newName))

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

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

a?.b?.c?.d

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

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