בפוסט הקודם דיברתי על כשלים במערכות תוכנה ברמות השונות. בפוסט הזה, אני מתמקד ברמת הנדסת התוכנה והקידוד עצמו.
חריגות
טיפול בחריגות (Exception Handling) הוא אחד מאבני היסוד של כל מערכת תוכנה כמעט; אף על פי כן, התפיסות לגבי טיפול בשגיאות שונות בצורה מהותית ועקרונית בין שפות שונות, טכנולוגיות שונות, ופעמים רבות – גם אנשים שונים. בעיני, זריקת Exceptions היוותה שינוי תפיסתי גדול אפילו יותר מאשר המעבר מתכנות פרוצדורלי לתכנות מונחה עצמים או משפות שהן strictly typed לשפות חופשיות. הכרתי לא מעט מפתחים מעולים שהצליחו להסתגל לשינויים רבים, אבל התקשו להיפרד מההרגל של בדיקת ערך החזרה של הפונקציה כדי לוודא שהיא התנהלה כשורה. אפילו כואב מזה – אני מכיר מפתחים רבים שדווקא משתמשים במנגנונים האלו, אבל עושים את זה בדרך כזו שמוטב היה לו היו בודקים את ערך הפונקציה במקום זה…
הנושא הזה הוא נושא מורכב, ופעמים רבות אין תשובה אחת נכונה לגבי אופן השימוש. בפוסט הזה, אנסה לתת מספר כללי "עשה" ו"אל-תעשה" שיסייעו להשתמש במנגנון הזה בצורה הנכונה והיעילה ביותר.
ברומא, התנהג כרומאי
לשפות שונות יחס שונה לניהול חריגות. בג'אווה, למשל, היחס ל-Exception הוא ליטרלי מאוד: אם נזרקה חריגה, פירוש הדבר שמשהו השתבש. במערכת אוטופית ללא בעיות (כמו תקשורת נופלת, ערכים שגויים, דיסק מלא…) – המערכת אמורה להתנהל באמצעות משפטי בקרה בלבד. בשפות אחרות, כמו פייתון – מתנהלים בצורה הפוכה לגמרי. "קל יותר לבקש סליחה מאשר לבקש רשות", אז תמיד נרוץ קדימה, עד שנעוף… במקום לבדוק אם הגענו לקצה הרשימה כדי להחליט אם לקרוא עוד איבר – פשוט נקרא אותו, ואם נזרקה שגיאה – מסתבר שעברנו את הסוף.
שתי הגישות תקינות לחלוטין – כל עוד הן מיושמות בקונטקסט הנכון. בג'אווה – התנהג כג'וואי. בפייתון – עשה את זה בדרך הפייתונית. תפיסה אישית לגבי הדבר ה"נכון" היא חשובה, אבל אם היא מיושמת בדומיין הלא נכון, היא תיצור קוד בלתי קריא לחלוטין בעיני שאר המפתחים ששותפים (או יהיו שותפים) לקוד, והיא תתנהג באופן שונה לגמרי מכל שאר פיסות הקוד אליהן היא מתחברת. לא פחות מזה, חשוב לשים לב לקונטקסט הספציפי. אם הבוקר התחלת לעבוד בחברה שהקוד שלה פייתוני, אבל מייסדיה הג'אוואיסטים התייחסו לחריגות כמו שהם רגילים – אל תנסה ללמד אותם מה גואידו היה אומר על זה. שמור על הקונבנציה המקומית – אחרת אף אחד לא יבין את הקוד שלך, שלא לדבר על לבצע איתו אינטגרציה.
שמרו על הלוגיקה ברורה
נתקלתי לא אחת במפתחים שכל כך התלהבו מהרעיון של exceptions, עד כדי כך שניסו לתפוס אותן בכל מקום. פונקציה של עשר שורות יכולה תוך שניות להפוך לפונקציה של חמישים שורות, כשמוסיפים מסביב לכל קריאה try ו-catch. ייתכן שיש מקרים שבהם זהו הפתרון הנכון – אך הם נדירים ביותר. בדרך כלל, אפשר לעטוף את כל עשר השורות האלו בבלוק אחד של try-catch, ולטפל בכל החריגות בצורה מרוכזת (אם בכלל נכון לטפל בהן בתוך הפונקציה הזו – אבל על כך בהמשך). כמובן, חשוב, מצד שני, לוודא שגם הקונטקסט של תפיסת החריגות קריא וברור כחלק מהלוגיקה של הקוד. אין טעם למקם את ה-try וה-catch במיקום מרוחק מדי ולא ברור, שלא יאפשר למי שיקרא את הקוד להבין מה הם, בעצם, עושים שם בכלל.
ודאו שהחריגות תורמות ליציבות המערכת, ולא להיפך
המטרה של ניהול החריגות היא לוודא שהמערכת מתנהגת כראוי גם במצבים לא צפויים, שאינם מאפשרים לה לפעול בדרך המתוכננת. שימוש לא נכון בזריקת exceptions עלול לגרום להיפך הגמור. קוד שבו חריגות פשוט עוצרות את הפעולה הנוכחית ו"עפות" חזרה במעלה מחסנית הפונקציות עלולות להשאיר את המערכת עם פורטים פתוחים, תהליכים יתומים, handles ללא שימוש, מבני נתונים "שבורים" ולא קוהרנטיים ובעצם במצב הרבה יותר גרוע מאשר סתם לא להצליח לבצע קריאה כלשהי. לכל שפה ומערכת יש את הכלים שלה כדי להתמודד עם הקושי הזה – אם זה כחלק אינהרנטי בהגדרת מנגנון החריגות (כמו משפט finally), באמצעות שימוש ב-smart objects או בכל דרך אחרת. ללא הבנה ברורה של המנגנונים האלו – אין טעם לטפל בחריגות.
בספר "Effective C++", סקוט מאיירס מגדיר שני כללים בסיסיים שלהם חייב לציית קוד במקרה של חריגות:
- קוד שהוא exception-safe, לעולם לא יאפשר דליפת משאבים;
- קוד שהוא exception-safe, לעולם לא ישאיר מבנה נתונים "שבור".
כמו כן, הוא מגדיר שלוש רמות של "exception safety", שמאפשרות כתיבה של מערכות יציבות. על מנת שהמערכת תהיה חסינה לזריקת חריגות, כל קריאה בה חייבת לקיים לפחות את אחת ההתחייבויות הבאות:
"התחייבות בסיסית" (The Basic Guarantee)
פונקציה המקיימת את ה"התחייבות הבסיסית", מבטיחה כי במקרה שתיזרק ממנה חריגה – המערכת תישאר במצב חוקי כלשהו. אין לנו הבטחה לגבי המצב עצמו, וייתכן שדברים ישתנו במערכת (למשל, חזרה לערכי ברירת מחדל כלשהם או איבוד נתונים) – אבל המערכת, ככלל, תהיה במצב תקין וחוקי.
אם, למשל, נרצה לשנות צבע של רכיב כלשהו מאדום לשחור – אחרי זריקת ה-exception ייתכן שהצבע נשאר אדום, ייתכן שהפך לשחור, וייתכן גם שחזר לברירת המחדל והפך ללבן – כל עוד לבן הוא צבע חוקי. אם המערכת בנויה כך שמספר רכיבים חייבים להיות תמיד בצבע זהה – ה"התחייבות הבסיסית" חייבת לדאוג לכך שאכן צבעם יישאר זהה, גם אם שונה מכפי שהיה טרם הקריאה לפונקציה.
אם נרצה לבצע פילטר כלשהו על תמונה – ייתכן שנצליח להחיל אותו על כל התמונה; ייתכן שהוא לא יוחל על אף פיקסל שהוא; ייתכן גם שבעקבות ה-exception, נפגע בפילטרים שכבר עשינו על התמונה קודם לכן ונחזיר אותה למצבה המקורי. זה כמובן יהיה מאוד לא נעים למעצב הגרפי שעובד עליה כרגע, אבל זה לפחות מבטיח שהמערכת נמצאת במצב "חוקי". מה שמובטח למערכת (ולמעצב הגרפי) הוא שלא יהיה מצב שבו המערכת נמצאת במצב לא חוקי – כזה שעלול בהמשך לגרום לקריסתה.
"התחייבות חזקה" (The Strong Guarantee)
המקרה שתיארנו קודם לכן, בו איבדנו עבודה שכבר עשינו על התמונה, אולי מבטיח מערכת יציבה אבל לא בהכרח פופולארית… במקרים בהם זה אפשרי, מוטב להשתמש ב"התחייבות החזקה". פונקציה הפועלת תחת "התחייבות חזקה", על פי מאיירס, מתחייבת כי לאחר הקריאה אליה, המערכת תהיה באחד משני מצבים בלבד: או שהפעולה תתבצע בהצלחה, והמערכת תעבור ממצב "A" למצב "B" (למשל – מצבע אדום לצבע שחור) או שהפעולה תיכשל, והמערכת תישאר בדיוק כפי שהיתה קודם הקריאה, ללא כל side effects או שינויים (למשל – תישאר בצבע אדום). אלו, ואלו בלבד, הן האפשרויות הקיימות. פונקצייה המתחייבת לעבוד תחת הגדרה חזקה זו מהווה למעשה "טרנזקציה" – או שהיא תתבצע במלואה, או שלא תתבצע כלל.
כמובן, מערכת הכתובה בצורה כזו היא יציבה בהרבה; אולם, לא תמיד ניתן לכתוב מערכות בכזו רמה של התחייבות, ולא תמיד ההשקעה בכתיבת הקוד הרלוונטי כדאית.
"התחייבות לאי-זריקה" (The No-Throw Guarantee)
זוהי הרמה הגבוהה ביותר של התחייבות – התחייבות לכך שהפעולה תתבצע בודאות גמורה. פעולות פשוטות ברמת המערכת, כמו למשל שינוי ערך של משתנה מטיפוס בסיסי, הן דוגמאות לקריאות הפועלת תחת התחייבות זו.
במקרה של בליעה, פנו מייד לרופא!
"בליעת חריגות" (exception swallowing) היא פרקטיקה מקובלת במקומות רבים. המשמעות של "בליעה" היא שאנחנו קוראים לפונקציה שעלולה לזרוק חריגה, ומוודאים שהחריגה אינה ממשיכה הלאה מהפונקציה שלנו, למרות שלא טיפלנו בה. במקרה הקיצוני, מדובר בבליעה של כל חריגה שהיא, ובהתעלמות מוחלטת ממנה. במקרים פחות קיצוניים, מדובר על בליעה של חריגות מטיפוס מסויים, ו/או על טיפול מינימלי, בסגנון שליחת הודעה ללוג שגיאות ולא יותר.
קיימים מקרים בהם יש הגיון בבליעת חריגות. הדבר נכון בעיקר בגישה מהסוג ה"פייתוני". אם יש לנו, למשל, פונקציה שתפקידה לעבור על רשימה לא עדכנית בהכרח של שמות טבלאות בבסיס נתונים, לכתוב את מספר הרשומות בכל טבלה קיימת ולהתעלם מטבלאות שאינן קיימות עוד – יכול להיות שנבחר לבצע תמיד את הפעולה, ובמקרה של כשלון – פשוט נמשיך הלאה. התעלמות מחריגות שנובעות מקריאת טבלה שאינה קיימת יכולה להיות סבירה במקרה כזה. ובכל זאת, כתיבה פשטנית של קוד שמתעלם מכל שגיאה שהיא במהלך קריאת טבלה תהיה מתכון לאסון. מה אם קיימת בעיית תקשורת? מה אם הקוד שלנו מכיל SQL לא תקני? מה אם טעינו בהגדרת ההרשאות ואנחנו פשוט לא מקבלים גישה? לעולם לא נדע – אם נבצע פשוט catch גנרי. לכן, גם אם אנחנו מחליטים שמ-exceptions מסויימים אנחנו מעוניינים להתעלם – הפרקטיקה הנכונה היא להגדיר בדיוק את סוג החריגה שהיא "לגיטימית" מבחינתנו ואנחנו בוחרים להתעלם ממנה, ולוודא שכל טיפוס אחר של חריגה יקבל טיפול.
הרבה פעמים תפיסה גנרית של exceptions היא בכלל שאריות של שלב הפיתוח או הדיבאג, שבו רצינו להיפטר מדברים שלא עניינו אותנו באותו רגע, ולאחר מכן הקוד נשאר כמו שהוא. לכן, כסוג של כלל אצבע – במהלך כל code review, או לפני כל commit, חשוב מאוד לזהות מקרים של אי-טיפול ולוודא שהם אכן אמורים להיות שם (ואם הקוד אמור להיות שם – זה כנראה מסוג המקומות שדורשים הערות בתוך הקוד, גם אם זה נראה Self Explained Code).
מה ואיך לזרוק?
בפוסט הזה דנתי בעיקר במשמעות של עצם הזריקה של Exceptions, מתי נכון לעשות זאת וכיצד.
בפוסט הבא אדון בשאלה אילו חריגות נכון לזרוק וכיצד לנהל אותן.
[…] זריקת Exceptions היא חלק מחתימת פונקציה, ולכן מימוש מחדש של פונקציה במחלקה יורשת לא יזרוק Exceptions שלא היו יכולות להיזרק מהפונקציה המתאימה במחלקה המקורית (אלא אם כן ה-Exceptions עצמם יורשים מ-Exceptions שנזרקו במחלקה המקורית; אפשר לראות דוגמא לעקרון הזה בפוסט שלי על ניהול Exceptions). […]