יום 3 - מימוש wc ב Rust

29/12/2022

היום נבנה גירסת Rust לתוכנית שורת פקודה פופולרית ביוניקס שנקראת wc. התוכנית סופרת כמה תווים, שורות ומילים היא קיבלה בקלט.

1. מה אנחנו בונים

לספור כמה שורות, מילים ותווים יש בקלט זה לא החלק הקשה בבניית wc, אלא האינטגרציה שלה עם העולם-

  1. התוכנית יכולה לקבל שם קובץ משורת הפקודה, ואז היא תקרא את כל הקלט מאותו קובץ.

  2. אם לא קיבלה שם קובץ התוכנית תקרא את הקלט מ Standard Input.

לכן האתגרים שלנו היום יהיו ללמוד איך קוראים מידע מקבצים ואיך קוראים מ Standard Input, ואיך לכתוב פונקציה שיודעת לקרוא משני המקורות באותה צורה.

2. קוד התוכנית

הקוד הפעם קצר. תחילה הדבקה ואחריה הסברים:

use std::env;
use std::fs;
use std::io::{self, BufRead, BufReader};

fn main() {
    let input = env::args().nth(1);
    let reader: Box<dyn BufRead> = match input {
        None => Box::new(BufReader::new(io::stdin())),
        Some(filename) => Box::new(BufReader::new(fs::File::open(filename).unwrap()))
    };

    let mut line_count: usize = 0;
    let mut char_count: usize = 0;
    let mut words_count: usize = 0;

    for line in reader.lines() {
        let the_line = line.unwrap();
        line_count += 1;
        words_count += the_line.split_whitespace().count();
        char_count += the_line.chars().count() + 1; // +1 because of the newline
    }

    println!("{} {} {}", line_count, words_count, char_count);
}

3. קריאת שם הקובץ משורת הפקודה

בחלק הראשון של התוכנית אני קורא את שם הקובץ מהארגומנט השני משורת הפקודה, בדיוק כמו שראינו בדוגמה מאתמול. מה ששונה הפעם הוא שאני נשאר עם Option בגלל שהפרמטר אופציונאלי - אולי יהיה שם קובץ, ואולי לא ואז נצטרך לקרוא מ stdin. בהינתן Option (ב Rust זה נקרא enum), אני יכול לטפל בכל האפשרויות שלו עם פקודת match.

בואו ננסה את זה בדוגמה פשוטה ב Playground לפני שנמשיך לקרוא את הקוד של wc. נתחיל עם ה enum הראשון שמגדיר 3 צבעים:

enum Color {
    Red,
    Blue,
    Green
}

fn main() {
    let my_color = Color::Red;
    match my_color {
        Color::Red => println!("It's red!"),
        Color::Blue => println!("It's blue!"),
        Color::Green => println!("It's green!"),
    }
}

הפקודה match מקבלת משתנה ובודקת אם הוא מתאים לכל אחת מהאפשרויות. בעיקרון אפשר להשתמש ב match לכל סוג משתנה, אבל ל enum הוא מתאים כמו כפפה ליד. enum-ים בראסט יכולים גם להחזיק ערך (או כמה), ואז אפשר לכתוב דברים מדליקים הדוגמה הבאה מהספר Rust By Example:

enum WebEvent {
    // An `enum` may either be `unit-like`,
    PageLoad,
    PageUnload,
    // like tuple structs,
    KeyPress(char),
    Paste(String),
    // or c-like structures.
    Click { x: i64, y: i64 },
}

// A function which takes a `WebEvent` enum as an argument and
// returns nothing.
fn inspect(event: WebEvent) {
    match event {
        WebEvent::PageLoad => println!("page loaded"),
        WebEvent::PageUnload => println!("page unloaded"),
        // Destructure `c` from inside the `enum`.
        WebEvent::KeyPress(c) => println!("pressed '{}'.", c),
        WebEvent::Paste(s) => println!("pasted \"{}\".", s),
        // Destructure `Click` into `x` and `y`.
        WebEvent::Click { x, y } => {
            println!("clicked at x={}, y={}.", x, y);
        },
    }
}

