一文搞定多态

C++ 多态(Polymorphism)课堂笔记

一、什么是多态

  • 多态 = 多种形态
  • 分为两类:
    • 静态多态(编译时多态):编译阶段就确定行为的多态
    • 动态多态(运行时多态):运行时才确定行为的多态 —— 本节课重点

1.1 静态多态

  • 主要指函数重载函数模板
  • 原理:例如 cout << intcout << double,编译器根据函数模板实例化出两个不同函数,本质上是函数重载,编译时就确定调用哪个函数
  • 表面看起来是同一个函数,实际上调用了不同函数,行为不同 —— 这就是多态,只是发生在编译时

1.2 动态多态

  • 定义:完成某个行为时,不同的对象去做这个行为,结果不一样
  • 运行的时候传入不同的对象,行为不同
  • 生活中的例子:
    • 买票:普通人全价、学生打折、军人优先。同样"买票"这个行为,传不同对象效果不同
    • 动物叫:传猫→"喵喵",传狗→"汪汪",传鸭子→"嘎嘎"

二、虚函数(virtual)与重写

2.1 虚函数

  • 用关键字 virtual 声明的成员函数
  • 回顾:之前在**菱形继承(虚继承)**解决数据冗余和二义性时也用过 virtual,同一关键字两种用途

2.2 重写(覆盖)

  • 定义:派生类中有一个与基类完全相同的虚函数,就称派生类的虚函数重写了基类的虚函数
  • "三同"条件:返回类型、函数名、参数列表完全相同
  • 唯一可以不同:函数体(实现)
  • 注意区分:
    • 重写:语法层面的概念
    • 覆盖:底层(虚表)层面的概念

三、多态的两个条件

  1. 必须通过基类的指针或引用调用虚函数
  2. 派生类必须重写基类的虚函数(且满足三同)

逻辑链:多态的两个条件之一 → 重写虚函数 → 重写又必须满足三同

如果少了条件(例如没完成虚函数重写),即使做了切片,也达不到多态效果

四、例题:带虚函数的类 sizeof(经典坑题)

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;
}

选项:A. 编译报错 B. 运行报错 C. 8 D. 12

答案:D. 12

计算过程(默认 32 位下,未说明也按 32 位算):

  • 类中有虚函数 → 对象多一个虚函数表指针 vfptr,占 4 字节
  • int _b:4 字节
  • char _ch:1 字节
  • 内存对齐:4(vfptr) + 4(int) + 1(char) = 9,再补 3 字节对齐 → 12 字节

要点回顾:

  • 计算类大小只考虑成员变量,成员函数不存进对象里
  • 要考虑内存对齐
  • 这题必须懂多态原理(虚函数表指针)才能做对,是易错点

五、虚函数表(vftable)

5.1 基本概念

  • 类中有虚函数 → 对象增加一个指针:虚函数表指针(vfptr)
  • 全称:vftable 指针(virtual function table),简写为 vfptr
  • vfptr 指向一张虚函数表(虚表)
  • 虚表本质:一个函数指针数组 —— 数组里存的是函数指针,一层层包裹:数组 → 存指针 → 存函数指针

5.2 虚表里放什么

  • 存放该类所有虚函数的地址,有几个放几个
  • 非虚函数(普通函数)不放
  • 可通过调试器监视窗口 / 内存窗口观察

代码示例:

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:
    // 重写基类的func1
    virtual void func1() { cout << "Derive::func1" << endl; }
    virtual void func3() { cout << "Derive::func1" << endl; }
    void func4() { cout << "Derive::func4" << endl; }
protected:
    int b = 2;
};

int main()
{
    Base b;
    Derive d;
    return 0;
}

说明:func1func2func3 进虚表;func4func5 是普通函数,不进虚表。

调试小技巧:监视窗口里虚表内容看起来不对时,先清理项目重新生成——有时只改了部分代码没重新编译,编译器会显示错乱。

六、多态的原理:指向谁,调用谁

买票示例(多态也发生在多个派生类之间):

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

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

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

void Func(Person* ptr)
{ // 这里可以看到虽然都是Person指针Ptr在调用BuyTicket
  // 但是跟ptr没关系,而是由ptr指向的对象决定的。
    ptr->BuyTicket();
}

int main(){ // 其次多态不仅仅发生在派生类对象之间,多个派生类继承基类,重写虚函数后
// 多态也会发生在多个派生类之间。
    Person ps;
    Student st;
    Soldier sr;
    Func(&ps);
    Func(&st);
    Func(&sr);
    return 0;
}

