2.3 - גבולות ספרייה ותאימות מימושים - הרצאה
מי רשאי להגדיר משמעות לטיפוס¶
נרצה להדפיס Vec של שורות באמצעות Display. ניסיון טבעי הוא לכתוב מימוש ישיר של התכונה עבור Vec. אם גם התכונה וגם הטיפוס שייכים לספרייה אחרת, ראסט מגבילה את המימוש הזה. ההגבלה אינה רק עניין של סגנון: בלעדיה שתי ספריות בלתי תלויות היו יכולות להגדיר משמעויות סותרות לאותו צירוף, והוספת תלות הייתה משנה את בחירת המימוש.
הקטע הבא נועד להידחות כאשר מכניסים אותו לקובץ עם main ריקה:
use std::fmt;
impl fmt::Display for Vec<String> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{} lines", self.len())
}
}
fn main() {}
הטיפוס Vec והתכונה Display אינם מוגדרים ב־crate שלנו. כללי orphan rules מחייבים נקודת שליטה מקומית מתאימה, עם פרטים נוספים הנוגעים לפרמטרים גנריים. בשלב הזה נשתמש בדרך פשוטה וברורה: טיפוס מעטפת מקומי.
מעטפת מבטאת החלטה של הספרייה שלנו¶
הגדירו טיפוס חדש:
use std::fmt;
struct Lines(Vec<String>);
impl fmt::Display for Lines {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, line) in self.0.iter().enumerate() {
if index != 0 {
writeln!(formatter)?;
}
write!(formatter, "{line}")?;
}
Ok(())
}
}
fn main() {
let lines = Lines(vec!["first".into(), "second".into()]);
println!("{lines}");
}
כעת המשמעות שייכת לטיפוס Lines, ולא לכל Vec בעולם. ספרייה אחרת יכולה להגדיר מעטפת אחרת עם הדפסה שונה בלי להתנגש בנו. אותו דפוס שימושי גם ליחידות מידה, למזהים ולערכים שעברו אימות.
למשל, JobId ו־UserId יכולים להיות שניהם u64 בזיכרון, אבל אסור להחליף ביניהם בטעות בקריאת API. שני טיפוסי מעטפת גורמים למהדר להבחין ביניהם. אם נשתמש רק ב־type alias, נקבל שם נוסף לאותו טיפוס ולא הבחנה חדשה.
#[derive(Clone, Copy)]
struct JobId(u64);
#[derive(Clone, Copy)]
struct UserId(u64);
fn assign(job: JobId, user: UserId) {
println!("job={} user={}", job.0, user.0);
}
fn main() {
assign(JobId(7), UserId(9));
}
מימושים חופפים אינם בחירה מאוחרת¶
נגדיר trait מקומי וננסה לתת לו גם מימוש לכל טיפוס וגם מימוש מיוחד לטיפוס אחד:
trait Label {
fn label(&self) -> &'static str;
}
impl<T> Label for T {
fn label(&self) -> &'static str { "generic" }
}
struct Job;
impl Label for Job {
fn label(&self) -> &'static str { "job" }
}
fn main() {}
הקוד נועד להידחות בגלל חפיפה. אין כלל כללי שאומר שהמימוש המסוים יותר תמיד מנצח. האפשרויות של specialization אינן משהו שעליו נבסס API יציב בקורס הזה. במקום זאת נגדיר חלוקה שאינה חופפת, או נעביר את ההחלטה לתוך מימוש יחיד שמתאים לחוזה.
ההגבלה משפיעה גם על התפתחות ספריות. מימוש רחב שמוסיפים היום יכול לתפוס צירופים שלקוחות רצו לממש בעצמם. לכן לפני הוספת blanket impl צריך לבדוק לא רק שהוא נוח בקוד שלנו, אלא גם איזה מרחב הרחבה הוא סוגר.
גבול crate הוא גבול API¶
בפרויקט קטן הכול יכול להישאר במודולים של אותה חבילה. כאשר מפרידים ספרייה, תוכנית וכלי בדיקות ל־crates שונים, השאלה מה ציבורי נעשית מוחשית. טיפוס פנימי שהיה נגיש במקרה דרך מודול סמוך כבר אינו זמין ללקוח חיצוני אלא אם חשפנו אותו.
נרצה לחשוף את הפעולות שהלקוח צריך ואת הטיפוסים שנדרשים כדי להשתמש בהן. אין צורך לחשוף את כל מבנה הנתונים הפנימי. לדוגמה, ספריית תור יכולה להחזיר Handle בלי לחשוף את רשימת ה־slots שמאחוריו. כך אפשר לשנות את האחסון בלי לשנות את המשמעות של הפעולות.
גם bound ציבורי הוא חלק מן הממשק. אם פונקציה דורשת Display למרות שהיא רק משווה ערכים, לקוח עם טיפוס שאפשר להשוות אך לא להדפיס יידחה ללא צורך. אם טיפוס שגיאה חדש משנה את צורת ההתאמה של לקוחות, מדובר בשינוי שדורש הערכת תאימות ולא רק שינוי מקומי בקובץ.
תכונות בנייה אינן מצבי ריצה¶
תכונות Cargo מאפשרות לכלול תמיכה לפי בחירת הבנייה. הן אינן מתג שמתאים אוטומטית לכל לקוח בזמן ריצה. כאשר כמה תלויות מבקשות תכונות של אותה חבילה, יש להתחשב באיחוד הבקשות בהתאם לפותר התלויות וההקשר. לכן תכונה שמתוכננת כיכולת נוספת בדרך כלל קלה יותר לשילוב מתכונה שמשנה בשקט את משמעות אותה פעולה.
כתבו במפורש אילו שילובים אתם תומכים בהם, ובדקו אותם. ספרייה שמתקמפלת רק עם ברירת המחדל אך מפרסמת תכונה ללא בדיקה יוצרת חוזה שקשה לסמוך עליו. בהמשך נשתמש במטריצת בנייה ובגרסת מינימום כדי לוודא שהממשק הציבורי תואם לסביבת המשתמש.
בתרגול נשתמש במעטפת כדי להפריד משמעות ולצמצם הרשאות שינוי. כללי החפיפה והבעלות על מימושים מפורטים במפרט המימושים, וכדאי לחזור אליו כאשר מימוש גנרי מורכב נדחה מסיבה שאינה גלויה מן המקרה הפשוט.