וזה יפה כי ה match מתאים למבנה של ה Enum, וגם מאפשר להגדיר משתנים שיקחו את הערכים מתוך ה enum.

בקוד של wc רשמתי את הביטוי הבא:

let reader: Box<dyn BufRead> = match input {
    None => Box::new(BufReader::new(io::stdin())),
    Some(filename) => Box::new(BufReader::new(fs::File::open(filename).unwrap()))
};

הקוד בודק את הערך של input מול שתי האפשרויות של Option - או שיש שם Some, ואז הפרמטר הוא שם הקובץ, או שיש שם None ואז אין לנו שם קובץ ועלינו לקרוא את הקלט מ stdin.

4. יצירת ה reader

קוד הטיפול ב None וב Some צריך להחזיר את אותו טיפוס למשתנה reader, אבל הבעיה ש BufReader::new מחזיר טיפוס מידע שונה לפי הדבר שהוא קיבל (זה נקרא Generics). בשביל לתקן את זה אני עוטף את שני ה reader-ים ב Box ומבקש מהקומפיילר לא להקצות מראש מקום בזיכרון ל Box, אלא לחשב את הגודל דינמית בזמן ריצה, בעזרת המילה השמורה dyn. סך הכל יהיה לי ב reader קופסה שבתוכה יהיה BufReader. אני לא יודע בזמן קומפילציה איזה BufReader בדיוק זה יהיה, אבל זה בסדר - כי עם שניהם אני עובד באותה צורה.

5. לולאת הקריאה עצמה

הפונקציה reader.lines מחזירה אוסף של כל השורות בדבר שה Reader קורא ממנו, בין אם זה קובץ או stdin. לכן אפשר לרוץ בלולאה:

for line in reader.lines() {
    let the_line = line.unwrap();
    line_count += 1;
    words_count += the_line.split_whitespace().count();
    char_count += the_line.chars().count() + 1; // +1 because of the newline
}

ושימו לב לפקודה line.unwrap - דיברנו על Option וראינו שפונקציות מסוימות שמחזירות ערך אופציונאלי מחזירות Option, וכך "מכריחות" את מי שקורא להן לבדוק אם היה שם ערך או לא (כלומר מונעות מצב של עבודה עם Null Values). החלופה ל Option היא Result. גם זה Enum שהערכים שהוא יכול להחזיק הם Ok ו Err. ל Ok יש פרמטר שזו התוצאה (במקרה שלנו השורה שנקראה), ול Err יש פרמטר שזו הודעת השגיאה. הפקודה unwrap של Result בודקת את מה שכתוב בפנים, אם זה Ok היא תחזיר את התוצאה ואם זה Err היא תרסק את התוכנית עם הודעת השגיאה. בתוכניות גדולות כדאי להיזהר מפקודה זו ולהשתמש בבדיקה מפורשת עם match, אבל בלולאה שלנו זה עדיין בסדר לסיים את התוכנית אם לא הצלחנו לקרוא שורה.

6. תרגילים להרחבה

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

  1. חלקו את ה main לשתי פונקציות, אחת מפענחת את הקלט שהתקבל משורת הפקודה והשניה מכילה את לולאת הקריאה מהקובץ.

  2. הוציאו את הפונקציות לקובץ נפרד, ושלבו אותו ב main עם פקודה mod.

  3. הוסיפו תמיכה במתגים האופציונאליים w, c ו-l כדי שאפשר יהיה להדפיס רק את מספר השורות, מספר המילים או מספר התווים בקלט. נסו לתמוך גם בכתיב נפרד למשל wc -l -w וגם בכתיב המשולב wc -cl.

  4. הוסיפו אפשרות לקרוא מספר קבצים, אם משתמש מעביר שמות של מספר קבצים בשורת הפקודה למשל wc file1.txt file2.txt.

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