C++ 多态详解

  C++多态是面向对象的核心灵魂,本文将由浅入深,带你循序渐进地掌握多态的方方面面,全程干货,坐稳发车~ ദ്ദി˶ー̀֊ー́ )✧


1. 什么是多态?

  “多态”这个词,字面上就是“多种形态”。

  现实中这种例子很多:同样是“买票”这件事,普通人买是全价,学生可能打五折,军人则享受优先服务。同样是“动物叫”,猫发出“喵喵”,狗发出“汪汪”。不同对象对同一个消息给出不同的响应,这就是多态。

在 C++ 中,多态可以分为两类:

  • 编译时多态(静态多态):在编译阶段就确定调用哪个函数。典型代表是函数重载函数模板。你传一个 int,编译器匹配 f(int);传一个 double,匹配 f(double)。这个过程在编译时就已经搞定了。

  • 运行时多态(动态多态):直到程序运行时,才根据实际指向的对象来决定调用哪个函数。这也是本文的重点。它依赖于继承、虚函数、基类指针或引用

我们可以通过一个简单的例子感受一下:

// 买票的例子
class Person {
public:
    virtual void BuyTicket() { cout << "买票-全价" << endl; }
};

class Student : public Person {
public:
    virtual void BuyTicket() { cout << "买票-打折" << endl; }
};

void Func(Person& ptr) {
    ptr.BuyTicket();   // 到底调用哪个BuyTicket,运行时才知道
}

int main() {
    Person ps;
    Student st;
    Func(ps);   // 输出:买票-全价
    Func(st);   // 输出:买票-打折  
}

  同样一个 ptr.BuyTicket(),当 ptr 引用的是 Person 对象时就执行全价逻辑,引用的是 Student 对象时就执行打折逻辑。这就是运行时多态的效果。


2. 运行时多态的实现前提

要触发运行时多态,必须同时满足两个条件:

  1. 必须通过基类的指针或引用来调用虚函数。
  2. 被调用的函数必须是虚函数,且派生类完成了对该虚函数的重写(覆盖)。

为什么必须是指针或引用?
  因为只有指针或引用才能既指向基类对象,又指向派生类对象。如果是普通对象(值传递),就会发生“对象切片”,只保留基类部分,永远调用的都是基类的函数。

在这里插入图片描述


3. 虚函数与虚函数的重写

3.1 虚函数

  在类的成员函数前加上 virtual 关键字,这个函数就是虚函数。例如:

virtual void BuyTicket() { ... }

注意: 只有类的非静态成员函数才可以声明为虚函数,全局函数、静态成员函数、构造函数都不能是虚函数。

3.2 虚函数的重写(覆盖)

重写要求派生类中提供一个与基类完全相同的虚函数

  • 返回值类型相同
  • 函数名相同
  • 参数列表完全相同(参数个数、类型、顺序)

  三者都一致,派生类的这个虚函数就重写了基类的虚函数。

  一个细节: 在派生类中重写时,可以省略 virtual 关键字,因为函数从基类继承下来时已经保持虚函数属性了,即使你不写 virtual,它依然是虚函数,并构成重写。
  但在实际开发中,强烈建议还是写上 virtual,可读性更好。面试选择题里偶尔会出现故意不写 virtual 来考察你是否理解重写的条件,务必当心!

3.3 一个小小的选择题,测一下你是否真正理解

看这段代码,你认为输出是什么?

A: A->0 B : B->1 C : A->1 D : B->0 E : 编译出错 F : 以上都不正确

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

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

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

公布答案——这题选B

