本文从一道经典的 C++ 继承多态笔试题切入,系统拆解继承、重写、多态的全体系知识点,覆盖虚函数、虚表、虚析构、抽象类、菱形继承等核心考点,同时整理高频坑点与速记口诀,不管是期末复习还是面试备战,这一篇就够了。

开篇:一道经典笔试题,测懂你的多态功底

先看这道 90% 的人都会踩坑的题,先思考它的输出结果,本文会全程围绕这道题拆解核心规则:

cpp

运行

#include <iostream>
using namespace std;

class A
{
public:
    virtual void func(int val = 1){ cout << "A->" << val << endl; }
    virtual void test(){ func(); }
};

class B : public A
{
public:
    void func(int val = 0){ cout << "B->" << val << endl; }
};

int main()
{
    B* p = new B;
    p->test();
    return 0;
}

错误答案:B->0正确答案:B->1

如果你答错了,没关系,本文会把背后的所有规则讲透,看完你就能彻底搞懂为什么是这个结果。


一、继承:面向对象代码复用的核心

面向对象三大特性:封装、继承、多态。封装是基础,继承是代码复用的核心,多态是面向对象的灵魂。

1. 继承的基本概念与语法

继承的本质,是让一个类(子类 / 派生类)拥有另一个类(父类 / 基类)的所有非私有成员,无需重复写相同代码,实现代码复用与功能扩展。

  • 核心语法:class 子类名 : 继承方式 父类名
  • 多态场景下,必须使用 public 公有继承,这是多态触发的前提条件。

cpp

运行

// 父类:封装公共属性与方法
class Animal {
public:
    string name;
    void eat(){ cout << "动物会进食" << endl; }
    virtual void speak(){} // 虚函数,为多态做准备
};

// 子类:公有继承父类,拥有父类所有public/protected成员
class Cat : public Animal {
public:
    // 扩展自己的专属功能
    void catchMouse(){ cout << "猫会抓老鼠" << endl; }
};

2. 继承中的构造与析构顺序

这是笔试必考的基础知识点,规则固定,记死不丢分:

  • 构造顺序:先父类构造 → 再子类构造(先有父,再有子)
  • 析构顺序:先子类析构 → 再父类析构(先析子,再析父,和构造完全相反)
  • 子类析构执行完毕后,编译器会自动隐式调用父类析构,无需手动编写。

3. 继承的高频坑点:函数隐藏

这是新手最容易混淆的知识点,也是理解重写的前置基础:

核心规则:子类定义了一个和父类同名的函数,只要不满足合法重写条件,父类的同名函数就会被隐藏

  • 隐藏的效果:父类的同名函数,在子类作用域中无法直接调用,必须加父类名::域限定符才能访问。
  • 关键结论:重写必隐藏,隐藏未必是重写(重写是隐藏的一个特殊子集)。

cpp

运行

class A {
public:
    void fun(){}
    void fun(int a){}
};

class B : public A {
public:
    // 只要同名,不管参数是否一致,直接隐藏父类所有同名函数
    void fun(int a, int b){}
};

int main() {
    B b;
    // b.fun(); 报错!父类无参版本被隐藏
    // b.fun(10); 报错!父类单参版本被隐藏
    b.fun(10, 20); // 只能调用子类自己的版本
    b.A::fun(); // 必须加域限定符,才能调用父类版本
    return 0;
}

二、多态:面向对象的灵魂

多态的本质,是一个接口,多种实现:用统一的父类接口,调用不同子类的实现,实现代码的高扩展性。

1. 多态的两大分类

表格

多态类型 绑定时机 核心实现 常见场景
编译时多态(静态多态) 编译阶段 静态绑定 函数重载、模板
运行时多态(动态多态) 程序运行阶段 动态绑定 虚函数 + 重写 + 父类指针 / 引用

我们常说的 C++ 多态,默认指运行时多态,这也是面试的核心考点。

