2.4 - גודל טיפוסים וחישוב בזמן קומפילציה - הרצאה
גודל כחלק מן הטיפוס¶
נרצה לכתוב פעולה על בלוק בייטים בגודל קבוע, אבל לא לקבע את הגודל היחיד בתוך הספרייה. אפשר להשתמש בפרמטר const שמופיע בטיפוס המערך:
struct Block<const N: usize> {
bytes: [u8; N],
}
impl<const N: usize> Block<N> {
fn new(fill: u8) -> Self {
Self { bytes: [fill; N] }
}
fn checksum(&self) -> u8 {
self.bytes.iter().fold(0u8, |sum, byte| sum.wrapping_add(*byte))
}
}
fn main() {
let small = Block::<4>::new(3);
let large = Block::<8>::new(3);
assert_eq!(small.checksum(), 12);
assert_eq!(large.checksum(), 24);
}
הערך N ידוע לטיפוס של כל שימוש. בלוק של ארבעה בייטים ובלוק של שמונה בייטים הם טיפוסים שונים, ולכן אפשר להגדיר API שמקבל רק גודל מסוים. המערך מאחסן את הבייטים בתוך הערך, בניגוד ל־Vec שמחזיק תיאור של אחסון דינמי.
הבחירה אינה אומרת שהערך מוכרח להיות במחסנית. אפשר לשים Block בתוך Box, בתוך ערך אחר או באחסון סטטי. יש להפריד בין גודל ידוע לטיפוס לבין החלטת מקום האחסון של מופע מסוים.
חישוב קבוע אינו היתר לכל ביטוי גנרי¶
קל לחשוב שאם N ידוע בזמן קומפילציה, כל פעולה אריתמטית עליו מותרת בכל מיקום של טיפוס. בפועל יש מגבלות על ביטויי const גנריים לפי גרסת השפה והיכולות היציבות. לדוגמה, ההגדרה הבאה אינה בסיס נייד לראסט היציב של המסלול:
הקטע משמש לבדיקת אבחון ואינו חלק מן הפרויקט התקין. כדי לבטא את המבנה בלי להישען על הרחבה לא יציבה אפשר להפריד כותרת מן המערך, או להעביר גודל כולל מפורש ולבדוק את היחס במקום המתאים.
הנה חלופה פשוטה שמחזיקה כותרת ובאפר בנפרד:
struct Frame<const N: usize> {
kind: u8,
payload: [u8; N],
}
fn main() {
let frame = Frame::<3> { kind: 1, payload: [10, 20, 30] };
println!("kind={} payload={:?}", frame.kind, frame.payload);
}
אין להסיק שהייצוג של Frame הוא פורמט חיצוני של בייט כותרת ומיד אחריו המטען. ההגדרה מתארת שדות לוגיים, והתחייבות לפורמט דורשת פענוח או קידוד מפורשים כפי שלמדנו בפרק הראשון.
פונקציה שאפשר להעריך מראש¶
הסימון const fn מאפשר להשתמש בפונקציה בהקשרים של הערכה קבועה, כאשר גופה עומד במגבלות המתאימות. הוא אינו אומר שכל קריאה לפונקציה תתבצע בזמן קומפילציה. אפשר לקרוא לאותה פונקציה גם עם קלט בזמן ריצה.
const fn low_mask(bits: u32) -> Option<u64> {
if bits > 64 {
None
} else if bits == 64 {
Some(u64::MAX)
} else {
Some((1u64 << bits) - 1)
}
}
const BYTE_MASK: Option<u64> = low_mask(8);
fn main() {
let bits: u32 = std::env::args().nth(1)
.unwrap_or_else(|| "4".into()).parse().expect("invalid bits");
println!("constant={BYTE_MASK:?} runtime={:?}", low_mask(bits));
}
הבדיקה למספר 64 מונעת הזזה ברוחב הטיפוס. ההערכה הקבועה מחייבת אותנו לטפל גם בגבולות האלה, ולא רק בשגיאות שהיו מופיעות בהרצה. אופטימיזציה יכולה לקפל חישוב רגיל כאשר הקלט גלוי, אבל const מאפשר לבקש במפורש תוצאה בהקשר שדורש ערך קבוע.
טיפוס שאורכו אינו חלק מן החתימה¶
הטיפוס [u8] מתאר פרוסה באורך דינמי. אין לו גודל קבוע אחד שאפשר להציב ב־size_of, אבל אפשר להחזיק אליו הפניה, משום שההפניה נושאת את המידע הדרוש. אותו רעיון מופיע גם ב־str ובאובייקטי trait.
use std::mem::{align_of_val, size_of_val};
fn inspect<T: ?Sized>(value: &T) {
println!("size={} align={}", size_of_val(value), align_of_val(value));
}
fn main() {
let bytes = [1u8, 2, 3, 4];
inspect(&bytes);
inspect(&bytes[1..]);
inspect("שלום");
}
ברירת המחדל של פרמטר טיפוס רגיל כוללת בדרך כלל את החסם Sized. הסימון ?Sized מאפשר לפונקציה לקבל גם טיפוס שהגודל שלו נקבע דרך הערך והמטא־נתונים. הפונקציה עדיין מקבלת הפניה בגודל מתאים לייצוג שלה, ואינה מנסה להעביר ערך דינמי בגודל לא ידוע ישירות כמשתנה מקומי רגיל.
בוחרים היכן הגודל צריך להיות ידוע¶
מערך גנרי מתאים כאשר הגודל הוא חלק מחוזה האלגוריתם, למשל בלוק של פרוטוקול קבוע. פרוסה מתאימה כאשר אותו אלגוריתם צריך לפעול על טווחים באורכים שונים. אוסף בבעלות כמו Vec מתאים כאשר התוכנית גם מנהלת את חיי האחסון ואת הגדילה שלו.
אין צורך להפוך כל מספר ל־const generic. ריבוי גדלים בטיפוס יכול לייצר יותר מימושי קוד ולהקשות על חיבור רכיבים. מצד שני, שמירת גודל חשוב רק בשדה זמן ריצה עלולה להעביר בדיקה שהטיפוס יכול היה לבצע אל כל קריאה. בתרגול נבחר גודל קבוע במקום שבו הוא מבטא חוזה, ונחזיר פרוסה במקום שבו הקורא רק צריך לקרוא נתונים.