Μετάβαση στο περιεχόμενο

Κληρονομικότητα & Σχέσεις Is-A

Μάθετε τη δημόσια κληρονομικότητα, τη σύνδεση κατασκευαστή, το πρόβλημα κοπής, την πολλαπλή κληρονομικότητα και το πρόβλημα του διαμαντιού — και πότε είναι κατάλληλη η κληρονομικότητα.

Τα μοντέλα κληρονομικότητας είναι-Α

Η δημόσια κληρονομιά μοντελοποιεί μια σχέση "is-a": έναDogείναι έναAnimal, έναSavingsAccountείναι έναBankAccount. Αυτό διέπεται από την Αρχή Αντικατάστασης Liskov (LSP): οπουδήποτε αναμένεται μια βασική κλάση, μια παραγόμενη κλάση πρέπει να λειτουργεί σωστά χωρίς εκπλήξεις.

Η κληρονομικότητα είναι ένα από τα πιο ισχυρά - και πιο κακώς χρησιμοποιημένα - χαρακτηριστικά του OOP. Δεν είναι κάθε σχέση «είναι-α». ΈναStackδεν είναιVector, παρόλο που το χρησιμοποιεί εσωτερικά. Η κακή χρήση της κληρονομικότητας όπου η σύνθεση είναι κατάλληλη οδηγεί σε εύθραυστο, δύσκολο να διατηρηθεί κώδικας.

Basic Inheritance & Constructor Chaining

Μια παράγωγη κλάση κληρονομεί όλα τα μέλη της βασικής κλάσης. Ο κατασκευαστής βασικής κλάσης εκτελείται πρώτα και μετά ο κατασκευαστής παραγόμενης κλάσης. Η καταστροφή συμβαίνει με την αντίστροφη σειρά.

inheritance_basic.cpp
#include <iostream>
#include <string>

class Animal {
    std::string name_;
    int age_;

public:
    Animal(const std::string& name, int age)
        : name_{name}, age_{age}
    {
        std::cout << "Animal constructed: " << name_ << '\n';
    }

    virtual ~Animal() {
        std::cout << "Animal destroyed: " << name_ << '\n';
    }

    const std::string& name() const { return name_; }
    int age() const { return age_; }

    virtual void speak() const {
        std::cout << name_ << " makes a sound\n";
    }
};

class Dog : public Animal {  // public inheritance = is-a
    std::string breed_;

public:
    Dog(const std::string& name, int age, const std::string& breed)
        : Animal{name, age}  // base class constructor MUST be called first
        , breed_{breed}
    {
        std::cout << "Dog constructed: " << name() << '\n';
    }

    ~Dog() override {
        std::cout << "Dog destroyed: " << name() << '\n';
    }

    void speak() const override {
        std::cout << name() << " barks! (" << breed_ << ")\n";
    }
};

int main() {
    Dog rex{"Rex", 5, "German Shepherd"};
    rex.speak();  // Rex barks! (German Shepherd)
    // Destruction order: Dog destroyed, then Animal destroyed
    return 0;
}

Το πρόβλημα του τεμαχισμού

Ο τεμαχισμός αντικειμένου συμβαίνει όταν ένα παραγόμενο αντικείμενο εκχωρείται ή μεταβιβάζεται με τιμή σε μια μεταβλητή βασικής κλάσης. Το προκύπτον τμήμα είναι "κομμένο σε φέτες" - παραμένει μόνο το τμήμα βάσης. Αυτό είναι ένα λεπτό και κοινό σφάλμα.

slicing.cpp
#include <iostream>
#include <string>
#include <vector>
#include <memory>

class Shape {
public:
    virtual ~Shape() = default;
    virtual std::string type() const { return "Shape"; }
};

class Circle : public Shape {
    double radius_;
public:
    explicit Circle(double r) : radius_{r} {}
    std::string type() const override { return "Circle"; }
};

// BUG: passing by value slices the object!
void print_type_bad(Shape s) {
    std::cout << s.type() << '\n';  // always prints "Shape"!
}

// CORRECT: pass by reference to preserve polymorphism
void print_type_good(const Shape& s) {
    std::cout << s.type() << '\n';  // prints actual type
}

int main() {
    Circle c{5.0};

    print_type_bad(c);   // "Shape"  — SLICED!
    print_type_good(c);  // "Circle" — correct

    // Slicing also happens with containers of values:
    // std::vector<Shape> shapes;  // BAD: slicing on insert
    // Use pointers instead:
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>(3.0));
    std::cout << shapes[0]->type() << '\n';  // "Circle"

    return 0;
}

Πολλαπλή κληρονομιά και πρόβλημα διαμαντιών

Το C++ επιτρέπει σε μια κλάση να κληρονομεί από πολλαπλές βάσεις. Το πρόβλημα διαμάντι εμφανίζεται όταν δύο βάσεις μοιράζονται έναν κοινό πρόγονο, με αποτέλεσμα η παραγόμενη κλάση να περιέχει δύο αντίγραφα των δεδομένων του προγόνου. Η εικονική κληρονομικότητα το λύνει αυτό.

diamond.cpp
#include <iostream>

// Diamond problem:
//       Device
//      /      \
//  Printer  Scanner
//      \      /
//     AllInOne