分析:

    • B 重写了 func,没有写 virtual,但依然构成重写。
  • 注意重写与参数名、缺省值无关。
    A::funcB::func 满足重写条件:
  • 函数名都是 func
  • 参数类型都是 int
  • 都是虚函数(A里的是virtual,B里的自动继承virtual属性)
    哪怕它们的默认值一个是1、一个是0,也不影响重写关系。
    • test 是继承下来的,内部调用 func()。此时 this 指向的是 B 对象,而 test 是基类的成员函数,它在基类中调用了 func(),这里发生多态调用:由于 thisA* 类型,指向了 B 对象,最终调用的是 B::func
    • 关键点来了: 函数的默认值是在编译阶段就确定好的,不是运行时动态决定的。
  • A::test() 里写的是 func(); ,编译器在编译这行代码时,会根据 A::func的声明,把它直接替换成 func(1) (因为 A::func 的默认值是1)。
  • 哪怕运行时实际调用的是 B::func ,它拿到的参数也已经是 1 了,和它自己定义的默认值 0 没有关系。
  • 所以 B::func 最终拿到的参数是 1 ,输出就是 B->1 ,而不是很多人误以为的 B->0 。

  如果直接通过 p->func() 调用呢?这时 p 的静态类型是 B*,默认参数绑定的就是 B 中定义的 val = 0,输出 B->0


4. 重写中的特殊情况

4.1 协变

  有时基类和派生类的返回值并不完全相同,但依然能构成重写,这就是协变

协变要求:

  • 基类虚函数返回基类类型的指针或引用
  • 派生类的重写函数返回派生类类型的指针或引用

示例:

class Person {
public:
    virtual Person* BuyTicket() { cout << "买票-全价" << endl; return nullptr; }
};

class Student : public Person {
public:
    virtual Student* BuyTicket() { cout << "买票-打折" << endl; return nullptr; }
};

  这里虽然返回值类型不一样(Person* vs Student*),但它们是具有继承关系的指针,编译器允许这种重写。协变在实际项目中用得不多,了解即可。

4.2 析构函数的重写

  如果类中定义了虚函数,那么它的析构函数最好也声明为虚函数。 这不是可选项,而是防止内存泄漏的重要原则。

看看为什么不加 virtual 会出问题:

class A {
public:
    ~A() { cout << "~A" << endl; }
};

class B : public A {
public:
    ~B() {
        cout << "~B->delete:" << _p << endl;
        delete _p;
    }
protected:
    int* _p = new int[10];
};

int main() {
    A* p2 = new B;
    delete p2;   // 只调用了~A,没有调用~B,_p泄漏!
    return 0;
}

  当使用基类指针 delete 派生类对象时,如果析构函数不是虚函数,编译器只根据指针的静态类型(A*)调用 A 的析构函数,不会执行 B 的析构函数,导致 B 里申请的资源无法释放。

  解决办法:把 A 的析构函数加上 virtual

class A {
public:
    virtual ~A() { cout << "~A" << endl; }
};

  这时,B 的析构函数无论是否写 virtual,都会自动和 A 的析构函数构成重写(因为编译器底层把析构函数统一命名为 destructor)。释放时就会走正常的析构流程:先调 ~B(),再调 ~A()(析构完子类后会自动调用基类的析构),资源安全释放。

  1. ~A() 是虚函数, delete p2 时会先找到真实类型 B ,调用 B::~B()
  2. B::~B() 执行,清理B自己的资源。
  3. B::~B() 执行完,编译器自动帮你调用 A::~A() ,清理套在里面的A的资源。

  这里还要注意一下: 派生类析构完自动调用基类析构,是因为派生类对象里嵌套了一个基类子对象,必须先析构外层再析构内层,而我们自己没法手动调用基类析构,所以编译器会自动调用基类析构的代码。

  总结: 只要一个类可能被继承,或者其中已有虚函数,就把它的析构函数声明为虚的。面试时也经常问到“为什么基类的析构函数要写成虚函数”,答案就是这个内存泄漏的风险。


5. override 和 final

  虚函数重写要求非常严格,参数列表差一个 const、函数名拼错一个字母,都不会构成重写,编译的时候也不会报错,只有在程序运行时没有得到预期结果才来debug会得不偿失。

  因此,C++11 引入了两个关键字来帮我们:

5.1 override

  在派生类虚函数后面加上 override,告诉编译器:“这个函数是用来重写基类虚函数的,如果没构成重写,请直接报错。”

class Car {
public:
    virtual void Drive() {}
};
class Benz : public Car {
public:
    virtual void Drive() override { cout << "Ben-舒适" << endl; }  // 拼写错误?立即报错
};

  如果你把 Drive 写成了 Dirve,编译器会立刻指出你并没有重写任何基类虚函数。这大大减少了调试时间。

