1.6 - העתקה והעברת בעלות - הרצאה
רוצים להעביר בעלות בלי לפתוח שוב¶
בשיעור הקודם אסרנו להעתיק בעלים של קובץ. עכשיו נרצה להחזיר בעלים מפונקציה או להכניס אותו למכולה בלי ליצור שני אובייקטים שחושבים שהם אחראים לאותו משאב.
הפעולה שאנחנו צריכים היא העברה, או move. האובייקט החדש מקבל את המשאב, והישן נשאר אובייקט חי במצב שהמחלקה מגדירה. החיים של המקור אינם מסתיימים רק בגלל שביצענו move.
נשתמש במחלקה שמחזיקה מספר יחיד בהקצאה דינמית כדי לראות את העברת הבעלות במלואה. בקוד מעשי נעדיף בדרך כלל unique_ptr, אבל המימוש הידני חושף את החוזה:
#include <cstdio>
#include <utility>
class Number {
int* value_ = nullptr;
public:
explicit Number(int value) : value_(new int(value)) {}
~Number() { delete value_; }
Number(const Number&) = delete;
Number& operator=(const Number&) = delete;
Number(Number&& other) noexcept
: value_(std::exchange(other.value_, nullptr)) {}
Number& operator=(Number&& other) noexcept {
if (this != &other) {
delete value_;
value_ = std::exchange(other.value_, nullptr);
}
return *this;
}
bool has_value() const noexcept { return value_ != nullptr; }
int value() const {
return *value_;
}
};
int main() {
Number first{42};
Number second{std::move(first)};
std::printf("first=%d second=%d\n",
first.has_value(), second.value());
}
המחלקה מגדירה במפורש שמקור אחרי העברה הוא ריק. הפעולה value() דורשת שיהיה ערך, ולכן אסור לקרוא לה על המקור בלי לבדוק has_value(). אפשר לבחור בממשק אחר שמחזיר optional או זורק שגיאה, אבל צריך להגדיר את הבחירה ולא להסתיר dereference של null.
מה עושה std::move¶
הביטוי std::move(first) אינו מעתיק pointer, אינו קורא ל־delete ואינו מאפס את המקור. הוא משנה את קטגוריית הביטוי כך שניתן לבחור בפעולת move של הטיפוס. הבנאי שנבחר הוא זה שמבצע את ההעברה בפועל.
אפשר לבדוק זאת על ידי הוספת שורה שאינה משתמשת בתוצאה:
המקור אינו משתנה בגלל השורה הזאת לבדה. המהדר עשוי להזהיר שתוצאת הביטוי לא נוצלה. זאת דוגמה לכך ששם פונקציה שנשמע כמו פעולה על משאב יכול לייצג בפועל התאמה של טיפוס ביטוי.
גם move מ־const אינו מבטיח העברה. בנאי move רגיל מקבל T&&, אבל ביטוי שמתקבל מ־std::move על אובייקט קבוע נשאר קבוע. לעיתים תיבחר העתקה, ואם העתקה אסורה הקוד עשוי להידחות.
בנייה מול השמה¶
בבנאי ההעברה האובייקט החדש עדיין אינו מחזיק משאב קודם. בפעולת השמה הוא עשוי להחזיק משאב שצריך לסיים את חייו לפני קבלת החדש. ההבדל הזה מסביר את ה־delete הנוסף ב־move assignment.
הבדיקה this != &other שומרת על self-move לפי החוזה שבחרנו. אפשר לכתוב מימושים אחרים שמטפלים במקרה הזה בצורה אחרת, אבל אסור להניח שהמקרה לא יקרה לעולם רק מפני שהוא נראה מוזר בקוד פשוט.
ה־destructor יודע למחוק גם pointer שערכו null, ולכן המקור הריק בטוח להשמדה. זהו invariant שימושי: בכל רגע יש או בעלים אחד של ההקצאה או שאין הקצאה, ולעולם לא שני בעלים לאותה כתובת.
מתי העתקה נעלמת לגמרי¶
הפונקציה הבאה יכולה להחזיר אובייקט ישירות:
ב־C++17 ואילך המקרה הזה מאפשר בניית תוצאת prvalue ישירות ביעד לפי כללי השפה, בלי שלב העתקה זמני כפי שאפשר לדמיין. במקרה של החזרת משתנה מקומי בשם, NRVO הוא מנגנון קרוב אך אינו אותה הבטחה בכל מצב.
אין צורך להוסיף std::move לכל return מתוך רצון לעזור למהדר. הוספה כזאת יכולה למנוע NRVO במקרים שבהם היה אפשר להשתמש בו. קודם כותבים החזרת ערך פשוטה ורק אחר כך חוקרים קוד נוצר כשיש שאלה מדידה.
למה noexcept חשוב¶
פעולת move שמסומנת noexcept מבטיחה שלא תצא ממנה חריגה. מכולה עשויה להסתמך על ההבטחה כדי להעביר איברים בזמן שינוי capacity בלי להסתכן במצב ביניים שקשה לשחזר.
ההכרזה אינה קסם שמונע כשל. אם חריגה בכל זאת יוצאת מפונקציה noexcept, התוצאה היא termination. לכן אין לסמן כל פעולה כך רק כדי לשפר performance; מסמנים כשהמימוש והחוזים של הפעולות שהוא קורא להם מצדיקים זאת.
בדוגמה שלנו ההעברה רק משחררת את ההקצאה הישנה ומחליפה מצביעים. היא אינה מקצה משאב חדש, ולכן נוח לתת לה חוזה כזה. ההשפעה על ספריות גנריות תחזור בפרק השני.
העברת פרמטר דרך wrapper¶
נניח ש־wrapper צריך להעביר את הארגומנט כפי שהתקבל. הדוגמה הבאה היא תוכנית עצמאית:
#include <cstdio>
#include <utility>
void consume(int&) { std::puts("lvalue"); }
void consume(int&&) { std::puts("rvalue"); }
template<class T>
void forward_to_consumer(T&& value) {
consume(std::forward<T>(value));
}
int main() {
int number = 5;
forward_to_consumer(number);
forward_to_consumer(5);
}
הפרמטר T&& הוא forwarding reference בהקשר הזה, מפני ש־T מוסק מהקריאה. השם value בתוך הפונקציה הוא lvalue, גם כאשר התקבל ערך זמני. הפעולה std::forward<T> משתמשת במידע מההסקה כדי לשמר את קטגוריית הארגומנט המקורי.
הכללים של reference collapsing מסבירים למה אפשר לקבל כאן גם lvalue וגם rvalue. בפרק התבניות נפתח אותם באופן שיטתי; כרגע חשוב להבין ש־std::move(value) תמיד היה מאפשר העברה מהפרמטר, גם כשהקורא מסר אובייקט שרצה להמשיך להשתמש בו.
בדיקת בעלות אחרי העברה¶
כדאי לבדוק בנייה, move construction, move assignment ו־self-move בנפרד. מוסיפים מונה הקצאות ושחרורים או לוג כתובות, ומוודאים שהמשאב לא נמחק פעמיים ושכל הקצאה מסתיימת בשחרור מתאים.
הבדיקה המהותית היא חוזה הבעלות, ולא מספר ההדפסות של constructors בלבד. אופטימיזציה יכולה להסיר פעולות יצירה מסוימות, אבל היא אינה מתירה לשני בעלים לשחרר אותה הקצאה.