C++:多态(原理篇)
文章目录
- 前言
- 一、常见笔试题(问题引入)
- 二、虚函数表感性认识
- 三、再谈笔试题
- 四、父类指针或引用与赋值
- 1. 父类的指针或引用
- 2. 子类给父类赋值
- 五、子类自己的虚函数在哪里?
- 1. 对于单继承
- 2. 对于多继承
- 1)Derive对象的大小
- 2)func3()去哪里了?
- 六、小总结
- 七、验证虚表存在什么地方?
- 八、验证子类虚函数存在了哪里?
- 九、多态的分类
- 1. 静态多态(编译时多态)
- 2. 动态多态(运行时多态)
- 十、对于多继承重写函数调用地址的不同
- 1. 对于ptr1来说:
- 2. 对于ptr2来说:
- 3. 分析:
- 十一、对于菱形继承的多态
- 1. 普通菱形继承
- 2. 菱形虚拟继承
- 十二、抽象类
- 1. 抽象类概念
- 2. 接口继承和实现继承
- 总结
前言
前面我们了解了多态的概念及语法,今天我们来谈一谈多态的原理~🥰🥰🥰🥰🥰
需要声明的,本节课件中的代码及解释都是在vs2013下的x86程序中,涉及的指针都是4bytes。
如果要其他平台下,部分代码需要改动。比如:如果是x64程序,则需要考虑指针是8bytes问题等等。
一、常见笔试题(问题引入)
请问:下列代码的运行结果是什么?
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 b1;
return 0;
}
答案:8
在 x86 系统上,sizeof(Base)
的结果是 8,原因如下:
-
虚函数的开销:由于
Base
类包含虚函数,编译器会为该类创建一个虚函数表(vtable),每个对象会包含一个指向 vtable 的指针。在 x86 系统中,这个指针通常占用 4 字节。 -
成员变量的大小:类中还有一个私有成员变量
_b
,它是一个char
类型,占用 1 字节。 -
对齐填充:由于内存对齐的要求,
Base
类的总大小需要满足特定的对齐要求。在 x86 系统上,通常使用 4 字节对齐。为了满足这个要求,编译器会在_b
后面添加填充字节,使得对象的大小对齐到 4 字节的边界。因此,_b
后面会有 3 字节的填充。
最终,类 Base
的内存布局如下:
- 4 字节(vtable 指针)
- 1 字节(
_b
) - 3 字节(填充)
所以,总大小为 4 + 1 + 3 = 8 字节。因此,运行 sizeof(Base)
的结果是 8。
这里面就牵扯到了一个概念,虚函数表 (virtual function table)
二、虚函数表感性认识
还是上面的代码,现在我们实例化出来一个Base对象,通过监视窗口,看他的成员。
我们可以发现,b1有一个_vfptr虚函数表指针
以及他自己的成员_b
,在_vfptr中,只有Func1以及Func2,因为它们带了关键字virtual
,作为函数,Func1,Func2,Func3都在代码段中,只不过因为virtual,Func1与Func2的地址被存到了虚函数表里,而_vfptr则指向这个虚函数表的地址。
以下面代码为例:
父类虚表中存了父类的虚函数,子类虚表中虚函数变成了子类的。
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
int _a = 1;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
int _b = 1;
};
void Func(Person& p)
{
// 符合多态,运行时到指向对象的虚函数表中找调用函数的地址
p.BuyTicket();
}
int main()
{
Person Mike;
Func(Mike);
Student Johnson;
Func(Johnson);
return 0;
}
因此多态是怎样实现的呢?
明明传的是Person类的引用,编译器是怎么分来父类与子类的呢?
明明它们的汇编是一样的:
调用 Func 函数时,虽然没有直接显示获取虚函数表地址的指令,但对象会通过其虚函数表调用适当的虚函数。实际调用虚函数时,会使用 ecx 寄存器(通常存放对象的指针)来查找 vtable,并从中获得要调用的函数地址,根据虚函数的偏移量,从 vtable 中获取目标函数的地址。
原来是因为对于普通函数在编译时确定他的地址,对于虚函数,运行时到指向对象的虚函数表中找调用函数的地址,因此会产生多态!
三、再谈笔试题
以下代码的输出结果是什么?
class A
{
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
virtual void test() { 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;
}
答:B
解析:
- 首先,我们来看一下p的对象模型:
p里是一个A类的虚表,他重写了func()。
- 在调用test的时候构成多态吗?
多态的两个条件:
(1)父类的指针或引用调用:这里这个func()是谁去调用的呢?
(2)重写虚函数:这里重写了虚函数吗?
3. 因此这里构成多态,那么,既然是多态就要走多态的调用,这里是那个对象去调用的呢?
答案是B(子类),我们说去找test函数的时候要去父类里找,这时候就会发生切片,这里A* a = p,实际上是子类的虚函数,因此走的是重写的func()。
所以会打印B->
4. 那这个val是什么呢?
重写,它的实质只是重写了函数的内容,前面的壳子用的还是父类的,改变的只是内容,因此这里子类不给缺省值都可以,壳子用的是父类的。
壳子:
内容:
因此打印:B->1。
四、父类指针或引用与赋值
我们前面在学习的时候留有这样一个疑问:
现在有这样一个场景:
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
virtual void func() { cout << "Person:: func()" << endl; }
protected:
int _a;
};
class Student : public Person
{
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
protected:
int _b;
};
int main()
{
Person p;
Student s;
p = s;
Person* p1 = &s;
Person& p2 = s;
return 0;
}
1. 父类的指针或引用
Person* p1 = &s;
Person& p2 = s;
对于指针和引用而言,我们可以理解为他就没有切片,那指针举例,他只是一个地址,指向了Student类的一个对象,它没有进行切片(也就是没有拷贝),只不过是限定了访问的范围(只能访问父类的东西),因此:对于指针和引用,它的虚表还是子类的虚表,虚表并没有进行改变
因此,多态的调用必须是父类的指针或引用,因为它的虚表不改变,只有通过子类的重写的虚表,才能找到对应的多态。
2. 子类给父类赋值
对于这个情况来说:
Person p;
Student s;
p = s;
实际上他是发生了切片,也就是发生了拷贝,而且p中的虚表不再是子类的,而是父类本身的。
也就是说,对于赋值,它的虚表并没有进行拷贝,赋给什么类型,虚表就是什么类型的
为什么这里不拷贝虚表呢?
因为有如下的场景:
Person p;
Student s;
p = s;
Person* p = &p;
构造了一个Peson对象的指针,我让他它指向赋值后的p,如果这里p的虚表被拷贝过来成了s的虚表,就会出问题,也就是父类的指针指向了父类的对象,却调用了子类的函数。
因此赋值他不拷贝虚表。
综上,这也就解释了为什么只能是父类的指针或引用的问题,以及必须要用virtual来形成虚表的问题。
五、子类自己的虚函数在哪里?
1. 对于单继承
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
virtual void func() { cout << "Person:: func()" << endl; }
protected:
int _a;
};
class Student : public Person
{
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
//自己的虚函数
virtual void myself() { cout << "myself" << endl; }
protected:
int _b;
};
int main()
{
Student s;
return 0;
}
对于单继承来说,监视窗口看不到子类自己的虚函数,但是内存窗口可以看到,它实际上就在Person虚表的最下面。
2. 对于多继承
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;
};
1)Derive对象的大小
int main()
{
Derive d;
cout << sizeof(d) << endl;
}
答案是20,实际上d继承了两个虚表,他没有必要自己再产生虚表,因此一共两个虚表加上三个变量共20字节。
2)func3()去哪里了?
首先,Derive对象模型是这样的:
从监视窗口,我们还是看不到fun3()在哪里:
因此,我们要强行打印虚表中的函数,打印的方法下面第八点会详细讲解。
这里不同的是:去第二个虚表的地址的时候要在对象的地址后面偏移Base1大小。
但是不能直接&d + sizeof(Base1),d是Derive类型的,我们要把他强转成char*类型的再去做加法。
int vft1 = *((int*)&d);
int vft2 = *((int*)((char*)&d + sizeof(Base1)));
还有一种简单的方式,就是让编译器自己去切片,切出来的不就是我们要找的base2的虚表的地址吗?
Base2* ptr = &d;
int vft2 = *((int*)ptr);
然后调用我们打印的函数(下面第八点会详细讲解)
PrintVFT((FUNC_PTR*)vft1);
PrintVFT((FUNC_PTR*)vft2);
运行的结果是:
因此,对于多继承,子类的自己的虚函数被放到第一个虚表的结尾处。
六、小总结
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func3()
{
cout << "Base::Func3()" << endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
通过观察和测试,我们发现了以下几点问题:
- 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚
表指针也就是存在部分的另一部分是自己的成员。 - 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表
中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数
的覆盖。重写是语法的叫法,覆盖是原理层的叫法。 - 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函
数,所以不会放进虚表。 - 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr(vs下),Linux下没有放nullptr。
- 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生
类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己
新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。 - 这里还有一个童鞋们很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在
虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。注意
虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是
他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的
呢?实际我们去验证一下会发现vs下是存在代码段的.
七、验证虚表存在什么地方?
这里我们采取打印地址对比的方式验证虚表存在的位置:(利用同区域变量地址偏移不会太远)
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; }
private:
virtual void Func3()
{
cout << "Student::Func3()" << endl;
}
protected:
int _b = 1;
};
int main()
{
Person ps;
Student st;
int a = 0;
printf("栈:%p\n", &a);
static int b = 0;
printf("静态区:%p\n", &b);
int* p = new int;
printf("堆:%p\n", p);
const char* str = "hello world";
printf("常量区:%p\n", str);
printf("虚表1:%p\n", *((int*)&ps));
printf("虚表2:%p\n", *((int*)&st));
return 0;
}
*((int*)&ps)
这段代码可以分解为几个部分来理解:
-
&ps
:&
是取地址运算符,返回对象ps
在内存中的地址。ps
是Person
类的一个对象。
-
(int*)&ps
:- 这里是一个类型转换,将
ps
的地址(&ps
)强制转换为int*
类型。这是将一个Person
类型的指针转换为一个整型指针。此时,编译器将不再关心ps
的真实类型,只会将其视为一个整型指针。
- 这里是一个类型转换,将
-
*
(解引用):*
是解引用运算符,它访问指针所指向的地址的值。在这里,它将访问ps
的地址所指向的内存内容,这个内容实际上是虚表指针的地址,我们就取地址前四个字节,就是虚表的地址。
综合起来,*((int*)&ps)
的作用是获取 ps
对象的虚表指针的地址。
运行结果是这样的:
也就是说虚表离常量区(代码段)是最接近的,也就是说,虚表是存在代码段里的。
通过以上我们也可以知道,同类型的对象是共用虚表的。
八、验证子类虚函数存在了哪里?
验证方法:强制去打印这个函数。
预处理:
对于每一个函数,调用的时候就打印对应的名称:
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; }
private:
virtual void Func3()
{
cout << "Student::Func3()" << endl;
}
protected:
int _b = 1;
};
首先,定义两个变量:
int main()
{
Person ps;
Student st;
return 0;
}
我们要了解到,虚表其实就是一个函数指针数组,他里面存了每一个虚函数的地址。
那么我们就首先需要一个函数指针:
typedef void (*FUNC_PTR)();
然后需要一个打印这个函数指针数组的函数。
我们取到每一个虚表里面虚函数的地址,vs在虚表的结尾会加一行nullptr,因此当地址等于nullptr就结束打印,并且在每个打印的时候调用这个函数,这个函数调用时被我们的预处理打印出当前函数名字。
void PrintVFT(FUNC_PTR* table)
{
for (size_t i = 0; table[i] != nullptr; i++)
{
printf("[%d]: %p-> ", i, table[i]);
//调用一下对应的函数
FUNC_PTR f = table[i];
f();
}
cout << endl;
}
那如何取到虚表中的函数指针呢?
我们这里是x86(32位),指针是4个字节。
和上面打印虚表地址是一样的,我们先&ps取到对象地址,强转成int*类型,在解引用就是头4个字节的地址。传到PrintVFT下再强转回去成函数指针就可以了。
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; }
private:
virtual void Func3()
{
cout << "Student::Func3()" << endl;
}
protected:
int _b = 1;
};
typedef void(*FUNC_PTR)();
void PrintVFT(FUNC_PTR* table)
{
for (size_t i = 0; table[i] != nullptr; i++)
{
printf("[%d]: %p-> ", i, table[i]);
//调用一下对应的函数
FUNC_PTR f = table[i];
f();
}
cout << endl;
}
int main()
{
Person ps;
Student st;
int vft1 = *((int*)&ps);s
PrintVFT((FUNC_PTR*)vft1);
int vft2 = *((int*)&st);
PrintVFT((FUNC_PTR*)vft2);
return 0;
}
如果编译发生了错误就清理以下已经生成的解决方案,再重新编译。
运行结果如下:
我们看监视窗口是看不到子类自己的虚函数的,只有在内存窗口才看得见,这里就做出了验证。
上述函数的调用方式已经越过了正常的调用方式,这里函数里面传参是没有this指针的,因此如果函数里有this->就会报错,这里相当于直接取到函数的地址,进行调用函数,因此,就算函数是私有的也能调用到。
九、多态的分类
多态是面向对象编程中的一个重要概念,主要分为静态多态和动态多态。下面是对这两种多态的详细讲解。
1. 静态多态(编译时多态)
- 定义:静态多态在编译时确定,通常通过函数重载和运算符重载实现。
- 特点:
- 函数重载:同一个函数名根据参数类型和数量的不同来选择调用哪个版本。例如:
void func(int a); void func(double b);
- 运算符重载:自定义运算符的行为,使其能够与自定义类型一起使用。
- 函数重载:同一个函数名根据参数类型和数量的不同来选择调用哪个版本。例如:
示例:
class OverloadExample {
public:
void print(int a) {
cout << "Integer: " << a << endl;
}
void print(double b) {
cout << "Double: " << b << endl;
}
};
2. 动态多态(运行时多态)
- 定义:动态多态在运行时确定,去对象里找对应的虚表。
- 特点:
- 虚函数:基类中声明为
virtual
的函数可以在派生类中被重写(override)。当通过基类指针或引用调用这些函数时,实际调用的是派生类的版本。 - 虚表:编译器在对象中维护一个虚表,指向每个虚函数的实现。通过虚表实现动态分派。
- 虚函数:基类中声明为
示例:
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
void BuyTicket() override { cout << "买票-半价" << endl; }
};
十、对于多继承重写函数调用地址的不同
书接第五点:
对于多继承,我们当时探究了fun3()存放在第一个虚表的最后面,但是这里还有一个问题:
对于Derive类,他在每一个虚表中都重写了fun1(),但是在调用的时候为什么地址不一样呢?
明明最终的结果都是成功调用了Derive::func1
,但是调用时它们的地址却不一样,这是怎么回事呢?
我们来通过汇编看一看这里是怎么回事?
首先,我们用这样的方式来模拟这个行为:
int main()
{
Derive d;
Base1* ptr1 = &d;
ptr1->func1();
Base2* ptr2 = &d;
ptr2->func1();
return 0;
}
1. 对于ptr1来说:
- call fun1的地址,我们可以发现eax的值与Base1中func1的地址是一样的。
- 接下来执行jmp
- 调用func1函数
整体流程是这样的:
2. 对于ptr2来说:
- call fun1的地址
- jmp
3.调整this指针,将this指针-8至正确的位置
4.调用func1
整体的流程图是这样的:
3. 分析:
为什么这里对于func2要多套一层呢?
来看下面的对象模型:
从前面汇编我们可以发现这样一句话:
这里对于func1的调用分两步:
- 传this指针
- call地址
对于ptr1来说,它指向的位置恰好是this指针也就是d对象头的位置,因此对于Base1来说,调用func1函数不需要更改ecx的值。
但是,对于ptr2,它指向的位置并不符合我们的预期,因此我们需要对ptr2指向的位置进行修改,也就是对this指针进行修改,因此要将ecx-8,只有这样,this指针才是正确的。
所以回到开始,为什么这两个地址不同呢?
我们可以把Base1中的这个地址看作真地址,而Base2中这个地址jmp进去让我们来修改this指针的值,使this指针指向开头。
十一、对于菱形继承的多态
对于菱形继承的多态我们这里只做了解不做过多的解释,实战中并不建议用菱形继承。
假设是这样的场景:
1. 普通菱形继承
对于普通的菱形继承,对于B和C会各开一张虚表。
2. 菱形虚拟继承
情况一: D类必须强制重写虚函数,不然就会报错,因为此时的虚表放B或放C都不合理,因此会创建A的虚表放进去。
class A
{
public:
virtual void func1()
{
cout << "A::func1" << endl;
}
public:
int _a;
};
class B : virtual public A
{
public:
virtual void func1()
{
cout << "B::func1" << endl;
}
public:
int _b;
};
class C : virtual public A
{
public:
virtual void func1()
{
cout << "C::func1" << endl;
}
public:
int _c;
};
class D : public B, public C
{
public:
virtual void func1()
{
cout << "D::func1" << endl;
}
public:
int _d;
};
情景二:B与C有自己的虚函数,就会给B和C开一个虚表。
class A
{
public:
virtual void func1()
{
cout << "A::func1" << endl;
}
public:
int _a;
};
class B : virtual public A
{
public:
virtual void func1()
{
cout << "B::func1" << endl;
}
virtual void func2()
{
cout << "B::func2" << endl;
}
public:
int _b;
};
class C : virtual public A
{
public:
virtual void func1()
{
cout << "C::func1" << endl;
}
virtual void func2()
{
cout << "C::func2" << endl;
}
public:
int _c;
};
class D : public B, public C
{
public:
virtual void func1()
{
cout << "D::func1" << endl;
}
public:
int _d;
};
情景三:D类也有自己的虚表。
那么D的虚表就会放到A的后面。有些地方放到B的后边也合理。
class D : public B, public C
{
public:
virtual void func1()
{
cout << "D::func1" << endl;
}
virtual void func3()
{
cout << "D::func3" << endl;
}
public:
int _d;
};
十二、抽象类
1. 抽象类概念
在虚函数的后面写上 =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;
}
};
void Test()
{
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
}
2. 接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
总结
到这里,多态的内容就结束啦!!!
下一次我们将对继承多态这里的习题做一个总结~
创作不易,求求佬们多多支持🫡🫡🫡(❤️´艸`❤️)