Menu

Undefined Behavior ב-C++: מה זה ואיך להימנע מזה

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

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

מה "undefined behavior" אומר בפועל

הדף הקודם הראה איך try/catch מטפל בשגיאות שהתוכנית שלכם מגדירה וזורקת בכוונה. undefined behavior הוא ההפך: זו קבוצת הפעולות שתקן C++ מסרב לתת להן משמעות כלשהי. אין חריגה לתפוס, אין קוד שגיאה, אין הבטחה לקריסה. המהדר חופשי להניח ש-UB אף פעם לא קורה, ולעשות מה שהוא רוצה כשהוא כן קורה.

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

int arr[3] = {1, 2, 3};
int x = arr[5];   // התנהגות לא מוגדרת: קריאה מעבר לסוף המערך

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

קריאה או כתיבה מחוץ לגבולות

הצורה הנפוצה ביותר של UB היא נגיעה בזיכרון שלא שייך לכם. מערכים מובנים ו-std::vector::operator[] לא בודקים גבולות: אינדקס מעבר לסוף (או שלילי) הוא UB מיידי, בין אם קוראים ובין אם כותבים.

הבאג שצריך לשים לב אליו הוא <= במקום שבו התכוונתם ל-<: כש-i == v.size() ניגשים לאינדקס אחד אחרי האיבר האחרון, וזה UB. העדיפו לולאת for range-based (שכיסינו קודם) כשלא צריך את האינדקס, כי היא לא יכולה לחרוג מהסוף. כשאתם כן עובדים עם אינדקסים ידנית ורוצים רשת ביטחון, v.at(i) זורק std::out_of_range במקום להשחית זיכרון בשקט:

השתמשו ב-at() בזמן שאתם צדים באג; חזרו ל-[] בלולאות חמות אחרי שהוכחתם שהאינדקסים תקינים.

מצביעים תלויים ו-use-after-free

מצביע או הפניה שחיים יותר מהאובייקט שהם מצביעים עליו הם תלויים (dangling). שימוש בהם הוא UB: ייתכן שהזיכרון נוצל מחדש, שוחרר, או בכלל לא היה קיים. זו המלכודת ש-smart pointers (מהפרק הקודם) עוזרים להימנע ממנה, אבל מצביעים גולמיים עדיין מאפשרים ליפול לתוכה.

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

int* makeNumber() {
    int n = 42;
    return &n;   // מחזיר כתובת של משתנה מקומי: הוא נעלם אחרי ה-return
}
// גישה דרך התוצאה היא התנהגות לא מוגדרת.

אותו דבר קורה אחרי delete, או כש-vector מקצה מחדש ומבטל את תוקפם של iterators או מצביעים לתוכו:

int* p = new int(5);
delete p;
cout << *p;   // use-after-free: התנהגות לא מוגדרת

vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4);   // עלול להקצות מחדש: 'first' עכשיו תלוי
cout << *first;   // התנהגות לא מוגדרת

ההגנות הן אלה שאתם כבר מכירים: שמרו על אובייקטים בחיים כל עוד מצביע כלשהו צריך אותם, העדיפו הפניות ו-smart pointers על פני מצביעים גולמיים שמחזיקים בבעלות, וקבלו מחדש מצביעים ו-iterators אחרי כל פעולה שיכולה לשנות את גודל הקונטיינר.

משתנים לא מאותחלים וגלישת signed

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

אילו sum היה מוצהר כ-int sum; חשוף, כל sum += i היה קורא קודם ערך לא מוגדר: UB, ובאג ידוע לשמצה בקושי שלו כי לעיתים קרובות הוא נראה כאילו הוא עובד. הפכו אתחול להרגל: int x = 0; או int x{};.

עבריין שקט נוסף הוא גלישה של מספר שלם עם סימן (signed integer overflow). דחיפה של int עם סימן מעבר למקסימום שלו היא UB (טיפוסים unsigned מתגלגלים באופן צפוי; טיפוסים signed לא):

int big = 2147483647;   // INT_MAX ב-int של 32 ביט
int oops = big + 1;     // גלישת signed: התנהגות לא מוגדרת

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