2. 运行时多态的触发三要素(缺一不可)

  1. 存在public 公有继承关系
  2. 父类声明virtual虚函数,子类完成合法重写
  3. 父类指针 / 引用指向子类对象

3. 核心:重写(覆盖)的完整规则

重写,是子类重新定义父类虚函数的行为,是多态的前提。

合法重写的核心条件
  1. 必须满足公有继承
  2. 父类函数必须加virtual声明为虚函数
  3. 子类与父类的函数签名完全一致:函数名、参数列表(个数 / 类型 / 顺序)完全相同
  4. 返回值必须兼容,支持协变返回值特例
协变返回值特例

重写时,父类返回基类的指针 / 引用,子类可以返回派生类的指针 / 引用,仍算作合法重写(仅限指针 / 引用,普通对象不支持协变)。

cpp

运行

class Base {};
class Derive : public Base {};

class A {
public:
    virtual Base* getObj() { return new Base; }
};

class B : public A {
public:
    // 协变:返回派生类指针,合法重写
    Derive* getObj() override { return new Derive; }
};
规范关键字:override

重写时,在函数末尾加override关键字,是 C++11 后的标准规范:

  • 作用:显式声明该函数是重写父类的虚函数,如果函数签名不匹配、父类没有对应虚函数,编译直接报错,避免手滑写错。
  • 笔试 / 面试 / 工程中,只要是重写,必须加override,这是专业度的体现。

4. 多态底层原理:虚表(vtable)与虚表指针(vptr)

很多人学多态只记规则,不懂底层,一考原理就懵,其实底层逻辑非常简单。

什么是虚表与虚表指针
  1. 虚表(vtable):编译器为含virtual虚函数的类,自动生成的一张存储虚函数地址的数组
    • 一个类全局只有一张虚表,存放在程序的只读数据段 / 全局静态区,所有该类的对象共享这张表。
    • 虚表里按顺序,存储该类所有虚函数的入口地址。
  2. 虚表指针(vptr):每个含虚函数的对象,内存最开头会隐藏一个指针,指向该类的虚表
    • 32 位系统占 4 字节,64 位系统占 8 字节,是对象内存的第一个成员。
继承后虚表的变化
  • 子类不重写父类虚函数:子类虚表直接复制父类的虚函数地址。
  • 子类重写父类虚函数:子类虚表中,对应位置的函数地址,会被替换为子类重写的函数地址
多态的底层执行流程

cpp

运行

A* p = new B; // 父类指针指向子类对象
p->func(); // 触发多态
  1. 编译阶段:编译器只知道pA*类型,只校验func()是否是 A 类的合法成员,不决定调用哪个版本。
  2. 运行阶段:
    • 拿到p指向的真实对象,取出对象开头的vptr
    • 通过vptr找到该对象真实所属类(B 类)的虚表。
    • 从虚表中取出func()对应的函数地址,调用子类重写的B::func()

这就是动态绑定的完整过程,也是多态的核心本质。

5. 开篇例题完整拆解

现在我们回头看开篇的例题,就能彻底搞懂为什么输出B->1了:

  1. B* p = new B;:创建 B 类对象,B 类继承 A 类,重写了func()虚函数,B 类虚表中func()的地址是B::func()
  2. p->test();:B 类没有重写test(),直接继承 A 类的test()实现,执行A::test()
  3. A::test()中执行this->func();
    • 这里的this,编译时类型是A* const,所以默认参数编译时静态绑定,使用 A 类的默认值val=1
    • 运行时,this指向的是 B 类对象,触发多态,通过虚表调用子类重写的B::func()
  4. 最终执行B::func(1),输出B->1

核心坑点:默认参数是编译时绑定,不参与多态,永远看调用处指针的编译时类型,这是笔试必考的陷阱。


三、核心避坑指南:必须掌握的细节规则

1. 虚析构函数:继承场景的内存安全保障

这是面试 100% 会问的知识点,必须吃透。

