C++:多态

C++多态机制详解

目录

概念与条件

1. 虚函数与重写(覆盖)

1) 什么是虚函数?

2) 什么是重写?

3) 子类不写 virtual 也能重写

4) 特殊的重写

 5) 切片

2. 析构函数的重写(覆盖)

3. 经典题目

4. 虚函数指针与虚函数表

存在

单继承

多态调用

多继承

编译器解决方案:Thunk技术

5. 动态绑定和静态绑定

6. 重写与覆盖


概念与条件

        多态(polymorphism)的概念:让不同的对象去做同一件事,得到不同的结果。(调用表面名字长的一样的函数,因为函数调用的对象的不同,得到的是不同的结果。)例如买票成人全价,学生半价。

想让多态发生,必须满足两个条件:

  • 通过 基类的“指针或引用” 调用虚函数

  • 被调用的成员函数必须是虚函数,且派生类必须对基类的虚函数进行重写(重写条件:虚函数+“三同”,协变返回类型不同)。

1. 虚函数与重写(覆盖)

1) 什么是虚函数?

        虚函数就是在成员函数前面加virtual,只有成员函数才可以变成虚函数,普通函数不可以。

        虚函数的关键字virtual,和之前的虚继承使用的关键字一样,其他没有任何关联。

class Person {
public:
    virtual void buy() { std::cout << "全价\n"; }
};

2) 什么是重写?

        派生类中有一个跟基类完全相同的 虚函数 (返回值类型、函数名字、参数列表均完全相同),称子类的虚函数重写了基类的虚函数。重写也叫做覆盖。

        用基类引用或引用去调用虚函数时,会动态决定调用哪个版本,从而出现多态。

class Person {
public:
    virtual void buy() { std::cout << "Person: 全价\n"; }
};

class Student : public Person {
public:
    virtual void buy() { std::cout << "Student: 半价\n"; }
};

void func(Person& p) { p.buy(); }

int main() {
    Person a;  Student b;
    func(a);   // Person: 全价
    func(b);   // Student: 半价
}

3) 子类不写 virtual 也能重写

        即使派生类函数省略 virtual,只要满足“三同”,也仍然视作重写,可以实现多态——因为“虚”属性是从基类继承来的。但强烈建议父子双方都显式写 virtual,更易读也更安全。

        函数名、返回值、参数类型一致(与形参无关,与类型有关)

        函数名、返回值、参数类型一致

        函数名、返回值、参数类型一致

 4) 特殊的重写

协变返回(covariant return types)

        发生虚函数重写要求三同,返回值类型、函数名字、参数列表均完全相同。

        当“三同”中,​​只有返回类型不同​​,且基类与派生类返回值类型​均是指针或引用时,即为协变。

        (返回类型必须同时是指针或同时是引用,不能一边是指针,一边是引用)

        协变细节多、易错、日常使用少,但“考试/面试常考”。

final  和  override

1. final:修饰虚函数,表示该虚函数不能再被重写

        final 关键词修饰位置 和 之前const修饰 this 指针一样,是在 参数列表括号的右边。(而且只能放在父类的虚函数上)

        当父类的 虚函数被 final 修饰之后,子类就不能再重写父类的这个虚函数了。

2. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错

这样就方式我们因为,派生类没有重写完成,而导致后序debug的麻烦了。

  5) 切片

对象传值

Person& 改成 按值 Person 传参就没有多态现象:两个输出都是“全价”。因为发生了对象切片(slicing)。

什么是切片?

切片是指:当你把“派生类对象”按值拷贝/赋值给一个基类对象时,只会拷贝“基类那一部分”的数据和行为,派生类独有的那部分被丢掉了。结果就是:接收者变成了一个纯粹的基类对象,它的动态类型就是基类,因此多态失效。

子类赋值给父类对象切片,不会拷贝虚表。

2. 析构函数的重写(覆盖)

问题背景

        在C++中,当使用基类指针指向派生类对象时,如果析构函数不是虚函数,会出现资源清理不完整的问题。

class Person {
public:
    ~Person() { cout << "~Person()" << endl; }  // 非虚析构函数
};

class Student : public Person {
public:
    ~Student() { cout << "~Student()" << endl; }  // 非虚析构函数
};

