C++基础的三大特性之二:继承

继承解决的核心问题是:如何在已有类的基础上扩展新功能,而不重复写代码。它表达的是「is-a(是一个)」关系:Dog 是一个 Animal。以下是我在职坐标的学习记录:

一、什么是继承

继承允许一个新类(派生类 / 子类)基于已有类(基类 / 父类)来构建:

  • 派生类自动获得基类的成员(数据 + 函数)
  • 派生类可以新增自己的成员
  • 派生类可以改写基类的行为(这一点引出了多态,见下篇)
        Animal (基类:通用属性 name,通用行为 eat/sleep)
          |
    +-----+-----+
    |           |
   Dog         Cat      (派生类:各自新增 bark / catchMouse)

二、基本语法

#include <iostream>
#include <string>
#include <utility>   // std::move

// ========== 基类 ==========
class Animal {
protected:                       // protected:派生类可访问,外部不可访问
    std::string name_;

public:
    Animal(std::string name) : name_(std::move(name)) {}

    void eat() const {
        std::cout << name_ << " 正在吃东西\n";
    }
    void sleep() const {
        std::cout << name_ << " 正在睡觉\n";
    }
};

// ========== 派生类 ==========
class Dog : public Animal {      // public 继承:Dog 是一个 Animal
private:
    std::string breed_;          // 派生类新增的成员

public:
    Dog(std::string name, std::string breed)
        : Animal(std::move(name)) // 必须先构造基类部分
        , breed_(std::move(breed))
    {}

    void bark() const {          // 派生类新增的行为
        std::cout << name_ << "(" << breed_ << ")汪汪叫\n";
    }
};

int main() {
    Dog d("小黑", "拉布拉多");
    d.eat();    // ✅ 继承自 Animal
    d.sleep();  // ✅ 继承自 Animal
    d.bark();   // ✅ Dog 自己的
    // std::cout << d.name_;  // ❌ protected,外部不可访问
}

三、三种继承方式:public / protected / private

继承方式决定了基类成员在派生类中的最高可见级别

基类中的成员 public 继承 protected 继承 private 继承
public public protected private
protected protected protected private
private 不可访问 不可访问 不可访问

实践中 99% 的情况用 public 继承,因为它保留了「is-a」语义。

  • private 继承的语义是「根据……实现出」(implemented-in-terms-of),等价于组合。现代 C++ 更推荐直接用成员对象(组合)来表达,可读性更好:
// ❌ 晦涩:private 继承
class Stack : private std::vector<int> { /* ... */ };

// ✅ 清晰:组合
class Stack {
private:
    std::vector<int> data_;   // Stack 有一个 vector,而不是"是一个" vector
};

设计准则:《Effective C++》条款 32 —— 确保 public 继承建模出 is-a 关系。
经典反例:class Square : public Rectangle。数学上正方形是矩形,但 Rectangle 有独立的 setWidth / setHeight,正方形却要求长宽必须相等——派生类削弱了基类的契约,这不是真正的 is-a。

四、构造与析构的顺序

4.1 构造顺序:基类 → 派生类

class Base {
public:
    Base() { std::cout << "Base 构造\n"; }
    ~Base() { std::cout << "Base 析构\n"; }
};

class Derived : public Base {
public:
    Derived() { std::cout << "Derived 构造\n"; }
    ~Derived() { std::cout << "Derived 析构\n"; }
};

int main() {
    Derived d;
}
// 输出:
// Base 构造
// Derived 构造
// Derived 析构      ← 析构顺序相反
// Base 析构

规则

  1. 先构造基类部分,再构造成员对象,最后执行派生类构造函数体
  2. 派生类构造函数必须在初始化列表中调用基类构造函数;不写则调用基类默认构造函数
  3. 析构顺序严格相反

4.2 必须显式调用基类构造函数的情况

class Animal {
public:
    Animal(std::string name) : name_(std::move(name)) {}  // 没有默认构造函数
private:
    std::string name_;
};

class Dog : public Animal {
public:
    Dog() {}                    // ❌ 编译错误:Animal 没有无参构造函数
    Dog() : Animal("无名") {}  // ✅ 正确
};

五、名字隐藏(Name Hiding)

派生类中声明的同名函数会隐藏基类中所有同名重载版本——这是 C++ 最容易踩的坑之一:

class Base {
public:
    void foo(int)    { std::cout << "Base::foo(int)\n"; }
    void foo(double) { std::cout << "Base::foo(double)\n"; }
};

class Derived : public Base {
public:
    void foo(std::string) { std::cout << "Derived::foo(string)\n"; }
};

