2359252726 טיפול בחריגות Exceptions – איך עושים את זה נכון? | גיקטיים

סוכן חכם
אישי ודיסקרטי

לוח משרות ההיי-טק
והטכנולוגיה של ישראל.

רוצים לדעת על משרות חדשות שמתאימות לכם?
תנו לנו לעבוד בשבילכם.
במקום לחפש משרות, קבלו אותן למייל ראשונים.

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

טיפול בחריגות Exceptions - איך עושים את זה נכון?

מספר כללי ׳עשה׳ ו׳אל תעשה׳ הנוגעים לטיפול בחריגות (Exception Handling)

StartUp Getty Images

קרדיט צלם\תמונה: vgajic, Getty Images Israel

טיפול בחריגות (Exception Handling) הוא אחד מאבני היסוד של כל מערכת תוכנה כמעט; אף על פי כן, התפיסות לגבי טיפול בשגיאות שונות בצורה מהותית ועקרונית בין שפות שונות, טכנולוגיות שונות, ופעמים רבות – גם אנשים שונים. בעיני, זריקת Exceptions היוותה שינוי תפיסתי גדול אפילו יותר מאשר המעבר מתכנות פרוצדורלי לתכנות מונחה עצמים או משפות שהן strictly typed לשפות חופשיות. הכרתי לא מעט מפתחים מעולים שהצליחו להסתגל לשינויים רבים, אבל התקשו להיפרד מההרגל של בדיקת ערך החזרה של הפונקציה כדי לוודא שהיא התנהלה כשורה. אפילו כואב מזה – אני מכיר מפתחים רבים שדווקא משתמשים במנגנונים האלו, אבל עושים את זה בדרך כזו שמוטב היה לו היו בודקים את ערך הפונקציה במקום זה…

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

ברומא, התנהג כרומאי

לשפות שונות יחס שונה לניהול חריגות. בג'אווה, למשל, היחס ל-Exception הוא ליטרלי מאוד: אם נזרקה חריגה, פירוש הדבר שמשהו השתבש. במערכת אוטופית ללא בעיות (כמו תקשורת נופלת, ערכים שגויים, דיסק מלא…) – המערכת אמורה להתנהל באמצעות משפטי בקרה בלבד. בשפות אחרות, כמו פייתון – מתנהלים בצורה הפוכה לגמרי. "קל יותר לבקש סליחה מאשר לבקש רשות", אז תמיד נרוץ קדימה, עד שנעוף… במקום לבדוק אם הגענו לקצה הרשימה כדי להחליט אם לקרוא עוד איבר – פשוט נקרא אותו, ואם נזרקה שגיאה – מסתבר שעברנו את הסוף.

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

שמרו על הלוגיקה ברורה

נתקלתי לא אחת במפתחים שכל כך התלהבו מהרעיון של exceptions, עד כדי כך שניסו לתפוס אותן בכל מקום. פונקציה של עשר שורות יכולה תוך שניות להפוך לפונקציה של חמישים שורות, כשמוסיפים מסביב לכל קריאה try ו-catch. כנראה שיש מקרים שבהם זהו הפתרון הנכון – אך הם נדירים ביותר. בדרך כלל, אפשר לעטוף את כל עשר השורות האלו בבלוק אחד של try-catch, ולטפל בכל החריגות בצורה מרוכזת (אם בכלל נכון לטפל בהן בתוך הפונקציה הזו – אבל על כך בהמשך). כמובן, חשוב, מצד שני, לוודא שגם הקונטקסט של תפיסת החריגות קריא וברור כחלק מהלוגיקה של הקוד. אין טעם למקם את ה-try וה-catch במיקום מרוחק מדי ולא ברור, שלא יאפשר למי שיקרא את הקוד להבין מה הם, בעצם, עושים שם בכלל.

ודאו שהחריגות תורמות ליציבות המערכת, ולא להיפך

