מזריקה לטיפול
בדף הקודם למדתם איך לעשות throw לחריגה כשמשהו משתבש. זריקה היא רק חצי מהסיפור: חריגה שאף פעם לא נתפסת קוראת ל-std::terminate ומפילה את התוכנית. משפט try/catch הוא הדרך לטפל במה שנזרק ולהמשיך לרוץ.
המבנה פשוט: שמים את הקוד המסוכן בתוך בלוק try, ואחריו בלוק catch אחד או יותר שמגיבים לטיפוסי שגיאה מסוימים. אם בלוק ה-try רץ בלי בעיות, כל ה-catch מדולגים. ברגע שמשהו נזרק, השליטה קופצת ישר ל-catch הראשון שמתאים.
שימו לב ש-"after" אף פעם לא מודפס. ברגע שה-throw מופעל, שאר בלוק ה-try ננטש והביצוע ממשיך בתוך ה-catch המתאים. אחרי שה-catch מסתיים, התוכנית ממשיכה כרגיל מתחתיו.
תפיסה כהפניה const
ההרגל החשוב ביותר בטיפול בשגיאות ב-C++: תפסו חריגות כהפניה const, לא by value.
תפיסה by value מעתיקה את החריגה, וגרוע מזה, חותכת אותה (slicing). החריגות הסטנדרטיות יוצרות היררכיה (runtime_error ו-logic_error שתיהן יורשות מ-std::exception), ולכן תפיסה של חריגה נגזרת כערך מטיפוס הבסיס חותכת את החלק הנגזר. תפיסה כהפניה שומרת על האובייקט שלם ופולימורפי:
כאן זורקים out_of_range אבל תופסים אותה כ-const exception&. מכיוון ש-out_of_range יורשת מ-exception, ה-handler של מחלקת הבסיס מתאים, וההפניה אומרת ש-e.what() עדיין מחזיר את ההודעה האמיתית. אילו הייתם כותבים catch (exception e) (by value), האובייקט היה נחתך ל-exception פשוט והייתם עלולים לאבד את ההודעה הספציפית.
כמה בלוקי catch
אחרי try אחד יכולים לבוא כמה בלוקי catch, כל אחד לטיפוס חריגה אחר. C++ בודקת אותם מלמעלה למטה ומריצה את הראשון שמתאים, אז סדרו אותם מהספציפי ביותר לכללי ביותר.
מכיוון ש-invalid_argument ספציפית יותר מ-exception, היא חייבת לבוא קודם. אם הייתם הופכים את הסדר ושמים את catch (const exception&) למעלה, הוא היה בולע כל חריגה, וה-handler של invalid_argument מתחתיו היה הופך לקוד מת שלעולם לא ירוץ. מהדרים רבים מזהירים על זה, אבל השפה לא תעצור אתכם.
catch (...) וזריקה מחדש
לפעמים רוצים רשת ביטחון לכל מה שלא צפיתם. ה-handler התופס-הכול catch (...) מתאים לכל טיפוס חריגה, כולל כאלה שלא יורשים מ-std::exception (מישהו יכול לכתוב throw 42; או throw "oops";).
המלכודת היא שלא מקבלים אובייקט: אין e לבדוק. לכן הכי טוב להשתמש ב-catch (...) כמוצא אחרון: לרשום ש_משהו_ נכשל, או לנקות ולזרוק מחדש.
כדי לזרוק מחדש את החריגה הנוכחית, כלומר להעביר אותה ל-handler חיצוני אחרי ניקוי מקומי או רישום ללוג, השתמשו ב-throw; חשוף בלי אופרנד. זה שומר על החריגה המקורית (הטיפוס וההודעה האמיתיים שלה), בניגוד ל-throw e;, שהיה זורק מחדש עותק חתוך:
ה-handler הפנימי רושם ללוג וזורק מחדש; ה-handler החיצוני ב-main מטפל בה אחר כך. השתמשו ב-throw; חשוף לזה, אף פעם לא ב-throw e;.
Stack unwinding ו-RAII
כשחריגה מתפשטת החוצה מבלוק try, C++ מבצעת stack unwinding (פריסת המחסנית): לכל אובייקט מקומי שבין ה-throw ל-catch המתאים נקרא ה-destructor, בסדר הפוך לסדר הבנייה. זה מה שהופך חריגות לבטוחות: משאבים שמוחזקים באובייקטים על המחסנית משתחררים אוטומטית.
זו בדיוק הסיבה להחזיק משאבים בטיפוסי RAII (כמו std::vector, std::string ו-smart pointers) ולא ב-new/delete גולמיים. שימו לב מה קורה כשחריגה חוצה הקצאה ידנית:
void leaky() {
int* buffer = new int[1000];
mightThrow(); // אם זה זורק, השורה הבאה אף פעם לא רצה...
delete[] buffer; // ...וה-buffer דולף
}
מכיוון שה-throw קופץ מעל delete[], הזיכרון אובד. smart pointer פותר את זה בחינם: ה-destructor שלו רץ במהלך ה-unwinding:
void safe() {
auto buffer = std::make_unique<int[]>(1000);
mightThrow(); // אם זה זורק, ה-destructor של buffer עדיין משחרר את הזיכרון
} // בלי delete ידני, בלי דליפה, גם במסלול החריגה
המסקנה: אל תנסו לעשות catch לחריגה רק כדי לעשות delete למשהו. תנו ל-destructors לנקות, ושמרו את catch להחלטות על איך להתאושש.
טעויות ומלכודות נפוצות
כמה מלכודות חוזרות שוב ושוב:
אל תשתמשו בחריגות לזרימת בקרה רגילה. זריקה ו-unwinding איטיים בהרבה מ-if פשוט. שמרו חריגות למצבי שגיאה חריגים באמת, לא ל"המשתמש הקליד מחרוזת ריקה".
בלוק catch ריק מסתיר באגים. כתיבת catch (...) {} כדי להשתיק שגיאה אומרת שכשלים נעלמים בלי להשאיר עקבות. לכל הפחות רשמו את הבעיה ללוג; בדרך כלל צריך לזרוק מחדש או לטפל בה כמו שצריך.
destructor שזורק הוא מסוכן. אם destructor זורק במהלך stack unwinding (כשחריגה אחרת כבר באוויר), התוכנית קוראת ל-std::terminate. ב-C++ מודרנית destructors הם noexcept באופן מובלע: אף פעם אל תתנו לחריגה לברוח מאחד.
catch רואה רק את מה ש-try מכסה. חריגה שנזרקת לפני הכניסה ל-try, או בפונקציה אחרת שלא נמצאת במסלול הקריאות שבתוכו, לא תיתפס כאן. ה-catch מגן רק על קוד שרץ בתוך בלוק ה-try שלו (ישירות או בפונקציות שהוא קורא להן).
הבא בתור: Undefined Behavior
חריגות הן הדרך המוגדרת שבה C++ אומרת לכם שמשהו השתבש: זורקים, תופסים, וההתנהגות צפויה. אבל ל-C++ יש גם פינה אפלה יותר שבה השפה לא מבטיחה שום דבר: גישה דרך מצביע תלוי (dangling), קריאה מעבר לסוף מערך, גלישה של מספר שלם עם סימן. הדף הבא עוסק ב-undefined behavior (התנהגות לא מוגדרת): מה גורם לה, למה היא יכולה להיראות כאילו היא "עובדת" עד לרגע שבו היא קורסת בצורה הרסנית, ואיך להרחיק אותה מהקוד שלכם.
שאלות נפוצות
איך try-catch עובד ב-C++?
שמים קוד שעלול לזרוק חריגה בתוך בלוק try { }. אם נזרקת חריגה, התוכנית מפסיקה להריץ את שאר בלוק ה-try וקופצת לבלוק ה-catch הראשון שמתאים, ושם מטפלים בשגיאה. אם שום דבר לא נזרק, בלוקי ה-catch מדולגים לגמרי.
למה כדאי לתפוס חריגות כהפניה const ב-C++?
תפיסה כהפניה (catch (const std::exception& e)) חוסכת העתקה של אובייקט החריגה, ובעיקר שומרת על פולימורפיזם: חריגה נגזרת שנתפסת כטיפוס הבסיס שלה עדיין קוראת ל-what() הנכון. תפיסה by value (catch (std::exception e)) חותכת את החלק הנגזר ועלולה לאבד מידע.
איך תופסים כל חריגה ב-C++?
השתמשו ב-catch (...): שלוש הנקודות תופסות כל חריגה, בלי קשר לטיפוס שלה. זה handler שימושי כמוצא אחרון, אבל מכיוון שאין אובייקט לבדוק, שימו אותו אחרי בלוקי ה-catch הספציפיים והשתמשו בו בעיקר כדי לרשום ללוג או לזרוק מחדש.