• בלוג
  • יום 1 - מספיק Rust בשביל שנוכל לקודד

יום 1 - מספיק Rust בשביל שנוכל לקודד

27/12/2022

הי חברים,

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

מוכנים? הדקו חגורות ומיד ממריאים.

1. הפעלת קוד ראסט בענן

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

fn main() {
    println!("Hello, world!");
}

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

נמשיך להגדיר קצת משתנים ולולאות עם התוכנית הקצת יותר מורכבת הבאה:

fn main() {
    let mut x = 10;
    let mut y = 20;

    for i in 1..10 {
        x += i;
        y *= i;
    }

    println!("x = {}, y = {}", x, y);
}

והתוצאה אחרי run:

x = 55, y = 7257600

ולמדנו ש let mut מגדיר משתנה חדש (המילה mut היא קיצור של mutable, כי זה משתנה שאפשר לשנות את הערך שלו), ו for היא פקודת הלולאה.

מעניין לגלות שאם אני מנסה להעלות את המספרים, למשל שהלולאה תרוץ עד 100 במקום עד 10, התוכנית נכשלת עם השגיאה:

thread 'main' panicked at 'attempt to multiply with overflow', src/main.rs:7:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

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

   Compiling playground v0.0.1 (/playground)
error[E0308]: mismatched types
 --> src/main.rs:5:9
  |
2 |     let mut x = 10;
  |                 -- expected due to this value
...
5 |     x = "hello";
  |         ^^^^^^^ expected integer, found `&str`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `playground` due to previous error

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

טיפוס הנתונים שראסט לקח בתור ברירת מחדל למספרים נקרא i32. ה i הוא קיצור של המילה integer וזה מייצג מספר שלם, והמספר הוא הגודל שלו בביטים. יש גם i64 ו i8, וגם u32 למספר שהוא Unsigned ו f32 למספר עם נקודה עשרונית. ואנחנו יכולים לבחור טיפוסים אחרים בעזרת סימן נקודותיים אחרי שם המשתנה למשל:

let mut x: f32 = 10.0;
let mut y: f32 = 20.0;

אבל אז בשביל להוסיף מספר שלם למשתנה נצטרך להגיד ל Rust שאנחנו רוצים להסתכל על המספר בתור Floating Point Number בעזרת המילה השמורה as. התוכנית המלאה תהיה:

fn main() {
    let mut x: f32 = 10.0;
    let mut y: f32 = 20.0;

    for i in 1..10 {
        x += i as f32;
        y *= i as f32;
    }

    println!("x = {}, y = {}", x, y);
}

2. התקנה וכתיבת תוכנית ראשונה מקומית

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

ראסט מאוד ידידותית להתקנה ואתם יכולים למצוא הוראות למערכת ההפעלה שלכם בקישור:

https://www.rust-lang.org/tools/install

בנוסף אני ממליץ להשתמש ב VS Code כדי לערוך קבצי rust מקומית. יש תוסף בשם rust-analyzer שמראה לכם מה הטיפוס של כל משתנה כשאתם עורכים את הקוד, ומציג שגיאות קומפילציה אם יש ממש מתוך העורך.

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

cargo new myapp

וזה ייצור תיקיה בשם myapp ובתוכה את הקבצים:

.
├── Cargo.lock
├── Cargo.toml
├── src
│   └── main.rs

בימים הבאים נלמד איך להתקין ספריות עזר ואז נראה למה הקובץ Cargo.toml חשוב. בינתיים תוכלו לעיין בקובץ main.rs שנוצר ולהריץ עם:

cargo run

3. איך ראסט מתייחס לזיכרון (כולל מה זה Box)

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

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

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

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

fn main() {
    let name = String::from("hello");

    println!("got: {}", name);
}

עד כאן לא קרה הרבה. עכשיו ננסה להעביר את ההדפסה לפונקציה אחרת:

fn print_name(name: String) {
    println!("{}", name);
}

fn main() {
    let name = String::from("hello");
    print_name(name);
}

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

