跳至内容

继承与 Is-A 关系

学习公有继承、构造函数链、切片问题、多重继承以及菱形继承问题 ── 以及何时适合使用继承。

继承建模 Is-A 关系

公有继承建模「is-a」关系:Dog 是一个 Animal,SavingsAccount 是一个 BankAccount。这受里氏替换原则(LSP)约束:在任何期望基类的地方,派生类必须能够正确工作,不会产生意外。

继承是面向对象编程最强大 ── 也是最容易被滥用的 ── 特性之一。并非每种关系都是「is-a」。Stack 不是 Vector,尽管它内部使用了一个。在组合更合适的地方滥用继承会导致脆弱、难以维护的代码。

基本继承与构造函数链

派生类继承基类的所有成员。基类构造函数先运行,然后是派生类构造函数。析构的顺序相反。

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

协变返回类型与 protected 访问

协变返回类型允许派生类重写虚函数并返回更具体的(派生的)指针或引用类型。例如,如果 Base::clone() 返回 Base*,Derived::clone() 可以返回 Derived*。

protected 访问说明符介于 public 和 private 之间:受保护的成员可由类本身及其派生类访问,但不能被外部代码访问。对于派生类确实需要的成员使用 protected ── 但要谨慎,因为它在基类和派生类之间创建了耦合。

常见陷阱

一个常见的错误是仅仅为了复用代码而使用继承。如果 Stack 继承自 std::vector,用户就可以调用 push_back、insert 和其他违反栈抽象的 vector 操作。继承应该建模满足里氏替换原则的真正的 is-a 关系。对于代码复用,应优先使用组合(一个 Stack「包含」一个 vector)。

最佳实践

始终使用 public 继承 ── private/protected 继承很少需要且令人困惑。始终将基类析构函数声明为 virtual。通过引用或指针传递多态对象,永远不要按值传递(以避免切片)。优先使用组合而非继承,除非关系确实是「is-a」。避免深层继承层次结构(2-3 层通常是实际限制)。

关键要点
  • 公有继承建模 is-a ── 派生类必须满足里氏替换原则
  • 基类构造函数先运行,析构函数最后运行(与构造顺序相反)
  • 对象切片发生在派生对象按值传递给基类时 ── 始终使用引用或指针
  • 菱形继承问题通过 virtual 继承解决,但优先使用组合
  • 继承是为了可替换性,而非代码复用 ── 后者应使用组合

测验 — 检验你的知识

(15 经验值)

1. 什么是对象切片?

2. 虚继承如何解决菱形继承问题?

3. 为什么不应该继承 `std::vector` 来创建 `Stack` 类?