一、多态的概念与分类
多态(polymorphism)即'多种形态',在 C++ 中分为两类:
- 编译时多态(静态多态):
- 典型代表:函数重载、函数模板
- 特点:在编译阶段,根据实参类型匹配到对应的函数地址,属于静态绑定
- 运行时多态(动态多态):
- 典型代表:通过虚函数实现的多态
- 特点:在运行阶段,根据指针/引用指向的实际对象类型,调用对应的虚函数,属于动态绑定
例子:
- 买票:普通人全价、学生半价/75 折、军人优先
- 动物叫:猫'喵'、狗'汪汪'
二、多态的定义及实现
2.1 多态的构成条件
多态发生在继承关系下,用基类的指针或引用调用虚函数,产生不同行为。
必须同时满足两个条件:
- 必须是基类的指针或引用调用虚函数
- 被调用的函数必须是虚函数,并且在派生类中完成了虚函数重写/覆盖
说明:
- 只有基类指针/引用才能既指向基类对象,又指向派生类对象
- 派生类必须对基类虚函数完成重写,才能在运行时表现出不同行为
代码示例:
#include <iostream>
using namespace std;
class Person {
public:
virtual void BuyTicket() {
cout << "买票 - 全价" << endl;
}
};
class Student : public Person {
public:
// 重写基类的虚函数
void BuyTicket() override {
cout << "买票 - 打折" << endl;
}
};
void Func(Person& ptr) {
// 这里虽然是 Person 引用 ptr 在调用 BuyTicket
// 但实际调用的函数由 ptr 引用的对象类型决定,这就是多态
ptr.BuyTicket();
}
int main() {
Person p;
Student s;
Func(p); // 输出:买票 - 全价
Func(s); // 输出:买票 - 打折
return 0;
}
核心要点解析
- 虚函数重写:
- Person 中的 BuyTicket 是 virtual 虚函数。
- Student 中的 BuyTicket 虽然没有写 virtual,但因为继承了基类的虚函数属性,所以依然构成重写(Override)。加上 override 关键字是更好的编程习惯,可以让编译器帮你检查是否真的重写了基类函数。
- 多态的触发条件:
- 这里使用了基类的引用 Person& ptr 作为函数参数。
- 当 ptr 引用 Person 对象时,调用 Person::BuyTicket()。
- 当 ptr 引用 Student 对象时,调用 Student::BuyTicket()。
- 这种在运行时根据对象实际类型来决定调用哪个函数的机制,就是动态绑定,也是多态的核心。
- 底层原理:
- 当类中存在虚函数时,编译器会为该类生成一张虚函数表(vtable),表中存放着所有虚函数的地址。
- 每个对象都会包含一个隐藏的虚函数表指针(_vfptr),指向所属类的虚函数表。
- 当通过基类引用调用虚函数时,程序会通过对象的 _vfptr 找到对应的虚函数表,再从表中查找并调用正确的函数地址。
2.1.1 虚函数
- 定义:在类的成员函数前加 virtual 修饰,该函数即为虚函数
- 注意:非成员函数不能加 virtual
class Person {
public:
// 虚函数示例
virtual void BuyTicket() {
cout << "买票 - 全价" << endl;
}
};
2.1.2 虚函数的重写/覆盖
- 定义:派生类中有一个跟基类完全相同的虚函数(返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。
- 注意:派生类虚函数不加 virtual 时,也可构成重写(因为继承后基类虚函数属性被保留),但写法不规范,不推荐。考试选择题常故意设置此'坑',用于判断是否构成多态。
代码示例 1
class Person {
public:
virtual void BuyTicket() {
cout << "买票 - 全价" << endl;
}
};
class Student : public Person {
public:
// 重写基类虚函数
virtual void BuyTicket() {
cout << "买票 - 打折" << endl;
}
};
// 使用示例:基类指针调用
void Func(Person* ptr) {
ptr->BuyTicket(); // 动态绑定,由 ptr 指向的对象决定调用哪个版本
}
int main() {
Person ps;
Student st;
Func(&ps); // 输出:买票 - 全价
Func(&st); // 输出:买票 - 打折
return 0;
}
代码示例 2
// 另一个示例:动物叫声
#include <iostream>
using namespace std;
class Animal {
public:
// 基类虚函数,定义接口
virtual void talk() const {}
};
class Dog : public Animal {
public:
// 重写基类虚函数
virtual void talk() const override {
std::cout << "汪汪" << std::endl;
}
};
class Cat : public Animal {
public:
// 重写基类虚函数
virtual void talk() const override {
std::cout << "(>^ω^<) 喵" << std::endl;
}
};
void letsHear(const Animal& animal) {
// 多态调用:根据 animal 实际引用的对象类型,调用对应的 talk()
animal.talk();
}
int main() {
Cat cat;
Dog dog;
letsHear(cat); // 输出:(>^ω^<) 喵
letsHear(dog); // 输出:汪汪
return 0;
}
核心解析
- 多态的体现:
- letsHear 函数接收一个 const Animal& 类型的引用。
- 当传入 Cat 对象时,调用 Cat::talk();传入 Dog 对象时,调用 Dog::talk()。
- 同一个函数 letsHear,在运行时根据传入对象的实际类型,表现出不同的行为,这就是多态。
- 关键机制:
- 虚函数:Animal 中的 talk() 被声明为 virtual,这是实现多态的基础。
- 重写(Override):Dog 和 Cat 类中重新定义了 talk(),与基类的虚函数签名完全一致,构成重写。
- 动态绑定:通过基类的引用(或指针)调用虚函数时,会在运行时查找对象的虚函数表,找到并调用正确的函数版本。
- 代码规范:在派生类的重写函数后加上 override 关键字是一个好习惯,它能让编译器检查你是否真的重写了一个存在的虚函数,避免因拼写错误等导致的'隐藏'而非'重写'的问题。
理解「通过基类的指针 / 引用调用虚函数」
基类指针/引用 = 可以指向/引用 父类对象 或 子类对象
void letsHear(const Animal& animal) {
animal.talk();
}
这里:animal 是 基类 Animal 的引用;但它既可以引用猫,也可以引用狗 letsHear(cat); // animal 引用了猫 letsHear(dog); // animal 引用了狗 然后你调用:animal.talk(); 这就叫:✅ 通过基类的引用,调用虚函数
再看指针版本(一样道理)
void func(Animal* ptr) {
ptr->talk(); // 通过基类指针调用虚函数
}
Animal* p1 = new Cat;
Animal* p2 = new Dog;
p1->talk();
p2->talk();
这也叫:✅ 通过基类的指针,调用虚函数
重点:为什么一定要「基类指针/引用」?因为只有基类指针/引用,才能同时接收父类和子类。 Animal& animal = cat; // ✅ 可以 Animal& animal = dog; // ✅ 可以 如果不用基类指针/引用,就不是多态: Cat c; c.talk(); // 这是普通调用,不是多态
总结:多态 = 基类指针/引用 + 调用虚函数;指针/引用是谁不重要;它指向/引用的对象是谁,就调用谁的函数
2.1.3 多态场景选择题解析
class A {
public:
// 虚函数,默认参数 val=1
virtual void func(int val = 1) {
cout << "A->" << val << endl;
}
virtual void test() {
func(); // 这里调用 func
}
};
class B : public A {
public:
// 重写虚函数,默认参数 val=0
void func(int val = 0) {
cout << "B->" << val << endl;
}
};
int main() {
B* p = new B;
p->test();
p->func();
return 0;
}
关键流程
① p->test(); p 是 B* ;B 自己没有写 test() ;所以去调用 父类 A::test()
② 进入 A::test()
virtual void test() {
func(); // 等价于 this->func();
}
这里的 this 是谁?this 是 A* 类型,但指向的是 B 对象 func() 是虚函数,满足多态条件 → 调用 B::func()
③ 调用 B::func(int val = 0)
这里是最坑的地方: 规则 -> 函数体:运行时动态决议(多态) 默认参数:编译期静态决议(看指针/引用类型)
在 A::test() 里:this 是 A*,编译阶段就把默认参数定为 A 里的 val = 1,不会用 B 里的 0
所以 -> 调用的是:B::func(1),输出:B->1
p->func(); 执行过程:p 是 B*,直接调用 B::func(),没有多态,就是普通调用,默认参数用 B 自己的:val = 0
所以第二句输出:B->0
总结
- test 找不到 → 调用 A::test
- test 里的 func 是虚函数 → 多态调用 B::func
- 默认参数看当前指针类型(A)→ 用 A 的默认值 1
- 在 test() 里调用 func:函数 → B,默认参数 → A 的 1
- 直接 p->func():函数 → B,默认参数 → B 的 0
- 函数体看对象,默认参数看类型。
2.1.4 虚函数重写的其他问题
协变(了解)
- 定义:派生类重写基类虚函数时,返回值类型不同,但基类返回基类对象指针/引用,派生类返回派生类对象指针/引用,这种情况称为协变
- 特点:实际意义不大,仅作了解
#include <iostream>
using namespace std;
// 先定义两个有继承关系的类
class A {
public:
virtual void show() {
cout << "I am A" << endl;
}
};
class B : public A {
public:
void show() override {
cout << "I am B" << endl;
}
};
// 父类
class Person {
public:
virtual A* BuyTicket() {
cout << "买票 - 全价" << endl;
return new A; // 返回 A*
}
};
// 子类
class Student : public Person {
public:
// 这里返回 B*,B 是 A 的子类 → 满足【协变】
B* BuyTicket() override {
cout << "买票 - 打折" << endl;
return new B; // 返回 B*
}
};
void Func(Person* ptr) {
// 多态调用
A* p = ptr->BuyTicket();
p->show();
delete p;
}
int main() {
Person ps;
Student st;
Func(&ps);
cout << "--------" << endl;
Func(&st);
return 0;
}
「协变」;子类重写虚函数时,返回值可以是父类返回值的「派生类指针/引用」。
- 类 A 和 类 B(用来演示协变):A 是父类;B 继承 A,是 A 的子类;它们有一个同名虚函数 show(),构成重写;这一对 A 和 B 就是为了满足:返回值类型是父子关系。
- Person 类(父类):有一个虚函数 BuyTicket(),返回值类型:A*
- Student 类(子类,重点!协变):Student 继承 Person,重写了虚函数 BuyTicket(),返回值是 B*,而 B 是 A 的子类 👉 这就是 C++ 协变:子类重写虚函数时,返回值可以是父类返回值的派生类指针/引用。
- 多态调用函数 Func:ptr 是 Person*,可以指向 Person 或 Student;ptr->BuyTicket() 是多态调用:指向 Person → 调用 Person::BuyTicket;指向 Student → 调用 Student::BuyTicket
- main 函数执行流程:
- 第一次调用:Func(&ps)
- 调用 Person::BuyTicket() ;输出:买票 - 全价 ;返回 new A ;调用 A::show() → 输出 I am A
- 第二次调用:Func(&st)
- 调用 Student::BuyTicket() ;输出:买票 - 打折 ;返回 new B ;调用 B::show() → 输出 I am B
- 第一次调用:Func(&ps)
- 协变的规则(必须记住):必须是 虚函数 重写;返回值必须是 指针 或 引用;子类返回值类型 必须是 父类返回值类型的 派生类。满足这三条,编译器就认为是正确的重写。
- 总结(超精简):协变 = 虚函数重写 + 返回值是父子类指针/引用
- 目的:让子类可以返回更具体的类型,同时保持多态
析构函数的重写
规则:如果基类的析构函数为虚函数,派生类析构函数只要定义,无论是否加 virtual,都与基类析构函数构成重写
原因:编译器对析构函数名称做了特殊处理,统一处理成 destructor
重要性:若基类析构函数不是虚函数,delete 基类指针指向派生类对象时,只会调用基类析构函数,导致派生类资源泄漏
面试高频考点:基类析构函数建议设计为虚函数
#include <iostream>
using namespace std;
class A {
public:
virtual ~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* p1 = new A;
A* p2 = new B;
delete p1;
delete p2;
return 0;
}
运行结果(控制台打印)
~A()
~B()->delete:0000021E45AFB420
~A()
逐行解释打印过程
- 第一行输出:~A() 对应代码:A* p1 = new A; delete p1; p1 指向 new A,是纯 A 对象;delete p1 调用 A 的析构函数;打印:~A()
- 第二行输出:~B()->delete:0x... 对应代码:A* p2 = new B; delete p2; p2 是父类指针 A*,但指向子类 B 对象;因为 ~A() 是 虚析构,所以 delete p2 会多态调用 ~B();因为父析构 是虚析构,delete p2 会 多态调用,先调用 ~B(),再自动调用 ~A(),子类、父类都被正确销毁,没有内存泄漏。先执行 B 的析构:打印:~B()->delete:地址;执行 delete _p; 释放 B 内部的数组。
- 第三行输出:~A() 子类析构执行完后,会自动调用父类析构,所以再打印:~A()
总结
- 析构函数是虚函数 → 构成多态
- 虚析构 = 保证「父类指针删子类对象」时,能删干净。
- 只要满足:有继承,父类指针指向子类对象,子类有动态内存 / 需要清理,父类析构函数,一律写成虚析构!只要子类里有动态申请的资源,父类析构必须是虚函数。
- 父类指针指向子类对象,delete 时,先调用子类析构,再自动调用父类析构,这样才不会内存泄漏。
2.1.5 override 和 final 关键字
C++11 引入,用于辅助虚函数重写的检查和控制:
- override:
- 作用:显式标记派生类函数是重写基类虚函数,编译器会检查是否真的重写了基类方法
- 若未重写,编译报错,避免拼写错误等导致的'假重写'
- final:
- 作用:修饰虚函数,表示该函数不能被派生类重写;修饰类,表示该类不能被继承
// override 示例
class Car {
public:
virtual void Drive() {} // 注意:原示例中拼写为 Dirve,会导致 Benz 中 Drive() 不构成重写,编译报错
};
class Benz : public Car {
public:
virtual void Drive() override { // 检查是否重写了基类的 Drive()
cout << "Benz-舒适" << endl;
}
};
// final 示例
class Car {
public:
virtual void Drive() final {} // 该虚函数不能被重写
};
class Benz : public Car {
public:
// 编译报错:无法重写 final 函数
virtual void Drive() {
cout << "Benz-舒适" << endl;
}
};
- override 是干嘛的?
- 作用:检查你有没有写对重写。
virtual void Drive() override;告诉编译器:我这个函数,是要重写父类的虚函数!- 如果写错了(比如名字拼错、参数不对),编译器直接报错。
- 不加 override,写错了编译器不报错,只会当成新函数,你还不知道错在哪。
- 举例:
- 父类:
virtual void Drive() {} - 子类写成:
virtual void Dirve() override;// 拼错了加了 override → 直接报错,马上就知道写错了。
- 父类:
- final 是干嘛的?
- 作用:禁止子类再重写我。
virtual void Drive() final {}意思就是:到此为止,不许子类再改我这个函数!谁再重写,就编译报错
- 一句话总结
- override:帮你检查重写是否正确,防止写错。
- final:禁止子类重写,断了继承的路。
- final 放在类后面:禁止继承
- 意思就是:这个类,不能当爸爸!不能被别人继承!
class Car final // 这里 final { }; // 报错!Car 被 final 了,不能被继承 class Benz : public Car { };class 类名 final;谁都不能继承它;子类都写不出来,直接编译报错
总结:final 写在类后面:禁止被继承;final 写在虚函数后面:禁止被重写
2.1.6 重载/重写/隐藏的对比
| 特性 | 重载 (Overload) | 重写/覆盖 (Override) | 隐藏 (Hide) |
|---|---|---|---|
| 作用域 | 同一作用域 | 继承体系的父类和子类(不同作用域) | 继承体系的父类和子类(不同作用域) |
| 函数名 | 相同 | 相同 | 相同 |
| 参数列表 | 不同(类型、个数、顺序) | 完全相同 | 可相同或不同 |
| 返回值 | 可相同或不同 | 必须相同(协变例外) | 可相同或不同 |
| virtual 关键字 | 无关 | 必须是虚函数 | 无关 |
| 本质 | 编译器多态 | 运行期多态 | 名字隐藏,编译期确定 |
三、纯虚函数和抽象类
- 纯虚函数:在虚函数声明后加 = 0,如
virtual void Drive() = 0;- 特点:不需要实现(语法上可实现,但无意义,因为要被派生类重写),仅用于声明接口
- 抽象类:包含纯虚函数的类
- 特点:不能实例化出对象;若派生类继承后不重写纯虚函数,派生类也是抽象类
- 作用:强制派生类重写虚函数,实现统一接口规范
class Car {
public:
// 纯虚函数,定义接口
virtual void Drive() = 0;
};
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 car; // 编译报错:无法实例化抽象类
Car* pBenz = new Benz;
pBenz->Drive(); // 输出:Benz-舒适
Car* pBMW = new BMW;
pBMW->Drive(); // 输出:BMW-操控
return 0;
}
什么是 纯虚函数? 写法:virtual 函数 = 0; 只有声明,没有实现;作用:规定子类必须重写这个函数
什么是 抽象类? 包含 至少一个纯虚函数 的类;不能创建对象!Car car; // 报错!抽象类不能实例化
子类必须做什么? 子类必须重写纯虚函数;不重写 → 子类也变成抽象类,也不能创建对象
main 函数里做了什么? 父类指针 指向子类对象;调用 Drive();发生 多态,调用对应子类的函数
总结:
- 纯虚函数:
virtual void Drive() = 0; - 抽象类:包含纯虚函数,不能创建对象
- 作用:制定接口/规则,强制子类实现
- 子类必须重写纯虚函数,否则也不能实例化
- 依然支持 多态调用
四、多态的原理
4.1 虚函数表指针(_vfptr)
当类中包含虚函数时,编译器会在对象布局中插入一个虚函数表指针(_vfptr)
该指针指向一个虚函数表(vtable),表中存放该类所有虚函数的地址
32 位程序中,_vfptr 占 4 字节;64 位程序中占 8 字节
class Base {
public:
virtual void Func1() {
cout << "Func1()" << endl;
}
virtual void Func2() {
cout << "Func2()" << endl;
}
void Func3() // 普通成员函数,不占对象空间
{
cout << "Func3()" << endl;
}
protected:
int _b = 1;
char _ch = 'x';
};
int main() {
Base b;
cout << sizeof(b) << endl; // 输出 12
return 0;
}
- 关键点:只要有 虚函数,对象里就多一个 虚表指针
- 只要类里有 至少一个 virtual 函数,对象就会多一个:vfptr 虚函数表指针
- 32 位平台:指针大小 4 字节,64 位平台:指针大小 8 字节
- 成员变量
int _b; // 4 字节 char _ch; // 1 字节 - 内存对齐(重点)
- 规则:整体对齐到 最大成员类型的大小;这里最大是 int → 对齐到 4 字节
- 计算 -> vfptr:4 _b:4 _ch:1 总和 = 4 + 4 + 1 = 9 对齐到 4 的倍数 → 12 字节
总结:有虚函数 → 多 4 字节 虚表指针;成员变量:int(4) + char(1);内存对齐 → 最终大小 12
普通成员函数(Func3)不占对象大小!只有:成员变量、虚表指针(有虚函数才存在),才算进 sizeof(对象);虚函数:也不存到对象里!只多一个 8 字节(64 位)或 4 字节(32 位)的指针 指向虚表 👉 函数本身,永远不算进对象大小!
4.2 多态的实现原理
4.2.1 动态绑定过程
满足多态条件时,函数调用在运行时动态绑定:
- 通过对象的 _vfptr 找到对应的虚函数表
- 在虚函数表中查找要调用的虚函数地址
- 根据实际对象类型,调用对应版本的虚函数
代码示例:加 virtual → 有多态(各自调用自己)
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) {
ptr->BuyTicket(); // 动态绑定
}
int main() {
Person ps;
Student st;
Soldier sr;
Func(&ps); // 调用 Person::BuyTicket
Func(&st); // 调用 Student::BuyTicket
Func(&sr); // 调用 Soldier::BuyTicket
return 0;
}
输出:
买票 - 全价
买票 - 打折
买票 - 优先
解释
- 父类加了 virtual → 变成虚函数
- 子类函数构成重写
- 用 Person* 调用时:看指向的对象,不看指针类型;指向谁,就调用谁的函数
对比代码:不加 virtual → 没有多态(全调用父类)
#include <iostream>
#include <string>
using namespace std;
class Person {
public:
// 没加 virtual
void BuyTicket() {
cout << "买票 - 全价" << endl;
}
protected:
string _name;
};
class Student : public Person {
public:
void BuyTicket() {
cout << "买票 - 打折" << endl;
}
protected:
int _id;
};
class Soldier : public Person {
public:
void BuyTicket() {
cout << "买票 - 优先" << endl;
}
protected:
string _codename;
};
void Func(Person* ptr) {
ptr->BuyTicket();
}
int main() {
Person ps;
Student st;
Soldier sr;
Func(&ps);
Func(&st);
Func(&sr);
return 0;
}
输出:
买票 - 全价
买票 - 全价
买票 - 全价
解释:
- 父类 BuyTicket 不是虚函数,子类加不加 virtual 都没用,子类的 BuyTicket 不叫重写,叫隐藏
- 什么叫「同名隐藏」?子类有个函数,名字和父类一样;但不是重写;用父类指针调用时,只看父类,不看子类
Func(&ps); // 调 Person::BuyTicket Func(&st); // 也调 Person::BuyTicket Func(&sr); // 也调 Person::BuyTicket - 用 Person* 指针调用时:编译器只看指针类型,不看指向对象
- 指针是 Person* → 一律调用 Person::BuyTicket
总结
- 不加 virtual:看指针类型
- 加 virtual:看指向对象
- 这就是多态的本质。
- 父类不加 virtual:指针是谁,就调用谁 → 全调用父类
- 父类加 virtual:指针指向谁,就调用谁 → 多态生效
4.2.2 动态绑定 vs 静态绑定
- 静态绑定:不满足多态条件的函数调用,编译期确定函数地址
- 例子:普通函数调用、非虚函数调用、对象直接调用函数
- 动态绑定:满足多态条件的函数调用,运行期通过虚函数表确定函数地址
- 例子:基类指针/引用调用虚函数
// 动态绑定(满足多态条件)
ptr->BuyTicket(); // 运行时在虚函数表中查找地址
// 静态绑定(不满足多态条件)
// BuyTicket 不是虚函数,编译期直接确定调用地址
ptr->BuyTicket();
4.2.3 虚函数表(vtable)
虚函数表是一个数组,存放类中所有虚函数的地址,最后通常以 0x00000000 作为结束标记(不同编译器实现略有差异)
基类和派生类有各自独立的虚函数表:
- 基类虚函数表:存放基类虚函数地址
- 派生类虚函数表:
- 先拷贝基类虚函数表的内容
- 若派生类重写了基类虚函数,用派生类虚函数地址覆盖对应位置
- 新增的派生类虚函数地址追加到表中
虚函数表存放在代码段/常量区,虚函数本身也存放在代码段
代码 1
#include <iostream>
using namespace std;
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 b1;
Base b2;
Derive d;
return 0;
}
- 先看类结构
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; }; - 先讲最重要:虚函数表(虚表)
- (1)Base 类的虚表
- Base 有 2 个虚函数:func1、func2
- 所以 Base 对象里会有:vfptr 虚表指针(4/8 字节)、成员变量 int a
- 64 位下 sizeof(Base) = 12(8 指针 + 4 int)
- (2)Derive 类的虚表
- Derive 继承 Base,会继承虚表。
- 发生两件事:
- func1 被重写 → 虚表中 func1 被替换成 Derive::func1
- 新的虚函数 func3 → 加到 Derive 自己的虚表里
- 最终 Derive 虚表内容:func1 → Derive::func1、func2 → Base::func2、func3 → Derive::func3
- Derive 对象内容:vfptr(8 字节)、Base::a(4)、Derive::b(4);sizeof(Derive) = 16
- (1)Base 类的虚表
- 哪些是重写?哪些不是?
- ✅ 构成重写:Base::func1() virtual Derive::func1() virtual
- ❌ 不构成重写:func2:子类没重写;func3:子类新虚函数,父类没有;func4、func5:普通成员函数,和多态无关
- 普通函数 vs 虚函数
- 虚函数(virtual):进虚表,对象存指针,支持多态
- 普通函数(func4、func5):不进虚表,不占对象大小,不支持多态
- main 里的对象
int main() { Base b1; // 有 vfptr + a Base b2; // 有 vfptr + a Derive d; // 有 vfptr + a + b return 0; }- b1 和 b2 是两个不同对象,各有一套成员变量;但它们共用同一张虚表 (所有 Base 对象共享一张虚表)
- d 是子类对象,有自己的虚表
- 总结:有虚函数 → 对象多一个 vfptr 虚表指针;子类重写虚函数 → 虚表中对应函数地址被替换;普通函数 不算进对象大小,不进虚表;同类对象 共享同一张虚表;子类会继承并改写虚表
代码 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::func3" << endl;
}
void func4() {
cout << "Derive::func4" << endl;
} // 普通函数
protected:
int b = 2;
};
int main() {
Base b;
Derive d;
// 虚函数表地址、虚函数地址示例(vs 下)
printf("Person 虚表地址:%p\n", *(int*)&b);
printf("Student 虚表地址:%p\n", *(int*)&d);
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);
return 0;
}
解释:&b:取对象 b 的地址 (int*)&b:把对象地址强转成 int 指针 (int)&b:解引用,取出对象最前面 4 字节的值 ;对象最前面 4 字节 → 就是虚表指针 vfptr → 也就是虚表地址!
子类对象 d 最前面也是 虚表指针,但它指向的是 Derive 自己的虚表,所以打印出来的地址 和 Base 不一样
&Base::func1:取虚函数的地址;这个地址 存在虚表里面
普通函数地址 直接存在代码段,不进虚表,不占对象空间
运行后你会看到什么(重点)
Base 虚表地址:0xXXXXXXXX
Derive 虚表地址:0xYYYYYYYY
虚函数地址:0xZZZZZZZZ
普通函数地址:0xWWWWWWWW
你会发现 3 个关键事实:
- Base 和 Derive 虚表地址不一样,各自有自己的虚表
- 虚函数、普通函数地址完全不同 -> 虚函数:走虚表;普通函数:直接调用
总结:有虚函数 → 对象里有虚表指针;每个类一张虚表;子类重写虚函数 → 改写自己虚表;普通函数不进虚表,不占对象空间 (int)&对象 就是在 取虚表地址
3. 子类不重写任何虚函数 → 虚表地址依然不一样!但 虚函数的地址会一样。
- 虚表地址((int)&b 和 (int)&d) 永远不一样!
- Base 有自己的虚表,Derive 有自己的虚表,只要是两个类,虚表就是两个不同的数组,所以它们的地址一定不同。
Base 虚表地址:0x123 Derive 虚表地址:0x456 ← 一定不同 - 虚函数地址(比如 func1、func2) 如果子类没有重写 → 地址完全一样!
- Base::func1
- Derive::func1(继承过来,没重写)它们是同一个函数,所以:虚表里面存的函数地址是一样的
- 普通函数地址:本来就和虚表无关,永远一样
总结:
- 虚表地址:Base 虚表地址 ≠ Derive 虚表地址;不管有没有重写,都不一样
- 虚函数地址:子类不重写 → 父子虚函数地址 相同;子类重写 → 父子虚函数地址 不同
虚表:每个类一张,地址永远不同;虚函数:重写才变,不重写就共用父类的
代码 3
#include <cstdio>
using namespace std;
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);
return 0;
}
逐行解释
int i = 0;存放在:栈(stack);局部变量,函数一结束就自动销毁;取地址 &i 就是栈上地址static int j = 1;- 存放在:静态区(全局/静态区);程序整个运行期间都存在;只初始化一次;取地址 &j 是静态区地址
int* p1 = new int;p1 本身在栈,new int 申请的空间在堆(heap),p1 存放的就是堆地址const char* p2 = "xxxxxxxx";- 字符串常量 "xxxxxxxx" 存放在常量区,p2 本身在栈,p2 的值就是常量区地址
这 4 个地址的特点(重点):你运行后会看到类似这样(地址只是示例)
栈:0x7ffee3b5c8ac
静态区:0x4040a0
堆:0x800003240
常量区:0x4020a4
规律一眼看懂:1. 栈地址最高 (接近 0x7fff…) 2. 堆 在中间 3. 静态区、常量区 很低 (靠近程序代码区)
总结:局部变量 → 栈;static / 全局 → 静态区;new / malloc → 堆;字符串常量 → 常量区
五、总结
- 多态是什么:父类指针/引用指向子类对象;调用同一个函数,不同对象表现不同行为
- 多态成立的 3 个条件:有 继承;子类 重写 父类 虚函数;父类指针/引用调用虚函数
- 虚函数
virtual void func() {}:允许子类重写,支持多态 - 重写(覆盖):函数名、参数、返回值完全相同;父类必须带 virtual
- 协变(特殊重写):返回值是父子类指针/引用,父虚函数返回父类指针,子虚函数返回子类指针
virtual A* f() {} virtual B* f() {} // B 继承 A - override:检查重写
void Drive() override;作用:必须重写成功,否则报错;防止拼写错、参数错 - final:禁止重写/继承
virtual void f() final {} // 不能重写 class A final {}; // 不能继承 - 虚析构函数
virtual ~A() {}- 父类指针指向子类对象 delete 时;必须用 虚析构;否则子类析构不调用 → 内存泄漏
- 纯虚函数 & 抽象类
virtual void Drive() = 0;- 包含纯虚函数 → 抽象类;抽象类 不能实例化;子类必须重写,否则还是抽象类
- 继承虚函数,重写加指针,多态就成立。父指子类象,析构必须虚,否则会泄漏。纯虚是接口,子类必须写,抽象不实例。override 检查,final 禁止改。
- 多态的核心是运行时根据对象类型调用对应函数,依赖虚函数和虚函数表实现
- 实现多态的关键:基类指针/引用 + 虚函数重写
- 虚函数表是实现多态的底层机制,每个含虚函数的类都有一张表,对象通过 _vfptr 访问
- 抽象类(含纯虚函数)用于定义接口,强制派生类实现具体功能
- 基类析构函数应设为虚函数,避免内存泄漏