输出:买票-全价买票-打折买票-优先

6.1 对象模型

  • 每个对象有自己的 vfptr,指向自己的虚表
  • Person:虚表里只有 Person::BuyTicket,成员 _name
  • Student:继承 _name,虚表里是 Student::BuyTicket,成员 _id
  • Soldier:继承 _name,虚表里是 Soldier::BuyTicket,成员 _codename(代号)

6.2 底层原理

  • 指针视角:父类指针无论指向 Person / Student / Soldier,从内存看都是一个 Person 对象(因为切片),但通过 vfptr 找到的虚表不同 → 找到的虚函数地址不同
  • 调用流程(汇编层面):
    1. 取指针(ptr 指向的对象地址)
    2. 从对象头部取 vfptr(对象第一个成员)
    3. 通过 vfptr 找到虚表
    4. 从虚表中取出对应虚函数的地址
    5. call 该地址

汇编演示(动态绑定,满足多态):

ptr->BuyTicket();  // ptr是指针+BuyTicket是虚函数满足多态条件
                   // 这就是动态绑定,编译在运行时到ptr指向对象的虚函数表中确定调用函数地址
00EF2001 mov eax,dword ptr [ptr]
00EF2004 mov edx,dword ptr [eax]
00EF2006 mov esi,esp
00EF2008 mov ecx,dword ptr [ptr]
00EF200B mov eax,dword ptr [edx]
00EF200D call eax
  • 核心理解:无论指向父类还是子类,执行的汇编指令完全一样;只是因为传的对象不同 → 对象虚表里存的地址不同 → 调用到不同的虚函数
  • 这也正是"重写"的意义:子类对象虚表里对应位置放的是子类的虚函数地址

七、静态绑定 vs 动态绑定

静态绑定动态绑定
发生时机编译时运行时
条件不满足多态满足多态
地址确定编译时根据 ptr 的类型确定运行时通过 ptr 指向对象的虚表确定
效率更高(指令少、不跳转)较低(多几条取虚表指令)
  • 静态绑定:编译阶段直接确定函数地址并 call
ptr->BuyTicket();  // BuyTicket不是虚函数,不满足多态条件
                   // 这就是静态绑定,编译器直接确定调用函数地址
00EA2C91 mov ecx,dword ptr [ptr]
00EA2C94 call Student::Student (0EA153Ch)
  • 静态 / 动态 = 编译时 / 运行时,记住这个对应关系即可

八、同类型对象共享虚表

  • 不同类型对象:虚表各自独立(父类虚表放父类虚函数地址,子类虚表放子类虚函数地址)
  • 同类型对象:虚表是同一个,共享(如 b1、b2 两个同类型对象虚表地址相同)
  • 为什么不把虚函数地址直接放对象里,而只放一个指针?
    • 同类型对象共享一张表,只需一个指针,节省空间
    • 例:3 个虚函数各 4 字节 = 12 字节,若每个对象都存一份就浪费;共享一张表,无论多少对象都只存 12 字节
  • 结论:同类型对象,虚表通用;不同类型对象,虚表各自独立

九、派生类虚表的构成(三部分)

  • 派生类对象成员由两部分构成:继承基类的成员 + 自己的成员
  • 虚函数表指针是继承基类的(基类有虚函数,对象里已有 vfptr),派生类不会额外生成一个;但派生类和基类的 vfptr 不是同一个(各自指向自己的虚表,互相独立,就像同名成员变量各自独立一样)
  • 派生类虚表的生成逻辑:
    1. 先把基类的虚表拷贝过来(继承得来的虚函数地址)
    2. 若重写了某个虚函数,用重写的地址覆盖基类对应位置
    3. 自己新增的虚函数追加到表尾
  • 普通函数(func4、func5)不进虚表
  • 覆盖的意义:父类指针指向父类 → 虚表对应位置是父类函数;指向子类 → 对应位置被覆盖成子类函数 → 完成多态

十、虚函数与虚表的存储位置(易错点)

  • 虚函数本身(函数代码):存在代码段,和普通函数一样(编译好的代码)
  • 虚函数表:是一个数组(指针数组),存的是虚函数地址;存放在只读数据段,不可修改
  • 易错点:虚表存的是"虚函数地址"而非函数代码本身,函数代码在代码段,两者别混淆
  • 补充(编译器实现差异):VS 在虚表末尾放一个 0000 作"哨兵"(每次运行地址会变);GCC 没有这个约定,了解即可
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值