2.7 - חריגות וערבויות פעולה - הרצאה
נכשלנו אחרי שכבר שינינו משהו¶
נניח שפונקציה מקבלת טקסט חדש ומחליפה בו הגדרה קיימת. היא צריכה להקצות זיכרון, לבדוק את התוכן ולבסוף לעדכן את המצב. אם אחת הפעולות נכשלת, הקורא צריך לדעת איזה מצב נשאר.
שאלת השגיאה אינה רק אם נזרקה exception. צריך לשאול אם המשאבים נוקו, אם האובייקט נשאר תקין ואם הוא שומר את הערך הקודם. אלה ערבויות שונות שאפשר לבחור כחלק מחוזה הפעולה.
הקוד הבא נותן ערבות חזקה לפעולת ההחלפה:
#include <cassert>
#include <stdexcept>
#include <string>
#include <string_view>
void replace_name(std::string& destination, std::string_view proposed) {
std::string candidate{proposed};
if (candidate.empty()) {
throw std::invalid_argument("name must not be empty");
}
destination.swap(candidate);
}
int main() {
std::string name = "original";
try {
replace_name(name, "");
} catch (const std::invalid_argument&) {
assert(name == "original");
}
replace_name(name, "updated");
assert(name == "updated");
}
הקוד בונה את המצב החדש בצד. אם הקצאת המחרוזת או הבדיקה נכשלת, destination עדיין לא השתנתה. רק אחרי שהמועמד מוכן מבצעים swap של שתי מחרוזות בעלות allocator רגיל ותואם.
נקודת ה־swap היא commit: כאן אנחנו הופכים את המצב החדש לרשמי. אחריה candidate מחזיקה את המצב הישן, וה־destructor שלה מנקה אותו. לא צריך לכתוב מסלול rollback שמנסה לבנות מחדש מחרוזת שכבר הושחתה.
שלוש ערבויות שונות¶
הערבות הבסיסית אומרת שלא נשארות דליפות או הפרות של invariants, אבל הערך יכול להשתנות. לדוגמה, פעולה שמוסיפה כמה איברים למכולה יכולה להיכשל אחרי שחלקם כבר נוספו, כל עוד החוזה מאפשר זאת והמכולה נשארת תקינה.
הערבות החזקה אומרת שכשל משאיר את המצב הנצפה כפי שהיה לפני הפעולה. הביטוי המקובל הוא commit or rollback, אבל המימוש לא חייב לבצע undo בפועל; בניית candidate מראש יכולה להיות פשוטה ובטוחה יותר.
ערבות no-throw אומרת שהפעולה אינה מוציאה exception. היא אינה אומרת שהפעולה בהכרח הצליחה מבחינה עסקית. פונקציה יכולה להחזיר קוד שגיאה, לשנות מצב מוגדר או לספק cleanup שאינו מדווח דרך חריגה.
מה קורה במחסנית¶
כאשר נזרקת חריגה ויש handler מתאים, מתבצע stack unwinding לאורך scopes שבין הזריקה ל־handler. אובייקטים מקומיים שנבנו בהצלחה מושמדים כחלק מהיציאה.
הקוד הבא מדגים את הסדר:
#include <cstdio>
#include <stdexcept>
struct Trace {
const char* name;
~Trace() { std::printf("destroy %s\n", name); }
};
void inner() {
Trace second{"inner"};
throw std::runtime_error("failure");
}
void outer() {
Trace first{"outer"};
inner();
}
int main() {
try {
outer();
} catch (const std::exception& error) {
std::printf("caught %s\n", error.what());
}
}
ההדפסה של inner מגיעה לפני outer, ורק אחריה מגיע ה־catch. אם בנאי נכשל לפני השלמת האובייקט, ה־destructor של האובייקט הלא גמור אינו מנגנון הניקוי; השדות שכבר נבנו הם שמושמדים.
לא כדאי להסתמך על cleanup כאשר אין handler והמערכת מגיעה ל־terminate. פרטי unwinding במצב כזה אינם תחליף לחוזה מפורש של טיפול בשגיאה בגבול מתאים.
תפיסה ושיגור מחדש¶
תופסים exception בדרך כלל באמצעות reference קבוע לטיפוס מתאים. תפיסה לפי ערך יכולה לבצע העתקה ואף slicing כאשר הטיפוס הנזרק נגזר מהטיפוס שבחתימת catch.
כאשר רוצים להוסיף log ולהעביר את אותה חריגה הלאה, משתמשים ב־throw ללא ארגומנט בתוך handler:
try {
outer();
} catch (const std::exception& error) {
std::fprintf(stderr, "context: %s\n", error.what());
throw;
}
הקטע דורש handler חיצוני מתאים במקום שבו משלבים אותו. הכתיבה throw error; יוצרת זריקה חדשה לפי הטיפוס של הביטוי, ועלולה לאבד מידע של הטיפוס הדינמי. זאת אינה גרסה ארוכה של אותה פעולה.
כשרוצים לשמור חריגה להעברה בין מסלולי עבודה, אפשר להשתמש ב־exception_ptr ולבצע rethrow בנקודה המתאימה. הפרט הזה יהיה שימושי בפרק המקביליות, שבו worker ו־consumer אינם בהכרח נמצאים באותה מחסנית.
החוזה של noexcept¶
הסימון noexcept מצהיר שחריגה לא תצא מהפונקציה. אם יוצאת חריגה בניגוד לחוזה, התוכנית מגיעה ל־terminate; המנגנון אינו ממיר אותה אוטומטית לקוד שגיאה.
זאת הסיבה שצריך לבדוק את כל הפעולות שהפונקציה קוראת להן. גם פעולה שנראית קטנה, כמו כתיבת הודעה למבנה דינמי, יכולה להקצות ולזרוק. הוספת noexcept בלי הוכחה עלולה להפוך כשל שניתן לטפל בו לסיום התהליך.
בפעולות move, החוזה משפיע גם על קוד גנרי. מכולה שרוצה לשמור ערבות חזקה בזמן גדילה יכולה להעדיף העתקה אם move עשוי לזרוק ויש copy זמין. אם copy אינו זמין, ייתכן שאין דרך לקבל אותה ערבות בכל מצב, וצריך לקרוא את החוזה המדויק של המכולה.
למה destructor אינו מקום לחריגה שנייה¶
במהלך unwinding כבר מטפלים בכשל אחד. חריגה נוספת שיוצאת מ־destructor בזמן הזה עלולה להביא ל־terminate. יתרה מכך, destructorים רבים מוגדרים כברירת מחדל כחסרי זריקה לפי הכללים של שדותיהם.
אם סגירת משאב יכולה להיכשל והלקוח צריך לדעת על כך, מספקים close מפורש שמחזיר תוצאה לפני ה־destructor. ה־destructor נשאר רשת ניקוי שמטפלת במצב שנותר לפי מדיניות שלא מוציאה חריגה.
זה אינו אומר שמותר לבלוע כל שגיאה. צריך להחליט מה נרשם, איזה מצב נשאר ואילו התחייבויות אי אפשר לקיים. ההחלטה שייכת לחוזה המשאב ולא לבחירה שרירותית של try/catch ריק.
בדיקה של כל נקודת כשל¶
מוסיפים fault injection אחרי רכישה, אחרי בניית מועמד ולפני commit. בכל נקודה מוודאים את הערבות שהובטחה: מצב תקין עבור basic, מצב קודם עבור strong וניקוי משאבים בכל המקרים.
הבדיקות צריכות לכלול גם כשל בהקצאה ולא רק חריגה עסקית שהוספנו ידנית. בפרק המקצים נשלוט באופן מדויק בנקודת הכשל ונראה אם המימוש תלוי במקרה בכך שהמערכת עדיין מסוגלת להקצות בזמן rollback.
ערבות חזקה טובה רק אם היא מתאימה לצורך ולמחיר. לפעמים היא דורשת העתקה גדולה שאינה מוצדקת, וערבות בסיסית מתועדת היא הבחירה הנכונה. המטרה היא חוזה שאפשר להוכיח, לא הבטחה מרשימה שהקוד אינו מקיים.