→ חזרו אחורה

פרק 15 - תכנות מונחה עצמים מתקדם

⏱ 24 דקות קריאה

בפרק 9 יצרנו מחלקות בודדות. עכשיו מרחיבים: מחלקה יכולה לרשת ממחלקה אחרת ולקבל ממנה שדות ומתודות "בחינם", אפשר לשתף מידע בין כל העצמים של מחלקה, ואפשר לבדוק בזמן ריצה מה הטיפוס האמיתי של עצם. זה בדיוק התוכן של מסלול ה-OOP בחלק ב' בשאלון 899271.

1. ירושה - עם ו-base

מחלקה יכולה להרחיב מחלקה אחרת (class Child : Parent) ולקבל את כל השדות והמתודות שלה, ולהוסיף משלה. נתחיל מהחלק הבסיסי ביותר - שדות ובנאי בלבד, בלי שום דבר מתקדם עדיין:

⁦C#⁩

public class Ticket {
    protected double price;
    protected int seat;

    public Ticket(double price, int seat) {
        this.price = price;
        this.seat = seat;
    }
}
            

protected (במקום private) על price ו-seat מאפשר למחלקות שיורשות מ-Ticket לגשת אליהם ישירות.

הבנאי של המחלקה היורשת קורא לבנאי של האב באמצעות : base(...) - זה חלק מהצהרת הבנאי עצמה, לא שורה בגוף שלו:

⁦C#⁩

public class VipTicket : Ticket {
    private int perks;

    public VipTicket(double price, int seat, int perks) : base(price, seat) {
        this.perks = perks;
    }
}
            

2. שדה static - משותף לכל האובייקטים

שדה static לא שייך לאובייקט בודד - יש ממנו עותק אחד בלבד, משותף לכל האובייקטים של המחלקה. נוסיף ל-Ticket מונה שסופר כמה כרטיסים נוצרו בסך הכול:

⁦C#⁩

private static int totalTickets = 0;    // משותף לכל האובייקטים - לא לכל אחד עותק משלו

public Ticket(double price, int seat) {
    this.price = price;
    this.seat = seat;
    totalTickets++;                     // כל בנאי מגדיל את אותו מונה משותף
}

public static int GetTotalTickets() {
    return totalTickets;
}
            

static על totalTickets אומר: יש עותק אחד בלבד של המשתנה הזה, משותף לכל העצמים מהמחלקה - כל קריאה לבנאי (גם דרך VipTicket, שקוראת ל-base(...)) מגדילה את אותו המונה, בלי קשר לכמה אובייקטים ספציפיים קיימים.

מה יודפס למסך על ידי הקוד הבא?

⁦C#⁩
Ticket a = new Ticket(10, 1);
Ticket b = new Ticket(20, 2);
Ticket c = new Ticket(30, 3);
Console.WriteLine(Ticket.GetTotalTickets());

3. דריסת מתודות (override) עם base

מחלקה יורשת יכולה לדרוס מתודה virtual של האב - לתת לה מימוש שונה, באמצעות מילת המפתח override. שם המתודה, סוג ההחזרה והפרמטרים חייבים להיות זהים בדיוק לגרסה של האב. ל-Ticket נוסיף מתודה שמחשבת את המחיר הסופי:

⁦C#⁩

// ב-Ticket:
public virtual double TotalCost() {
    return price;
}

// ב-VipTicket, דורסת את הגרסה של Ticket:
public override double TotalCost() {
    return base.TotalCost() + 50;   // תוספת VIP, מעל המחיר הרגיל
}
            

virtual על TotalCost ב-Ticket מסמן שמותר למחלקות יורשות לדרוס אותה - בלי virtual, override לא יתקמפל בכלל. base.TotalCost() קוראת לגרסה של האב - דריסה לא חייבת להתעלם לגמרי מהמימוש המקורי, אפשר "להוסיף עליו" במקום לשכתב אותו מאפס.