המטרה של ניהול החריגות היא לוודא שהמערכת מתנהגת כראוי גם במצבים לא צפויים, שאינם מאפשרים לה לפעול בדרך המתוכננת. שימוש לא נכון בזריקת exceptions עלול לגרום להיפך הגמור. קוד שבו חריגות פשוט עוצרות את הפעולה הנוכחית ו"עפות" חזרה במעלה מחסנית הפונקציות עלולות להשאיר את המערכת עם פורטים פתוחים, תהליכים יתומים, handles ללא שימוש, מבני נתונים "שבורים" ולא קוהרנטיים ובעצם במצב הרבה יותר גרוע מאשר סתם לא להצליח לבצע קריאה כלשהי. לכל שפה ומערכת יש את הכלים שלה כדי להתמודד עם הקושי הזה – אם זה כחלק אינהרנטי בהגדרת מנגנון החריגות (כמו משפט finally), באמצעות שימוש ב-smart objects או בכל דרך אחרת. ללא הבנה ברורה של המנגנונים האלו – אין טעם לטפל בחריגות.

בספר "Effective C++", סקוט מאיירס מגדיר שני כללים בסיסיים שלהם חייב לציית קוד במקרה של חריגות:

  1. קוד שהוא exception-safe, לעולם לא יאפשר דליפת משאבים;
  2. קוד שהוא exception-safe, לעולם לא ישאיר מבנה נתונים "שבור".

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

"התחייבות בסיסית" (The Basic Guarantee)

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

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

אם נרצה לבצע פילטר כלשהו על תמונה – ייתכן שנצליח להחיל אותו על כל התמונה; ייתכן שהוא לא יוחל על אף פיקסל שהוא; ייתכן גם שבעקבות ה-exception, נפגע בפילטרים שכבר עשינו על התמונה קודם לכן ונחזיר אותה למצבה המקורי. זה כמובן יהיה מאוד לא נעים למעצב הגרפי שעובד עליה כרגע, אבל זה לפחות מבטיח שהמערכת נמצאת במצב "חוקי". מה שמובטח למערכת (ולמעצב הגרפי) הוא שלא יהיה מצב שבו חלק מהתמונה עבר שינוי, וחלק אחר נותר כפי שהיה קודם לכן.

"התחייבות חזקה" (The Strong Guarantee)

המקרה שתיארנו קודם לכן, בו איבדנו עבודה שכבר עשינו על התמונה, אולי מבטיח מערכת יציבה אבל לא בהכרח פופולארית… במקרים בהם זה אפשרי, מוטב להשתמש ב"התחייבות החזקה". פונקציה הפועלת תחת "התחייבות חזקה", על פי מאיירס, מתחייבת כי לאחר הקריאה אליה, המערכת תהיה באחד משני מצבים בלבד: או שהפעולה תתבצע בהצלחה, והמערכת תעבור ממצב "A" למצב "B" (למשל – מצבע אדום לצבע שחור) או שהפעולה תיכשל, והמערכת תישאר בדיוק כפי שהיתה קודם הקריאה, ללא כל side effects או שינויים (למשל – תישאר בצבע אדום). אלו, ואלו בלבד, הן האפשרויות הקיימות. פונקצייה המתחייבת לעבוד תחת הגדרה חזקה זו מהווה למעשה "טרנזקציה" – או שהיא תתבצע במלואה, או שלא תתבצע כלל.

כמובן, מערכת הכתובה בצורה כזו היא יציבה בהרבה; אולם, לא תמיד ניתן לכתוב מערכות בכזו רמה של התחייבות, ולא תמיד ההשקעה בכתיבת הקוד הרלוונטי כדאית.

"התחייבות לאי-זריקה" (The Nothrow Guarantee)

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

מקור: cc0-by-pixabay

מקור: cc0-by-pixabay

במקרה של בליעה, פנו מייד לרופא!

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

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

