2.9 - אתחול מאוחר וחוזי כשל בממשק - הרצאה
נתון שמתמלא בפעם הראשונה שצריך אותו¶
נרצה לחשב ערך יקר רק כאשר משתמשים בו, ואז לשמור אותו לקריאות הבאות. אפשר להשתמש ב־Option בתוך RefCell, אבל אם הנתון אינו אמור להשתנות אחרי האתחול, OnceCell מבטאת חוזה ממוקד יותר: בתחילה אין ערך, ולאחר האתחול ניתן להחזיר הפניות משותפות אליו.
use std::cell::{Cell, OnceCell};
fn main() {
let cached = OnceCell::new();
let calls = Cell::new(0);
for _ in 0..3 {
let value = cached.get_or_init(|| {
calls.set(calls.get() + 1);
String::from("computed")
});
println!("{value}");
}
assert_eq!(calls.get(), 1);
}
הפונקציה שמאתחלת נקראת פעם אחת במקרה הזה. לאחר שהערך נוצר, הקריאות מקבלות הפניה אליו בלי להעתיק אותו ובלי להחזיק guard של בדיקת השאלה. המחיר הוא שלא ניתן להחליף אותו כרצוננו דרך אותה הפניה משותפת.
הטיפוס שבדוגמה הוא std::cell::OnceCell, המיועד לשימוש שאינו משתף אותו באופן כזה בין תהליכונים. בפרק התהליכונים נבחן את OnceLock. הדמיון בשם אינו סיבה להניח שלשני הטיפוסים יש אותו חוזה סנכרון.
כשל אתחול אינו תמיד נתון שצריך לשמור¶
נניח שהערך מחושב מפענוח טקסט. אם נשמור Result<Value, Error> בתוך OnceCell, גם Err היא ערך שנשמר. הקריאה הבאה לא תנסה מחדש רק מפני שהתוצאה הקודמת הייתה שגיאה. זה יכול להיות בדיוק מה שאנחנו רוצים, או טעות אם מקור הקלט השתנה והכשל היה זמני.
הדוגמה הבאה מדגימה שתי מדיניות שונות בלי להסתמך על ממשק ניסויי:
use std::cell::OnceCell;
fn cached_parse<'a>(cell: &'a OnceCell<u32>, input: &str) -> Result<&'a u32, &'static str> {
if cell.get().is_none() {
let value = input.parse().map_err(|_| "invalid number")?;
cell.set(value).map_err(|_| "already initialized")?;
}
Ok(cell.get().expect("initialized above"))
}
fn main() {
let cell = OnceCell::new();
assert!(cached_parse(&cell, "bad").is_err());
assert_eq!(cached_parse(&cell, "42"), Ok(&42));
assert_eq!(cached_parse(&cell, "99"), Ok(&42));
}
במימוש הזה כשל פענוח אינו מאתחל את התא. לאחר הצלחה הערך נשמר, ולכן קלט חדש אינו מחליף אותו. ההנחה היא שאין כאן callback שמבצע אתחול חוזר בתוך אותה פעולה, ואין גישה מקבילית לתא. כאשר מנגנון מורכב יותר מאפשר כניסה חוזרת, צריך לתכנן גם את המצב שנוצר באמצע האתחול.
הקריאה expect בסוף מתייחסת לאינווריאנט פנימי שהפונקציה עצמה יצרה. היא שונה מקריאה ל־unwrap על קלט לא תקין. עדיין צריך לשמור את הטיעון ברור, כדי ששינוי עתידי לא יפריד בטעות בין ההוכחה לבין השימוש.
פאניקה אינה עסקה אוטומטית¶
נניח שאנחנו טוענים מחדש רשימת הגדרות ומוחקים קודם את הרשימה הישנה. אם הפענוח נכשל באמצע, המערכת נשארת עם תוצאה חלקית. העובדה שכל הזיכרון בטוח אינה אומרת שהמצב הלוגי נשאר תקין.
נבנה קודם את המצב החדש, ורק אחרי הצלחתו נחליף את הישן:
struct Config {
limits: Vec<u32>,
}
impl Config {
fn reload(&mut self, text: &str) -> Result<(), &'static str> {
let mut candidate = Vec::new();
for part in text.split(',') {
let value: u32 = part.trim().parse().map_err(|_| "invalid limit")?;
if value == 0 { return Err("zero limit"); }
candidate.push(value);
}
self.limits = candidate;
Ok(())
}
}
fn main() {
let mut config = Config { limits: vec![10, 20] };
assert!(config.reload("30,bad,40").is_err());
assert_eq!(config.limits, [10, 20]);
config.reload("30,40").unwrap();
assert_eq!(config.limits, [30, 40]);
}
ההחלפה מתבצעת בנקודה אחת אחרי האימות. במקרה של Err מקומי, candidate מושמד והמצב הישן נשאר. המימוש אינו מבטיח עסקה מול קובץ או מול שרת חיצוני; הוא שומר על אינווריאנט של אובייקט בזיכרון. בהמשך נרחיב את אותו רעיון לשמירה עמידה ולתהליכים מרובים.
חוזה כשל הוא חלק מן הממשק¶
פונקציה יכולה להבטיח שבכשל לא בוצע שינוי, או להחזיר גם כמה עבודה כבר בוצעה. שתי האפשרויות יכולות להיות נכונות, אבל אסור להשאיר אותן לעמימות. למשל, כתיבה חלקית לקובץ אינה הופכת לאטומית רק מפני שהעטיפה מחזירה Result.
גם תפיסת פאניקה אינה משחזרת מצב אוטומטית. אם קוד כבר שינה חלק מהאובייקט, עצירת הפאניקה בגבול חיצוני אינה מחזירה את הערכים הישנים. לכן נבנה מצבים תקינים בכל נקודת יציאה, או נבודד את השינוי עד לנקודת commit מתוכננת.
לפני המעבר לתרגול קראו את התנהגות האתחול והפאניקה בתיעוד OnceCell. החוזה של הטיפוס קובע מה קורה לתא, ואילו הקוד שלנו קובע מה קורה למשאבים ולמצב החיצוני שה־initializer נגע בהם.