int main() {
    Person* p = new Person();
    delete p;  // 输出:~Person()
    
    p = new Student();
    delete p;  // 问题:只输出 ~Person(),没有调用 ~Student()
    
    return 0;
}

        在堆上分配Person() 类大小字节的内存。 在该内存上调用构造函数,初始化对象。

    Person* p = new Person();

        delete p时,编译器知道 p的类型是 Person*,直接调用 Person::~Person()析构函数,释放内存。

    delete p;  // 输出:~Person()

第二次delete(问题所在)

    p = new Student();  // p是Person*类型,但指向Student对象
    delete p;           // 问题发生在这里

        delete p 时,编译器​​只看指针类型​​(Person*),不知道实际指向的是 Student 对象。由于析构函数不是虚函数,​​没有多态机制​​。编译器直接调用 Person::~Person(),完全忽略 Student 的析构函数。

释放内存时,Student类 没有被正确清理,只清楚了Person类。内存泄露。

输出结果:

 解决方案:虚析构函数(在成员函数前面加 virtual )

class Person {
public:
    virtual ~Person() {  // 虚析构函数
        cout << "~Person()" << endl;
    }
};

class Student : public Person {
public:
    virtual ~Student() {  // 虚析构函数(virtual可省略,但建议写上)
        cout << "~Student()" << endl;
    }
};

int main() {
    Person* p = new Person();
    delete p;  // 输出:~Person()
    
    p = new Student();
    delete p;  // ✅ 正确调用派生类析构函数
    
    return 0;
}

输出结果:

编译器的处理

1. 析构函数名称统一化

        编译器将所有析构函数统一命名为 destructor()

// 实际编译器处理后的名称
class Person {
public:
    virtual void destructor() {  // 统一名称
        // 清理Person资源的代码
        cout << "~Person()" << endl;
    }
};

class Student : public Person {
public:
    virtual void destructor() override {  // 统一名称,构成重写
        // 先清理Student特有资源
        cout << "~Student()" << endl;
        // 再调用基类析构
        Person::destructor();
    }
};

2. 多态调用机制

        当使用 delete p时,实际发生的过程:

// delete p 的底层实现相当于:
p->destructor();    // 多态调用:根据p指向的实际对象类型决定
operator delete(p); // 释放内存

        清理完p指向的实际对象类型,接着去清理它的基类析构。

3. 经典题目

p是B*类型,p指向test(),重点在于test虚函数调用的是哪一个func() ?

首先,B继承A,所以B中肯定有一个test(),但是由于继承,这个test()的this指针肯定是指向A。

然后,查看是否构成重写,满足条件:

  • 函数名相同
  • 参数类型相同【虽然形参不同,一个是val = 1,一个是val = 0,但参数类型都是 int,满足重写条件】
  • 返回值相同
  • 基类指针或引用去调用

构成重写条件,B的虚函数重写了A的虚函数。

你以为结果就是B->0了吗?其实答案是B !

重写,重写的是实现,前面的架子根本不管

4. 虚函数指针与虚函数表

虚函数表是什么?

当类中有虚函数时,编译器会为该类生成一个虚函数表,这是一个存储虚函数地址的数组。

存在

第一个例子分析

class Bass
{
public:
    virtual void func() {}
private:
    char _b;
};

int main()
{
    cout << sizeof(Bass) << endl;  // 输出:8(在64位系统)
    Bass b;
    return 0;
}

我们知道,类的大小只计算成员大小,不计算函数。上述输出不是1,而是8。

我们打开调试发现,在 b 这个对象当中多了一个 _vfptr 指针(virtual function),这指针是 虚函数表 指针。

内存计算 (32位)

所以,

每个有虚函数的类对象都有一个隐藏的虚函数表指针(_vfptr),指向该类的虚函数表。

如果没有多态的需求,就不要在类中使用虚函数。因为一旦使用虚函数,就会产生虚函数表,每个对象都会有一个虚函数表指针,增加空间开销。但是,虚函数本身的代码是存储在代码段中的,而虚函数表存储的是虚函数的地址。

单继承

  1. 拷贝父类虚函数表:子类先拷贝父类的虚函数表

  2. 覆盖重写函数:如果子类重写了虚函数,用子类函数地址覆盖对应位置

  3. 添加新虚函数:子类新增的虚函数添加到虚函数表末尾

(注意,虚函数表以nullptr结尾,图片未显示)