הרבה פעמים תפיסה גנרית של exceptions היא בכלל שאריות של שלב הפיתוח או הדיבאג, שבו רצינו להיפטר מדברים שלא עניינו אותנו באותו רגע, ולאחר מכן הקוד נשאר כמו שהוא. לכן, כסוג של כלל אצבע – במהלך כל code review, או לפני כל commit, חשוב מאוד לזהות מקרים של אי-טיפול ולוודא שהם אכן אמורים להיות שם (ואם הקוד אמור להיות שם – זה כנראה מסוג המקומות שדורשים הערות בתוך הקוד, גם אם זה נראה Self Explained Code).

הייררכיה של טיפול בחריגות

איך ומה?

טיפוסים: איזה טיפוס exception צריך לזרוק? האם להסתפק בסתם IOException, או אולי להשתמש בטיפוס ספציפי יותר המוגדר בשפה? ואולי, בכלל, ליצור טיפוס מיוחד עבור המקרה הזה?

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

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

כללי אצבע

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

הכלל השני הוא הכלל אותו הגדרנו בהתחלה – ברומא, התנהג כרומאי. אם המערכת בנויה כך ששגיאות מטופלות מקומית – טפל בהן כך. אם המערכת מצפה שכל שגיאה תוצף עד לרמה חיצונית כלשהי שתבצע בה טיפול "אחיד" – אז זוהי הדרך. אי שמירה על הכלל הזה תוביל לכך שתקלות פשוטות למדי תגרומנה לקריסה, או לחילופין, שלא ניתן יהיה לנתח ולהגיב לתקלות ב-scale מערכתי. זה נכון לא רק ל"טיפול" אלא גם ל"טיפוס". אל תזרוק שגיאה מסוג string ב-C++, למרות שאתה יכול – זרוק שגיאה ששייכת למשפחת הטיפוסים של exceptions. אל תזרוק שגיאת RunTime בג'אווה אם מדובר בשגיאה שניתן היה לצפות מראש… כל שפה וסביבה והכללים שלה לטיפוסים הרלוונטיים.

ובכל זאת?

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

טיפוסי חריגות מתאימים לטיפוסי הקוד

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

למה, בעצם?

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

אבל – אם אנחנו יוצאים לקונטקסט רחב יותר, המשמעות של החריגה כבר אינה ידועה. אם מתקבלת IO-Exception מפונקציה מורכבת, שאחראית לביצוע מספר רב של משימות, לא נוכל להבין ממנה איזו תקלה בדיוק אירעה, מה התבצע ומה לא, ולפיכך גם לא נוכל לתכנן טיפול יעיל במקרה שכזה. לעומת זאת, אם באותה הפונקציה מתקבלת שגיאה שמקורה ידוע – למשל, מפני שהיא מטיפוס PerformanceLogIOException ולא מטיפוס אחר, נניח UserDataIOException או StockTradeIOException, נדע לקבל החלטה מתאימה. למשל, שכל עוד מדובר רק בבעיה כלשהי שנוצרה בספריית רישום הביצועים, לא מדובר בתקלה קריטית במערכת, ולכן אפשר פשוט להפנות את המשימה מחדש לשרת אחר, לשנות קונפיגורציה, או לפעול בכל דרך מתאימה אחרת. כמובן, לא בהכרח יעניין אותנו הטיפוס הספציפי של השגיאה; ייתכן שיעניין אותנו רק המקור שלה. נניח, למשל, שכל טיפוסי החריגות במודול PerformanceLog יורשים טיפוס בסיסי של PerformanceLogException. במצב כזה, חלקים באיזורים "גבוהים יותר" בקוד יכולים להסתפק בלתפוס את טיפוס החריגה הזה, ולבצע טיפול גנרי לשירות רישום הביצועים.

