1.3 - ערכים זמניים וחיי אובייקטים - הרצאה
הכתובת נשארה והאובייקט כבר איננו¶
נניח שכתבנו פונקציה שמחזירה את השם של משימה. כדי לחסוך העתקה החלטנו להחזיר reference. הקוד הבא מדגים את הטעות, ולכן אסור להשתמש בערך שהוא מחזיר:
ה־reference מצביע על אובייקט מקומי שחייו מסתיימים ביציאה מהפונקציה. ייתכן שהbytes שמייצגים את האובייקט עדיין נמצאים במחסנית, אבל זאת אינה הבטחה שאפשר להשתמש בו. בתוכנה מורכבת הקריאה הבאה לפונקציה אחרת יכולה למחזר את האחסון, והסימפטום יופיע הרבה אחרי ההחזרה.
התיקון הפשוט הוא להחזיר ערך:
הקוד הזה מגדיר תוצאה עצמאית של הפונקציה. מהדר C++ מודרני אינו חייב ליצור שרשרת של העתקות כפי שלפעמים מדמיינים מקריאת המקור. בהמשך נבדוק copy elision, אבל כבר עכשיו ברור שהחוזה הנכון קודם לניסיון לחסוך פעולה שלא בהכרח מתרחשת.
משך אחסון מול משך חיים¶
אחסון הוא מקום שיכול להכיל אובייקט. משך חיים, או lifetime, הוא פרק הזמן שבו האובייקט קיים לפי כללי השפה. אלה מושגים קשורים, אך לא זהים: אפשר להחזיק אחסון ולהחליף את האובייקט שבתוכו, ואפשר לסיים חיי אובייקט לפני שמשחררים את המקום.
למשתנה מקומי רגיל יש automatic storage duration. לאובייקט שהוקצה דינמית עשוי להיות dynamic storage duration, בלי קשר לשאלה אם המצביע אליו הוא משתנה מקומי. גם משתנה גלובלי ו־thread-local object מקבלים כללי אחסון שונים, אבל אף אחד מהם אינו פוטר אותנו מהוכחת lifetime.
נרצה לראות בנייה והשמדה בלי לנחש את מיקום המחסנית. נשמור את התוכנית הבאה בקובץ lifetime.cpp:
#include <cstdio>
struct Trace {
const char* label;
explicit Trace(const char* text) : label(text) {
std::printf("construct %s\n", label);
}
~Trace() {
std::printf("destroy %s\n", label);
}
};
int main() {
Trace outer{"outer"};
{
Trace first{"first"};
Trace second{"second"};
std::puts("inside");
}
std::puts("outside");
}
הבנאי נקרא עם יצירת האובייקט, וה־destructor נקרא כשמסתיימים חייו. בקוד הזה second מושמד לפני first, ושניהם לפני ההדפסה outside. האובייקט outer מושמד בסוף main, אחרי ההדפסה האחרונה.
הסדר חשוב במשאבים תלויים. אם אובייקט אחד משתמש באחר בזמן ניקוי, צריך לוודא שהתלות עדיין חיה. סדר הכרזת משתנים או שדות יכול להיות חלק מחוזה חיים, ולא רק העדפה אסתטית.
שלוש קטגוריות ביטוי¶
לכל ביטוי יש טיפוס וגם קטגוריית ערך. נשתמש במושגים lvalue, prvalue ו־xvalue כדי להסביר מה אפשר לקשור אליו ואיך מותר להעביר את תכולתו.
הביטוי outer הוא lvalue שמזהה אובייקט. הביטוי Trace{"temporary"} הוא prvalue שמייצג תוצאה חדשה. הביטוי std::move(outer) הוא xvalue שמאפשר להתייחס לאובייקט כמועמד להעברת משאב; עצם הביטוי אינו מסיים את חייו ואינו מעביר דבר.
השם של משתנה הוא בדרך כלל lvalue גם כשהטיפוס המוצהר שלו הוא rvalue reference. זאת נקודה שנראית מוזרה עד שמפרידים בין טיפוס המשתנה לבין קטגוריית הביטוי שבו השתמשנו. בשיעור ההעברה נראה מדוע ההבחנה הזאת נדרשת ב־wrapper שמעביר פרמטר הלאה.
מתי אובייקט זמני מושמד¶
נוסיף לתוכנית פונקציה שמקבלת reference קבוע:
הקריאה הבאה יכולה להופיע בתוך main:
האובייקט הזמני צריך להישאר חי בזמן הקריאה. הוא מושמד בסוף ה־full-expression שמכיל אותה, לפני ההדפסה הבאה. המושג full-expression אינו תמיד זהה לשורה במסך; כאן נקודת הפסיק מסיימת את ביטוי הקריאה.
אם הפונקציה שומרת מצביע לאובייקט לשימוש מאוחר, עצם קבלת const Trace& אינה מאריכה את חיי האובייקט מעבר לקריאה. זאת בעיה נפוצה ב־callbacks ובתורים: הממשק נראה זול ונוח, אבל העבודה נדחית למועד שבו הקלט כבר מת.
הארכת חיים היא כלל נקודתי¶
קישור ישיר ל־reference מקומי יכול להאריך חיי temporary:
במקרה הזה הזמני נשאר חי עד סוף הבלוק. אין פירוש הדבר שכל העתקה של reference יוצרת הארכת חיים נוספת. ההארכה נובעת מאופן האתחול המסוים, ולא ממספר המשתנים שמחזיקים alias לאובייקט.
הדוגמה הבאה היא קטע לניתוח בלבד; אין לגשת דרך stored אחרי סוף הבלוק:
const Trace* stored = nullptr;
{
const Trace& held = Trace{"held"};
stored = &held;
}
// stored does not keep the object alive.
המצביע אינו בעלים, וה־reference המקומי כבר יצא מתחומו. גם אם נשמור את המצביע במכולה ארוכת חיים, לא שינינו את מועד השמדת האובייקט שאליו הוא מתייחס.
חיי שדות ובנייה חלקית¶
מחלקה עשויה להכיל כמה שדות שלכל אחד מהם יש lifetime משלו. סדר בניית שדות הוא סדר ההצהרה שלהם במחלקה, ולא סדר הכתיבה ברשימת האתחול. סדר ההשמדה הפוך לסדר הבנייה של השדות שנבנו בהצלחה.
ההבחנה תהיה חשובה במיוחד אם בנאי של שדה זורק חריגה. האובייקט החיצוני עדיין לא השלים את בנייתו, אבל שדות שכבר נבנו דורשים ניקוי. מנגנון השפה דואג להשמדת אותם שדות, ולכן נרצה שמשאבים יהיו עטופים באובייקטים ולא מפוזרים כמצביעים גולמיים.
איך חוקרים בלי לקרוא זיכרון לא תקף¶
מוסיפים לוג בנייה והשמדה, מציבים breakpoint ב־destructor ובודקים איזה קוד מנסה להשתמש באובייקט לאחר מכן. אפשר להשתמש גם בכלי sanitizer מתאים, אבל היעדר דיווח אינו הוכחה שאין dangling reference.
השאלה שאפשר לענות עליה מהקוד היא מי יצר את האובייקט ומתי חייו מסתיימים. השאלה הנפרדת היא איזה alias עשוי לשרוד מעבר לנקודה הזאת. כשלא ניתן להוכיח שהalias קצר חיים מהבעלים, צריך לשנות את הממשק או את הבעלות.
לקריאה נוספת ראו את כללי lifetime בטיוטת תקן C++ ואת כללי קישור references, תוך התאמה לגרסת C++20 שבה משתמשים בתרגילים.