4. is וקאסטינג

לפעמים מתודה דרוסה צריכה לגשת לשדה שקיים רק במחלקה היורשת, ולא במחלקת האב. לשם כך קודם בודקים is, ורק אם התוצאה true מבצעים קאסטינג:

⁦C#⁩

// ב-Ticket:
public virtual void Combine(Ticket other) {
    this.price += other.price;
    Console.WriteLine("Combined price: " + this.price);
}

// ב-VipTicket, דורסת ומוסיפה טיפול ב-perks:
public override void Combine(Ticket other) {
    base.Combine(other);            // קודם מריצים את ההתנהגות הרגילה
    if (other is VipTicket) {
        this.perks += ((VipTicket)other).perks;
        Console.WriteLine("Combined perks: " + this.perks);
    }
}
            

is בודק אם העצם other הוא בעצם (גם) מטיפוס VipTicket - ורק אם כן, מותר לבצע קאסטינג ((VipTicket)other) כדי לגשת לשדה perks שקיים רק ב-VipTicket ולא ב-Ticket.

5. טיפוס ההפניה מול טיפוס האובייקט בפועל

זו הנקודה שהכי הרבה תלמידים מתבלבלים בה: Ticket t2 = new VipTicket(80, 5, 3); - איך זה אפשרי בכלל? t2 מוצהר (declared) כ-Ticket - זה טיפוס ההפניה, ומגביל אילו שדות ומתודות אפשר לכתוב על t2 ישירות בקוד. אבל האובייקט שבאמת נוצר בזיכרון עם new, ושה-t2 מצביע עליו, הוא VipTicket - זה הטיפוס האמיתי (בפועל) של האובייקט.

הכלל: מתודה דרוסה (override) תמיד רצה לפי הטיפוס האמיתי של האובייקט, לא לפי טיפוס ההפניה. לעומת זאת, אילו שדות/מתודות בכלל מותר לקרוא להן על המשתנה בלי קאסטינג נקבע לפי טיפוס ההפניה - למשל t2.perks לא יתקמפל בכלל, למרות שהאובייקט בפועל הוא VipTicket, כי המשתנה t2 "יודע" להיות רק Ticket אלא אם עושים קאסטינג מפורש.

נתון: Ticket t = new VipTicket(10, 1, 2);. כשקוראים ל-t.TotalCost(), איזו גרסה של המתודה באמת רצה - זו של Ticket או זו של VipTicket?

6. הכל ביחד - המחלקות המלאות

עכשיו שהכרנו כל חלק בנפרד, הנה שתי המחלקות המלאות יחד - בדיוק כפי שהן עשויות להופיע בשאלון:

⁦C#⁩

public class Ticket {
    protected double price;
    protected int seat;
    private static int totalTickets = 0;    // משותף לכל האובייקטים - לא לכל אחד עותק משלו

    public Ticket(double price) {
        this.price = price;
        this.seat = 0;
        totalTickets++;
    }

    public Ticket(double price, int seat) {
        this.price = price;
        this.seat = seat;
        totalTickets++;
    }

    public static int GetTotalTickets() {
        return totalTickets;
    }

    public virtual double TotalCost() {
        return price;
    }

    public virtual void Combine(Ticket other) {
        this.price += other.price;
        Console.WriteLine("Combined price: " + this.price);
    }
}

public class VipTicket : Ticket {
    private int perks;

    public VipTicket(double price, int seat, int perks) : base(price, seat) {
        this.perks = perks;
    }

    public override double TotalCost() {
        return base.TotalCost() + 50;   // תוספת VIP, מעל המחיר הרגיל
    }

    public override void Combine(Ticket other) {
        base.Combine(other);            // קודם מריצים את ההתנהגות הרגילה
        if (other is VipTicket) {
            this.perks += ((VipTicket)other).perks;
            Console.WriteLine("Combined perks: " + this.perks);
        }
    }
}
            

