【C++】多态深度解析:虚函数表与动态绑定的奥秘
💻作 者 简 介:曾 与 你 一 样 迷 茫,现 以 经 验 助 你 入 门 C++。
💡个 人 主 页:@笑口常开xpr 的 个 人 主 页
📚系 列 专 栏:C++ 炼 魂 场:从 青 铜 到 王 者 的 进 阶 之 路
✨代 码 趣 语:多 态 就 像 遥 控 器,按 同 一 个 按 钮,不 同 设 备 做 不 同 事,因 为 每 个 设 备 都 有 自 己 的 “功 能 表”(虚 表)。
💪代 码 千 行,始 于 坚 持,每 日 敲 码,进 阶 编 程 之 路。
📦gitee 链 接:gitee
文 章 目 录
- 多 态
- 定 义
- 虚 函 数
- 虚 函 数 的 重 写
- 定 义
- 特 殊 情 况
- 构 成 条 件
- 条 件
- 代 码 展 示
- 结 论
- override 和 final
- 重 载、覆 盖(重 写)、隐 藏(重 定 义)的 对 比
- 抽 象 类
- 多 态 的 原 理
- 虚 函 数 表
- 结 论
- 动 态 绑 定 与 静 态 绑 定
- 多 继 承 中 的 虚 函 数 表
- 总 结
多 态 是 C++ 面 向 对 象 的 核 心 特 性,是 实 现 代 码 灵 活 扩 展 的 关 键,但 学 习 中 常 遇 “概 念 混 淆、原 理 抽 象” 问 题。本 文 从 虚 函 数、重 写 规 则 等 基 础 入 手,拆 解 多 态 构 成 条 件,深 入 虚 表 与 动 态 绑 定 底 层,帮 你 从 “会 用” 到 “理 解” 多 态。
多 态
定 义
多 态 就 是 多 种 形 态,当 不 同 的 对 象 去 完 成 某 个 行 为, 会 产 生 出 不 同 的 状 态。
虚 函 数
被 virtual 修 饰 的 类 成 员 函 数 称 为 虚 函 数。
class Person
{
public://虚函数virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
全 局 函 数 不 能 构 成 虚 函 数,虚 函 数 必 须 是 成 员 函 数。
virtual void BuyTicket()
{cout << "买票-全价" << endl;
}
虚 函 数 的 重 写
定 义
派 生 类 中 有 一 个 跟 基 类 完 全 相 同 的 虚 函 数(即 派 生 类 虚 函 数 与 基 类 虚 函 数 的 返 回 值 类 型、函 数 名 字、参 数 类 型 完 全 相 同),称 子 类 的 虚 函 数 重 写 了 基 类 的 虚 函 数。
特 殊 情 况
重 写 的 条 件 是 虚 函 数 + 三 同(返 回 值 类 型、函 数 名 字、参 数 列 表 )。
- 子 类 可 以 不 加
virtual
,父 类 必 须 加,在 这 里 建 议 都 加 上。原 因 是 子 类 已 经 继 承 了 父 类 的 虚 函 数 特 性。 - 协 变,返 回 的 值 可 以 不 同,但 是 要 求 返 回 值 必 须 是 父 子 关 系 指 针 和 引 用,不 一 定 是 当 前 父 子 关 系,父 子 必 须 同 时 是 指 针 或 者 引 用。协 变 的 使 用 场 景 很 少。
协 变
#include<iostream>
using namespace std;
class Person
{
public:virtual Person* BuyTicket(){ cout << "买票-全价" << endl;return 0;}
};
class Student : public Person
{
public:virtual Student* BuyTicket() { cout << "买票-半价" << endl; return 0;}/*void BuyTicket() { cout << "买票-半价" << endl; }*/
};
void Func(Person& p)
{p.BuyTicket();
}
int main()
{Person ps;Student st;Func(ps);Func(st);return 0;
}
3. 析 构 函 数 加 virtual,是 虚 函 数 重 写,因 为 类 析 构 函 数 都 被 处 理 成 destructor 这 个 统 一 的 名 字,让 它 们 构 成 重 写。
情 景 1
#include<iostream>
using namespace std;
class Person
{
public:virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person
{
public:virtual ~Student() { cout << "~Student()" << endl; }
};
int main()
{Person p;Student s;return 0;
}
这 里 父 类 被 析 构 了 两 次 是 因 为 父 类 调 用 完 成 后 被 析 构 。然 后 子 类 对 象 中 有 父 类 对 象,先 析 构 子 类 中 的 父 类,然 后 析 构 子 类。
情 景 2
析 构 函 数 是 虚 函 数 吗?为 什 么 要 一 定 是 虚 函 数?
#include<iostream>
using namespace std;
class Person
{
public:~Person() { cout << "~Person()" << endl; }
};
class Student : public Person
{
public:~Student() { cout << "~Student()" << endl; delete[] ptr;}
private:int* ptr = new int[10];
};
int main()
{//Person p;//Student s;Person* p = new Person;delete p;p = new Student;delete p;//p->destructor() + operator delete(p)//这里期望p->destructor()是一个多态调用,而不是普通调用return 0;
}
这 段 代 码 中 只 调 用 了 父 类 的 析 构 函 数,没 有 调 用 子 类 的 析 构 函 数,多 态 调 用 看 的 是 指 向 的 对 象。普 通 对 象 看 当 前 调 用 对 象 的 类 型。在 p = new Student;
这 句 代 码 中,p 的 类 型 是 Person*
,这 里 p 被 当 成 了 普 通 对 象,没 有 调 用 子 类 的 析 构 函 数,而 发 生 了 内 存 泄 漏。
在 C++ 中,当 通 过 基 类 指 针 删 除 派 生 类 对 象 时,如 果 基 类 析 构 函 数 不 是 虚 函 数,只 会 调 用 基 类 析 构 函 数,而 不 会 调 用 派 生 类 析 构 函 数。所 以,需 要 将 析 构 函 数 改 成 重 写 防 止 内 存 泄 漏。
这 里 建 议 只 要 子 类 要 继 承 父 类,建 议 写 出 子 类 的 析 构 函 数,并 将 父 子 类 前 面 加 上 virtual
改 成 虚 函 数
情 景 3
虚 函 数 重 写 改 变 的 是 函 数 的 实 现 内 容。虚 函 数 重 写 的 本 质 是 在 派 生 类 中,用 一 个 新 的 函 数 体,替 换 掉 基 类 中 虚 函 数 的 实 现。当 通 过 基 类 指 针 或 引 用 调 用 该 虚 函 数 时,会 根 据 对 象 的 实 际 类 型(运 行 时 类 型)调 用 对 应 的 派 生 类 版 本。
#include<iostream>
using namespace std;
class A
{
public:virtual void func(int val = 1) { std::cout << "A->" << val << std::endl;}virtual void test() { func();//this*->func(),this*指的是A*,调用的是父类的func函数}
};
class B : public A
{
public:void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{B* p = new B;p->test();return 0;
}
这 里 使 用 的 是 基 类 的 函 数 名,派 生 类 的 函 数 体。这 也 就 是 为 什 么 可 以 省 略 派 生 类 的 virtual
。
构 成 条 件
条 件
多 态 是 在 不 同 继 承 关 系 的 类 对 象,去 调 用 同 一 函 数,产 生 了 不 同 的 行 为。那 么 在 继 承 中 要 构 成 多 态 还 有 两 个 条 件:
(1)必 须 通 过 父 类 的 指 针 或 者 引 用 调 用 虚 函 数。
为 什 么 必 须 是 父 类 的 指 针 或 引 用?
在 C++ 中,动 态 绑 定(多 态)是 通 过 虚 函 数 表(vtable)和 虚 表 指 针(vptr) 实 现 的:
- 每 个 包 含 虚 函 数 的 类 编 译 时 会 生 成 一 张 虚 函 数 表(vtable),里 面 存 放 着 虚 函 数 的 地 址。
- 每 个 对 象 会 有 一 个 虚 表 指 针(vptr),指 向 自 己 所 属 类 的 vtable。
- 当 你 通 过 父 类 的 指 针 或 引 用 调 用 虚 函 数 时,编 译 器 会:
(1)找 到 该 对 象 的 vptr
(2)根 据 vptr 找 到 对 应 的 vtable
(3)在 vtable 中 找 到 真 正 要 调 用 的 函 数 地 址 并 执 行。
这 样,即 使 指 针 是 父 类 型,但 实 际 对 象 是 子 类 型 时,也 会 调 用 子 类 的 虚 函 数。
为 什 么 不 能 是 子 类?
如 果 你 用 子 类 指 针,它 只 能 指 向 子 类 对 象,编 译 器 在 编 译 期 就 可 以 确 定 调 用 的 是 子 类 的 函 数(静 态 绑 定),不 需 要 动 态 绑 定。多 态 的 意 义 在 于 用 父 类 指 针 指 向 不 同 子 类 对 象,调 用 同 一 个 方 法,行 为 不 同。如 果 用 子 类 指 针,就 失 去 了 这 种 灵 活 性。
为 什 么 不 能 是 父 类 的 对 象?
如 果 你 直 接 用 父 类 对 象,那 它 就 是 一 个 纯 粹 的 父 类 对 象,它 的 vptr 指 向 父 类 的 vtable。
当 你 把 子 类 对 象 赋 值 给 父 类 对 象 时,会 发 生 对 象 切 片:
- 子 类 对 象 中 属 于 父 类 的 部 分 会 被 复 制,子 类 特 有 的 成 员 和 vptr 会 被 “切 掉”。
- 这 个 父 类 对 象 的 vptr 依 然 指 向 父 类 的 vtable,调 用 虚 函 数 时 只 会 调 用 父 类 版 本。这 就 是 为 什 么 对 象 切 片 会 破 坏 多 态。
首 先 派 生 类 会 将 父 类 的 虚 表 拷 贝 下 来,然 后 去 重 写 覆 盖 对 应 的 地 址。虚 函 数 表 没 有 拷 贝。
对 象 的 切 片 和 引 用 的 切 片 是 不 同 的。子 类 赋 值 给 父 类 切 片,不 会 拷 贝 虚 表,如 果 拷 贝 虚 表,则 不 能 确 定 父 类 的 对 象 中 虚 表 是 父 类 还 是 子 类 的 虚 函 数。
(2)被 调 用 的 函 数 必 须 是 虚 函 数 且 派 生 类 必 须 对 基 类 的 虚 函 数 进 行 重 写。
代 码 展 示
#include<iostream>
using namespace std;
class Person
{
public:virtual void BuyTicket() { cout << "买票-全价" << endl;}
};
class Student : public Person
{
public://虚函数的重写virtual void BuyTicket() { cout << "买票-半价" << endl; }/*void BuyTicket() { cout << "买票-半价" << endl; }*/
};
void Func(Person& p)
{p.BuyTicket();
}
int main()
{Person ps;Student st;Func(ps);Func(st);return 0;
}
结 论
- 对 象 中 不 存 在 成 员 函 数,只 存 在 成 员 变 量。
- 多 态 调 用 看 的 是 指 向 的 对 象。普 通 对 象 看 当 前 调 用 对 象 的 类 型。
- 2 个 条 件 缺 一 不 可。派 生 类 发 生 了 切 片。
override 和 final
final:修 饰 虚 函 数,表 示 该 虚 函 数 不 能 再 被 重 写。final 添 加 在 基 类 后 面。
#include<iostream>
using namespace std;
class Car
{
public:virtual void Drive() final {}
};
class Benz :public Car
{
public:virtual void Drive() { cout << "Benz-舒适" << endl;}
};
int main()
{Benz b;b.Drive();return 0;
}
override: 检 查 派 生 类 虚 函 数 是 否 重 写 了 基 类 某 个 虚 函 数,如 果 没 有 重 写 编 译 报 错。
#include<iostream>
using namespace std;
class Car
{
public:virtual void Drive() {cout << "virtual void Drive() " << endl;}
};
class Benz :public Car
{
public:virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
int main()
{Benz b;b.Drive();return 0;
}
重 载、覆 盖(重 写)、隐 藏(重 定 义)的 对 比
抽 象 类
多 态 的 原 理
虚 函 数 表
过 程
编 译 器 在 编 译 时,为 每 个 有 虚 函 数 的 类 生 成 一 个 独 立 的 虚 函 数 表,这 是 一 个 静 态 数 组,存 的 是 函 数 指 针。
基 类 有 自 己 的 vtable。
派 生 类 也 有 自 己 的 vtable。
派 生 类 的 vtable 构 建 方 式:
- 先 把 基 类 的 虚 函 数 列 表 内 容 “继 承” 过 来(不 是 运 行 时 拷 贝,而 是 编 译 时 直 接 生 成 新 的 表)。
- 如 果 派 生 类 重 写 了 某 个 虚 函 数,就 在 自 己 的 vtable 中 替 换 掉 对 应 位 置 的 函 数 指 针,指 向 新 的 实 现。
- 如 果 派 生 类 新 增 了 虚 函 数,就 会 把 这 些 新 函 数 的 指 针 追 加 到 vtable 末 尾。
对 象 里 只 有 一 个 vptr(虚 表 指 针),指 向 所 属 类 的 vtable。
- 构 造 对 象 时,编 译 器 自 动 插 入 代 码,让 vptr 指 向 该 类 的 vtable。
虚 函 数 放 在 代 码 段 里,虚 函 数 表 里 存 放 着 虚 函 数 的 地 址。
#include<iostream>
using namespace std;
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}virtual void Func2(){cout << "Func1()" << endl;} void Func3(){cout << "Func1()" << endl;}
private:char _b = 1;
};
int main()
{cout << sizeof(Base) << endl;Base b;return 0;
}
结 论
- 派 生 类 对 象 d 中 也 有 一 个 虚 表 指 针,d 对 象 由 两 部 分 构 成,一 部 分 是 父 类 继 承 下 来 的 成 员,虚 表 指 针 也 就 是 存 在 部 分 的 另 一 部 分 是 自 己 的 成 员。
- 基 类 b 对 象 和 派 生 类 d 对 象 虚 表 是 不 一 样 的,这 里 我 们 发 现 Func1 完 成 了 重 写,所 以 d 的 虚 表 中 存 的 是 重 写 的 Derive::Func1,所 以 虚 函 数 的 重 写 也 叫 作 覆 盖,覆 盖 就 是 指 虚 表 中 虚 函 数 的 覆 盖。重 写 是 语 法 的 叫 法,覆 盖 是 原 理 层 的 叫 法。
- 另 外 Func2 继 承 下 来 后 是 虚 函 数,所 以 放 进 了 虚 表,Func3 也 继 承 下 来 了,但 是 不 是 虚 函 数,所 以 不 会 放 进 虚 表。
- 虚 函 数 表 本 质 是 一 个 存 虚 函 数 指 针 的 指 针 数 组,一 般 情 况 这 个 数 组 最 后 面 放 了 一 个 nullptr。
- 派 生 类 的 虚 表 生 成:
(1)先 将 基 类 中 的 虚 表 内 容 拷 贝 一 份 到 派 生 类 虚 表 中 (2)如 果 派 生 类 重 写 了 基 类 中 某 个 虚 函 数,用 派 生 类 自 己 的 虚 函 数 覆 盖 虚 表 中 基 类 的 虚 函 数
(3)派 生 类 自 己 新 增 加 的 虚 函 数 按 其 在 派 生 类 中 的 声 明 次 序 增 加 到 派 生 类 虚 表 的 最 后。 - 虚 表 存 储 在 代 码 段,虚 表 存 的 是 虚 函 数 指 针,不 是 虚 函 数,虚 函 数 和 普 通 函 数 一 样 的,都 是 存 在 代 码 段 的,只 是 他 的 指 针 又 存 到 了 虚 表 中。另 外 对 象 中 存 的 不 是 虚 表,存 的 是 虚 表 指 针。同 类 型 的 对 象 共 用 虚 表。
代 码 验 证 虚 表 存 在 代 码 段
#include<iostream>
using namespace std;
class Person
{
public:virtual void BuyTicket(){cout << "买票-全价" << endl;}virtual void Func1(){}virtual void Func2(){}
protected:int _a = 0;
};
class Student : public Person
{
public:virtual void BuyTicket(){cout << "买票-半价" << endl;}
protected:int _b = 1;
};
int main()
{int a = 0;printf("栈:%p\n", &a);static int b = 0;printf("静态区:%p\n", &b);int* c = new int;printf("堆区:%p\n", &c);const char* d = "hello world";printf("常量区(代码段):%p\n", d);Person ps1;Student st1;printf("虚表1:%p\n", *((int*)&ps1));printf("虚表2:%p\n", *((int*)&st1));return 0;
}
找 到 隐 藏 的 Func3 函 数
#include<iostream>
using namespace std;
class Person
{
public:virtual void BuyTicket(){cout << "买票-全价" << endl;}virtual void Func1(){cout << "Person:Func1()" << endl;}virtual void Func2(){cout << "Person:Func2()" << endl;}
//protected:int _a = 0;
};
class Student : public Person
{
public:virtual void BuyTicket(){cout << "买票-半价" << endl;}virtual void func3(){cout << "Student:Func3()" << endl;}
protected:int _b = 1;
};
void Func(Person& p)
{p.BuyTicket();
}
//打印函数指针数组
typedef void(*FUNC_PIR) ();
//void PrintVFT(FUNC_PIR table[])
void PrintVFT(FUNC_PIR* table)
{for (size_t i = 0; table[i] != nullptr; i++){printf("[%d]:%p->", i, table[i]);FUNC_PIR f = table[i];f();}printf("\n");
}
int main()
{Person ps;Student st;int vft1 = *((int*)&ps);//取出4个字节PrintVFT((FUNC_PIR*)vft1);int vft2 = *((int*)&st);PrintVFT((FUNC_PIR*)vft2);return 0;
}
监 视 窗 口 没 有 显 示 Func3
,经 过 内 存 查 找 得 到 了 Func3 的 地 址。
动 态 绑 定 与 静 态 绑 定
- 静 态 绑 定 又 称 为 前 期 绑 定 (早 绑 定),在 程 序 编 译 期 间 确 定 了 程 序 的 行 为,也 称 为 静 态 多 态,比 如:函 数 重 载
- 动 态 绑 定 又 称 后 期 绑 定(晚 绑 定),是 在 程 序 运 行 期 间,根 据 具 体 拿 到 的 类 型 确 定 程 序 的 具 体 行 为,调 用 具 体 的 函 数,也 称 为 动 态 多 态。
多 继 承 中 的 虚 函 数 表
#include<iostream>
using namespace std;
class Base1
{
public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; }
private:int b1;
};
class Base2
{
public:virtual void func1() { cout << "Base2::func1" << endl; }virtual void func2() { cout << "Base2::func2" << endl; }
private:int b2;
};
class Derive : public Base1, public Base2
{
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }
private:int d1;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{cout << "虚表地址>" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf("第%d个虚函数地址:0X%x->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}
int main()
{Derive d;Base1 b1;Base2 b2;cout << "sizeof(d):" << sizeof(d) << endl;int vft1 = *((int*)&d);PrintVTable((VFPTR*)vft1);//方法1//int vft2 = *((int*)((char*)&d + sizeof(Base1)));//方法2Base2* ptr = &d;int vft2 = *((int*)ptr);PrintVTable((VFPTR*)vft2);return 0;
}
寄 存 器 ecx 存 储 的 是 this 指 针 的 地 址。Derive 和 Base1 的 地 址 的 开 始 重 叠。ptr1 的 this 指 针 指 向 的 是 Derive 对 象 的 开 始。ptr2 的 this 指 针 指 向 的 是 Base2 的 开 始,距 离 Base1 的 开 始 有 8 个 字 节。
总 结
多 态 的 核 心 是 “运 行 时 确 定 函 数 调 用”,依 赖 虚 表(vtable)与 虚 表 指 针(vptr):子 类 继 承 基 类 虚 表 后,重 写 函 数 覆 盖 表 项,通 过 vptr 实 现 动 态 绑 定。需 牢 记 析 构 函 数 设 虚 函 数、用 override/final 规 范 重 写 等 细 节。建 议 结 合 案 例 改 代 码、自 定 义 类 验 证 虚 表,通 过 实 践 掌 握 多 态,写 出 高 可 维 护 的 C++ 代 码。