C++ | 多态
前言
本篇博客讲解c++中的继承
💓 个人主页:普通young man-CSDN博客
⏩ 文章专栏:C++_普通young man的博客-CSDN博客
⏩ 本人giee: 普通小青年 (pu-tong-young-man) - Gitee.com
若有问题 评论区见📝
🎉欢迎大家点赞👍收藏⭐文章
————————————————
目录
多态的概念
多态的定义及其实现
多态构成的条件
虚函数
虚函数的重写/覆盖
虚函数重写的⼀些其他问题
协变(了解即可)
协变的基本概念
析构函数的重写
析构函数与虚析构函数
override 和 final关键字
override 关键字
final关键字
重载/重写/隐藏的对比(重点)
纯虚函数和抽象类
纯虚函数
抽象类
多态的原理
虚函数表指针
虚函数表指针(vtable pointer)的大小
虚函数表(vtable)的大小
原理讲解
多态性(Polymorphism)
非多态性(Non-Polymorphism)
动态绑定与静态绑定
静态绑定(Static Binding)
定义
特点
动态绑定(Dynamic Binding)
定义
特点
虚函数表
基本概念回顾
代码分析
Base 类
Derive 类
虚函数表的情况
多态的概念
多态(Polymorphism)通俗来讲,指的是多种形态。在编程中,多态分为两种类型:编译时多态(也称为静态多态)和运行时多态(也称为动态多态)。这里我们会重点讲解这两种形式的多态性。
编译时多态(静态多态)通常包括函数重载和函数模板。这些特性允许根据传递的不同类型的参数来调用不同的函数实现。之所以称其为“编译时多态”,是因为参数与函数之间的匹配是在代码编译阶段确定的。
相对地,运行时多态(动态多态)则是在程序执行期间才决定具体使用哪个方法或函数的实现。这种多态性的实现通常是通过继承和虚函数机制来完成的,它使得父类引用可以指向子类对象,并能够在运行时调用子类的方法。
“编译时”一般指的是在编写代码后、程序实际运行前由编译器处理的部分,而“运行时”则涉及程序正在被执行期间发生的行为。
举例:
运行时多态指的是,在执行某个行为(如调用一个函数)时,可以传递不同的对象,从而表现出不同的行为,即实现了多种形态。例如,【在购票这一行为中,不同的对象可能会有不同的购票方式:普通人需要支付全价票;学生可以享受折扣票(可能是五折或七五折);军人则可能享有优先购票的权利。】
【另一个例子是动物叫声的行为。如果传递的是一个猫的对象,那么输出的声音可能是“(>^ω^<)喵”;如果是狗的对象,则输出的声音可能是“汪汪”。】
这两个例子其实就可以大概理解一点多态什么意思
多态的定义及其实现
多态构成的条件
多态的构成需要满足一定的条件。在面向对象编程中,多态是指在一个继承关系下,不同的类对象调用同一个函数时产生了不同的行为。例如,Student 类继承自 Person 类,Person 对象购买车票时需要支付全价,而 Student 对象则可以享受折扣票价。
为了实现多态,需要满足以下条件:
必须通过基类(父类)的指针或引用调用虚函数:这是因为只有基类的指针或引用能够同时指向基类和派生类的对象,从而实现多态的效果。
被调用的函数必须是虚函数:这意味着在基类中声明为虚函数的成员函数可以在派生类中被重写(覆盖)。通过这种方式,派生类可以根据自身的特点提供不同的实现方式,从而展示出多态的不同形态。
总结来说,为了实现多态效果:
首先需要有基类的指针或引用,这样才能既指向基类对象也能指向派生类对象;
其次,派生类必须重写(覆盖)基类中的虚函数,这样派生类就能展现出不同的行为,从而实现多态的效果。
虚函数
类定义中,如果成员函数前面加上 virtual 关键字进行修饰,那么这个成员函数被称为虚函数。需要注意的是,virtual 关键字只能用于类的成员函数,非成员函数不能使用 virtual 进行修饰。
代码举例:
class Person
{
public:
//虚函数
virtual void fun() {
cout << "全票" << endl;
}
};
你或许会觉得有什么区别,那我们来进行F11调试一下,你会发现他多了一个_vfptr的指针,这里我先不说这个是什么,我下面会说到这个东西是干什么的,我们先往下看
虚函数的重写/覆盖
虚函数的重写(或称为覆盖)是指在派生类中定义了一个与基类中虚函数完全相同的成员函数,即这两个函数具有相同的返回值类型、函数名以及参数列表(参数列表的类型,与缺省值无关)。此时,派生类的虚函数被认为重写了基类的虚函数。
这边我分别举两个例子来让大家理解一下多态:
动物叫声示例
#include <iostream>
using namespace std;
// 基类 Animal
class Animal {
public:
virtual void talk() const {
// 默认行为
}
};
// 派生类 Dog
class Dog : public Animal {
public:
void talk() const override {
cout << "汪" << endl;
}
};
// 派生类 Cat
class Cat : public Animal {
public:
void talk() const override {
cout << "喵" << endl;
}
};
// 测试函数,用于展示多态
void test_animal_talk(const Animal& animal) {
animal.talk();
}
int main() {
Dog d1;
Cat c1;
// 使用基类引用调用多态函数
test_animal_talk(d1); // 输出 "汪"
test_animal_talk(c1); // 输出 "喵"
return 0;
}
买票示例
#include <iostream>
using namespace std;
// 基类 Person
class Person {
public:
virtual void fun(int a = 1) const {
cout << "买票全价 " << a << endl;
}
};
// 派生类 Student
class Student : public Person {
public:
void fun(int b = 2) const override {
cout << "买票半价 " << b << endl;
}
};
// 测试函数,用于展示多态
void test_buy_ticket(const Person& person) {
person.fun();
}
int main() {
Person p1;
Student s1;
// 使用基类引用调用多态函数
test_buy_ticket(p1); // 输出 "买票全价 1"
test_buy_ticket(s1); // 输出 "买票半价 2"
return 0;
}
这里其实就可以发现我上面的那些多态的概念,你可以仔细的研究一下我这个两个代码,都是遵守这些概念
但是,友友们可能不理解这里的重写,我先前也是有点不理解,这里我先埋个伏笔,简单说重写其实就是,当我们传入不同的对象时候,我们的指针是基类然后大家可以想到我上一篇文章的切片的概念
然后我们再一次的调试,会发现:
ps:
在重写基类的虚函数时,即使派生类中的虚函数没有显式地加上 virtual 关键字,它仍然可以构成重写,因为从基类继承下来的虚函数在派生类中保持其虚函数的属性。然而,这种不加 virtual 关键字的做法并不是最佳实践,不建议在实际开发中使用。//动物 class animal { public: //virtual -- 虚拟 virtual void talk() {} }; //狗 class dog : public animal { public: void talk() { cout << "汪" << endl; } }; //猫 class cat : public animal { public: void talk() { cout << "喵" << endl; } };
你会发现dog和cat都没有加virtual也可以是多态,其实当你理解透继承和多态,其实这个规则是祖师爷定的,如果你要理解,那就是这个虚函数被继承了下来:
基类中的虚函数:一旦基类中的某个函数被声明为虚函数,那么这个函数在所有派生类中都是虚函数,除非派生类显式地将其声明为非虚函数。
派生类中的重写:派生类中的重写(覆盖)只需要提供一个新的实现,而不需要再次声明为虚函数。即使没有显式地加上
virtual
关键字,只要函数签名完全一致(包括返回类型、函数名和参数列表),那么它就是一个有效的重写。也可以看图理解
下面我们来一个比较经典的题目,来更好的理解虚函数和虚函数的重写:
#include <iostream>
using namespace std;
// 基类 A
class A {
public:
virtual void func(int val = 1) {
cout << "A->" << val << endl;
}
virtual void test() {
func();
}
};
// 派生类 B
class B : public A {
public:
void func(int val = 0) {
cout << "B->" << val << endl;
}
};
int main() {
B* p = new B();
p->test();
delete p; // 释放动态分配的内存
return 0;
}
A: A->0 | B: B->1 | C: A->1 | D: B->0 | E: 编译出错 | F: 以上都不正确
这里大家可能都会和我一样选D,但是运行的结果:
这是为什么?其实如果你认真的看了我前面的概念讲解,及基本就会选出来这个答案:
首先我们分析,他的走向
首先他会在子类中去找(这个调试的时候看不出来),没找到他就会向上基类找这个函数,这个函数由于指向的是B这个类,他就会去调用子类重写的基类这个函数,所以最终输出的就是B->1,其实大家还会疑问为什么是1,这边可以看个图:
其实你需要这样理解,这个提这种场景是这样的,如果我修改一下场景
我重写一下test他就会直接调用B类的func
虚函数重写的⼀些其他问题
协变(了解即可)
在面向对象编程中,特别是C++中,协变(Covariance)是指派生类重写基类的虚函数时改变了返回类型的特性。具体来说,当基类的一个虚函数返回的是基类的对象(指针或引用),而派生类中的相同函数返回的是派生类的对象(指针或引用)时,这就构成了协变。
协变的基本概念
-
定义:在继承体系中,如果一个派生类中的方法覆盖了基类中的方法,并且返回类型从基类变成了派生类,则称这种现象为协变。
-
语法支持:在C++中,协变是通过编译器自动支持的,不需要特殊的语法结构来实现。
#include <iostream>
using namespace std;
class A {};
class B : public A {};
class Person {
public:
virtual A* BuyTicket() {
cout << "买票-全价" << endl;
return nullptr;
}
};
class Student : public Person {
public:
virtual B* BuyTicket() {
cout << "买票-打折" << endl;
return nullptr;
}
};
void Func(Person* ptr) {
ptr->BuyTicket();
}
int main() {
Person ps;
Student st;
Func(&ps);
Func(&st);
return 0;
}
大家看到这段代码会发现为什么返回类型不同?
1. C++ 中的协变
在C++中,协变是指派生类的虚函数可以返回与基类虚函数不同的类型,但这个不同的类型必须是从基类类型派生的类型。例如,
Person
类的BuyTicket()
函数返回A*
,而Student
类的BuyTicket()
函数返回B*
,其中B
是A
的派生类。2. 编译器行为
C++ 标准允许返回类型协变的情况,这意味着在派生类中重写基类的虚函数时,可以改变返回类型,只要新的返回类型是原返回类型的派生类型。这是通过隐式类型转换实现的。
3. 类型安全性和动态绑定
在C++中,如果你有一个指向基类的指针或引用,并通过它来调用一个虚函数,那么实际上执行的是指向的对象所属类的该函数版本。这种行为被称为动态绑定或动态调度。
在这种情况下,即使返回类型不同,也能正常工作,因为:
- 当你通过基类指针或引用调用虚函数时,返回的实际类型将由动态类型决定。
- 如果返回的是一个派生类对象,那么它可以被隐式转换成基类对象。
4. 实际例子
Func
函数接受Person*
指针,并调用BuyTicket()
函数。无论传入的是Person
对象还是Student
对象,都会调用相应类的BuyTicket()
函数,并且返回值将被隐式转换成A*
类型,因为B
是A
的派生类。5. 注意事项
尽管C++允许返回类型协变,但在实际编程中应当谨慎使用,以避免潜在的类型安全问题。例如,如果你在派生类的函数中返回了一个指向派生类的指针,而在基类函数的上下文中期望的是基类指针,那么必须确保不会发生任何非法操作。
析构函数的重写
在C++中,基类的析构函数如果被声明为虚函数(
virtual
),那么派生类的析构函数即使没有显式地加上virtual
关键字,也会被视为重写了基类的析构函数。这是因为编译器对析构函数的名称进行了特殊处理,统一处理成了一个特定的名字(通常称为“mangled name”),使得它们在底层被视为相同的函数。析构函数与虚析构函数
虚析构函数的作用:
- 如果基类的析构函数被声明为虚函数,那么当通过基类指针删除派生类对象时,会先调用派生类的析构函数,然后再调用基类的析构函数。这样可以确保派生类的资源被正确释放。
编译器对析构函数的处理:
- 在C++中,编译器会对析构函数进行名称重整(name mangling),即生成一个唯一的内部名称。
- 这个唯一的名称(destructor)在不同的编译器中可能有所不同,但通常都会包含类名和其他标识符信息。
- 因此,尽管析构函数在源代码中看起来名称不同,编译器会把它们处理成同一个函数,以支持多态性。
#include <iostream> using namespace std; class A { public: virtual ~A() { cout << "~A" << endl; } }; class B : public A { public: virtual ~B(){ cout << "~B" << endl; } }; int main(int argc, char* argv[]) { A* p1 = new A; A* p2 = new B; delete p1; delete p2; return 0; }
被处理成一个名称可以在汇编代码中看到
override 和 final关键字
override
关键字
目的:
override
关键字用于明确指出派生类中的成员函数意图重写基类中的虚函数。- 使用
override
可以帮助编译器检查是否确实重写了基类中的某个虚函数。优点:
- 如果派生类中没有正确的重写基类的虚函数,编译器会在编译时期报错,而不是等到运行时才发现问题。
- 增加了代码的安全性和可读性,使得意图更加明确。
class Person
{
public:
virtual void func() {
cout << "完成" << endl;
}
};
class Student : Person
{
public:
virtual void func() override {
cout << "不完成" << endl;
}
};
假如我把基类的virtual去掉:
final关键字
目的:
final
关键字用于指定一个类或成员函数不能被进一步继承或重写。- 用于防止派生类进一步重写基类中的虚函数。
优点:
- 防止无意中重写某些关键的虚函数,从而保护代码的完整性。
- 增强了代码的健壮性,使得设计意图更加清晰。
class Person final
{
public:
virtual void func() final {
cout << "完成" << endl;
}
};
class Student : Person
{
public:
virtual void func() {
cout << "不完成" << endl;
}
};
在继承里面我也提到过这个final这个关键字来实现一个无法继承的类
重载/重写/隐藏的对比(重点)
纯虚函数和抽象类
纯虚函数
-
定义:
- 在C++中,如果一个虚函数后面加上
= 0
,则这个函数被称为纯虚函数。 - 纯虚函数本身不需要提供具体的实现,因为它的目的是在派生类中被重写。
- 在C++中,如果一个虚函数后面加上
-
语法:
- 纯虚函数可以在声明时直接定义为
= 0
。
- 纯虚函数可以在声明时直接定义为
class Person
{
public:
//纯虚函数
virtual void func() = 0;
};
- 强制派生类必须提供一个具体的实现,否则派生类也将成为一个抽象类。
- 用于定义接口,确保所有派生类都实现了特定的行为。
抽象类
-
定义:
- 包含至少一个纯虚函数的类被称为抽象类。
- 抽象类不能被实例化,也就是说,不能创建抽象类的对象。
-
作用:
- 抽象类主要用于定义一个框架或接口,提供一个基础的类层次结构。
- 强制派生类遵守一定的规范,即必须实现所有的纯虚函数。
//抽象类
class Person
{
public:
//纯虚函数
virtual void func() = 0;
};
class Student : Person
{
public:
virtual void func() {
cout << "不完成" << endl;
}
};
纯虚函数与抽象类的关系
- 强制实现:如果派生类没有重写基类中的纯虚函数,那么这个派生类也将成为抽象类,不能被实例化。
- 接口定义:纯虚函数可以看作是一种接口定义的方式,确保所有继承自该抽象类的派生类都实现了所需的功能。
注意事项
- 默认实现:虽然纯虚函数通常不提供默认实现,但语法上允许这样做。不过,这样的实现通常是没有意义的,因为编译器不允许创建抽象类的实例。
- 多态性:纯虚函数的存在使得基类指针或引用可以指向派生类对象,从而实现多态性。
//抽象类
class Car
{
public:
virtual void func() = 0; //纯虚函数
};
class BMW : public Car
{
public:
virtual void func() {
cout << "宝马" << endl;
}
};
class BEN : public Car
{
public:
virtual void func() {
cout << "奔驰" << endl;
}
};
int main() {
//宝马
Car* pa = new BMW;
pa->func();
//奔驰
Car* pb = new BEN;
pb->func();
return 0;
}
多态的原理
虚函数表指针
虚函数表指针(vtable pointer)的大小
虚函数表指针的大小取决于目标平台的指针大小:
- 32位系统:指针大小为
4
字节。- 64位系统:指针大小为
8
字节。虚函数表指针本身总是占据一个指针大小的空间,无论虚函数表本身有多大。
虚函数表(vtable)的大小
虚函数表的大小取决于类中虚函数的数量。每个虚函数在虚函数表中占据一个地址的位置。因此,虚函数表的大小等于虚函数的数量乘以指针大小。
例如,如果一个类中有三个虚函数,那么在32位系统中,虚函数表的大小将是
3 * 4
字节,即12
字节;在64位系统中,虚函数表的大小将是3 * 8
字节,即24
字节。
这边我用一道题引入:
下⾯编译为32位程序的运⾏结果是什么()
A.编译报错 B.运⾏报错 C.8 D.12
#include <iostream>
class Base2 {
public:
virtual void Func1() {
std::cout << "Func1()" << std::endl;
}
protected:
int _b = 1;
char _ch = 'x';
};
int main() {
Base2 b;
std::cout << "Size of Base2: " << sizeof(b) << std::endl;
return 0;
}
大家可以自己先想一想,大家可能以为是8,但是:
上⾯题⽬运⾏结果12bytes,除了_b和_ch成员,还多⼀个__vfptr放在对象的前⾯(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有⼀个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
简单理解_vfptr就是一个函数指针数组,这个数组里存的就是每个类虚函数的地址
原理讲解
我们来看一段代码:
从底层的⻆度Func函数中ptr->BuyTicket(),是如何作为ptr指向Person对象调⽤Person::BuyTicket,ptr指向Student对象调⽤Student::BuyTicket的呢?通过下图我们可以看到,满⾜多态条件后,底层不再是编译时通过调⽤对象确定函数的地址,⽽是运⾏时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引⽤指向基类就调⽤基类的虚函数,指向派⽣类就调⽤派⽣类对应的虚函数。第⼀张图,ptr指向的Person对象,调⽤的是Person的虚函数;第⼆张图,ptr指向的Student对象,调⽤的是Student的虚函数。
#include <iostream> class Person { public: virtual void BuyTicket() { std::cout << "买票-全价" << std::endl; } }; class Student : public Person { public: virtual void BuyTicket() { std::cout << "买票-打折" << std::endl; } }; class Soldier : public Person { public: virtual void BuyTicket() { std::cout << "买票-优先" << std::endl; } }; 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; }
那这段代码举例:
这边有一个概念就是用基类指针或者引用,指想谁就调用谁,指向哪一个对象,运行的时候,直接到这个虚函数表中去找对应的虚函数地址,这边大家一定要用切片的概念,上面的图上其实也给大家看到了,其实如果说回来还是在调用基类,因为本来就是基类的对象重写
我带大家看一下虚函数,和非虚函数的汇编代码其实也是不一样的:
从这些我们又可以看出:
多态和非多态哪个的消耗大?
多态性(Polymorphism)
优点
- 灵活性:多态性允许你在运行时决定调用哪个函数,这对于实现通用接口和动态行为非常有用。
- 可扩展性:通过虚函数,可以轻松地扩展功能而不修改现有代码。
开销
- 虚函数表查找:每次调用虚函数时,都需要通过虚函数表指针找到正确的函数地址。这增加了额外的一次间接寻址操作。
- 间接调用:调用虚函数时,需要通过指针间接跳转到函数地址,这比直接调用函数多了一步。
非多态性(Non-Polymorphism)
优点
- 简单直接:非多态函数调用直接通过函数名或指针调用,没有中间环节。
- 执行速度快:由于没有虚函数表的间接寻址,非多态函数调用的速度更快。
开销
- 固定调用:非多态函数调用是静态绑定的,即在编译时确定调用哪个函数。这意味着一旦代码编译完成,就不能改变调用的函数。
性能对比
虚函数表的开销
- 虚函数表指针:每个对象中都有一个指向虚函数表的指针,这个指针本身占用一定的空间。
- 虚函数表:虚函数表中存储了类中所有虚函数的地址,这需要一定的内存空间。
- 间接寻址:每次调用虚函数时,都需要通过虚函数表指针找到相应的函数地址,增加了CPU指令执行的复杂度。
直接函数调用的优势
- 直接调用:非多态函数可以直接通过函数名调用,减少了中间环节,提高了执行效率。
- 静态绑定:编译器在编译时就可以确定调用哪个函数,不需要运行时查找虚函数表。
总结
总体而言,多态性在运行时带来了额外的开销,主要包括虚函数表的查找和间接调用。而非多态性则更加直接和高效。但在实际应用中,选择哪种方式取决于具体的需求和场景。如果需要动态行为和高度的灵活性,多态性是必要的;如果性能是关键因素,且不需要动态行为,则非多态性更为合适。
动态绑定与静态绑定
静态绑定(Static Binding)
定义
静态绑定指的是在编译时就已经确定了函数调用的目标地址。也就是说,编译器在编译阶段就能知道调用哪个函数。
特点
- 非多态性:通常发生在非虚函数的调用过程中。
- 直接调用:函数调用通过直接的函数名或静态绑定的指针进行。
- 编译时确定:编译器在编译时就能确定具体的函数地址。
动态绑定(Dynamic Binding)
定义
动态绑定指的是在运行时才能确定函数调用的目标地址。这意味着编译器在编译时并不能确定具体的函数地址,而是在运行时通过虚函数表查找。
特点
- 多态性:通常发生在通过指针或引用调用虚函数的过程中。
- 间接调用:函数调用通过虚函数表指针间接进行。
- 运行时确定:在运行时通过虚函数表指针找到具体的函数地址。
这两个其实我们直接就可以通过汇编代码看出,静态绑定直接call函数地址,动态绑定需要在运行时对应的类的虚函数表中call
虚函数表
- 对于一个含有虚函数的基类对象,其虚函数表包含了该类所有虚函数的地址。
- 当一个派生类从基类继承而来,通常继承自基类的部分已经包含了一个指向虚函数表的指针,因此派生类本身通常不需要再生成一个新的虚函数表指针。
- 尽管派生类继承了基类的虚函数表指针,但这并不意味着它们共享同一个虚函数表;实际上,派生类可能有一个不同的虚函数表来存储自己的虚函数地址以及任何它重写了的基类虚函数的新地址。
- 如果派生类重写了基类中的某个虚函数,那么在派生类的虚函数表中,对应的位置会被更新为指向派生类中重写后的虚函数地址。
- 派生类的虚函数表通常包括三部分内容:从基类继承而来的虚函数地址、派生类重写的虚函数地址以及派生类自身定义的虚函数地址。
这个重写其实你可以想成就是子类会把父类的虚表的虚函数拷贝过来,然后修改了属性,然后前面我说过为什么要用父类指针什么什么的
- 虚函数表本质上是一个指针数组,每个元素指向一个虚函数。某些编译器(如Visual Studio)可能会在数组末尾添加一个空指针(0x00000000)作为结束标志,但并非所有编译器都这样做(例如g++可能不会添加这样的标志)。
- 虚函数和其他普通函数一样,在编译后存在于程序的代码段中,只是虚函数的地址也被存储在了虚函数表中。
- 关于虚函数表的具体存储位置,并没有一个严格的C++标准规定,这取决于编译器的实现细节。例如,在Visual Studio编译器中,虚函数表可能会被存储在代码段(常量区)中。
同类型对象虚表共用,不同类型对象虚表各自
给个例子:
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; }
基本概念回顾
- 虚函数表:一个类如果含有虚函数,则该类的对象会包含一个指向虚函数表的指针。虚函数表中存放的是该类所有虚函数的地址。
- 派生类的虚函数表:当一个类从另一个含有虚函数的类派生时,如果派生类重写了基类中的虚函数,那么在派生类的虚函数表中,对应虚函数的位置将被更新为指向派生类版本的虚函数地址。
代码分析
Base
类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; // 成员变量 };
Base
类包含两个虚函数func1
和func2
。- 它还有一个非虚函数
func5
。Base
类的每个对象都会有一个指向虚函数表的指针。
Derive
类class Derive : public Base { public: // 重写基类的 func1 virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func1" << endl; } // 这里应该是 Derive::func3 void func4() { cout << "Derive::func4" << endl; } // 非虚函数 protected: int b = 2; // 成员变量 };
Derive
类继承自Base
类。Derive
重写了Base
中的func1
方法。Derive
添加了一个新的虚函数func3
。Derive
也有一个非虚函数func4
。
虚函数表的情况
对于
Base
类来说,它的虚函数表至少包含以下两个条目:Base::func1
的地址Base::func2
的地址对于
Derive
类来说,它的虚函数表至少包含以下三个条目:Derive::func1
的地址(因为func1
被重写了)Base::func2
的地址(继承自Base
)Derive::func3
的地址注意,即使
Derive
继承了Base
的虚函数表指针,但是它们指向的虚函数表是不同的,因为Derive
重写了func1
并且添加了func3
。此外,
func5
和func4
是非虚函数,所以它们的地址不会出现在各自的虚函数表中。通过这种方式,C++ 支持了动态绑定(即多态),使得在运行时可以根据对象的实际类型调用正确的虚函数。
下面我写一个程序,来测试虚表和虚函数在存在什么区域:
#include <iostream>
using namespace std;
// 基类 Base 包含两个虚函数 func1 和 func2,以及一个非虚函数 func5。
class Base {
public:
// 虚函数 func1
virtual void func1() { cout << "Base::func1" << endl; }
// 虚函数 func2
virtual void func2() { cout << "Base::func2" << endl; }
// 非虚函数 func5
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1; // 成员变量
};
// 派生类 Derive 继承自 Base,并重写了 func1,添加了一个新的虚函数 func3。
class Derive : public Base {
public:
// 重写基类的 func1
virtual void func1() { cout << "Derive::func1" << endl; }
// 新的虚函数 func3
virtual void func3() { cout << "Derive::func3" << endl; }
// 非虚函数 func4
void func4() { cout << "Derive::func4" << endl; }
protected:
int b = 2; // 成员变量
};
int main() {
int i = 0; // 栈上的局部变量
static int j = 1; // 静态区的数据
int* p1 = new int; // 堆上的数据
const char* p2 = "xxxxxxxx"; // 常量区的数据
// 输出各个变量的地址
printf("栈:%p\n", &i); // 输出栈上变量 i 的地址
printf("静态区:%p\n", &j); // 输出静态区变量 j 的地址
printf("堆:%p\n", p1); // 输出堆上变量 p1 的地址
printf("常量区:%p\n", p2); // 输出常量区字符串 p2 的地址
// 创建 Base 类的对象 b
Base b;
// 创建 Derive 类的对象 d
Derive d;
// 创建指向 Base 类型的指针 p3,指向 b
Base* p3 = &b;
// 创建指向 Derive 类型的指针 p4,指向 d
Derive* p4 = &d;
// 输出 Base 类型对象 b 的虚函数表地址
printf("Base 虚表地址:%p\n", *(int*)p3);
// 输出 Derive 类型对象 d 的虚函数表地址
printf("Derive 虚表地址:%p\n", *(int*)p4);
// 输出虚函数 func1 的地址
printf("虚函数地址:%p\n", &Base::func1);
// 输出非虚函数 func5 的地址
printf("普通函数地址:%p\n", &Base::func5);
return 0;
}
这个大家可能会有疑惑我们先看这个
这个时虚函数的地址
这里强制转换指针类型以获取虚函数表指针地址,前面我讲过了虚表就是一个数组,数组的首元素就是这个数组的地址,所以这里强制转换为int*,解引用4个字节,不就是虚表的地址嘛,这种方法前提是要知道他的排列方式,如果虚表不是在第一个那这种方法无效,可以打开地址窗口查看(ps:小端 :右大左小)
打印结果:
PS:
多态为什么不能继承缺省值?
多态是父类指针引用子类对象,这个父类指针访问的虚函数,其实是想调用父类的函数,只是因为子类用自己的函数地址覆盖了父类的函数地址导致调用到了子类函数,但是函数的签名还是父类的,只是实际调用的地址发生了变化,所以缺省值使用的还是父类函数的