לדלג לתוכן

1.7 - השאלה בלעדית משותפת וחוזרת - הרצאה

הפניה לאיבר מול הגדלת האוסף

ניקח את ניסוי ה־Vec מהשיעור הקודם ונוסיף הפניה לאיבר הראשון. התוכנית הבאה נועדה להידחות על ידי המהדר:

fn main() {
    let mut values = vec![10, 20, 30];
    let first = &values[0];
    values.push(40);
    println!("first={first}");
}

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

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

ההשאלה מסתיימת כאשר השימוש מסתיים

שנו את סדר הפעולות:

fn main() {
    let mut values = vec![10, 20, 30];
    let first = &values[0];
    println!("first={first}");
    values.push(40);
    println!("len={}", values.len());
}

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

אם נרצה לזכור את המספר הראשון גם אחרי השינוי, אפשר לשמור ערך מועתק במקום הפניה. במקרה של i32 הערך מקיים Copy, ולכן let first = values[0] מחזיק מספר עצמאי. במקרה של אובייקט מורכב צריך להחליט אם רוצים לשכפל אותו, לזכור מזהה או לשנות את סדר העבודה.

גישה משותפת וגישה בלעדית

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

הדוגמה הבאה אינה חוקית:

fn main() {
    let mut value = 10;
    let read = &value;
    let write = &mut value;
    *write += 1;
    println!("{read}");
}

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

שדות זרים מאפשרים עבודה נפרדת

המהדר מסוגל להבחין בין שני שדות של אותו מבנה:

struct Counters {
    reads: u64,
    writes: u64,
}

fn main() {
    let mut counters = Counters { reads: 0, writes: 0 };
    let reads = &mut counters.reads;
    let writes = &mut counters.writes;
    *reads += 1;
    *writes += 2;
    println!("reads={reads} writes={writes}");
}

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

לשם כך קיימים ממשקים שמוכיחים את ההפרדה כחלק מהפעולה שלהם. המתודה split_at_mut מחלקת פרוסה לשני טווחים שאינם חופפים:

fn main() {
    let mut values = [10, 20, 30, 40];
    let (left, right) = values.split_at_mut(2);
    left[0] += 1;
    right[0] += 100;
    println!("{values:?}");
}

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

משאילים מחדש בלי להעביר את ההרשאה לתמיד

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

fn increment(value: &mut i32) {
    *value += 1;
}

fn main() {
    let mut value = 0;
    let access = &mut value;
    increment(&mut *access);
    increment(&mut *access);
    println!("{access}");
}

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

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