1.4 - ייצוג אובייקט ואחסון - הרצאה
המידע שמסביב לשדות¶
נניח שאנחנו שולחים ברשת מבנה קטן שמכיל סוג הודעה ומספר מזהה. אפשר להתפתות לשלוח את כל ה־struct כמו שהוא, אבל לפני שעושים זאת צריך לבדוק מה באמת נמצא בזיכרון.
התוכנית הבאה בודקת את המיקום של כל שדה במבנה פשוט:
#include <cstddef>
#include <cstdio>
#include <type_traits>
struct Message {
char kind;
int id;
};
int main() {
static_assert(std::is_standard_layout_v<Message>);
std::printf("size=%zu alignment=%zu id_offset=%zu\n",
sizeof(Message), alignof(Message), offsetof(Message, id));
}
ב־MSVC על x64 נפוץ שנראה גודל שמונה, alignment של ארבע ו־offset ארבע לשדה id. חשוב להשאיר את התוצאה כתצפית של היעד, ולא להפוך אותה לכלל אוניברסלי של C++.
המבנה מכיל byte של kind, אחריו יכול להופיע padding, ואחריו ה־int. הpadding מאפשר לשדה להתחיל בכתובת שמתאימה לדרישת היישור שלו. גודל המבנה כולל גם מקום שנדרש כדי שאיבר הבא במערך מאותו טיפוס יתחיל ביישור מתאים.
הגודל הכולל אינו רק סכום הגדלים של השדות. מכאן נובע שגם השוואת bytes של שני structים אינה בהכרח השוואה של הערכים הלוגיים שלהם. הpadding אינו חלק מהמידע שהחלטנו לייצג.
צפייה מותרת בייצוג¶
רוצים להסתכל ב־bytes של מספר בלי להפר את כללי הגישה לטיפוס. אפשר להשתמש ב־std::as_bytes, שמחזיר תצוגה לקריאה של ייצוג האובייקט:
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <span>
int main() {
std::uint32_t value = 0x01020304;
auto bytes = std::as_bytes(std::span{&value, 1});
for (std::byte b : bytes) {
std::printf("%02x ", std::to_integer<unsigned>(b));
}
std::puts("");
}
ב־x64 little-endian מצפים לראות 04 03 02 01. התצוגה אינה יוצרת עותק של המספר ואינה מאריכה את חייו. כאן השימוש נשאר בתוך אותו scope ולכן האובייקט עדיין קיים לאורך כל הלולאה.
היכולת לצפות בייצוג דרך byteים אינה אומרת שאפשר לגשת לכל אובייקט כאילו היה מכל טיפוס אחר. הכללים של C++ מאפשרים גישות מסוימות לייצוג, אבל המרה של pointer אינה יוצרת באופן אוטומטי את האובייקט שאנחנו רוצים לראות.
המרת ערך מול המרת ייצוג¶
נניח שרוצים לראות את הביטים של float. ההמרה static_cast<std::uint32_t>(value) ממירה ערך מספרי, ולכן אינה עונה על השאלה הזאת.
הקוד הבא הוא תוכנית שלמה שמשתמשת ב־bit_cast:
#include <bit>
#include <cstdint>
#include <cstdio>
#include <limits>
int main() {
static_assert(sizeof(float) == sizeof(std::uint32_t));
static_assert(std::numeric_limits<float>::is_iec559);
float value = 1.0f;
auto bits = std::bit_cast<std::uint32_t>(value);
std::printf("%08x\n", static_cast<unsigned>(bits));
}
הבדיקות מציינות במפורש את ההנחות של הניסוי. ביעד שבחרנו מצפים לייצוג 3f800000. הפונקציה מחזירה אובייקט חדש מטיפוס היעד, והדרישות כוללות גודל שווה וטיפוסים שמתאימים להעתקה טריוויאלית.
הקטע הבא, לעומת זאת, מדגים גישה שאסור לאמץ כתחליף:
float value = 1.0f;
// Do not dereference this as a uint32_t:
// auto bits = *reinterpret_cast<std::uint32_t*>(&value);
ה־cast משנה את סוג המצביע, אבל אינו הופך את אובייקט ה־float לאובייקט של מספר שלם. גם אם המעבד מסוגל לקרוא את אותם bytes, גישה שאינה מותרת לפי השפה מאפשרת למהדר להסיק הנחות שהקוד מפר.
העתקה טריוויאלית אינה פרוטוקול¶
אפשר להעתיק representation של אובייקט trivially copyable בדרכים שהשפה מאפשרת, אך עדיין אין לנו פורמט יציב לרשת או לדיסק. ה־endianness, גודל הטיפוסים, הpadding וסדר השדות עשויים להיות שונים בין יעדים וגרסאות.
נרצה להגדיר בפורמט נתונים כמה bytes יש לכל שדה ובאיזה סדר הם נכתבים. נרצה גם לבדוק bounds לפני הקריאה. היכולת לבצע memcpy בלי להפר lifetime אינה מוכיחה שלצד השני יש אותו חוזה בינארי.
הטעות הזאת מופיעה גם בממשקי DLL. שני רכיבים שנבנו עם הנחות שונות על packing יכולים להסכים על שם טיפוס ועדיין לקרוא שדות בכתובות שונות. פרק ה־ABI יחזור לבעיה הזאת עם דוגמה של ספרייה ממשית.
יצירת אובייקט בתוך אחסון¶
עכשיו נפריד במפורש בין קבלת מקום לבין בניית אובייקט. התוכנית הבאה יוצרת מחלקה בעלת בנאי ו־destructor בתוך buffer מקומי מיושר:
#include <cstddef>
#include <cstdio>
#include <memory>
struct Item {
int value;
explicit Item(int n) : value(n) {
std::puts("constructed");
}
~Item() {
std::puts("destroyed");
}
};
int main() {
alignas(Item) std::byte storage[sizeof(Item)];
auto* item = std::construct_at(
reinterpret_cast<Item*>(storage), 42);
std::printf("%d\n", item->value);
std::destroy_at(item);
}
האחסון קיים לפני ש־Item נבנה, אבל איננו קוראים דרך item->value לפני הבנייה. ה־alignment מבטיח שהמקום מתאים לדרישת הטיפוס, והגודל מבטיח שיש מספיק bytes. הקריאה ל־construct_at מתחילה את החיים בצורה המתאימה למחלקה הזאת.
אחרי destroy_at האחסון עדיין קיים, אבל אין לקרוא עוד את השדה. אפשר לבנות אובייקט חדש באותו אחסון, ולשמור את המצביע שהבנייה החדשה מחזירה. הכללים לשימוש במצביעים ישנים אחרי החלפת אובייקט עדינים, ולכן בתחילת הדרך מעדיפים גבול ברור: כל חיים חדשים מקבלים תוצאת בנייה חדשה.
יש ב־C++ גם כללים ליצירה משתמעת של טיפוסים מסוימים. הם אינם תירוץ לוותר על בירור של הטיפוס והפעולה המדויקים. הדוגמה שלנו משתמשת במחלקה עם תופעות לוואי מפורשות כדי להפוך את גבול החיים לגלוי.
בדיקת כשל שימושית¶
אפשר להסיר את alignas לצורך קריאת אזהרות או ניתוח סטטי, אבל אין צורך להריץ dereference לא מיושר כדי להבין שהחוזה נשבר. גם כתובת שבמקרה יצאה מיושרת אינה מוכיחה שההצהרה מבטיחה זאת בכל הרצה.
כדאי לצייר שלושה דברים בנפרד: טווח bytes, אובייקט שחי בטווח, ו־alias שניגש אליו. המודל הזה ימנע הרבה טעויות כשנבנה arena, allocator או buffer שמוחזר ל־pool.
לקריאה נוספת אפשר לעיין בכללי המרת ייצוג עם bit_cast ובכללי חיי אובייקטים, תוך שימוש בכללים התואמים ל־C++20.