为什么需要虚析构?

先看这段有问题的代码:

cpp

运行

class Base {
public:
    ~Base() { cout << "父类析构" << endl; }
};

class Derive : public Base {
public:
    ~Derive() { cout << "子类析构" << endl; }
};

int main() {
    Base* p = new Derive;
    delete p; // 只输出"父类析构",子类析构不执行!
    return 0;
}

问题根源:非虚的析构函数是静态绑定delete时只看指针的编译时类型Base*,只调用父类析构,导致子类析构不执行,子类的资源无法释放,造成内存泄漏。

解决方案:父类析构加virtual,声明为虚析构

cpp

运行

class Base {
public:
    virtual ~Base() { cout << "父类析构" << endl; }
};
  • 原理:析构函数加virtual后,会进入虚表,delete父类指针时触发多态,先调用子类析构,再自动调用父类析构,保证资源完整释放。
  • 工程规范:只要一个类可能被继承,父类的析构函数必须声明为 virtual
补充规则
  • 构造函数不能是虚函数:构造函数执行时,对象还未创建完成,虚表指针还未初始化,无法实现多态。
  • 析构函数可以是纯虚函数:但必须给纯虚析构函数提供实现体,否则子类析构时无法调用父类析构,编译报错。

2. final 关键字:锁死重写与继承

C++11 引入的final关键字,有两个核心用法,笔试常考:

  1. 修饰虚函数:禁止子类重写该函数,子类重写会直接编译报错。

    cpp

    运行

    class A {
    public:
        virtual void fun() final {}
    };
    class B : public A {
        // void fun() {} 报错!fun被final禁止重写
    };
    
  2. 修饰类:禁止该类被继承,任何类继承它都会编译报错。

    cpp

    运行

    class A final {};
    // class B : public A {} 报错!A被final封闭,不能继承
    

3. 重载、重写、隐藏 终极区分

这是笔试选择题的高频考点,一张表彻底分清:

表格

特性 重载 重写 隐藏
作用范围 同一个类内 父子继承关系 父子继承关系
函数名 必须相同 必须相同 必须相同
参数列表 必须不同 必须相同 可同可不同
virtual 要求 不需要 必须有 不需要
绑定时机 编译时静态绑定 运行时动态绑定 编译时静态绑定
多态效果 不触发 触发运行时多态 不触发

速记口诀:同类同名不同参 = 重载,父子虚函数签名一致 = 重写,父子同名非重写 = 隐藏。


四、进阶:纯虚函数、抽象类与接口设计

1. 纯虚函数与抽象类的定义

  • 纯虚函数:格式为virtual 返回值 函数名(参数列表) = 0;,只有函数声明,无默认实现,作用是定义接口规范,强制子类实现对应功能。
  • 抽象类:包含至少一个纯虚函数的类,就是抽象类。

2. 抽象类的核心规则

  1. 抽象类不能实例化创建普通对象,只能定义指针 / 引用,专门用于做多态基类。
  2. 抽象类可以包含普通成员函数、成员变量、构造函数、析构函数,只有纯虚函数会限制它的实例化。
  3. 抽象类的派生类,只有两种结局:
    • 结局 1:重写实现了所有纯虚函数:变为普通类,可实例化对象,正常使用多态。
    • 结局 2:不重写 / 仅重写部分纯虚函数:依旧是抽象类,不能实例化对象,只能继续作为基类被下一代继承。

cpp

运行

// 抽象类:图形基类
class Shape {
public:
    // 纯虚函数:立下规矩,所有图形必须实现求面积、求周长
    virtual double getArea() = 0;
    virtual double getPerimeter() = 0;
    virtual ~Shape() = default; // 虚析构,保证内存安全
};

// 子类:矩形,重写所有纯虚函数,变为普通类
class Rect : public Shape {
private:
    double width, height;
public:
    Rect(double w, double h) : width(w), height(h) {}
    double getArea() override { return width * height; }
    double getPerimeter() override { return 2 * (width + height); }
};

