לדלג לתוכן

2.7 - פונקציות סגורות ואיטרטורים - הרצאה

פונקציה שמגיעה יחד עם הנתונים שלה

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

fn main() {
    let prefix = String::from("batch-7");
    let print_job = |job: &str| println!("{prefix}: {job}");
    print_job("scan");
    print_job("backup");
    println!("prefix remains {prefix}");
}

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

סוג הקריאה נובע מן הפעולה

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

fn twice<F: FnMut()>(mut action: F) {
    action();
    action();
}

fn main() {
    let mut count = 0;
    twice(|| count += 1);
    assert_eq!(count, 2);

    let text = String::from("owned");
    let consume = move || drop(text);
    consume();
}

הפעולה הראשונה משנה את count ולכן דורשת גישה מתאימה לסביבה. הפעולה השנייה משמידה את String שבבעלותה, ולכן אחרי הקריאה אין עוד ערך שאפשר להשמיד שוב. אם נוסיף consume() פעם שנייה נקבל שגיאת שימוש לאחר העברה.

שימו לב שהמילה move לבדה אינה אומרת שה־closure היא רק FnOnce. היא בוחרת כיצד נתונים נכנסים לסביבה. אם הסביבה מחזיקה String בבעלות ורק מדפיסה אותה, אפשר לקרוא לפעולה שוב. השאלה היא מה גוף הפעולה עושה עם הערכים אחרי הלכידה.

מעבירים פעולה בלי למחוק את הטיפוס

פונקציה גנרית יכולה לקבל closure באמצעות bound מתאים. אין צורך לארוז כל פעולה ב־Box של dyn Fn. אם הטיפוס ידוע באתר הקריאה, המהדר יכול להתאים את הקוד לסביבה הקונקרטית.

fn visit<F>(names: &[&str], mut action: F)
where
    F: FnMut(&str),
{
    for name in names {
        action(name);
    }
}

fn main() {
    let mut bytes = 0;
    visit(&["one", "three"], |name| bytes += name.len());
    assert_eq!(bytes, 8);
}

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

איטרטור מתאר עבודה שעוד לא בוצעה

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

fn main() {
    let values = [1, 2, 3];
    let mapped = values.iter().map(|value| {
        println!("mapping {value}");
        value * 10
    });
    println!("constructed");
    let result: Vec<_> = mapped.collect();
    println!("result={result:?}");
}

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

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

סוג האיבר קובע בעלות

הפעולה iter על Vec מאפשרת לעבור על הפניות משותפות. הפעולה iter_mut מאפשרת לעבור על גישות בלעדיות לאיברים, והפעולה into_iter על Vec בבעלות יכולה להעביר את האיברים החוצה. שלוש הפעולות עשויות להיראות דומות בלולאה, אבל הן מספרות סיפור אחר על מי נשאר בעלים אחרי הסיום.

כאשר כותבים collect::<Vec<_>>(), נוצר אוסף בבעלות של האיברים שהאיטרטור מחזיר. אם האיברים הם הפניות, האוסף מחזיק הפניות ואינו הופך אותן אוטומטית לנתונים עצמאיים. אם רוצים String עצמאית לכל איבר, צריך פעולה שמייצרת בעלות כזאת, ולהבין את עלותה.

הפשטה שנעלמת רק כאשר אפשר

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

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