fn print_name(name: String) {
    println!("{}", name);
}

fn main() {   
    let name = String::from("hello");

    print_name(name);
    print_name(name);
    print_name(name);
}

ו-התוכנית כבר לא מתקמפלת. השגיאה מופיעה בקריאה השניה לפונקציית ההדפסה:

use of moved value: `name`

זאת אחת השגיאות הראשונות שקיבלתי ב Rust ואחת הנפוצות. מה שראסט אומר לי כאן זה שלכל String בתוכנית יש Owner, שזה המשתנה ש"שומר" עליה. כשהמשתנה הזה יוצא מ Scope המחרוזת תימחק. כשהתוכנית מתחילה ה Owner הוא המשתנה name, אבל כשאני מעביר את name לפונקציה print_name שיניתי גם בעלות, ועכשיו הפונקציה "השתלטה" על המידע. ניסיון לעבוד עם המשתנה אחרי שנתתי אותו לפונקציה ייכשל, כי המשתנה הוא כבר לא ה"בעלים" של המידע, ואולי המידע כבר לא בתוקף. ראסט מנסה לשמור עליי מטעויות של גישה למידע אחרי שמחקתי אותו.

בשביל להצליח להעביר משתנה לפונקציה בלי שהפונקציה תיקח "בעלות" על המידע, אנחנו יכולים להעביר Reference למשתנה. זה נראה ככה:

fn print_name(name: &String) {
    println!("{}", name);
}

fn main() {   
    let name = String::from("hello");

    print_name(&name);
    print_name(&name);
    print_name(&name);

    println!("My name is still {}", name);
}

ההבדל הוא האפשרות של ראסט לבצע בדיקת טיפוסים - שימו לב מה קורה אם אני מנסה לשנות את המחרוזת מתוך הפונקציה:

fn print_name(name: &String) {
    name.push_str("yay");
}

הקוד הזה מנסה להוסיף את הסיומת yay למחרוזת, אבל הוא לא מתקמפל. המחרוזת נכנסה לפונקציה בתור Reference ולכן אין לפונקציה הרשאה לשנות אותה.

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

fn print_name(name: &String) {
    println!("{}", name)
}

fn change_name(name: &mut String) {
    name.push_str("--- the end; ");
}

fn main() {
    let mut name = String::from("hello");

    change_name(&mut name);
    change_name(&mut name);
    change_name(&mut name);

    print_name(&name);
    print_name(&name);
    print_name(&name);

    println!("My name is still {}", name);
}

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

ככל שנכתוב יותר קוד Rust נראה יותר דרכים יצירתיות להעביר משתנים בין פונקציות ונצטרך לזכור לבדוק מי ה Owner של כל מידע במהלך ריצת התוכנית.

4. סטראקטים ב Rust

רכיב מרכזי נוסף בשפת Rust הוא ה Struct. אפשר לחשוב על סטראקט כמו אוביקט בשפת JavaScript, רק בלי הפונקציות. סטראקט מחזיק מידע ואנחנו יכולים לכתוב פונקציות שיהיו "קשורות" לאותו סטראקט. בנוסף אפשר לכתוב אוספים של פונקציות שאפשר "להדביק" על כל Struct, וכל אוסף כזה נקרא Trait.

אז הנה דוגמה Struct ראשון מייצג ספר:

struct Book {
    title: String,
    author_name: String,
    cost: i32,
}

אני יכול ליצור כמה ספרים ולהדפיס מהם מידע:

struct Book {
    title: String,
    author_name: String,
    cost: i32,
}

fn main() {   
    let b1 = Book { title: "The Picture of Dorian Gray".to_string(), author_name: "Oscar Wilde".to_string(), cost: 0 };
    let b2 = Book { title: "Dracula".to_string(), author_name: "Mary Wollstonecraft Shelley".to_string(), cost: 0 };
    let b3 = Book { title: "Jane Eyre".to_string(), author_name: "Charlotte Brontë".to_string(), cost: 0 };

    println!("{} was written by {}", b1.title, b1.author_name);
}

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