举例:

此时 Base 的虚函数表:

覆盖重写函数

构建过程:

  • 拷贝 Base 的虚函数表,用 Derived::func1覆盖 Base::func1

添加新虚函数,将&Derived::func3添加到末尾:

最终虚函数表:

完整的内存映射

虚函数表存储在代码段(常量区)

  • 栈地址:局部变量

  • 堆地址:通过new/malloc分配

  • 数据段:全局变量、静态变量

  • 代码段:函数地址、虚函数表

多态调用

#include <iostream>
using namespace std;

class Base {
public:
    virtual void func1() { cout << "Base::func1" << endl; }
    virtual void func2() { cout << "Base::func2" << endl; }
    int base_data = 100;
};

class Derived : public Base {
public:
    void func1() override { cout << "Derived::func1" << endl; }  // 重写
    virtual void func3() { cout << "Derived::func3" << endl; }    // 新增
    int derived_data = 200;
};

int main() {
    Derived d;
    Base* pb = &d;
    
    // 多态调用 - 对应图片中的过程
    pb->func1();  // 输出: Derived::func1
    pb->func2();  // 输出: Base::func2
    
    return 0;
}

pb->func1() 调用过程:

  1.  pb → Derived对象起始地址,pb是 Base*类型指针,指向 Derived对象
  2.  编译器知道 pb指向的内存起始处是 vptr
  3.  vptr → Derived虚函数表地址  
  4.  虚函数表地址 → 虚函数表,虚函数表是一个函数指针数组,存储的是函数的实际地址
  5.  计算函数索引位置:编译器在编译时就知道func1在虚函数表中的索引是 0,func2的索引是 1,func3的索引是2 。计算地址:虚函数表起始地址 + 0 * sizeof(函数指针)。
  6.  准备函数调用
  7.  函数调用:跳转到 Derived::func1 执行

多继承

(注意,虚函数表以nullptr结尾,图片未显示)

#include <iostream>
using namespace std;

class Base1 {
public:
    virtual void func1() { cout << "Base1::func1" << endl; }
    virtual void func2() { cout << "Base1::func2" << endl; }
    int b1;
};

class Base2 {
public:
    virtual void func1() { cout << "Base2::func1" << endl; }
    virtual void func2() { cout << "Base2::func2" << endl; }
    int b2;
};

class Derive : public Base1, public Base2 {
public:
    virtual void func1() { cout << "Derive::func1" << endl; }
    virtual void func3() { cout << "Derive::func3" << endl; }
    int d1;
};

int main() {
    Derive d;
    
    // 验证函数调用行为
    Base1* pb1 = &d;
    Base2* pb2 = &d;
    
    cout << "通过Base1指针调用:" << endl;
    pb1->func1();  // 输出: Derive::func1
    pb1->func2();  // 输出: Base1::func2
    
    cout << "通过Base2指针调用:" << endl;
    pb2->func1();  // 输出: Derive::func1 (经过this调整)
    pb2->func2();  // 输出: Base2::func2
    
    cout << "对象大小: " << sizeof(d) << endl;  // 输出: 20(32位)
    
    return 0;
}

在看内存布局前,我们先自己分析一波代码:(浅层意义)

对象d是派生类类型。pb1是Base1*类型,pb2是Base*2类型。

对于func2(),由于派生类继承了基类,所以都能够找到自己基类的func2()。

对于func1(),由于构成重载,找到的是派生类的func1()。

怎么找到的先不管。

深层意义

1. 基类的原始虚函数表

首先,我们看看两个基类各自的虚函数表:

Base1 的原始虚函数表:

Base2 的原始虚函数表:

2. 构建Base1部分的虚函数表

内存布局分配

过程:拷贝 → 覆盖 → 添加

拷贝Base1的虚函数表:


覆盖重写的函数:Derive重写了func1,用&Derive::func1替换索引0的位置

添加新虚函数:将&Derive::func3添加到表末尾

3. 构建Base2部分的虚函数表

过程相对简单,只有:拷贝 → 覆盖

拷贝Base2的虚函数表

覆盖重写的函数&Derive::func1替换索引0的位置

对象的内存布局

关键问题:为什么两个表的func1地址不同?

这是多继承最核心的技术细节!

函数调用的this指针问题

情况1:通过Base1指针调用

