Menu

Destructors ב-C++: ~ClassName, RAII וניקוי משאבים

Destructor רץ אוטומטית כשאובייקט מושמד. למדו את התחביר ~ClassName(), מתי הוא מופעל, למה הוא משחרר משאבים, ואת ה-Rule of Three/Five.

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

מה זה destructor

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

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

שימו לב שהודעת ה-destructor מודפסת אחרי ש-main מסיימת את הגוף שלה, אבל לפני שהתוכנית יוצאת. כש-log יוצא מהטווח בסוגר המסולסל הסוגר, C++ מריצה בשבילכם את ~Logger().

מתי destructors רצים

התזמון המדויק תלוי במקום שבו האובייקט חי:

  • אובייקטים על ה-stack (מקומיים) מושמדים כשהם יוצאים מהטווח, ב-} הסוגר של הבלוק.
  • אובייקטים ב-heap (שנוצרו עם new) מושמדים כשקוראים ל-delete. אם שוכחים את ה-delete, ה-destructor אף פעם לא רץ ויש דליפה.

הדוגמה הזו הופכת את ההבדל לגלוי:

אובייקטים מושמדים בסדר הפוך לסדר הבנייה. a נבנה ראשון, ולכן הוא מת אחרון. סדר ה-LIFO הזה (last-in, first-out) חשוב כשאובייקטים תלויים זה בזה.

למה destructors חשובים: RAII

הכוח האמיתי של destructors הוא שהם הופכים את הניקוי ל_אוטומטי ובטוח מפני חריגות_. במקום לזכור לשחרר משאב בכל מסלול בקוד, שמים את השחרור ב-destructor ונותנים לשפה להבטיח שהוא ירוץ. הדפוס הזה נקרא RAII, Resource Acquisition Is Initialization, והוא עמוד השדרה של C++ מודרנית.

כאן מחלקה מחזיקה באפר ב-heap: היא מקצה אותו בבנאי ומשחררת ב-destructor, כך שמי שמשתמש בה אף פעם לא נוגע בעצמו ב-new/delete.

התובנה המרכזית: גם אם חריגה הייתה נזרקת אחרי ש-squares נוצר, ה-stack היה נפרם ו-~IntArray() עדיין היה רץ. ההבטחה הזו היא הסיבה ש-RAII כל כך אמין, והסיבה שבקוד C++ טוב כמעט לא כותבים delete חשוף.

ה-Rule of Three (ו-Five)

מחלקה עם destructor מותאם אישית כמעט תמיד מחזיקה משאב גולמי, וזה יוצר סכנה נסתרת. בנאי ההעתקה והשמת ההעתקה שהמהדר מייצר מבצעים העתקה רדודה: הם מעתיקים את המצביע, לא את הבאפר שהוא מצביע עליו. עכשיו שני אובייקטים מחזיקים את אותו מצביע, ושני ה-destructors יבצעו עליו delete, מה שגורם לקריסת double-free.

IntArray a(5);
IntArray b = a;   // העתקה רדודה: a.data ו-b.data הם אותו מצביע בדיוק
// בסוף הטווח: ה-destructor של b משחרר את הבאפר,
// ואז ה-destructor של a משחרר אותו שוב -> undefined behavior (double free)

מכאן נובע ה-Rule of Three: אם כותבים אחד מהשלושה, destructor, בנאי העתקה או אופרטור השמת העתקה, כמעט בוודאות צריך את שלושתם. ב-C++11 ואילך זה מתרחב ל-Rule of Five, שמוסיף את בנאי ההעברה (move constructor) ואת השמת ההעברה.

אבל יש כלל טוב אפילו יותר, ה-Rule of Zero: לתכנן מחלקות כך שלא ינהלו משאבים גולמיים בכלל. החזיקו במקום זאת std::vector, std::string או smart pointer, וה-destructor שהמהדר מייצר יעשה את הדבר הנכון בחינם.

השתמשו ב-Rule of Zero כברירת מחדל. כתבו destructor מותאם אישית רק כשאתם באמת מחזיקים משאב גולמי שאף טיפוס סטנדרטי לא עוטף בשבילכם.

Virtual destructors

כשמוחקים אובייקט דרך מצביע למחלקת בסיס, ה-destructor חייב להיות virtual; אחרת רק החלק של הבסיס מושמד והחלק הנגזר דולף. זה אחד הבאגים הנפוצים ביותר בקוד פולימורפי, והמהדר לא מזהיר עליו כברירת מחדל.

בלי virtual על ~Base, delete p היה קורא רק ל-~Base(): undefined behavior, והחלק Derived של האובייקט אף פעם לא מנוקה. כלל אצבע: כל מחלקה עם פונקציות וירטואליות (מחלקת בסיס פולימורפית) צריכה virtual destructor. תראו בדיוק למה זה חשוב ברגע שתתחילו לגזור מחלקות.

טעויות ומלכודות נפוצות

כמה מלכודות מכשילות כמעט את כולם:

אי התאמה בין new ל-delete. אם הקציתם עם new[], שחררו עם delete[]. ערבוב של new[] עם delete רגיל (או להפך) הוא undefined behavior.

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

לתת לחריגות לצאת מ-destructor. Destructor שזורק חריגה בזמן פרימת ה-stack מסיים את התוכנית. ב-C++ מודרנית destructors הם noexcept באופן מרומז: דאגו שקוד הניקוי לא יזרוק, או בלעו את החריגה בתוך ה-destructor.

לכתוב destructor שלא צריך. אם החברים שלכם כבר מנקים אחרי עצמם, ~ClassName() {} ריק מוסיף רעש ויכול לבטל בשקט את פעולות ההעברה. כשאין מה לנקות, אל תכתבו destructor בכלל.

הבא בתור: ירושה

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

שאלות נפוצות

מה זה destructor ב-C++?

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

מתי destructor רץ ב-C++?

עבור אובייקט מקומי (על ה-stack), ה-destructor רץ כשהאובייקט יוצא מהטווח, ב-} הסוגר. עבור אובייקט ב-heap שנוצר עם new, הוא רץ כשקוראים ל-delete. החברים ומחלקות הבסיס מושמדים אוטומטית אחר כך, בסדר הפוך לסדר הבנייה.

האם תמיד צריך לכתוב destructor ב-C++?

לא. אם המחלקה שלכם מחזיקה רק חברים שמנקים אחרי עצמם (כמו std::string, std::vector או smart pointers), ה-destructor שהמהדר מייצר מספיק: אל תכתבו אחד. צריך destructor מותאם אישית רק כשהמחלקה מחזיקה משאב גולמי, כמו זיכרון מ-new או ידית לקובץ פתוח.

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

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

להתחיל