על פי אותו העיקרון, נוכל לבצע הפשטה של כמה רמות. למשל, יצירת PerformanceException, שתחתיו תוגדרנה כל החריגות הרלוונטיות למודול הביצועים; ממנו, נוכל לרשת טיפוסי-אב נוספים, כמו למשל PerformanceLogException, שיהיה אחראי לכל תת-המודול העוסק ברישום ללוג של הביצועים, או PerformanceTweakException, שיהיה אחראי לכל תת-המודול העוסק בביצוע שינויים בביצועים. ותחתם, נוכל לרשת רמות נוספות, כמו למשל PerformanceLogIOException. כך, בכל רמה בהייררכיות המערכת, נוכל להחליט אם לטפל בשגיאה הספציפית או שנכיר אותה רק בצורה כללית יותר.

לדוגמא:

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

השימוש ברמות שונות של ירושה מאפשר רמות שונות של היכרות והחלטה.

שימו לב, כמובן, מה אתם חושפים החוצה. אם אתם לא מעוניינים שצד שלישי המשתמש בספריות או בשירותים שלכם יידע שאתם משתמשים באלגוריתם של חברת BestAlgComp, מוטב שלא לתת לחריגה מטיפוס BestAlgCompException לחלחל החוצה. וכמובן, לא תרצו לזרוק למעלה והלאה חריגה הנושאת מידע לפיו לא הצליחה להתחבר לשרת עם שם משתמש XXX וסיסמא YYY…

שמרו על המשמעות

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

לדוגמא:

נניח שיצרנו מבנה נתונים חדש, שמתנהג כמו Map או Dictionary – מחזיק אוסף של מפתחות ולכל מפתח ערך מתאים. אם ננסה לגשת למפתח שלא קיים, אנחנו אמורים לזרוק חריגה מסוג של NoSuchKeyException, או משהו בדומה לזה. אם ננסה לקבל את המפתח לערך כלשהו שאינו קיים, נקבל חריגה מסוג שונה – משהו בסגנון NoSuchValException. עד כאן הכל הגיוני… אבל מה אם יצרנו מבנה נתונים A שמסיבות כלשהן (נניח, זמן גישה) שומר את הערכים שלו כמפתחות ב-Dictionary פנימי B? במקרה כזה, גישה לערך שאינו קיים ב-A תגרום לזריקת NoSuchKeyException כאשר יתבצע המימוש שלה באמצעות קריאה ל-B. אם נחליט פשוט שאנחנו זורקים כל חריגה לרמה שמעליה, נזרוק למעשה (ברמת המבנה A) חריגה שאינה נכונה ואינה מובנת. המערכת פנתה לקבל ערך, וקיבלה חריגה על מפתח שאינו קיים – זו אינה התנהגות צפוייה, והיא יכולה להיות אפילו מסוכנת, כי מי שקרא לערך ינסה לתפוס אך ורק חריגות רלוונטיות, ולא חריגות מטיפוסים שלא אמורים להיזרק. לכן, במקרה כזה, הדבר הנכון יהיה לתפוס את החריגה מ-B, ולזרוק במקומה חריגה מטיפוס NoSuchValException. בחלק מהשפות ניתן לכלול את החריגה המקורית כחלק מהמידע שנושאת איתה החריגה החדשה (לא בהכרח נרצה לעשות את זה – תלוי האם אנחנו בכלל מעוניינים לחשוף את המימוש הפנימי).

לא משנה אם אנחנו משתמשים בסט של טיפוסי חריגות שהוגדרו במיוחד למודול מסויים או בחריגות סטנדרטיות – המשמעות שלהן חייבת להיות רלוונטית כדי שניתן יהיה לפעול נכון כשתופסים אותן.

הגדירו בדיוק אילו חריגות עשויה כל קריאה לזרוק

