ההקשר
כישלון ב-Zero הוא לא זרימת בקרה נפרדת ומקבילה. הוא חלק מזרימת הבקרה הרגילה, מוצהר בחתימת הפונקציה ומוכר בכל מקום קריאה. שני חלקים גורמים לזה לעבוד:
raisesבחתימת הפונקציה: "הפונקציה הזו עלולה להיכשל."checkבמקום הקריאה: "אם זה נכשל, הכשל את הפונקציה שלי עם אותה שגיאה."
השילוב מספיק כדי לבטא את מה שרוב השפות מטפלות בו עם try/catch או עם טיפוסי Result.
הצהרה על פונקציה שעלולה להיכשל
הוסיפו raises אחרי טיפוס ההחזרה:
fun validate(ok: Bool) -> i32 raises { InvalidInput } {
if ok == false {
raise InvalidInput
}
return 42
}
שתי דרכים לקרוא את החתימה:
- "
validateמחזירהi32או מעלהInvalidInput." - "קבוצת התוצאות האפשריות היא {
i32,InvalidInput}."
שתי הקריאות נכונות. הקומפיילר עוקב אחרי שתי האפשרויות ודורש מהקוראים לעשות משהו מפורש לגבי כל אחת.
אפשר גם לכתוב פסוקית raises לבדה:
pub fun main(world: World) -> Void raises {
check world.out.write("hello\n")
}
raises (בלי רשימת שגיאות) פירושו "הפונקציה הזו עלולה להיכשל עם כל שגיאה". ב-main זו הצורה המקובלת: התוכנית יכולה לצאת עם סטטוס שאינו אפס אם משהו משתבש, וסביבת הריצה דואגת להציג את השגיאה.
בפונקציות עמוק יותר במחסנית הקריאות, העדיפו את הצורה המפורשת raises { ErrorA, ErrorB } כדי שדרכי הכישלון יהיו מתועדות בכל רמה.
העלאת שגיאה
בתוך פונקציה שעלולה להיכשל, raise יוצא מהפונקציה עם השגיאה שצוינה:
fun validate(ok: Bool) -> i32 raises { InvalidInput } {
if ok == false {
raise InvalidInput
}
return 42
}
raise InvalidInput מייצר שגיאת InvalidInput בשורה הזו. הפונקציה לא ממשיכה אחרי הנקודה הזו: הבקרה חוזרת לקורא, והקורא רואה את השגיאה במקום i32. פסוקית ה-raises מפרטת את טיפוסי השגיאה היחידים שהפונקציה רשאית להעלות; העלאת משהו שלא ברשימה היא שגיאת קומפילציה.
העברת שגיאה עם check
קורא שמפעיל פונקציה שעלולה להיכשל חייב להכיר בכישלון האפשרי. ההכרה הנפוצה ביותר היא check:
fun run() -> Void raises { InvalidInput } {
check validate(true)
}
check validate(true) עושה שני דברים:
- קורא ל-
validate(true). - אם
validateהעלתה שגיאה, מעביר אותה למעלה:runמעלה את אותה שגיאה אל הקורא שלה.
כדי שההעברה תהיה מותרת, run חייבת להצהיר בפסוקית ה-raises שלה שהיא יכולה להעלות InvalidInput (או משהו תואם). הקומפיילר בודק את זה. אם בחתימה של run היה כתוב raises { OtherError }, ההעברה לא הייתה מתקמפלת כי InvalidInput לא נמצא בקבוצה.
דוגמה מלאה מתוך הדוגמאות הרשמיות של השפה. לחצו על Run כדי לראות את ההעברה מצליחה:
טיפוס השגיאה נוסע יחד עם חתימת הפונקציה לאורך כל מחסנית הקריאות. ה-raises הכללי של main מקבל כל דבר ש-run עשויה להעלות, ולכן ההעברה נוחתת בבטחה.
למה לא try/catch?
המשמעת העיצובית שמאחורי raises/check היא שכישלון אף פעם לא בלתי נראה במקום הקריאה. בשפה עם try/catch, חריגה יכולה לעבור בשקט דרך פונקציה שאפילו לא יודעת שהיא עשויה להיות מעורבת: הפונקציה נראית טהורה, אבל קריאה עמוקה אי שם בגוף שלה זורקת, והחריגה מתגלגלת דרכה.
זה נוח למי שכותב את הקוד שזורק. זה יקר לכל השאר:
- קוראים (וסוכנים) לא יכולים לדעת מהחתימה אם פונקציה שותפה למסלולי כישלון.
- ריפקטורינג הופך למלחיץ: העברת קריאה בין פונקציות יכולה לשנות אילו חריגות אפשר להגיע אליהן.
- קוד ההתאוששות נמצא רחוק מהמקום שיודע מה לעשות.
Zero משלמת את המחיר מראש, הערות על כל פונקציה שעלולה להיכשל ו-check בכל קריאה שעלולה להיכשל, כדי לקבל את התכונה "דרכי הכישלון שפונקציה שותפה להן גלויות מהחתימה שלה." זו תכונה שגם בני אדם וגם סוכנים יכולים לסמוך עליה.
למה לא פשוט Result<T, E>?
אפשר היה לבטא את אותו דבר עם choice: טיפוס Result<T, E> עם הוריאנטים ok ו-err. Zero נותנת לכם גם את הדפוס הזה; הוא כלי שימושי כשהכישלון הוא נתון שרוצים לבדוק, לשמור או להעביר הלאה.
מה ש-raises/check מוסיפים הוא מוסכמה ברמת התחביר למקרה הנפוץ: "אם זה נכשל, הכשל את הפונקציה שלי באותה דרך." בלעדיה, כל קריאה הייתה עטופה ב-match שכמעט תמיד אורז מחדש את השגיאה לתוך ה-Result של הקורא עצמו. check הוא הקיצור לזה, כשהקומפיילר מוודא שההעברה תקינה מבחינת טיפוסים.
כלומר:
Result<T, E>(choice): כשרוצים לבדוק כישלון או לשאת אותו כערך.raises+check: כשרוצים פשוט להעביר כישלון למעלה במחסנית הקריאות.
שניהם זמינים; הם עונים על צרכים ארגונומיים שונים.
כמה טיפוסי שגיאה
פונקציה יכולה להעלות יותר מסוג שגיאה אחד:
fun parse(input: String) -> i32 raises { Empty, Malformed } {
if std.mem.len(input) == 0 {
raise Empty
}
// ... לוגיקת ניתוח ...
raise Malformed
}
קורא יכול:
- להעביר הלאה עם
check parse(input)אם החתימה שלו עצמו מפרטת גם אתEmptyוגם אתMalformed(או קבוצה רחבה יותר). - לטפל באחת השגיאות או בשתיהן במפורש עם
matchאו עם מבנים בסגנוןtryשהשפה חושפת לשם כך.
התחביר המדויק לטיפול מדויק (התאמה לפי טיפוסי שגיאה ספציפיים לעומת העברה של אחרים) הוא אחד המשטחים שעשויים להשתנות ב-Zero שלפני 1.0. החוזה, שכל טיפוס שגיאה שפונקציה יכולה להעלות נמצא בחתימה שלה, הוא החלק היציב.
מודל מנטלי
מערכת הכישלונות של Zero היא גרסה קפדנית וכנה של מה שיש בכל שפה אימפרטיבית:
| מושג | Try/Catch | Zero |
|---|---|---|
| סימון פונקציה כעלולה להיכשל | כלום (מרומז) | raises { ... } |
| העלאת שגיאה | throw e | raise E |
| העברה לקורא | מבעבעת באופן בלתי נראה | check call(...) |
| טיפול מקומי | try { ... } catch(e) { ... } | match על Result מוחזר, או צורת טיפול עם טיפוסים |
ההבדל ההתנהגותי קטן. ההבדל בהערות גדול, ובכוונה. אפקטים הם מפורשים. כישלונות הם אפקטים.
הערות סגנון
- השתמשו ב-
checkבנדיבות. זו ברירת המחדל הנכונה כשאין לכם משהו ספציפי לעשות ברמה הזו. - הימנעו מ-
raisesכללי בפונקציות עזר פנימיות. ככל שקבוצת השגיאות צרה יותר, החתימה שימושית יותר. - חברו פעולות שעלולות להיכשל ליכולות שהן צריכות. פונקציה שמשתמשת ב-
Worldוכותבת ל-stdout כמעט תמיד צריכהraises, כי כתיבות עלולות להיכשל.
הבא בתור: דיאגנוסטיקות JSON
raises/check עובדים יחד עם הדיאגנוסטיקות של הקומפיילר של Zero: כשכותבים check על פונקציה שלא מצהירה על השגיאות הנכונות, הקומפיילר אומר לכם בדיוק מה לא בסדר, בצורה מובנית. המסמך הבא מכסה דיאגנוסטיקות JSON, הפלט הקריא למכונה שסוכן קורא כדי לתקן קוד.
שאלות נפוצות
מה המשמעות של raises ב-Zero?
raises ב-Zero?raises בחתימת פונקציה מצהיר שהפונקציה עלולה להיכשל. raises לבדו מאפשר כל טיפוס שגיאה. צורה ספציפית כמו raises { InvalidInput } מגבילה את הפונקציה להיכשל רק עם טיפוסי השגיאה שברשימה. הקוראים חייבים להכיר באפשרות של כישלון, עם check או עם צורת טיפול מפורשת אחרת.
מה עושה האופרטור check?
check?check expr מחשב את expr, ואם הוא מייצר שגיאה, מעביר את השגיאה הזו למעלה אל הקורא של הפונקציה הנוכחית. חשבו על זה כ'הרץ את זה, ואם זה נכשל, הכשל את הקורא שלי עם אותה שגיאה.' כדי שפסוקית ה-raises של הקורא תאפשר את ההעברה, הקורא עצמו חייב להצהיר שהוא יכול להעלות שגיאה תואמת.
איך מעלים שגיאה ב-Zero?
משתמשים ב-raise ErrorName בתוך פונקציה שפסוקית ה-raises שלה כוללת את השגיאה הזו. דוגמה: if ok == false { raise InvalidInput }. הפונקציה יוצאת בנקודה הזו; השגיאה הופכת לתוצאה של הפונקציה, והקורא יכול לבצע עליה check.
למה Zero לא משתמשת ב-try/catch?
try/catch מאפשר לחריגות לעבור בשקט דרך פונקציות שלא מודעות להן. העיצוב של Zero הוא שהחתימה של כל פונקציה חייבת להכיר בדרכי הכישלון שהיא שותפה להן. אין זרימת בקרה נסתרת: אם פונקציה עלולה להיכשל, החתימה שלה אומרת את זה, וכל קורא חייב להכיר בכך במפורש עם check.
האם פונקציה יכולה להעלות כמה טיפוסי שגיאה ב-Zero?
כן. כותבים אותם בפסוקית raises { ... }, מופרדים בפסיקים (או לפי התחביר העדכני של Zero לקבוצות שגיאות). קבוצת השגיאות האפשריות היא חלק מהחוזה של הפונקציה, בדיוק כמו טיפוסי הפרמטרים וטיפוס ההחזרה שלה. הקוראים יכולים לבצע התאמת תבניות לפי השגיאה שהועלתה, או פשוט להעביר אותה הלאה עם check.