Menu

מצביעים חכמים ב-C++: unique_ptr ו-shared_ptr

מצביעים חכמים מחזיקים בזיכרון heap ומשחררים אותו אוטומטית. למדו את unique_ptr, shared_ptr, make_unique ו-make_shared, ולמה כמעט אף פעם לא תצטרכו לכתוב שוב new/delete.

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

הבעיה שמצביעים חכמים פותרים

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

מצביעים חכמים פותרים את זה על ידי קשירת אורך החיים של זיכרון ה-heap לאובייקט רגיל על המחסנית. כשהאובייקט הזה יוצא מהטווח, ההורס שלו מריץ בשבילכם delete, באופן מובטח, גם אם חריגה מפרקת את המחסנית. הרעיון הזה נקרא RAII (Resource Acquisition Is Initialization), והמצביעים החכמים נמצאים בקובץ הכותרת <memory>.

משתמשים ב-*p וב-p->member בדיוק כמו במצביע גולמי. ההבדל הוא שלעולם לא קוראים ל-delete: המצביע החכם עושה את זה.

unique_ptr: בעלים אחד, בלי שיתוף

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

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

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

auto a = make_unique<int>(10);
auto b = a;   // error: call to deleted copy constructor of unique_ptr

כדי להעביר את הבעלות למישהו אחר, מעבירים (move) אותה עם std::move. אחרי ההעברה, המצביע המקורי ריק (הוא מחזיק nullptr):

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

shared_ptr: בעלות משותפת עם ספירת הפניות

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

יוצרים אותם עם make_shared:

בניגוד ל-unique_ptr, העתקה של shared_ptr בסדר גמור: זו כל המטרה. המחיר הוא עלות: ספירת ההפניות שמורה ב-heap ומתעדכנת באופן אטומי (בטוח לריבוי תהליכונים), ולכן shared_ptr כבד יותר מ-unique_ptr. השתמשו בו רק כשהבעלות באמת משותפת, לא רק כדי להימנע מלחשוב מי הבעלים של מה.

make_shared גם יעיל יותר מ-shared_ptr<T>(new T(...)): הוא מקצה את האובייקט ואת בלוק הבקרה בהקצאה אחת במקום בשתיים.

weak_ptr ושבירת מעגלי הפניות

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

struct Node {
    shared_ptr<Node> next;   // אם שני צמתים מצביעים זה על זה,
};                           // הם שומרים זה את זה בחיים לנצח

הפתרון הוא weak_ptr: צופה שאינו בעלים של shared_ptr. הוא לא מעלה את ספירת ההפניות, ולכן לעולם לא שומר אובייקט בחיים. כדי להשתמש באובייקט קוראים ל-.lock(), שנותן shared_ptr אם האובייקט עדיין קיים, או ריק אם הוא כבר נעלם.

השתמשו ב-weak_ptr ל"מצביעים לאחור" ולמטמונים: בכל מקום שבו רוצים להתייחס לאובייקט בלי לתבוע עליו בעלות.

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

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

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

int* raw = new int(5);
unique_ptr<int> a(raw);
unique_ptr<int> b(raw);   // אסון: שניהם ימחקו את אותו int (שחרור כפול)

זו בדיוק הסיבה להעדיף make_unique/make_shared: אין מצביע גולמי משוחרר שאפשר להשתמש בו לא נכון.

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

void consume(unique_ptr<int> p);   // לוקחת בעלות (מעבירים לתוכה)
void observe(int* p);              // רק מסתכלת, לא מחזיקה בכלום

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

unique_ptr למערכים צריך את צורת המערך. make_unique<int[]>(n) נותן לכם unique_ptr<int[]> שקורא נכון ל-delete[]. בפועל, העדיפו std::vector למערכים דינמיים: הוא מנהל את הזיכרון בשבילכם ונותן בנוסף מעקב אחרי הגודל.

הבא בתור: מחרוזות

עכשיו ניהול הזיכרון בשליטה שלכם: מצביעים חכמים נותנים הקצאה ב-heap בלי הדליפות. אחד הדברים הנפוצים ביותר שתקצו ותעבירו הוא טקסט, ו-C++ נותנת לכם כלי בטוח בהרבה מחוצצי char* גולמיים. העמוד הבא מסביר את std::string: איך הוא גדל בעצמו, הפעולות שתשתמשו בהן כל יום, ולמה הוא פוטר אתכם לגמרי מעבודת זיכרון ידנית.

שאלות נפוצות

מה הם מצביעים חכמים ב-C++?

מצביעים חכמים הם אובייקטים מ-<memory> (unique_ptr, shared_ptr, weak_ptr) שעוטפים מצביע גולמי ומבצעים אוטומטית delete לזיכרון כשהם יוצאים מהטווח. הם נותנים לכם הקצאה ב-heap בלי ה-delete הידני ובלי הדליפות שבאות עם שכחה שלו.

מה ההבדל בין unique_ptr ל-shared_ptr?

unique_ptr הוא הבעלים היחיד של האובייקט שלו: אי אפשר להעתיק אותו, רק להעביר (move), והוא משחרר את הזיכרון ברגע שהוא מת. shared_ptr מאפשר בעלות משותפת בעזרת ספירת הפניות: הרבה shared_ptr יכולים להצביע על אותו אובייקט, והאובייקט משתחרר רק כשהאחרון מושמד. העדיפו unique_ptr, אלא אם באמת צריך בעלות משותפת.

האם להשתמש ב-make_unique או ב-new ב-C++ מודרנית?

השתמשו ב-make_unique וב-make_shared. הם מקצים את האובייקט ועוטפים אותו בצעד אחד, כך שאין new גולמי שהתוצאה שלו עלולה לדלוף לפני שהיא מגיעה למצביע חכם. ככלל אצבע, בבסיס קוד C++ מודרני כמעט לא אמורים להיות new או delete חשופים בכלל.

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

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

להתחיל