class Device {
protected:
    int id_;
public:
    Device(int id) : id_{id} {
        std::cout << "Device(" << id_ << ")\n";
    }
    virtual ~Device() = default;
};

// Virtual inheritance: only ONE copy of Device in AllInOne
class Printer : virtual public Device {
public:
    Printer(int id) : Device{id} {
        std::cout << "Printer\n";
    }
    void print_doc() { std::cout << "Printing on device " << id_ << '\n'; }
};

class Scanner : virtual public Device {
public:
    Scanner(int id) : Device{id} {
        std::cout << "Scanner\n";
    }
    void scan_doc() { std::cout << "Scanning on device " << id_ << '\n'; }
};

class AllInOne : public Printer, public Scanner {
public:
    // With virtual inheritance, the most-derived class
    // must initialize the virtual base directly
    AllInOne(int id) : Device{id}, Printer{id}, Scanner{id} {
        std::cout << "AllInOne\n";
    }
};

int main() {
    AllInOne aio{42};
    aio.print_doc();  // Printing on device 42
    aio.scan_doc();   // Scanning on device 42
    // Only ONE Device sub-object exists — no ambiguity
    return 0;
}

Τύποι Επιστροφών Συμμεταβλητών & Προστατευμένη Πρόσβαση

Τύποι επιστροφής συμμεταβλητών επιτρέπουν σε μια παραγόμενη κλάση να παρακάμπτει μια εικονική συνάρτηση και να επιστρέφει έναν πιο συγκεκριμένο (παραγόμενο) δείκτη ή τύπο αναφοράς. Για παράδειγμα, εάν τοBase::clone()επιστρέψει τοBase*, τοDerived::clone()μπορεί να επιστρέψει τοDerived*.

Ο προσδιοριστής πρόσβασης protected βρίσκεται μεταξύpublicκαιprivate: τα προστατευμένα μέλη είναι προσβάσιμα στην ίδια την κλάση και τις παραγόμενες κλάσεις της, αλλά όχι στον εξωτερικό κώδικα. Χρησιμοποιήστε τοprotectedγια μέλη που χρειάζονται νόμιμα παράγωγες κλάσεις — αλλά να είστε προσεκτικοί, καθώς δημιουργεί μια σύζευξη μεταξύ βάσης και παράγωγης.

Παγίδα

Ένα συνηθισμένο λάθος είναι η χρήση της κληρονομικότητας αποκλειστικά για την επαναχρησιμοποίηση του κώδικα. Εάν έναStackκληρονομήσει από τοstd::vector, οι χρήστες μπορούν να καλέσουνpush_back,insertκαι άλλες διανυσματικές λειτουργίες που παραβιάζουν την αφαίρεση στοίβας. Η κληρονομικότητα θα πρέπει να μοντελοποιήσει μια αληθινή σχέση is-a που να ικανοποιεί την Αρχή Αντικατάστασης Liskov. Για επαναχρησιμοποίηση κώδικα, προτιμήστε σύνθεση (έναStackπου *περιέχει* έναvector).

Βέλτιστη πρακτική

Να χρησιμοποιείτε πάντα την κληρονομιά public — η ιδιωτική/προστατευμένη κληρονομιά σπάνια χρειάζεται και προκαλεί σύγχυση. Να δηλώνετε πάντα τους καταστροφείς κλάσης βάσης ως virtual. Περάστε πολυμορφικά αντικείμενα με αναφορά ή δείκτη, ποτέ με τιμή (για αποφυγή κοπής). Προτιμήστε σύνθεση έναντι κληρονομικότητας εκτός εάν η σχέση είναι πραγματικά "είναι-α". Αποφύγετε τις βαθιές ιεραρχίες κληρονομικότητας (2-3 επίπεδα είναι συνήθως το πρακτικό όριο).

Βασικά συμπεράσματα
  • Μοντέλα δημόσιας κληρονομικότητας is-a — η παραγόμενη κλάση πρέπει να ικανοποιεί την αρχή της αντικατάστασης Liskov
  • Οι κατασκευαστές βάσης τρέχουν πρώτοι, οι καταστροφείς τρέχουν τελευταίοι (αντίστροφη σειρά κατασκευής)
  • Ο τεμαχισμός αντικειμένου συμβαίνει όταν ένα παραγόμενο αντικείμενο μεταβιβάζεται με τιμή σε μια βάση — χρησιμοποιείτε πάντα αναφορές ή δείκτες
  • Το πρόβλημα με το διαμάντι επιλύεται με κληρονομικότηταvirtual, αλλά προτιμήστε τη σύνθεση
  • Η κληρονομικότητα είναι για δυνατότητα υποκατάστασης, όχι για επαναχρησιμοποίηση κώδικα — χρησιμοποιήστε τη σύνθεση για το τελευταίο

Κουίζ — Δοκιμάστε τις γνώσεις σας

(15 XP)

1. Τι είναι ο τεμαχισμός αντικειμένων;

2. Πώς η εικονική κληρονομιά λύνει το πρόβλημα του διαμαντιού;

3. Γιατί ΔΕΝ πρέπει να κληρονομήσετε από το`std::vector`για να δημιουργήσετε μια κλάση`Stack`;