מבין כל הכללים וההצעות לקביעת רמות הטיפול בחריגות – זהו הכלל החשוב ביותר. הגדרת ה-API של פונקציה כוללת גם את החריגות שהיא עשויה לזרוק. ישנן שפות בהן השפה מחייבת הגדרה ברורה ומלאה של חריגות אפשריות כחלק מחתימת הפונקציה. בשפות אחרות, ובמקרים שונים, הקומפיילר/אינטרפרטר אינו אוכף זאת. אף על פי כן, זוהי חובתו של כל מפתח לוודא שברור לחלוטין אילו חריגות הפונקציות שלו עלולות לזרוק (אם בדרך של רישום בחתימת הפונקציה גם כאשר אין אכיפה, אם בדרך של תיעוד בעזרת הערות או בכלים אחרים). כאשר קוראים לפונקציה הזו, חובה לקבל החלטה ברורה לגבי הפעולה שתתבצע עבור כל חריגה לגיטימית שתיזרק ממנה. אפשר להחליט לטפל, ואפשר להחליט לזרוק אותה הלאה, אבל ההחלטה חייבת להיעשות בצורה מודעת.

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

תפוס וזרוק

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

פולימורפיזם

אפשרות אפילו טובה יותר, אם כי לא מתאימה לכל סיטואציה, תהיה להשתמש בעקרון הפולימורפיזם. אם הטיפוס DatabaseAuthenticationError יורש את הטיפוס DatabaseConnectionError, הבעיה אינה צריכה פתרון – היא פשוט אינה קיימת. כל המנגנונים הקיימים יודעים שעליהם לטפל ב-DatabaseConnectionError. כמובן, כעת נוכל לשפר אותם ולהוסיף טיפול ייחודי עבור המקרה של בעיית אותנטיקציה, אבל לא שברנו את הקוד בשום מקום. ירושה כזו, כמובן, אינה תמיד אפשרית; אולם זהו אחד היתרונות בבנייה של מערכת חריגות הייררכית כפי שהצעתי קודם לכן – היא מאפשרת פתרונות מהסוג הזה ושומרת על המערכת יציבה יותר. כצעד מקדים, ניתן לוודא שפונקציות זורקות טיפוסים "כלליים" לצד טיפוסים ספציפיים יותר. למשל, גם אם בזמן כתיבת הפונקציה אנחנו יודעים שהיא עלולה לזרוק DatabaseNotExistsError (שיורש מהטיפוס הכללי של חריגת DatabaseConnectionError), לא נגדיר את החתימה שלה רק עם DatabaseNotExistsError, אלא גם עם הטיפוס הכללי יותר, DatabaseConnectionError. כך נוכל לוודא שאם בעתיד תהיינה חריגות DB אחרות – כמו, למשל, DatabaseAuthenticationError – הן תטופלנה ולא תיגרם שבירת קוד.

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

טפלו בכל חריגה צפויה. אל תטפלו בחריגות בלתי צפויות

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

סיכום

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

  • שמירה על "חוזה" חריגות תקף
  • טיפול / זריקה במודע של כל חריגה לגיטימית
  • אי טיפול בחריגות שאינן צפויות
  • שמירה על ה"ההתחייבויות", בעדיפות ל"התחייבות החזקה"

וכלל חשוב נוסף שכדאי ליישם הוא, לבחון את מנגנוני הטיפול בחריגות בקפידה בכל code review ולפני כל commit – אלו בדיוק המקומות המועדים לפורענות, ועלולים להישאר בלתי שלמים לאחר שהלוגיקה הבסיסית מתקמפלת ורצה בהצלחה.

הפוסט פורסם לראשונה בבלוג ״ארכיטקט בכפכפים״

מייק לינדנר

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

הגב

3 Comments on "טיפול בחריגות Exceptions - איך עושים את זה נכון?"

avatar
Photo and Image Files
 
 
 
Audio and Video Files
 
 
 
Other File Types
 
 
 
Sort by:   newest | oldest | most voted
סטארטאפיסט
Guest

זה אמיתי?
מחר: כך תוסיפו קריאות logs לקוד. ובשבוע הבא: איך לסגור חיבור לDB.

Lala
Guest

ומה שאפשר לפתור בif תמיד עדיף לפתור בif

יהב
Guest

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

wpDiscuz

תגיות לכתבה: