לדלג לתוכן

8.2 - קיבוע כתובת והבטחת ההשמדה - הרצאה

קיבוע של מקום אחסון

בהרצאה הקודמת הצמדנו עתיד בעזרת Box::pin והעברנו אל poll מצביע מוצמד. עכשיו נבין מדוע החתימה הזאת קיימת. נתחיל בהבחנה שנראית קטנה, אבל בלעדיה קל מאוד לכתוב עטיפה שנראית בטוחה ומפרה את החוזה שלה.

העברת בעלות אינה בהכרח הזזה של הערך שהמשתמש מתעניין בו. כאשר אנחנו מעבירים Box<T> למשתנה אחר, אנחנו מעבירים את המצביע שמוחזק במעטפת. האובייקט שהוקצה בערימה נשאר באותו מקום. לעומת זאת, הפעלת mem::replace על הערך שבתוך הקופסה יכולה להוציא אותו משם ולהחליף אותו. לכן עצם השימוש בערימה אינו יוצר חוזה של כתובת קבועה. צריך גם להגביל את הפעולות שהממשק מאפשר לבצע דרך המצביע.

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

מתי הכתובת היא חלק מהמצב

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

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

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

ניסוי של כתובת יציבה

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

use std::cell::Cell;
use std::marker::PhantomPinned;
use std::pin::Pin;

struct AddressChecked {
    original: Cell<*const AddressChecked>,
    value: u64,
    _pinned: PhantomPinned,
}

impl AddressChecked {
    fn new(value: u64) -> Pin<Box<Self>> {
        let object = Box::pin(Self {
            original: Cell::new(std::ptr::null()),
            value,
            _pinned: PhantomPinned,
        });
        let reference = object.as_ref().get_ref();
        reference.original.set(reference as *const Self);
        object
    }

    fn check(self: Pin<&Self>) -> u64 {
        let reference = self.get_ref();
        assert_eq!(reference.original.get(), reference as *const Self);
        reference.value
    }
}

impl Drop for AddressChecked {
    fn drop(&mut self) {
        assert_eq!(self.original.get(), self as *const Self);
        println!("drop: {}", self.value);
    }
}

fn main() {
    let first = AddressChecked::new(41);
    let before = first.as_ref().get_ref() as *const AddressChecked;
    let second = first;
    let after = second.as_ref().get_ref() as *const AddressChecked;
    assert_eq!(before, after);
    assert_eq!(second.as_ref().check(), 41);
    drop(second);
}

הפקודות הבאות בונות ומריצות את הדוגמה בסביבת המעבדה.

rustc --edition=2024 address_checked.rs -o address_checked
./address_checked

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

drop: 41

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

הפעולה שאסור להציע למשתמש

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

rust,compile_fail let mut object = AddressChecked::new(41); let mutable = object.as_mut().get_mut();

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

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

הצמדה מבנית והשמדת שדות

כאשר מבנה חיצוני מוצמד, צריך להחליט אילו מהשדות שלו נחשבים מוצמדים כחלק מהמבנה. ההחלטה הזאת נקראת הצמדה מבנית. אם הממשק מחזיר Pin<&mut Field> לשדה, הוא מבטיח שהשדה יקיים את אותו חוזה גם כאשר המבנה החיצוני מושמד או מטופל במתודות אחרות. אי אפשר להציע במקביל מתודה בטוחה שמחליפה את אותו שדה באמצעות השאלה רגילה.

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

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

החלפה חוקית אינה הזזה החוצה

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

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

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