לדלג לתוכן

1.3 - ייצוג נתונים במבנים בטיפוסי סכום ובפרוסות - הרצאה

מייצגים את המצבים שהתוכנית באמת יכולה לקבל

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

הכניסו את הדוגמה הבאה לפרויקט חדש:

#[derive(Debug)]
enum Header {
    Ready { length: u32 },
    Missing,
    Invalid { byte: u8 },
}

fn describe(header: Header) {
    match header {
        Header::Ready { length } => println!("length={length}"),
        Header::Missing => println!("missing"),
        Header::Invalid { byte } => println!("invalid byte={byte}"),
    }
}

fn main() {
    describe(Header::Ready { length: 12 });
    describe(Header::Missing);
    describe(Header::Invalid { byte: 255 });
}

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

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

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

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

בדקו את הגדלים בתוכנית נפרדת:

use std::mem::{align_of, size_of};
use std::num::NonZeroU32;

struct Record {
    kind: u8,
    value: u64,
}

fn main() {
    println!("record size={} align={}", size_of::<Record>(), align_of::<Record>());
    println!("u32 option={}", size_of::<Option<u32>>());
    println!("nonzero option={}", size_of::<Option<NonZeroU32>>());
    let record = Record { kind: 1, value: 7 };
    println!("kind={} value={}", record.kind, record.value);
}

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

הטיפוס NonZeroU32 אינו יכול לייצג אפס. הספרייה מבטיחה ש־Option של הטיפוס הזה יכול להשתמש בייצוג האפס עבור None בלי להוסיף תג נפרד. לעומת זאת, כל אחד מהייצוגים של u32 הוא ערך חוקי, ולכן אי אפשר פשוט להקדיש את האפס שלו לציון היעדרות. המקרה מדגים רעיון שנקרא niche: ייצוג שאינו חוקי לערך הפנימי יכול לשמש לייצוג מצב נוסף. אין להסיק מכך שכל enum בכל צורה מקבל אותו מיטוב.

מערך מחזיק נתונים ופרוסה מתארת טווח

המערך [u8; 4] כולל בדיוק ארבעה ערכים. האורך הוא חלק מן הטיפוס, ולכן [u8; 5] הוא טיפוס אחר. פרוסה מאפשרת לפונקציה לקבל טווח שהאורך שלו אינו קבוע בחתימה.

fn sum(bytes: &[u8]) -> u32 {
    let mut total = 0u32;
    for byte in bytes {
        total += u32::from(*byte);
    }
    total
}

fn main() {
    let data = [10u8, 20, 30, 40];
    println!("all={}", sum(&data));
    println!("middle={}", sum(&data[1..3]));
}

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

הגישה data[4] נמצאת מחוץ למערך. בגישה בטוחה ראסט מבצעת בדיקת גבולות כאשר לא ניתן להוכיח שהגישה חוקית. אם אנחנו מצפים לאינדקס שמגיע מקלט, אפשר להשתמש ב־get ולקבל Option במקום פאניקה.

fn main() {
    let data = [10u8, 20, 30, 40];
    for index in [0usize, 3, 4] {
        match data.get(index) {
            Some(value) => println!("index={index} value={value}"),
            None => println!("index={index} is outside"),
        }
    }
}

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

מפרקים ערך לפי הצורה שלו

ב־match אפשר לפרק גם מבנה וגם טווח קטן של ערכים. היתרון הוא שהמשתנים שנוצרים בכל ענף קיימים רק כשיש משמעות לנתון שהם מייצגים. בענף Ready אנחנו מקבלים length, ובענף Missing אין שדה פיקטיבי שעלול להיקרא בטעות.

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