struct Book {
    title: String,
    author_name: String,
    cost: i32,
}

impl Book {
    fn describe(&self) {
        println!("{} was written by {}. It costs {}$", self.title, self.author_name, self.cost);
    }
}

fn main() {
    let b1 = Book { title: "The Picture of Dorian Gray".to_string(), author_name: "Oscar Wilde".to_string(), cost: 0 };
    let b2 = Book { title: "Dracula".to_string(), author_name: "Mary Wollstonecraft Shelley".to_string(), cost: 0 };
    let b3 = Book { title: "Jane Eyre".to_string(), author_name: "Charlotte Brontë".to_string(), cost: 0 };

    b1.describe();
    b2.describe();
    b3.describe();
}

עכשיו בואו נבנה עוד Struct, נניח עבור מכונית צעצוע:

struct ToyCar {
    color: String,
    cost: i32,
}

ונשים לב שלשני הסטראקטים שלנו יש מאפיין cost. אז אפשר לכתוב פונקציה ש"תתאים" לשניהם, למשל הפונקציה sell שמוכרת את הדבר. ראסט מאפשר כתיב מקוצר עבור אוסף של פונקציות שאפשר להפעיל על סטראקטים וזה נקרא Trait.

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

trait Sellable {
    fn get_price(&self) -> i32;

    fn sell(&self) {
        println!("Got {}$", self.get_price());
    }
}

נגדיר ש Book ו ToyCar מממשים את ה Trait, ונבנה לכל אחד מהם את הפונקציה get_price שלו:

impl Sellable for Book {
    fn get_price(&self) -> i32 {
        return self.cost;
    }
}

impl Sellable for ToyCar {
    fn get_price(&self) -> i32 {
        return self.cost;
    }
}

שימו לב שב Trait אי אפשר לגשת לשדות המידע של Struct, רק להפעיל פונקציות שלו, ולכן הייתי צריך להגדיר את הפונקציה get_price שרק מחזירה את ערך שדה המידע cost.

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

book.sell();
car.sell();

והתוכנית המלאה:

trait Sellable {
    fn get_price(&self) -> i32;

    fn sell(&self) {
        println!("Got {}$", self.get_price());
    }
}

impl Sellable for Book {
    fn get_price(&self) -> i32 {
        return self.cost;
    }
}

impl Sellable for ToyCar {
    fn get_price(&self) -> i32 {
        return self.cost;
    }    
}

struct ToyCar {
    color: String,
    cost: i32,
}

struct Book {
    title: String,
    author_name: String,
    cost: i32,
}

impl Book {
    fn describe(&self) {
        println!("{} was written by {}. It costs {}$", self.title, self.author_name, self.cost);
    }
}


fn main() {
    let b1 = Book { title: "The Picture of Dorian Gray".to_string(), author_name: "Oscar Wilde".to_string(), cost: 0 };
    let b2 = Book { title: "Dracula".to_string(), author_name: "Mary Wollstonecraft Shelley".to_string(), cost: 0 };
    let b3 = Book { title: "Jane Eyre".to_string(), author_name: "Charlotte Brontë".to_string(), cost: 0 };
    let car = ToyCar { color: "green".to_string(), cost: 15 };

    b1.describe();
    b2.describe();
    b3.describe();

    b1.sell();
    car.sell();
}

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

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

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

  1. התקינו ראסט אצלכם על המכונה.

  2. כתבו תוכנית ראסט שמוצאת את כל המספרים הראשוניים בין 1 ל 100.

  3. כתבו תוכנית ראסט שמגרילה מחרוזת בת 6 תווים - התוכנית תרוץ בלולאה וכל איטרציה תוסיף עוד אות למחרוזת.

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

  5. ב Struct של ספר כתבתי:

impl Book {
    fn describe(&self) {
        println!("{} was written by {}. It costs {}$", self.title, self.author_name, self.cost);
    }
}

בשביל מה הייתי צריך את סימן ה & לפני ה self? מה יקרה אם נוותר עליו?