תפיסת UB עם sanitizers ואזהרות

אי אפשר להגיע לביטחון לגבי UB רק מבדיקות, כי הרצה מוצלחת לא מבטיחה כלום. מה שכן עובד הוא להפוך UB לרועש בזמן ריצה בעזרת ה-sanitizers של המהדר (זמינים ב-GCC וב-Clang).

// AddressSanitizer: מחוץ לגבולות, use-after-free, דליפות זיכרון
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app

// UndefinedBehaviorSanitizer: גלישת signed, גישה דרך null, casts שגויים
g++ -fsanitize=undefined -g main.cpp -o app && ./app

הריצו את הבדיקות הקיימות שלכם עם הדגלים האלה, והקריאה מחוץ לגבולות, ה-use-after-free או גלישת ה-signed ש"עבדו בסדר" יהפכו לדיווח מדויק שמציין קובץ ושורה. שלבו אותם עם -Wall -Wextra כדי שהמהדר יסמן גם קוד חשוד (כמו קריאה שכנראה לא מאותחלת) עוד לפני שתריצו אותו.

==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
    #0 main.cpp:7 in main

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

סיכום

undefined behavior הוא החלק ב-C++ שבו מעקות הבטיחות יורדים: גישה מחוץ לגבולות, מצביעים תלויים, use-after-free, קריאות לא מאותחלות וגלישת signed מייצרים כולם קוד בלי משמעות מוגדרת, ו"זה רץ בסדר" אף פעם לא הוכחה שהוא נכון. הדרך להישאר בטוחים היא לכתוב בזהירות (לאתחל כל משתנה, לכבד את גבולות הקונטיינרים, לתת ל-smart pointers להחזיק את זיכרון ה-heap) ואז לאמת עם -fsanitize=address, -fsanitize=undefined ו--Wall -Wextra, כך ש-UB שקט יהפוך לדיווח רועש שאפשר לתקן.

בזה מסתיים פרק השגיאות והדיבוג. בין חריגות, try/catch ופחד בריא מ-UB, יש לכם עכשיו את הכלים לכתוב C++ שנכשלת ברעש ובכוונה, ולא בשקט ובמקרה.

שאלות נפוצות

מה זה undefined behavior ב-C++?

undefined behavior (UB, התנהגות לא מוגדרת) היא כל פעולה שתקן C++ משאיר במפורש בלי תוצאה מוגדרת, למשל קריאה מעבר לסוף מערך או גישה דרך מצביע תלוי. המהדר רשאי לעשות כל דבר: לקרוס, להחזיר זבל, למחוק את הקוד באופטימיזציה, או להיראות כאילו הוא עובד היום ולהישבר אחרי קומפילציה מחדש. זה באג בתוכנית שלכם, לא תכונה של השפה.

למה התוכנית שלי ב-C++ עובדת למרות שיש בה undefined behavior?

"זה רץ בסדר" לא מוכיח כלום לגבי UB. התקן לא נותן שום הבטחה לאף כיוון, אז באג של UB יכול להפיק את התוצאה שציפיתם לה על המחשב שלכם עם המהדר שלכם היום, ואז לקרוס ברמת אופטימיזציה אחרת, בפלטפורמה אחרת או בגרסת מהדר אחרת. אף פעם אל תתייחסו להרצה מוצלחת כהוכחה ש-UB לא מזיק: השתמשו ב-sanitizer כדי לתפוס אותו באמת.

איך תופסים undefined behavior ב-C++?

קמפלו עם sanitizers: -fsanitize=address (AddressSanitizer) מוצא קריאות וכתיבות מחוץ לגבולות ו-use-after-free, ו--fsanitize=undefined (UndefinedBehaviorSanitizer) מסמן גלישה של signed, גישה דרך מצביע null ו-casts שגויים. הפעילו אזהרות (-Wall -Wextra) והריצו את הבדיקות שלכם עם הדגלים האלה: הם הופכים UB שקט לדיווח ברור בזמן ריצה.

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל