1.5 - בעלות וניקוי באמצעות RAII - הרצאה
הפונקציה קיבלה עוד דרך לצאת¶
נניח שפונקציה פותחת קובץ, קוראת ממנו ובסוף סוגרת אותו. בגרסה הראשונה יש מסלול אחד, ולכן קל לזכור את הסגירה. אחר כך מוסיפים בדיקת קלט, עוד קובץ ו־return מוקדם, ופתאום חלק מהמסלולים מדלגים על הניקוי.
הבעיה אינה רק שמפתח שכח שורה. הקוד מפריד בין רכישת המשאב לבין המנגנון שאמור להבטיח את סיום חייו. נרצה שהמבנה של התוכנית יחבר בין השניים, כך שהוספת מסלול כשל לא תחייב להעתיק אליו רשימת סגירות.
הרעיון של RAII הוא לתת לאובייקט בעלים להחזיק את המשאב, ולשחרר אותו ב־destructor. השם הארוך הוא Resource Acquisition Is Initialization, אבל הדבר החשוב הוא הקשר לחיי האובייקט: אם האובייקט נבנה בהצלחה, יש לו אחריות ברורה לניקוי.
בעלים קטן של קובץ¶
הקוד הבא הוא תוכנית מלאה עבור CRT של MSVC. הוא יוצר קובץ טקסט בתיקיית העבודה ומוודא שסגירתו מתבצעת גם ביציאה מוקדמת:
#include <cstdio>
#include <stdexcept>
class File {
std::FILE* file_ = nullptr;
public:
explicit File(const char* path) {
if (fopen_s(&file_, path, "wb") != 0) {
throw std::runtime_error("open failed");
}
}
~File() noexcept {
if (file_) {
std::puts("closing file");
std::fclose(file_);
}
}
File(const File&) = delete;
File& operator=(const File&) = delete;
std::FILE* get() const noexcept { return file_; }
};
void save(bool stop_early) {
File output{"raii-demo.txt"};
if (stop_early) {
std::puts("early return");
return;
}
if (std::fputs("hello\n", output.get()) == EOF) {
throw std::runtime_error("write failed");
}
}
int main() {
try {
save(true);
save(false);
} catch (const std::exception& error) {
std::fprintf(stderr, "%s\n", error.what());
return 1;
}
}
הפונקציה fopen_s מקבלת כתובת של משתנה pointer ומעדכנת אותו. היא מחזירה קוד שגיאה, ולכן אנחנו בודקים את קוד החזרה לפני שמצהירים שהבנייה הצליחה. הפונקציה get מאפשרת גישה למשאב אך אינה מעבירה את הבעלות עליו.
ה־throw מעביר שגיאה אל ה־catch ב־main. כרגע חשוב להבין שביציאה באמצעות חריגה, אובייקטים מקומיים שבנייתם הושלמה מושמדים בדרך החוצה. בפרק הספריות נלמד לעומק את ערבויות החריגה ואת התנהגות ה־runtime.
ה־destructor מסומן noexcept, מפני שניקוי בזמן טיפול בשגיאה אינו מקום טוב לזרוק שגיאה נוספת. הדוגמה מתעלמת מקוד החזרה של fclose בתוך ה־destructor, ולכן אינה מספקת חוזה של אימות כתיבה durable. לתוכנה שחייבת לדווח על כשל בסגירה נצטרך פעולה מפורשת שמחזירה תוצאה, לצד destructor שמשמש רשת ביטחון לניקוי.
למה העתקה אסורה¶
המחלקה מכילה pointer גולמי, אבל המשמעות שלו היא בעלות. העתקה אוטומטית של המחלקה הייתה מעתיקה את הכתובת, בלי לפתוח קובץ נוסף ובלי ליצור מנגנון שיתוף.
אם שני אובייקטים חושבים שהם הבעלים, שניהם ינסו לסגור אותו FILE*. הסגירה השנייה אינה פעולה תמימה שהמערכת בהכרח תתעלם ממנה. אנחנו משתמשים ב־= delete כדי לדחות את הטעות בזמן קומפילציה.
הקטע הבא מיועד לבדיקת קומפילציה שלילית:
התגובה הנכונה לשגיאת המהדר אינה להוסיף copy constructor שמעתיק את הpointer. קודם בוחרים חוזה: בעלות יחידה, העברת בעלות או שיתוף אמיתי. בשיעור הבא נוסיף move, ובשיעור על shared ownership נבדוק מה שיתוף דורש.
בנאי שנכשל באמצע¶
כעת נניח שמחלקה מחזיקה שני קבצים. אם הפתיחה השנייה נכשלת, האובייקט החיצוני עדיין לא נבנה במלואו. ה־destructor שלו לא ישמש לניקוי של בנייה שלא הושלמה, אבל שדות שכבר נבנו בהצלחה כן יושמדו.
אפשר לבדוק זאת בעזרת המחלקה הבאה, שמוסיפים לאותו קובץ:
struct TwoFiles {
File first;
File second;
TwoFiles()
: first("first.txt"),
second("missing-directory/second.txt") {}
};
הניסוי מניח שהתיקייה בשם missing-directory אינה קיימת בתיקיית העבודה. אם היא קיימת, יש לבחור שם אחר לצורך הכשל המבוקר. הפתיחה הראשונה מצליחה, השנייה נכשלת, והשדה first מנוקה גם בלי שה־destructor של TwoFiles רץ.
אם היינו שומרים את המשאב הראשון כמצביע גולמי ללא בעלים, היה צריך לנקות אותו ידנית בכל כשל עתידי של הבנאי. זאת הסיבה שנוח לבנות מחלקות גדולות מתוך אובייקטים שכבר יודעים לנהל את משאביהם.
כלל האפס¶
הכלל שנקרא rule of zero מציע להימנע ממימוש ידני של פעולות מיוחדות כשאפשר להרכיב את המחלקה משדות שמנהלים בעלות נכון. מחלקה שמחזיקה std::string ו־std::vector בדרך כלל אינה צריכה לכתוב destructor רק כדי לשחרר את הזיכרון שלהם.
מחלקת File היא גבול שבו אנחנו עוטפים משאב שאינו יודע להשתלב לבד במודל האובייקטים של C++. כאן יש הצדקה ל־destructor ולמדיניות העתקה. המחלקות שמשתמשות בה יכולות להסתמך על החוזה הזה ולא לשכפל אותו.
הכלל אינו אומר שתמיד נכון להעתיק כל מחלקה. גם בעלות דרך טיפוסים קיימים עשויה לגרום להעתקה להיות אסורה או יקרה, ויש לבחור את הממשק לפי המשמעות של הרכיב.
מה המנגנון לא מבטיח¶
ה־destructor פועל במסלולי סיום רגילים וב־stack unwinding מתאים. הריגה כפויה של תהליך, כשל חומרה או עצירה שאינה מבצעת unwinding אינם מבטיחים הרצת קוד ניקוי של המשתמש. לכן RAII אינו תחליף לפרוטוקול recovery של נתונים על דיסק.
גם פונקציית ניקוי יכולה להיכשל מבחינה חיצונית. התוכנה צריכה להחליט איזה כשל אפשר לדווח, איזה כדאי לרשום ללוג ואיזה משאב נשאר במצב לא ידוע. עצם השימוש ב־destructor אינו מבטל את חוזה מערכת ההפעלה.
היתרון הוא מקומי וחשוב: כל יציאה רגילה מהscope עוברת דרך אותה אחריות ניקוי. כשנעטוף HANDLE, mapping או נעילה, נשתמש באותו רעיון אבל נבחר לכל משאב פונקציית שחרור, ערך כשל וחוזה נפרדים.