1.9 - מודל מחלקות ופולימורפיזם - הרצאה
פעולה אחת וכמה מימושים¶
נניח שיש תוכנית ששולחת הודעות ליעדים שונים. חלק מההודעות נכתבות למסך וחלק נשמרות בזיכרון. הקוד שמשתמש ביעד רוצה לבקש כתיבה בלי לדעת מה המימוש המדויק.
אפשר לייצג את החוזה באמצעות מחלקת בסיס:
#include <cstdio>
#include <memory>
#include <string>
#include <string_view>
struct Sink {
virtual void write(std::string_view text) = 0;
virtual ~Sink() = default;
};
struct ConsoleSink final : Sink {
void write(std::string_view text) override {
std::fwrite(text.data(), 1, text.size(), stdout);
std::fputc('\n', stdout);
}
};
struct MemorySink final : Sink {
std::string contents;
void write(std::string_view text) override {
contents.append(text);
}
};
void greet(Sink& sink) {
sink.write("hello");
}
int main() {
ConsoleSink console;
MemorySink memory;
greet(console);
greet(memory);
std::printf("stored=%s\n", memory.contents.c_str());
}
הפונקציה greet רואה reference לבסיס. הקריאה הווירטואלית בוחרת בזמן ריצה את המימוש של האובייקט שאליו הreference מתייחס. הסימון override מבקש מהמהדר לוודא שבאמת החלפנו פונקציה וירטואלית קיימת ולא יצרנו בטעות פונקציה אחרת.
הסימון final ב־ConsoleSink מונע ירושה נוספת ממנו. הוא מתעד החלטת תכנון ויכול לעזור למהדר בחלק מהמצבים, אבל אין להסיק ממנו שכל קריאה וירטואלית תיעלם באופטימיזציה.
מחיקת אובייקט דרך בסיס¶
כעת נרצה להחזיק מימוש מאחורי unique_ptr לבסיס. הקוד הבא יכול להחליף את main:
בעל המשאב רואה טיפוס Sink, אבל האובייקט שנוצר הוא MemorySink. ה־virtual destructor מבטיח שמחיקה דרך מצביע הבסיס תפעיל את רצף ההשמדה המתאים גם לחלק הנגזר.
הסרת virtual מה־destructor אינה אופטימיזציה תקפה לממשק שמוחק אובייקטים דרך בסיס באופן הזה. העובדה שבדוגמה קטנה אין crash אינה אומרת שהמחיקה נכונה. אם הממשק אינו מיועד למחיקה דרך בסיס, אפשר לעצב אותו אחרת, למשל destructor מוגן, אבל צריך למנוע את מסלול השימוש במקום לקוות שלא יקרה.
חיתוך אובייקט¶
הבעיה הבאה מופיעה כשמעבירים בסיס לפי ערך. נבחן תוכנית קטנה אחרת:
#include <cstdio>
struct Base {
int id = 1;
virtual int code() const { return 10; }
virtual ~Base() = default;
};
struct Derived : Base {
int extra = 2;
int code() const override { return 20; }
};
void by_value(Base item) {
std::printf("%d\n", item.code());
}
void by_reference(const Base& item) {
std::printf("%d\n", item.code());
}
int main() {
Derived item;
by_value(item);
by_reference(item);
}
הקריאה הראשונה יוצרת אובייקט Base עצמאי מתוך תת־אובייקט הבסיס של Derived. החלק הנגזר אינו חלק מהעותק, ולכן מצפים לעשר. הקריאה השנייה מתייחסת לאובייקט המקורי, ולכן מצפים לעשרים.
התופעה נקראת slicing. היא אינה כשל ב־virtual dispatch; פשוט כבר אין אובייקט נגזר במקום שעליו הפעולה פועלת. מעבר ל־reference מתאים כשהפונקציה שואלת גישה זמנית, והעתקה פולימורפית אמיתית דורשת חוזה כמו clone.
מה נמצא בזיכרון¶
במימושים נפוצים מחלקה פולימורפית מכילה מידע שמאפשר למצוא טבלת פונקציות וירטואליות. אפשר לראות vtable או vftable באסמבלי ובכלי הדיבוג, ולעקוב אחר pointer שמכוון אליה.
תקן C++ אינו מחייב שכל מימוש ישתמש בדיוק באותו layout. גודל הטבלה, מיקום pointerים ותצורת RTTI תלויים ב־ABI ובמהדר. לכן תצפית על אובייקט אחד אינה הצדקה לכתוב parser כללי שמניח offsets קבועים לכל מחלקה.
מומלץ לבדוק sizeof של Base ושל Derived, להציב breakpoint ב־code ולראות מהו this. אחר כך משווים עם build ממוטב, שבו המהדר עשוי לדעת את הטיפוס המדויק ולבצע devirtualization.
ירושה מרובה והתאמת this¶
כאשר למחלקה יש שני בסיסים, כל base subobject עשוי להימצא ב־offset אחר בתוך האובייקט השלם. המרת pointer אל הבסיס השני יכולה לשנות את הכתובת המספרית, אף ששני הpointerים קשורים לאותו אובייקט שלם.
הדוגמה הבאה מציגה זאת בלי להניח מה יהיה ה־offset:
#include <cstdio>
struct Left {
virtual ~Left() = default;
int left = 1;
};
struct Right {
virtual ~Right() = default;
int right = 2;
};
struct Both : Left, Right {
int own = 3;
};
int main() {
Both item;
Left* left = &item;
Right* right = &item;
std::printf("whole=%p left=%p right=%p\n",
static_cast<void*>(&item),
static_cast<void*>(left),
static_cast<void*>(right));
}
ההתאמה מבוצעת לפי כללי ההמרה וה־ABI. בקריאות וירטואליות עשוי להופיע thunk שמתקן את this לפני כניסה למימוש. אין לחשב את ההמרה ידנית בעזרת cast לכתובת של האובייקט הראשון.
בירושה וירטואלית מתווסף צורך לשתף base מסוים בין מסלולי ירושה. הlayout והגישה נעשים מורכבים יותר, ולכן נבחר בה רק כשיש משמעות אמיתית למודל האובייקטים הזה, ולא כדי לחסוך כמה שורות composition.
בנייה אינה מצב ריצה רגיל¶
בזמן בנייה והשמדה, חלקים נגזרים עשויים עדיין לא להתקיים או שכבר סיימו את חייהם. קריאה וירטואלית מבנאי של בסיס אינה מפעילה באופן רגיל את override של חלק נגזר שעדיין לא נבנה.
צריך להיזהר במיוחד מקריאה לפונקציה pure virtual דרך מסלול כזה. עדיף להעביר ערכי אתחול מפורשים או להפריד פעולת start שבוצעת אחרי שהאובייקט הושלם. ההחלטה הזאת מחברת מודל מחלקות להוכחת lifetime שכבר למדנו.
בחירת מנגנון הרחבה¶
ירושה וירטואלית מתאימה כשממשק יציב צריך לאפשר הוספת מימושים. אם מספר האפשרויות סגור, variant יכול לייצג בחירה בין טיפוסים ולחייב טיפול בכל מקרה במקום אחר בקוד.
מנגנון type erasure מאפשר להציג חוזה משותף בלי לחייב שכל מימוש יירש מחלקת בסיס ציבורית. הוא עדיין צריך לנהל אחסון, העתקה או העברה ושיגור פעולות. היכולת להסתיר את הטיפוס אינה מעלימה את עלות החיים שלו.
הבחירה אינה נקבעת לפי סיסמה שפולימורפיזם דינמי תמיד איטי. מודדים את מסלול העבודה, את locality ואת הצורך בהרחבה, ואז בוחרים מנגנון שאפשר להסביר ולתחזק.
לקריאה נוספת ראו את תיעוד ה־destructorים של Microsoft.