7. דוגמה מלאה - מעקב אחרי הרצה

שאלות מהסוג הזה בבגרות נותנות כמה שורות קוד ומבקשות לחזות מה כל שורה מדפיסה - בואו נעקוב אחרי הרצה מלאה:

⁦C#⁩

Ticket t1 = new Ticket(100);
Ticket t2 = new VipTicket(80, 5, 3);      // הפניה מטיפוס Ticket, אובייקט בפועל VipTicket
VipTicket v1 = new VipTicket(120, 12, 2);

Console.WriteLine(Ticket.GetTotalTickets());   // (1)
Console.WriteLine(t2.TotalCost());              // (2)
t2.Combine(v1);                                  // (3)
            

(1) מדפיס 3 - GetTotalTickets היא מתודת static ששייכת למחלקה כולה, וכל שלוש הקריאות לבנאי (גם דרך VipTicket, שקוראת ל- base(...)) הגדילו את אותו מונה משותף.

(2) מדפיס 130 - t2 מוצהר כ-Ticket, אבל האובייקט בפועל הוא VipTicket. C# תמיד מריצה את הגרסה הדרוסה לפי הטיפוס האמיתי של האובייקט, לא לפי טיפוס ההפניה - לכן רץ VipTicket.TotalCost(): 80 + 50 = 130.

(3) מדפיס "Combined price: 200" ואז "Combined perks: 5" - מאותה סיבה, t2.Combine(v1) מריצה את VipTicket.Combine: קודם base.Combine מחבר את המחירים (80 + 120 = 200), ואז - מכיוון ש-v1 is VipTicket הוא true - מחבר גם את ה-perks (3 + 2 = 5).

8. טעויות נפוצות

קאסטינג בלי בדיקת is קודם: (VipTicket)other כשה- other בפועל הוא Ticket רגיל גורם ל-InvalidCastException.

שכחת virtual/override: מתודה שלא מוגדרת virtual באב לא ניתנת לדריסה עם override - זו שגיאת קומפילציה.

בלבול בין טיפוס ההפניה לטיפוס האובייקט: משתנה מוצהר כ-Ticket עדיין יכול "להחזיק" אובייקט VipTicket - ומתודות דרוסות תמיד רצות לפי האובייקט בפועל, לא לפי סוג המשתנה.

🎓 זווית הבגרות

איך זה מופיע בבגרות: זה בדיוק תוכן חלק ב' של מסלול ה-OOP בשאלון 899271 - שאלה עם 2-3 מחלקות בירושה, ובקשה לחזות מה תדפיס סדרת שורות קוד.

סוג שאלה טיפוסי: מעקב אחרי הרצה (בדיוק כמו הדוגמה המלאה למעלה) - פולימורפיזם, שדה static משותף, ו-is + קאסטינג.

טעות נפוצה: לחשוב שמתודה דרוסה רצה לפי טיפוס המשתנה (Ticket) ולא לפי הטיפוס האמיתי של האובייקט (VipTicket) - זו הטעות מספר 1 בנושא.

מה לזכור למבחן: תמיד לשאול "מה האובייקט באמת בפועל?" ולא "איך המשתנה מוצהר?" - הטיפוס בפועל קובע איזו גרסה דרוסה תרוץ.

9. תרגול

מה יודפס למסך על ידי הקוד הבא?

⁦C#⁩
Ticket t = new VipTicket(50, 1, 10);
Console.WriteLine(t.TotalCost());

כתבו את חלק ה-base בהצהרת הבנאי של VipTicket, עם הפרמטרים price ו-seat.

כתבו את הביטוי שבודק אם המשתנה other מצביע בפועל על אובייקט מטיפוס VipTicket.

כתבו את שורת הקוד בתוך TotalCost() של VipTicket שקוראת לגרסה של מחלקת האב ומוסיפה לה 50.

פרק 14 - עצים בינריים פרק 16 - מבוא ליעילות וסיבוכיות זמן ריצה