לדלג לתוכן

1.7 - בעלות יחידה ותצוגות - הרצאה

מי מחזיק את הbuffer

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

הקוד הבא הוא תוכנית מלאה:

#include <cstddef>
#include <cstdio>
#include <memory>
#include <span>

unsigned sum(std::span<const unsigned> values) {
    unsigned result = 0;
    for (unsigned value : values) result += value;
    return result;
}

int main() {
    constexpr std::size_t count = 4;
    auto owner = std::make_unique<unsigned[]>(count);
    for (std::size_t i = 0; i < count; ++i) {
        owner[i] = static_cast<unsigned>(i + 1);
    }
    std::span<const unsigned> view{owner.get(), count};
    std::printf("sum=%u\n", sum(view));
}

הבעלים הוא unique_ptr שמתמחה במערך. הוא משתמש בניקוי המתאים למערך, ולכן לא צריך לזכור לקרוא ל־delete[]. התצוגה מכילה גישה לטווח עם גודל, אבל אינה אחראית לשחרור ואינה מאריכה את חייו.

הפונקציה sum מקבלת span לפי ערך. ההעתקה הזאת מעתיקה תיאור קטן של הטווח, ולא את כל תוכן הbuffer. השימוש ב־const unsigned מונע שינוי איברים דרך התצוגה הזאת, בלי לטעון שכל alias אחר הוא לקריאה בלבד.

פעולות שנראות דומות

לבעלים יחיד יש כמה פעולות שחשוב להפריד ביניהן. הפעולה get מחזירה pointer לצפייה; הפעולה reset מסיימת את הבעלות הקיימת, ולעיתים מחליפה אותה; הפעולה release מוותרת על הבעלות ומחזירה את הpointer בלי לשחרר אותו.

הקטע הבא מצטרף ל־main בתוכנית עצמאית קטנה:

auto owner = std::make_unique<int>(7);
int* borrowed = owner.get();
std::printf("%d\n", *borrowed);

int* transferred = owner.release();
std::unique_ptr<int> next{transferred};

המצביע borrowed אינו בעלים. אסור לקרוא עליו ל־delete בזמן שה־unique_ptr עדיין מחזיק את אותה הקצאה. אחרי release צריך להיות מסלול ברור שמקבל אחריות; בדוגמה הבעלים החדש עושה זאת מיד.

שימוש ב־release רק כדי שהקוד יתקמפל הוא סימן שצריך לבדוק את תכנון הממשק. אם אף אחד לא מקבל את האחריות, נוצרה דליפה. אם שני אנשים מקבלים אותה, נוצרה סגירה כפולה.

העברה היא גם תיעוד

פונקציה שמקבלת std::unique_ptr<int> לפי ערך מבקשת לקבל בעלות. כדי למסור לה בעלים קיים נשתמש ב־move:

#include <cstdio>
#include <memory>
#include <utility>

void consume(std::unique_ptr<int> value) {
    std::printf("%d\n", *value);
}

int main() {
    auto item = std::make_unique<int>(9);
    consume(std::move(item));
    std::printf("empty=%d\n", item == nullptr);
}

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

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

ניקוי מותאם למשאב

לא כל משאב משוחרר ב־delete. בקובץ CRT משתמשים ב־fclose, ובמשאבי Windows רבים משתמשים ב־CloseHandle. גם שם צריך להיזהר, מפני של־socket יש פעולת closesocket משלו.

הקוד הבא עוטף stream של CRT:

#include <cstdio>
#include <memory>

struct FileCloser {
    void operator()(std::FILE* file) const noexcept {
        if (file) std::fclose(file);
    }
};

int main() {
    std::FILE* raw = nullptr;
    if (fopen_s(&raw, "unique-demo.txt", "wb") != 0) return 1;
    std::unique_ptr<std::FILE, FileCloser> file{raw};
    if (std::fputs("owned\n", file.get()) == EOF) return 2;
}

ה־deleter הוא חלק מטיפוס הבעלים. הוא מתאר איך לסיים את חיי המשאב, ולא רק את הכתובת שלו. בדוגמה הזאת destructor עדיין אינו מדווח על שגיאת close; חוזה עמידות כתיבה דורש פעולה מפורשת שנלמד בפרקי האחסון.

אי אפשר להעתיק את העטיפה הזאת אוטומטית לכל HANDLE. חלק מה־APIים מחזירים null בכשל, ואחרים מחזירים INVALID_HANDLE_VALUE. בעלים שמפרש רק null כריק עלול לנסות לסגור ערך כשל שאינו משאב תקף.

תצוגה ששרדה יותר מדי

הקטע הבא הוא דוגמת באג לניתוח, ולא פונקציה בטוחה לשימוש:

#include <string>
#include <string_view>

std::string_view bad_view() {
    std::string text = "temporary text";
    return text;
}

ה־string_view אינו שומר את המחרוזת בחיים. הוא שומר תיאור של תווים שהבעלים שלהם מושמד בסוף הפונקציה. גם אם אופטימיזציה כמו small-string optimization משנה את מקום התווים, הבאג נשאר אותו באג של lifetime.

אפשר להחזיר string עצמאי, או לקבל buffer של הקורא ולהחזיר תצוגה שתלויה בו לפי חוזה מפורש. החוזה חייב לציין גם אילו פעולות על הבעלים פוסלות את התצוגה.

גודל נכון אינו חיים נכונים

לתצוגה יכול להיות גודל שנראה סביר ו־pointer שאינו null, ובכל זאת היא אינה תקפה. שינוי capacity של vector עשוי להעביר את הנתונים לכתובת חדשה, בזמן שה־span הישן עדיין מצביע אל האחסון הקודם.

הבעיה לא נפתרת על ידי בדיקת bounds בכל גישה. בדיקת bounds בודקת אינדקס ביחס לגודל התצוגה, ולא האם המקור עדיין חי או עדיין נמצא באותו מקום. ב־C++20 גם אין להניח שכל גישה באמצעות span מבצעת בדיקת טווח אוטומטית.

בחקירה מציבים breakpoint בפעולה שמשנה את הבעלים, לא רק בשורה שבה התצוגה קורסת. רושמים מתי נוצרה התצוגה ומתי הקצאה הוחלפה, ומחברים את שני האירועים.

חוזה קצר שאפשר לבדוק

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

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