Base1* pb1 = &d;  // pb1指向Derive对象起始地址
pb1->func1();     // this指针就是Derive对象的正确起始位置
  • 不需要调整this指针

  • 直接调用Derive::func1

情况2:通过Base2指针调用

Base2* pb2 = &d;  // pb2指向Base2子对象起始地址
pb2->func1();     // this指针指向错误位置!
  • this指针指向的是Base2子对象,而不是完整的Derive对象

  • 如果直接调用,函数内部访问Derive特有成员时会出错

为什么this指针指向的是Base2子对象?

&d,取的不是Derived的首地址吗?首地址应该是指向Base1的虚函数指针。  

    

情况1:Base1指针转换

    

情况2:Base2指针转换(关键!)

为什么pb2指向0x1008?因为编译器在类型转换时自动进行了指针调整

    

详细解释:

  1. &d确实是Derived对象的首地址(0x1000)

  2. Base2* pb2 = &d不是简单的地址复制

  3. pb2是 Base2* 类型

  4. 编译器知道Base2子对象在Derived中的偏移量

  5. 编译器自动加上这个偏移量,让pb2指向正确的位置

     

为什么需要这样设计?

因为每个基类子对象必须保持完整的内存布局

    

    

一句话总结:

    

编译器在类型转换时自动调整指针,确保每个基类指针都指向对应的完整子对象,这是多继承能够正常工作的基础机制。

编译器解决方案:Thunk技术

编译器为Base2的虚函数表生成一个thunk(跳板函数):

Thunk函数是编译器自动生成的一段小代码,专门用于调整this指针,然后跳转到真正的函数实现。

thunk函数对this指针进行修改(图示)

所以实际上:

从二进制层面理解:

附加了解:C++ 虚函数表解析 | 酷 壳 - CoolShell

C++ 对象的内存布局 | 酷 壳 - CoolShell

关键结论

  1. 派生类新虚函数放在第一个基类的虚表中

  2. 每个基类都有自己的虚表指针

  3. 重写的虚函数会更新所有相关基类的虚表

  4. 非首基类调用需要this指针调整(thunk技术)

  5. 对象大小 = 所有基类大小 + 派生类成员大小 + 虚表指针

5. 动态绑定和静态绑定

动态绑定和静态绑定也叫动态多态和静态多态。

其实多态这一现象不止发生在对象当中,在函数的当中时常发生。如下例子:

int a = 1;
double b = 1;
 
cout << a << endl;
cout << b << endl;

 库函数当中的 cout 流插入之所以实现自动判别类型,其实底层实现就是使用 函数重载。当我们传入不同类型的参数的时候,编译器就会自动的去寻找参数列表对应的函数来调用。

上述这种用函数重载来实现的多态,就叫做静态多态

而我们上述的多态,也就是使用继承,虚函数来实现的多态,就是动态多态
 

6. 重写与覆盖

Override 与 Overwrite​

  • ​重写(Override)​​:这是我们写代码时的叫法。指子类重新定义了父类中具有相同签名(函数名、参数列表)的虚函数。

  • ​覆盖​(Overwrite)​:这是底层实现的叫法,非常形象!它描述了底层发生的事实:​​子类在创建自己的虚函数表时,会用自己重写的虚函数地址,去“覆盖”掉从父类虚函数表中继承来的对应函数地址。​​

这就是多态的秘密:​​不是函数代码在对象内部移动,而是每个对象都通过一个指针,指向一张属于自己的“函数地址查询表”。调用虚函数时,实际是“按图索骥”​​。

总结与要点回顾

  1. ​代价​​:多态不是免费的。它需要额外的空间(每个对象一个指针)和时间(一次间接的查表调用)。

  2. ​静态与动态​​:非虚函数调用在编译时就能确定地址(静态绑定)。虚函数调用在运行时通过查表确定地址(动态绑定)。

  3. ​虚函数表属于类​​:虚函数表(vTable)是每个类一份,在编译期就生成好了,存放在代码段或数据段。而 _vfptr是每个对象一份,指向本类的虚函数表。

  4. ​继承关系​​:子类会先复制一份父类的虚函数表。如果子类重写了虚函数,就修改表中相应的项为子类函数的地址。如果子类定义了新的虚函数,就把新函数的地址添加到这张表的后面。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值