לדלג לתוכן

1.2 - טיפוסים מספריים וזרימת ערכים בתוכנית - הרצאה

כשגודל חוקי הופך להקצאה שגויה

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

פתחו פרויקט חדש והכניסו ל־src/main.rs:

fn main() {
    let count: u8 = std::env::args()
        .nth(1)
        .expect("missing count")
        .parse()
        .expect("count must fit u8");
    let bytes = count * 8;
    println!("bytes={bytes}");
}

הריצו פעם עם 10 ופעם עם 40. בפרופיל הפיתוח הרגיל הפעולה השנייה גורמת לפאניקה בגלל גלישה. הסיבה אינה שגיאה ב־parse: המספר ארבעים אכן נכנס ב־u8. הבעיה היא שתוצאת הכפל אינה נכנסת בשמונה ביטים. שינוי פרופיל או הגדרת overflow checks עשויים לשנות את ההתנהגות, ולכן אסור לבסס את נכונות פענוח הקובץ על עצם קיומה של בדיקת הפיתוח.

קובעים מדיניות גלישה

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

fn main() {
    let count: usize = std::env::args()
        .nth(1)
        .expect("missing count")
        .parse()
        .expect("invalid count");

    match count.checked_mul(8) {
        Some(bytes) if bytes <= 1024 * 1024 => {
            println!("accepted bytes={bytes}");
        }
        Some(_) => println!("size exceeds policy limit"),
        None => println!("size overflow"),
    }
}

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

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

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

המרה אינה תמיד בדיקה

הריצו את התוכנית הקצרה הבאה:

fn main() {
    let input: u16 = 300;
    let narrowed = input as u8;
    let checked = u8::try_from(input);
    println!("narrowed={narrowed}");
    println!("checked={checked:?}");
}

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

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

ביטויים מחזירים ערך

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

fn main() {
    let requested = 4096usize;
    let capacity = if requested <= 1024 {
        1024
    } else {
        requested.next_power_of_two()
    };
    println!("capacity={capacity}");
}

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

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

מספרים שאינם מספרים רגילים

הטיפוסים f32 ו־f64 משתמשים בנקודה צפה. הם מסוגלים לייצג גם אינסוף ו־NaN, ולכן השוואה אינה תמיד מתנהגת כמו השוואה בין מספרים שלמים. בדקו:

fn main() {
    let value = f64::NAN;
    println!("equal={}", value == value);
    println!("is_nan={}", value.is_nan());
}

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

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