למה ירושה לבדה לא מספיקה
בדף הקודם בניתם היררכיית מחלקות: מחלקה נגזרת יורשת חברים ממחלקת הבסיס שלה. אבל יש מלכודת. כשקוראים למתודה דרך מצביע למחלקת בסיס, C++ מחליטה איזו פונקציה להריץ לפי הטיפוס של המצביע, לא לפי הטיפוס האמיתי של האובייקט. לכן Animal* שבפועל מצביע על Dog עדיין קורא לגרסה של Animal.
זה כמעט אף פעם לא מה שרוצים. ברוב המקרים יש לכם אוסף של מצביעים למחלקת בסיס, כל אחד מצביע על אובייקט נגזר אחר, ואתם רוצים שכל אחד יתנהג כמו עצמו. פונקציות וירטואליות גורמות לזה לקרות.
האובייקט הוא באמת Dog, ובכל זאת a->speak() הריץ את Animal::speak(). מכיוון ש-speak לא וירטואלית, המהדר בחר את הפונקציה בזמן קומפילציה לפי הטיפוס הסטטי Animal*. זה הבאג שפונקציות וירטואליות נועדו לתקן.
איך הופכים פונקציה לווירטואלית
מוסיפים את מילת המפתח virtual למתודה במחלקת הבסיס. עכשיו הקריאה מוכרעת בזמן ריצה לפי הטיפוס האמיתי של האובייקט: זה dynamic dispatch.
לולאה אחת על Animal*, שלוש התנהגויות שונות. המצביע לבסיס "יודע" את הטיפוס האמיתי בזמן ריצה ומפנה את הקריאה בהתאם. המנגנון היחיד הזה, ממשק אחד ומימושים רבים, הוא מה ש_פולימורפיזם_ אומר ב-C++.
שימו לב ש-virtual צריך להופיע רק בהצהרה של הבסיס; ברגע שפונקציה וירטואלית, היא נשארת וירטואלית בכל מחלקה נגזרת אוטומטית. לכתוב את זה שוב במחלקה הנגזרת זה אופציונלי ומיותר.
תמיד השתמשו במילת המפתח override
בדוגמה שלמעלה כל מתודה נגזרת מסומנת ב-override. זה לא הכרחי כדי שהקוד יעבוד, אבל כדאי להתייחס לזה כחובה. override (C++11) מבקש מהמהדר לוודא שאתם באמת דורסים פונקציה וירטואלית של הבסיס עם חתימה תואמת. אם החתימה שגויה בפרט קטן, מקבלים שגיאה ברורה במקום באג שקט.
struct Animal {
virtual void speak() const { } // שימו לב: const
};
struct Dog : Animal {
void speak() { } // לא const: זו פונקציה חדשה, לא דריסה!
void speak() override { } // שגיאה: 'speak' לא דורסת, המהדר אומר לכם מיד
};
בלי override, ה-speak() הראשונה מתקמפלת בלי בעיה אבל אף פעם לא נקראת דרך Animal*, כי החתימה שלה שונה מזו של הבסיס (חסר const). הייתם מבזבזים אחר צהריים שלם בתהייה למה הדריסה שלכם לא עושה כלום. עם override, המהדר תופס את אי ההתאמה במקום. הוסיפו אותו לכל פונקציה דורסת.
פונקציות וירטואליות טהורות ומחלקות אבסטרקטיות
לפעמים למחלקת הבסיס אין ברירת מחדל הגיונית: איזה קול משמיע "Animal" כללי? במקרה כזה, הצהירו על הפונקציה כ_וירטואלית טהורה_ על ידי השמה של = 0. זה משאיר אותה בלי גוף והופך את המחלקה למחלקה אבסטרקטית שאי אפשר ליצור ממנה מופע בפני עצמה. היא קיימת רק כדי להגדיר ממשק שמחלקות נגזרות חייבות למלא.
כל תת-מחלקה קונקרטית חייבת לממש את area(), אחרת גם היא נשארת אבסטרקטית. כך C++ מבטאת "ממשקים": מחלקה אבסטרקטית עם פונקציות וירטואליות טהורות בלבד היא המקבילה ב-C++ ל-interface בשפות כמו Java.
כלל ה-destructor הווירטואלי
זו המלכודת שנושכת את כולם לפחות פעם אחת. כשעושים delete לאובייקט דרך מצביע למחלקת בסיס, C++ קוראת ל-destructor שהיא מוצאת, ואם ה-destructor הזה לא וירטואלי, היא מריצה רק את ה-destructor של הבסיס. החלק הנגזר אף פעם לא מושמד, וכל מה שהיה בבעלותו דולף. התקן מגדיר את זה כהתנהגות לא מוגדרת.
התיקון הוא מילה אחת: הפכו את ה-destructor של הבסיס ל-virtual. אז delete p מריץ קודם את ~Derived ואחר כך את ~Base, בדיוק כמו שצריך.
struct Base {
virtual ~Base() { cout << "~Base\n"; } // נכון
};
// עכשיו: ~Derived ואחר כך ~Base
כלל אצבע: ברגע שלמחלקה יש פונקציה וירטואלית כלשהי, תנו לה גם destructor וירטואלי. אם מחלקה נועדה להיות מחלקת בסיס שמשתמשים בה דרך מצביעים, ה-destructor שלה חייב להיות וירטואלי.
טעויות ומלכודות נפוצות
עוד כמה מלכודות שכדאי לשים לב אליהן כשאתם כבר מרגישים בנוח עם פונקציות וירטואליות:
Object slicing. אם מעבירים או שומרים אובייקט נגזר by value במשתנה מטיפוס הבסיס, החלק הנגזר "נחתך" ונשארים עם אובייקט בסיס פשוט: ה-dispatch הווירטואלי כבר לא מגיע לדריסה. תמיד השתמשו במצביעים או בהפניות לפולימורפיזם:
Dog d;
Animal a = d; // נחתך: a הוא עכשיו רק Animal, החלק של Dog נעלם
a.speak(); // מריץ את Animal::speak למרות שהיא וירטואלית
Animal& ref = d; // בסדר: ההפניה שומרת על הטיפוס האמיתי
ref.speak(); // מריץ את Dog::speak
אל תקראו לפונקציות וירטואליות מבנאים או מ-destructors. בזמן הבנייה החלק הנגזר עוד לא קיים, אז קריאה וירטואלית מוכרעת לגרסה של המחלקה הנוכחית, לא לדריסה הנגזרת: לעיתים רחוקות זה מה שהתכוונתם.
ל-dispatch וירטואלי יש עלות קטנה. כל קריאה וירטואלית עוברת דרך טבלה נסתרת של מצביעים לפונקציות (ה-"vtable"), הפניה עקיפה אחת לכל קריאה. זה זול, אבל לא בחינם, אז אל תהפכו פונקציה לווירטואלית אלא אם באמת צריך דריסה.
קריאה לגרסת הבסיס בכוונה. בתוך דריסה עדיין אפשר להפעיל את המימוש של הבסיס במפורש עם Base::method(): שימושי כשההתנהגות הנגזרת מרחיבה את הבסיס ולא מחליפה אותו.
הבא בתור: העמסת אופרטורים
פונקציות וירטואליות מאפשרות לאובייקטים שלכם להתאים את ה_התנהגות_ שלהם דרך ממשק משותף. הדף הבא מראה איך להתאים את ה_אופרטורים_ שפועלים על האובייקטים שלכם: עם operator overloading אפשר ללמד את הטיפוסים שלכם להגיב ל-+, ==, << ועוד, כך ש-Vector + Vector או cout << myObject נקראים באופן טבעי כמו בטיפוסים מובנים.
שאלות נפוצות
מה זו פונקציה וירטואלית ב-C++?
פונקציה וירטואלית היא פונקציה חברה שמוצהרת עם מילת המפתח virtual במחלקת בסיס, כך שכשקוראים לה דרך מצביע או הפניה למחלקת הבסיס, C++ מריצה את הגרסה הדורסת של המחלקה הנגזרת במקום את גרסת הבסיס. הבחירה הזו בזמן ריצה נקראת dynamic dispatch, והיא הבסיס של פולימורפיזם.
מה ההבדל בין פונקציה וירטואלית לפונקציה וירטואלית טהורה?
לפונקציה וירטואלית יש גוף ואפשר לדרוס אותה. פונקציה וירטואלית טהורה מוצהרת עם = 0 ואין לה גוף במחלקת הבסיס: היא מחייבת כל מחלקה נגזרת קונקרטית לספק מימוש. כל מחלקה עם לפחות פונקציה וירטואלית טהורה אחת היא מחלקה אבסטרקטית, ואי אפשר ליצור ממנה מופע.
למה מחלקת בסיס צריכה destructor וירטואלי ב-C++?
אם עושים delete לאובייקט נגזר דרך מצביע למחלקת הבסיס וה-destructor של הבסיס לא וירטואלי, רק ה-destructor של הבסיס רץ: החלק הנגזר אף פעם לא מנוקה, מה שגורם לדליפת משאבים וזו התנהגות לא מוגדרת. הפכו ל-virtual את ה-destructor של כל מחלקה שנועדה לשימוש פולימורפי.