3.2 - מצביעים גולמיים ומוצא המצביע - הרצאה
מצביע גולמי אינו נושא את כל ההוכחה¶
הפניה בטוחה מבטאת בבת אחת כמה התחייבויות: חיי נתון, יישור וגישה מותרת. מצביע גולמי יכול לשמור כתובת גם כאשר איננו מסוגלים לבטא את כל היחסים האלה בטיפוס. המחיר הוא שכאשר ניגשים דרכו לזיכרון, אנחנו חייבים לספק את הטיעון שההפניה הבטוחה הייתה דורשת.
נתחיל בגישה קטנה ומוגבלת:
fn sum_raw(values: &[u32]) -> u64 {
let base = values.as_ptr();
let mut total = 0u64;
for index in 0..values.len() {
// SAFETY: index is in bounds, the slice is alive and shared,
// and u32 values are initialized and aligned.
let value = unsafe { base.add(index).read() };
total += u64::from(value);
}
total
}
fn main() {
assert_eq!(sum_raw(&[10, 20, 30]), 60);
assert_eq!(sum_raw(&[]), 0);
}
הקוד אינו עדיף על איטרטור בטוח. הוא נועד להכריח אותנו לפרק את הטיעון. המצביע נגזר מפרוסה חיה, כל אינדקס קטן מן האורך, והקריאה אינה מתחרה בשינוי שאסור לפי ההשאלה. הטיפוס u32 הוא Copy, ולכן קריאה של הערך אינה יוצרת בעלים שני למשאב שיש להשמיד.
תקפות נבדקת ביחס לפעולה¶
השאלה האם מצביע תקף אינה שלמה בלי לציין מה רוצים לעשות דרכו. כתובת יכולה להתאים לקריאת בייט אחד אך לא למבנה שלם, להתאים לקריאה אך לא לכתיבה, או להצביע לאזור שכבר שוחרר. גם מצביע שאינו null אינו מספק הוכחה לנגישות.
הפעולה add דורשת עמידה בחוזי אריתמטיקה בתוך הקצאה, לרבות נקודת הסוף המותרת לצורך חישוב. מצביע שמגיע בדיוק לסוף אינו הופך לכתובת שמותר לקרוא ממנה איבר נוסף. הפעולה wrapping_add מאפשרת סוג חישוב אחר, אבל אינה מבטלת את תנאי הגישה כאשר מנסים לקרוא או לכתוב.
אין צורך להריץ קריאה מחוץ לטווח כדי ללמוד שהיא שגויה. תוכנית עם התנהגות לא מוגדרת עשויה להיראות תקינה בניסוי אחד ולהתקלקל תחת אופטימיזציה. במקום זאת נבנה בדיקה שמוכיחה את הטווח לפני הפעולה ונשתמש בכלי אבחון כדי לחפש הפרות של ההוכחה.
מאין המצביע הגיע¶
מוצא המצביע, או provenance, קשור להקצאה ולהרשאות שממנה הוא נגזר. כתובת מספרית לבדה אינה תיאור מלא של כל מה שנדרש לגישה חוקית. אם שתי הקצאות מתחלפות באותה כתובת, המספר הזהה אינו מוכיח שהמצביע הישן רשאי לגשת לערך החדש.
אפשר לקרוא את הכתובת לצורך תיעוד בלי להחזיר אותה למצביע:
fn main() {
let values = [10u32, 20];
let pointer = values.as_ptr();
println!("address={:#x}", pointer.addr());
println!("pointer={pointer:p}");
}
המספר המודפס מועיל להשוואת תצפיות, אבל אינו תחליף לבעלות על values. כאשר בונים קוד שצריך לשנות כתובת בתוך אותה הקצאה, עדיף להשתמש בממשקי מצביעים ששומרים את הקשר במקום להמיר הלוך ושוב דרך מספר בלי סיבה.
חלק מפרטי מודל ה־aliasing והמוצא ממשיכים להתחדד. לכן נבדיל בין חוזה API מתועד, מודל ניסויי של כלי ובחירת מימוש של מהדר. אין להפוך הצלחה של קוד תחת מודל אחד להוכחה שהוא עומד בכל החוזים. תיעוד המצביעים הוא נקודת הבדיקה לכל פעולה.
יצירת הפניה מוסיפה התחייבויות¶
אם יש לנו raw pointer, עצם ההמרה שלו ל־&T או ל־&mut T דורשת יותר מאשר שמירת המספר. ההפניה חייבת להיות תקפה ומיושרת, והגישה שהיא מייצגת חייבת לקיים את כללי השיתוף או הבלעדיות. לא נכון ליצור הפניה פגומה ואז לומר שלא השתמשנו בה עדיין.
נשתמש בדוגמה של שדה צפוף כדי לראות למה צריך פעולה מיוחדת:
#[repr(C, packed)]
struct Packed {
tag: u8,
value: u32,
}
fn main() {
let packed = Packed { tag: 1, value: 0x12345678 };
let pointer = &raw const packed.value;
// SAFETY: the field contains an initialized u32; read_unaligned
// does not require u32 alignment and the object remains alive.
let value = unsafe { pointer.read_unaligned() };
assert_eq!(value, 0x12345678);
}
הכתיבה &raw const נמנעת מיצירת הפניה רגילה לשדה שעשוי להיות לא מיושר. הפעולה read_unaligned מסירה את דרישת היישור של הקריאה המסוימת, אבל לא את הדרישה שהבייטים שייכים לאובייקט חי ומייצגים u32 מאותחל. בחירה בפריסה packed אינה רישיון להתעלם משאר החוזים.
חוזרים לממשק בטוח¶
המטרה אינה לפזר raw pointers בכל הקוד. בדרך כלל נרצה אזור קטן שמבצע פעולה גולמית ומחזיר תוצאה בטוחה, כאשר הבדיקות והבעלות מספקות טיעון מקומי. אם כל לקוח צריך לזכור תנאי שלא מופיע בחתימה, צריך לשאול אם הפונקציה עצמה אמורה להיות unsafe.
בתרגול נכתוב פונקציה בטוחה שמחזירה איבר אופציונלי ומממשת את הקריאה באמצעות raw pointer. אחר כך נשווה אותה למימוש בטוח לגמרי. אם אין יתרון מדוד או צורך בממשק חיצוני, הפתרון הבטוח והפשוט נשאר הבחירה הטובה יותר.