10、继承与多态:继承方式、派生类、虚函数与纯虚函数、抽象类、虚析构函数

好,咱们今天聊一个C++里真正有分量的东西——继承与多态。

说实话,我刚入行那会儿,觉得继承不就是“儿子用爹的东西”嘛,多简单。直到我在一个通信协议栈项目里,因为漏写了一个 virtual 关键字,导致内存泄漏查了整整两天……嗯,从那以后,我再也不敢小看这一章了。

你想想看,没有继承和多态,我们写代码就得不停地复制粘贴,改一个功能要改十几个地方。有了它们,代码才能“活”起来。

核心一句话:继承解决代码复用,多态解决接口统一。两者结合,才是面向对象设计的灵魂。

10.1 继承方式:三种权限,三种玩法

C++的继承有三种方式:publicprotectedprivate。说白了,就是控制基类成员在派生类里“能见度”的开关。

继承方式 基类 public 成员 基类 protected 成员 基类 private 成员
public 继承 public protected 不可见
protected 继承 protected protected 不可见
private 继承 private private 不可见

我个人习惯,90% 的场景都用 public 继承。为什么?因为它最符合“is-a”关系——派生类就是一个基类。比如“轿车”继承“汽车”,轿车就是汽车,没毛病。

小提示:private 继承其实更像“has-a”关系,相当于组合的另一种写法。我一般只在需要访问基类 protected 成员,但又不想对外暴露继承关系时才用它。不过说实话,这种场景很少见。

10.2 派生类:构造与析构的顺序

派生类对象创建时,先调用基类构造函数,再调用派生类构造函数。析构时顺序相反。这个顺序是硬性规定,不能乱。

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;
    return 0;
}
// 输出:
// Base构造
// Derived构造
// Derived析构
// Base析构

我在项目中遇到过一个问题:派生类构造函数里用了基类的某个成员,结果那个成员还没初始化完——因为基类构造还没执行。嗯,这种坑踩过一次就记住了。

注意:如果基类没有默认构造函数,派生类必须在初始化列表中显式调用基类的带参构造函数。否则编译不过。

10.3 虚函数:让程序“晚点做决定”

虚函数是C++实现多态的核心机制。说白了,就是让程序在运行时(而不是编译时)决定调用哪个函数。

class Animal {
public:
    virtual void speak() {
        std::cout << "动物叫\n";
    }
};

class Dog : public Animal {
public:
    void speak() override {
        std::cout << "汪汪\n";
    }
};

void makeSound(Animal& a) {
    a.speak();  // 运行时决定调用哪个 speak()
}

int main() {
    Dog dog;
    makeSound(dog);  // 输出:汪汪
    return 0;
}

你看,makeSound 函数根本不知道传进来的是 Dog 还是 Cat,但它能正确调用对应的 speak()。这就是多态的魅力。

我个人建议:只要你觉得某个函数在派生类里可能被重写,就把它声明为 virtual。C++11 之后还可以加 override 关键字,编译器会帮你检查签名是否匹配。这个习惯能省掉很多调试时间。

关键点:虚函数通过虚函数表(vtable)实现。每个有虚函数的类都有一个 vtable,对象里有一个 vptr 指向它。调用虚函数时,先通过 vptr 找到 vtable,再找到函数地址。多了一步间接寻址,所以性能上比普通函数略慢——但这点代价换来的灵活性,绝对值。

10.4 纯虚函数与抽象类:定义“接口”

纯虚函数就是“只有声明,没有实现”的虚函数。写法是在函数声明后面加 = 0

class Shape {
public:
    virtual double area() const = 0;  // 纯虚函数
    virtual ~Shape() = default;
};

class Circle : public Shape {
private:
    double radius;
public:
    Circle(double r) : radius(r) {}
    double area() const override {
        return 3.14159 * radius * radius;
    }
};

含有纯虚函数的类叫抽象类。抽象类不能实例化——你不能写 Shape s;,编译器会报错。它存在的意义就是定义接口,让派生类去实现。

我曾经在一个图形编辑器项目里,把所有图形都抽象成 Shape 基类。后来要加新的图形类型,只需要继承 Shape 并实现 area()draw() 就行了。代码几乎不用改,这就是抽象类的威力。

设计建议:如果你在设计一个基类,并且不确定某个函数该不该有默认实现,那就把它做成纯虚函数。让派生类自己决定怎么实现。这叫“接口隔离原则”。

10.5 虚析构函数:避免“析构不完整”

这个知识点,我愿称之为“新手杀手”。

看个例子:

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

class Derived : public Base {
private:
    int* data;
public:
    Derived() { data = new int[100]; }
    ~Derived() { 
        delete[] data;
        std::cout << "~Derived()\n"; 
    }
};

int main() {
    Base* p = new Derived();
    delete p;  // 只调用了 ~Base(),没有调用 ~Derived()!
    return 0;
}

输出只有 ~Base()~Derived() 根本没执行。这意味着 data 指向的内存没有被释放——内存泄漏了。

为什么会这样?因为 delete p 时,编译器看到 pBase* 类型,就只调用了 Base 的析构函数。它不知道 p 实际指向的是 Derived 对象。

解决办法很简单:把基类析构函数声明为虚函数。

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

这样 delete p 就会先调用 ~Derived(),再调用 ~Base()。完美。

黄金法则:只要一个类有虚函数,它的析构函数就应该是虚的。如果类没有虚函数,析构函数不要加 virtual——没必要,还会增加 vtable 开销。

10.6 知识体系总览

下面这张图,把本章的核心知识点串起来了。你可以把它当作一张“地图”,学完后再回来看,会更有感觉。

继承与多态 继承方式 public 继承:is-a 关系 protected 继承:受限继承 private 继承:实现继承 派生类 构造:先基类后派生类 析构:先派生类后基类 初始化列表调用基类构造 虚函数 运行时多态 vtable + vptr 机制 override 关键字 纯虚函数与抽象类 纯虚函数:= 0 声明 抽象类:不能实例化 定义接口规范 虚析构函数 避免内存泄漏 基类指针 delete 派生类对象 有虚函数 → 虚析构

10.7 避坑指南

最后,我把自己踩过的坑整理一下,希望能帮你少走弯路。

  • 基类析构函数不是虚函数:这个前面说过了,后果就是内存泄漏。我曾经在一个大型项目里,因为基类析构漏了 virtual,导致程序跑几天后内存暴涨。查了三天才定位到问题。
  • 构造函数里调用虚函数:不会触发多态!因为派生类还没构造完,vptr 指向的还是基类的 vtable。所以构造函数里调虚函数,调的是基类的版本。
  • 忘记加 override:C++11 之前,你写了个函数签名跟基类虚函数不一样,编译器不会报错,只是把它当成一个新函数。加上 override 后,编译器会帮你检查。
  • 抽象类作为函数参数:不能传值,只能传指针或引用。因为抽象类不能实例化,传值会尝试拷贝构造,编译不过。

我的习惯:每次写一个新类,先问自己三个问题:这个类需要被继承吗?需要多态吗?析构函数要不要加 virtual?想清楚了再动手,能省很多事。

好了,继承与多态就聊到这儿。这些东西看起来概念多,其实用起来就那么几个套路。多写几个小例子,慢慢就熟了。

公众号:蓝海资料掘金营,微信 deep3321