5.2 final

  final 修饰虚函数,表示该虚函数不能被后续的派生类再次重写。

class Car {
public:
    virtual void Drive() final {}
};
class Benz : public Car {
public:
    virtual void Drive() {}  // 编译错误,Drive被final禁止重写
};

  此外 final 也可以修饰类,表示这个类不能被继承。


6. 重载/重写/隐藏对比

在这里插入图片描述

现象 作用域 函数名 参数列表 返回值 virtual 发生时期
重载 同一作用域 相同 不同 可同可不同 不要求 编译时
重写 基类与派生类 相同 相同 相同(协变除外) 基类必须 virtual 运行时
隐藏 基类与派生类 相同 不同(或基类非虚) 任意 非虚或参数不同 编译时

7. 纯虚函数与抽象类

  在虚函数后面加上 = 0,这个函数就变成了纯虚函数。含有纯虚函数的类叫做抽象类

class Car {
public:
    virtual void Drive() = 0;   // 纯虚函数
};

  抽象类不能实例化对象。这很合理——一个“车”的概念太抽象了,你不知道它是怎么开的,只有具体到“奔驰”、“宝马”,才能理解驾驶行为。

Car car;   // 编译错误!无法实例化抽象类

  派生类继承了抽象类后,必须重写所有纯虚函数,否则它自己也还是一个抽象类,无法实例化。这实际上是在强制派生类实现某些接口。

class Benz : public Car {
public:
    virtual void Drive() { cout << "Benz-舒适" << endl; }
};
class BMW : public Car {
public:
    virtual void Drive() { cout << "BMW-操控" << endl; }
};

  父类对象不能实例化,但是可以作为指针类型来使用:

int main(){
	Car* pBenz = new Benz;
	pBenz->Drive();

	Car* pBMW = new BMW;
	pBMW->Drive();

	return 0;
}

  虽然纯虚函数通常不需要实现(因为会被重写),但语法上你依然可以给它一个定义,不过必须在类外定义。


8. 多态的核心原理:虚函数表(vtable)

8.1 对象中隐藏的指针:__vfptr

先来看一个题:
下面编译为32位程序的运行结果是什么()
A.编译报错 B.运行报错 C.8 D.12

class Base {
public:
    virtual void Func1() { cout << "Func1()" << endl; }
protected:
    int _b = 1;
    char _ch = 'x';
};
int main(){
	Base b;
	cout << sizeof(b) << endl;
	return 0;
}

  直观来看,int 占 4 字节,char 占 1 字节,还有内存对齐,可能是 8。

  但实际结果是 12!多出来的 4 字节就是一个指针——虚函数表指针__vfptr,v 代表 virtual,f 代表 function,ptr 代表 pointer)。

  这个指针比较特殊,它通常放在对象的最前面(有些编译器可能放在末尾,但主流放在前面),指向一个虚函数表
在这里插入图片描述

  只要一个类含有虚函数,那么该类的每个对象中都至少有一个虚函数表指针(从基类继承下来的也算)。基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。

8.2 多态到底是怎么实现的?

我们把多态的例子用更完整的版本来演示:

class Person {
public:
    virtual void BuyTicket() { cout << "买票-全价" << endl; }
protected:
    string _name;
};

class Student : public Person {
public:
    virtual void BuyTicket() { cout << "买票-打折" << endl; }
protected:
    int _id;
};

class Soldier : public Person {
public:
    virtual void BuyTicket() { cout << "买票-优先" << endl; }
protected:
    string _codename;
};

void Func(Person* ptr) {
    ptr->BuyTicket();    // 这里发生了什么?
}

当我们这样调用时:

Person ps;
Student st;
Soldier sr;
Func(&ps);   // 买票-全价
Func(&st);   // 买票-打折
Func(&sr);   // 买票-优先

  即使 Func 内部是通过同一个 Person* 指针调用 BuyTicket,最终还是产生了不同的行为。这个过程编译器帮我们做了什么呢?
在这里插入图片描述

  在满足多态条件(指针+虚函数)的情况下,函数调用不再像普通函数那样在编译时直接确定地址,而是在运行时到对象的虚表中去查找应该调用哪个函数