int main() {
    Derived d;
    d.foo("hi");   // ✅ Derived::foo(string)
    d.foo(42);     // ❌ 编译错误!Base::foo(int) 被隐藏了,重载集不跨类
}

注意:这与虚函数覆盖不同。名字隐藏只看名字,不看参数签名。

解决方法:用 using 声明把基类版本引入派生类作用域:

class Derived : public Base {
public:
    using Base::foo;                 // 引入 Base 的所有 foo 重载
    void foo(std::string) { /*...*/ }
};

d.foo(42);  // ✅ 现在可以调用 Base::foo(int)

六、多重继承与菱形问题

C++ 支持一个派生类继承多个基类:

class Flying { public: void fly() { /*...*/ } };
class Swimming { public: void swim() { /*...*/ } };

class Duck : public Flying, public Swimming {};   // 鸭子既能飞又能游

菱形继承(Diamond Problem)

      Animal
      /    \
   Dog     Cat
      \    /
      DogCat

DogCat 会通过两条路径各继承一份 Animal 的成员,导致数据冗余 + 访问歧义

class Animal { public: int age; };
class Dog : public Animal {};
class Cat  : public Animal {};
class DogCat : public Dog, public Cat {};

DogCat dc;
dc.age = 3;        // ❌ 编译错误:age 有歧义(Dog::Animal::age 还是 Cat::Animal::age?)
dc.Dog::age = 3;   // ✅ 勉强可用,但存在两份独立数据

解决方案:虚继承(virtual inheritance)

class Animal { public: int age = 0; };
class Dog : virtual public Animal {};   // 虚继承
class Cat : virtual public Animal {};   // 虚继承
class DogCat : public Dog, public Cat {};

DogCat dc;
dc.age = 3;        // ✅ 只有一份 Animal 子对象,无歧义

虚继承保证了最顶层基类在最终派生类中只存在一份共享实例。代价是有额外的运行时开销(虚基类表指针)和更复杂的构造顺序(虚基类由最底层派生类负责初始化)。

实践建议:多重继承容易引入复杂性,优先使用单继承 + 多接口(只含纯虚函数的类),或者干脆用组合。虚继承是最后的手段。

七、组合优先于继承

继承是最强的耦合关系之一——派生类与基类紧密绑定,基类的任何改动都会波及派生类。

判断准则

关系 语义 推荐做法
is-a Dog 是一个 Animal 用 public 继承
has-a Car 有一个 Engine 用组合(成员对象)
is-implemented-in-terms-of Stackvector 实现 用组合(成员对象)
// ✅ 组合:灵活、低耦合,Engine 可替换、可复用
class Car {
private:
    Engine engine_;      // Car 有一个引擎
    Wheel  wheels_[4];
public:
    void start() { engine_.ignite(); }
};

// ❌ 继承:Car 不是一个 Engine,"汽车是一种引擎"在语义上就是错的
class Car : public Engine {};

组合的优势:

  1. 编译期依赖更小(可用前向声明 + Pimpl)
  2. 可在运行时替换实现(配合多态)
  3. 不会破坏封装(不像 protected 成员那样暴露给派生类)

八、常见误区

8.1 基类的析构函数必须考虑继承场景

如果一个类可能被继承并通过基类指针删除,析构函数可以是非虚的——但一旦涉及多态删除,就必须是虚函数:

class Base {
public:
    ~Base() { /* ... */ }   // ⚠️ 非虚析构:通过 Base* 删除 Derived 对象是未定义行为
};

正确做法见 多态篇——基类析构函数要么 virtual,要么 protected

8.2 不要为了复用而继承

// ❌ 为了用 List 的排序功能而继承 List,但 Student 显然不是一个 List
class Student : public List { /* ... */ };

8.3 protected 数据成员同样是耦合

protected 数据成员等于把实现细节暴露给所有派生类,改一个字段会波及所有子类。更稳妥的是提供 protected 的接口函数 + private 的数据

九、小结

要点 说明
本质 在已有类基础上扩展,表达 is-a 关系
语法 class Derived : public Base { ... };
构造顺序 基类 → 成员 → 派生类自身;析构严格相反
可见性 优先 public 继承;private 继承应改用组合
陷阱 名字隐藏(用 using 解决)、菱形继承(用 virtual 继承解决)
设计准则 组合优先于继承;public 继承必须满足真正的 is-a

下一步:继承让代码可以复用,但真正的威力来自 多态——让基类指针在运行时调用到派生类的实现。

posted @ 2026-09-19 10:55  Solitude0420  阅读(2)  评论(0)    收藏  举报