// 子类:圆形,重写所有纯虚函数,变为普通类
class Circle : public Shape {
private:
    double r;
public:
    Circle(double r) : r(r) {}
    double getArea() override { return 3.14159 * r * r; }
    double getPerimeter() override { return 2 * 3.14159 * r; }
};

int main() {
    // Shape s; 报错!抽象类不能实例化
    Shape* s1 = new Rect(10, 20);
    Shape* s2 = new Circle(5);
    cout << "矩形面积:" << s1->getArea() << endl;
    cout << "圆形面积:" << s2->getArea() << endl;
    delete s1;
    delete s2;
    return 0;
}

3. 抽象类的工程价值

  1. 定规范:强制子类必须实现指定的功能,避免漏写,统一项目的接口标准。
  2. 屏蔽实现细节:面向接口编程,调用方只需要关注父类的接口,无需关心子类的具体实现。
  3. 极致扩展性:新增子类时,无需修改原有代码,只需要继承抽象类、实现纯虚函数,符合开闭原则。

五、多重继承与菱形继承问题

1. 什么是菱形继承

菱形继承(钻石继承)是多重继承的典型场景,继承结构呈菱形:

plaintext

    公共基类A
   /         \
子类B        子类C
   \         /
    最终子类D
  • 场景:类 B 和类 C 都公有继承类 A,类 D 同时公有继承类 B 和类 C。

2. 菱形继承的两大致命问题

  1. 数据冗余:公共基类 A 的成员,会被 B 和 C 各继承一份,最终 D 的对象里会有两份 A 的子对象,造成内存浪费。
  2. 二义性:D 访问 A 的成员时,编译器无法判断要访问 B 继承的 A,还是 C 继承的 A,直接编译报错。

cpp

运行

class A {
public:
    int a;
};
class B : public A {};
class C : public A {};
class D : public B, public C {};

int main() {
    D d;
    // d.a = 10; 报错!二义性,不知道是B::a还是C::a
    d.B::a = 10;
    d.C::a = 20;
    cout << d.B::a << " " << d.C::a << endl; // 输出10 20,两份重复数据
    return 0;
}

3. 解决方案:虚继承

C++ 通过虚继承解决菱形继承的问题,核心语法:让中间层 B 和 C,virtual public虚继承公共基类 A。

cpp

运行

class A {
public:
    int a;
};
class B : virtual public A {}; // 虚继承
class C : virtual public A {}; // 虚继承
class D : public B, public C {};

int main() {
    D d;
    d.a = 10; // 不再报错,只有唯一一份a
    cout << d.a << endl; // 输出10
    return 0;
}
虚继承的底层原理

虚继承通过 ** 虚基类指针(vbptr)虚基类表(vbtable)** 实现:

  • 每个虚继承的子类对象里,会多一个虚基类指针,指向虚基类表。
  • 虚基类表里存储着虚基类子对象的偏移量,不管多少条继承路径,最终只会找到唯一的一份虚基类子对象,彻底消除数据冗余和二义性。

六、高频考点速记口诀

  1. 多态三要素:公有继承、虚函数、父类指针指子类,缺一不可。
  2. 重写核心:接口不变,只改内部实现;默认参数不参与多态,编译就定死。
  3. 虚析构:父类析构加 virtual,父指删子类,先子后父不泄漏。
  4. 虚表虚指针:类有虚表,对象有指针,运行查表找函数,多态就靠它实现。
  5. 抽象类:纯虚赋值等于零,抽象不能造对象,子类必须全实现,才能实例化使用。
  6. 菱形继承:中间层加 virtual,虚继承解难题,基类只留一份子,二义冗余全消去。

结尾总结

继承的核心是代码复用,多态的核心是接口统一、扩展灵活,二者共同构成了 C++ 面向对象编程的核心骨架。

更多推荐