具体来说:

  • ptr 指向 Person 对象时,ptr->BuyTicket() 会根据该对象的虚表找到 Person::BuyTicket 的地址并调用。
    在这里插入图片描述

  • ptr 指向 Student 对象时,就会调用派生类的版本。
    在这里插入图片描述

这就是动态绑定

静态绑定:编译时就能确定函数地址(比如普通函数调用、非虚函数的对象调用)。
动态绑定:编译时不确定,运行时查虚表确定调用函数的地址(虚函数 + 指针/引用)。

在这里插入图片描述

8.3 深入虚函数表

  我们再用一个包含多个虚函数的例子,详细剖析虚表的内部结构。

class Base {
public:
    virtual void func1() { cout << "Base::func1" << endl; }
    virtual void func2() { cout << "Base::func2" << endl; }
    void func5() { cout << "Base::func5" << endl; }
protected:
    int a = 1;
};

class Derive : public Base {
public:
    virtual void func1() { cout << "Derive::func1" << endl; }
    virtual void func3() { cout << "Derive::func3" << endl; }
    void func4() { cout << "Derive::func4" << endl; }
protected:
    int b = 2;
};

这个继承关系中的虚函数表是什么样子的?

  • 基类 Base 的虚表
    存储 Base::func1 的地址,存储 Base::func2 的地址。

  • 派生类 Derive 的虚表
    首先,它也有一个虚表。因为 Derive 继承了 Base,基类部分中的虚函数表指针不再指向基类的虚表,而是指向 Derive 自己的虚表。
    Derive 的虚表包含:

    • 重写的 Base::func1 被替换成了 Derive::func1 的地址(覆盖)。
    • 未重写的 Base::func2 依然保留基类的地址。
    • 派生类独有的虚函数 Derive::func3 的地址被追加到表中。
    • (VS 编译器下)虚表最后通常有一个 0x00000000 作为结束标记,但这不是标准规定,g++ 就没有。

在这里插入图片描述
在这里插入图片描述

  所以虚函数表本质就是一个函数指针数组,存放着该类所有需要动态调用的虚函数地址。

重要细节一览:
  • 普通函数(如 func5func4)不在虚表中,它们直接由类型决定,编译时绑定。
  • 派生类的虚表与基类的虚表是完全不同的两个表
  • 派生类对象中并不会额外生成一个新的虚函数表指针,而是沿用从基类继承下来的那个指针(只不过现在它指向的是派生类自己的虚表)。
  • 通过 VS 的内存窗口可以直观地看到这些函数地址,有些在监视窗口看不到的虚函数(如 func3),在内存中却是真实存在的。

8.4 虚函数和虚表存放在内存的哪个区?

  这是一个开放性问题,因为 C++ 标准没有硬性规定。但我们可以通过打印不同区域的地址来进行对比验证。

int main() {
    int i = 0;
    static int j = 1;
    int* p1 = new int;
    const char* p2 = "xxxxxxxx";

    printf("栈:%p\n", &i);
    printf("静态区:%p\n", &j);
    printf("堆:%p\n", p1);
    printf("常量区:%p\n", p2);

    Base b;
    Derive d;
    Base* p3 = &b;
    Derive* p4 = &d;
    printf("Base虚表地址:%p\n", *(int**)p3);    // 解引用对象首地址得到虚表指针
    printf("Derive虚表地址:%p\n", *(int**)p4);
    printf("虚函数地址:%p\n", &Base::func1);
    printf("普通函数地址:%p\n", &Base::func5);
}

  输出中你会发现,虚表地址通常与常量区的地址比较接近(在 VS 中甚至就在代码段),而普通函数地址也在代码段。

  虚函数存在哪?虚函数本身是代码,存储在代码段,只是虚函数的地址又存到了虚表中
  虚函数表存在哪? 虚表是一个存放这些函数地址的数组,通常也放在代码段(常量区)(C++标准并没有规定到底应该存在哪,不过VS下是存在代码段的)


结语:

  今天的内容到这里就结束了,希望你能有所收获~

干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

更多推荐