3.3 - אתחול חלקי ופעולות זיכרון גולמיות - הרצאה
אחסון שהוקצה אבל עוד לא מכיל ערכים¶
נרצה לבנות באפר בעל קיבולת קבועה של String, אבל לא ליצור מחרוזת ריקה בכל תא לפני שהוכנס אליו ערך. ההבחנה היא בין מקום שיכול לאחסן T לבין מקום שכבר מכיל T תקף. אם ניצור מערך String לא מאותחל ונציג אותו כאילו כל האיברים קיימים, עצם הנחת התקפות תהיה שגויה.
הטיפוס MaybeUninit מאפשר להחזיק מקום לא מאותחל בלי לטעון שכבר יש בתוכו ערך חוקי. הוא אינו מנהל עבורנו כמה תאים אותחלו, ואינו משמיד אוטומטית את התוכן שהכנסנו אליו. אנחנו צריכים להחזיק אינווריאנט מפורש.
מגדירים בדיוק אילו תאים חיים¶
בבאפר שלנו נבחר את הכלל הבא: התאים בטווח מאפס עד len מכילים ערכים מאותחלים בבעלות הבאפר, ושאר התאים הם אחסון שאסור לקרוא ממנו כ־T. ההבדל בין len לקיבולת הוא כעת גם הבדל בין ערכים חיים למקומות אחסון.
use std::mem::MaybeUninit;
struct Buffer<T, const N: usize> {
slots: [MaybeUninit<T>; N],
len: usize,
}
impl<T, const N: usize> Buffer<T, N> {
fn new() -> Self {
Self { slots: [const { MaybeUninit::uninit() }; N], len: 0 }
}
fn push(&mut self, value: T) -> Result<(), T> {
if self.len == N {
return Err(value);
}
self.slots[self.len].write(value);
self.len += 1;
Ok(())
}
fn pop(&mut self) -> Option<T> {
if self.len == 0 {
return None;
}
self.len -= 1;
// SAFETY: the former last initialized slot is removed from
// the owned range before its value is moved to the caller.
Some(unsafe { self.slots[self.len].assume_init_read() })
}
}
impl<T, const N: usize> Drop for Buffer<T, N> {
fn drop(&mut self) {
for slot in &mut self.slots[..self.len] {
// SAFETY: exactly this prefix contains owned initialized T values.
unsafe { slot.assume_init_drop(); }
}
}
}
fn main() {
let mut buffer = Buffer::<String, 2>::new();
assert!(buffer.push("first".into()).is_ok());
assert!(buffer.push("second".into()).is_ok());
assert_eq!(buffer.push("third".into()), Err("third".into()));
assert_eq!(buffer.pop(), Some("second".into()));
assert_eq!(buffer.pop(), Some("first".into()));
assert_eq!(buffer.pop(), None);
}
סדר העדכון הוא חלק מן ההוכחה¶
ב־push אנחנו כותבים את הערך ורק אחר כך מגדילים את len. הפעולה write לתוך MaybeUninit אינה משמידה ערך קודם, ולכן האינווריאנט מחייב שהתא הזה אינו מחזיק כרגע ערך בבעלות הבאפר. אם נכתוב שוב לתא חי בלי להוציא או להשמיד את הערך הקודם, נאבד את אחריות הניקוי שלו.
ב־pop אנחנו מקטינים את len לפני העברת הערך החוצה. הקריאה assume_init_read מעתיקה את ייצוג הערך בלי לאפס את הבייטים הישנים. ההקטנה היא מה שמונע מ־Drop להתייחס לאותו מקום כבעל ערך חי נוסף. במקרה של String, קריאה כפולה מאותו תא כאילו שתיהן יוצרות בעלות הייתה יכולה להביא לשחרור כפול.
הבייטים הישנים עשויים להישאר בזיכרון אחרי pop, אבל הם אינם עוד ערך שהבאפר רשאי להשמיד. זו דוגמה חשובה להבדל בין תוכן פיזי לבין מצב לוגי של בעלות. אי אפשר לקבוע חיים של ערך רק באמצעות בדיקה אם האזור מלא אפסים.
כשל באמצע הבנייה¶
נניח שאנחנו ממלאים את הבאפר מתוך קלט, ובאמצע פענוח רשומה נכשל. כל הערכים שכבר נכנסו לטווח המאותחל יושמדו כאשר הבאפר יוצא מן התחום. התאים שמחוץ לטווח לא ייקראו ולא יושמדו. זה בדיוק הטיעון שה־Drop שלנו צריך לקיים.
אפשר להוסיף טיפוס עם destructor שסופר השמדות ולוודא שהספירה מתאימה למספר הערכים שבבעלות הבאפר. בדיקה כזאת מועילה, אבל אינה מחליפה את ההוכחה על כל מסלול. במיוחד צריך לבדוק מה קורה אם קוד של משתמש, למשל פעולת clone של T, יכול להיכשל בין כתיבה לעדכון מונה.
המימוש הנוכחי אינו קורא קוד משתמש בתוך המקטע הקטן שבין write להגדלת len. כאשר נרחיב אותו עם פעולות שמייצרות ערכים באמצעות callback, נצטרך לשמור את אותו עיקרון: תחילה ניצור ערך שלם מחוץ למצב הפנימי, ואז נכניס אותו באופן ששומר על האינווריאנט.
פאניקה בזמן השמדה¶
אם destructor של אחד האיברים עצמו נכנס לפאניקה, ייתכן שחלק מן האיברים הנותרים לא יושמדו במימוש הפשוט הזה. מדובר בשאלת הבטחת ניקוי ופאניקה, ולא בהיתר לקרוא תאים לא מאותחלים. ספרייה כללית שמבקשת חוזה חזק יותר צריכה לתכנן גם guard לניקוי ההמשך ולבדוק פאניקה כפולה.
לצורך הלימוד אנחנו מצהירים על הגבול הזה ולא מציגים את המבנה כתחליף מלא ל־Vec. כבר עכשיו הוא מאפשר להבין כיצד אוסף סטנדרטי מפריד בין קיבולת לא מאותחלת לבין מספר איברים חיים. תיעוד MaybeUninit מפרט את חובות כל פעולת assume ואת העובדה שהמעטפת אינה מפעילה destructor של T בעצמה.
אין סיבה למהר לחשיפה בטוחה¶
לפני שמוסיפים as_slice, צריך להוכיח שכל הטווח המוחזר מאותחל, מיושר ובעל חיי נתון מתאימים. לפני שמוסיפים העתקה בין מאגרים, צריך להחליט האם אנחנו משכפלים ערכים או מעבירים בעלות. תוספת קטנה בממשק יכולה לשנות את כל חובות ההוכחה.
בתרגול נוסיף clear שמוציא ומשמיד כל ערך דרך המסלול שכבר הוכח. כך נבדוק אם אפשר להרחיב את המבנה באמצעות פעולה קיימת לפני שמוסיפים בלוק unsafe נוסף.