Chuyển đến nội dung

Kế thừa & Mối quan hệ là-A

Tìm hiểu về kế thừa công khai, chuỗi hàm tạo, vấn đề cắt lát, đa kế thừa và vấn đề kim cương - và khi nào thì kế thừa là phù hợp.

Mô hình kế thừa Is-A

Kế thừa công khai mô hình hóa mối quan hệ "is-a":DoglàAnimal,SavingsAccountlàBankAccount. Điều này được điều chỉnh bởi Nguyên tắc thay thế Liskov (LSP): ở bất kỳ nơi nào cần có lớp cơ sở, lớp dẫn xuất phải hoạt động chính xác mà không gặp bất ngờ.

Kế thừa là một trong những tính năng mạnh mẽ nhất - và bị lạm dụng nhiều nhất - của OOP. Không phải mọi mối quan hệ đều là "là-a."Stackkhông phải làVectormặc dù nó sử dụng nội bộ. Việc sử dụng sai tính kế thừa khi bố cục phù hợp sẽ dẫn đến mã dễ vỡ, khó bảo trì.

Kế thừa cơ bản và chuỗi hàm tạo

Lớp dẫn xuất kế thừa tất cả các thành viên của lớp cơ sở. Hàm tạo của lớp cơ sở chạy đầu tiên, sau đó là hàm tạo của lớp dẫn xuất. Sự hủy diệt xảy ra theo thứ tự ngược lại.

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;
}

The Slicing Problem

Object slicing occurs when a derived object is assigned or passed by value to a base class variable. The derived part is "sliced off" — only the base portion remains. This is a subtle and common bug.

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;
}

Đa kế thừa và vấn đề kim cương

C++ cho phép một lớp kế thừa từ nhiều cơ sở. Vấn đề kim cương xảy ra khi hai cơ sở có chung một tổ tiên, khiến lớp dẫn xuất chứa hai bản sao dữ liệu của tổ tiên. Kế thừa ảo giải quyết vấn đề này.

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;
}

Covariant Return Types & Protected Access

Covariant return types let a derived class override a virtual function and return a more specific (derived) pointer or reference type. For example, ifBase::clone()returnsBase*,Derived::clone()can returnDerived*.

The protected access specifier sits betweenpublicandprivate: protected members are accessible to the class itself and its derived classes, but not to outside code. Useprotectedfor members that derived classes legitimately need — but be cautious, as it creates a coupling between base and derived.

Lỗi thường gặp

A common mistake is using inheritance solely to reuse code. If aStackinherits fromstd::vector, users can callpush_back,insert, and other vector operations that violate the stack abstraction. Inheritance should model a true is-a relationship satisfying the Liskov Substitution Principle. For code reuse, prefer composition (aStackthat *contains* avector).

Thực hành tốt

Always use public inheritance — private/protected inheritance is rarely needed and confusing. Always declare base class destructors as virtual. Pass polymorphic objects by reference or pointer, never by value (to avoid slicing). Prefer composition over inheritance unless the relationship is truly "is-a." Avoid deep inheritance hierarchies (2-3 levels is usually the practical limit).

Điểm chính
  • Mô hình kế thừa công khai is-a - lớp dẫn xuất phải đáp ứng Nguyên tắc thay thế Liskov
  • Hàm tạo cơ sở chạy trước, hàm hủy chạy cuối cùng (thứ tự xây dựng ngược lại)
  • Cắt đối tượng xảy ra khi một đối tượng dẫn xuất được truyền theo giá trị cho cơ sở — luôn sử dụng tham chiếu hoặc con trỏ
  • Vấn đề về kim cương được giải quyết bằng tính kế thừavirtual, nhưng thay vào đó lại ưu tiên thành phần hơn
  • Kế thừa là để thay thế, không phải tái sử dụng mã - sử dụng thành phần cho cái sau

Câu đố — Kiểm tra kiến thức

(15 XP)

1. Cắt đối tượng là gì?

2. How does virtual inheritance solve the diamond problem?

3. Why should you NOT inherit from`std::vector`to create a`Stack`class?