לדלג לתוכן

1.1 - כלי עבודה והבנת אבחוני המהדר - הרצאה

מתחילים מתוכנית שלא מתקמפלת

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

נשתמש ב־Rust דרך Cargo. התפקיד של Cargo הוא לנהל חבילות, תלויות ופקודות בנייה, והתפקיד של rustc הוא לבדוק ולקמפל את קוד השפה. הכלי rustup מנהל גרסאות של שרשרת הכלים. ההפרדה הזאת חשובה כשמשהו נכשל: כשל בהורדת תלות, שגיאת טיפוסים וכשל linker אינם אותה תקלה, גם אם כולם מופיעים אחרי הפקודה cargo build.

הריצו במסוף של סביבת הלינוקס שלכם:

rustc --version --verbose
cargo --version
rustup show active-toolchain

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

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

מבנה הפרויקט הראשון

צרו פרויקט בשם ownership_lab:

cargo new --edition 2024 ownership_lab
cd ownership_lab
cargo run

התיקייה src מחזיקה את המקור, והקובץ src/main.rs הוא נקודת הכניסה לבינארי הזה. הקובץ Cargo.toml מתאר את החבילה, את מהדורת השפה ואת התלויות שלה. התיקייה target מכילה תוצרי בנייה שאפשר לייצר מחדש. הקובץ Cargo.lock שומר את הגרסאות שנפתרו עבור התלויות, כאשר יש כאלה.

שימו לב להבדל בין מהדורת שפה לגרסת מהדר. הערך edition = "2024" בוחר קבוצת כללי שפה ותאימות. הערך הזה אינו מקבע את גרסת rustc, ולכן שתי מכונות עם אותה מהדורה יכולות עדיין להפיק אבחונים או קוד מכונה שונים. לצורך ניסוי משוחזר נשמור גם את מספר גרסת המהדר, ובהמשך נלמד לקבע אותו בקובץ שרשרת כלים.

החליפו את תוכן src/main.rs בתוכנית הבאה:

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

fn main() {
    let name = String::from("amittech");
    print_name(name);
    println!("again={name}");
}

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

קוראים את החוזה של הפונקציה

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

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

fn print_name(name: &str) {
    println!("name={name}");
}

fn main() {
    let name = String::from("amittech");
    print_name(&name);
    println!("again={name}");
}

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

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

בודקים מקמפלים ומריצים

הריצו את שלוש הפקודות הבאות בנפרד:

cargo check
cargo build
./target/debug/ownership_lab

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

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

הריצו גם cargo build --release. תוצר השחרור נמצא בתיקייה אחרת ומופק עם פרופיל בנייה אחר. בהמשך נבדוק כיצד אופטימיזציות משפיעות על קוד המכונה ועל נוחות הדיבוג. בשלב הזה רשמו בכל ניסוי איזו פקודה בנתה את התוכנית ואיזה קובץ הופעל בפועל.

משתמשים באבחון כדי לשפר תכנון

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

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

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