[C++进阶]对于多态的底层逻辑

上回我们了解到了多态的定义,概念以及实现,对于多态如何使用和使用条件进行了了解,本篇我们将了解多态的原理。


一、虚函数表

首先我们看看如下代码:

#include<iostream>
using namespace std;
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	char _c = 1;
	int _data = 222;

};

int main()
{
	Base B;
	cout << sizeof(B) << endl;
	return 0;
}

如果让我们猜,很可能大家会猜8(char是1,int是4,考虑内存对齐,所以得出是8,如果不知道内存对齐的,大家可以去看看结构体的内存对齐。)

在x86的环境下我们运行一下,会发现:

运行结果是12,太神奇了!!!

我们进入调试看一下,我们会发现它里面似乎多了点东西:

这个好像是个指针!!!

那这样的话,在x64的环境下就应该是16,不信的话我们确认一下:

那么这个指针是什么呢?它又指向何处呢?

其实这个指针是虚拟函数指针,它叫_vfptr(v代表虚拟,f代表函数,ptr代表指针)

从void**,我们能知道它是一个二级指针,指向另一个指针,所以其实它是一个虚函数表。

通过这个例子,我们可以知道,如果我们一般不适用多态的话,最好还是不要用virtual,避免造成浪费。

在那个虚表中,存储的就是虚函数的地址。而虚函数是存放在代码段中的。如果我们有两个虚函数,那么这个虚表中就会有两个虚拟表的地址。

以上就是由于虚函数导致的对象中的一些变化,虚函数是用来重写的,那么如果我们重写会发生什么呢?

class Person {
public:
	virtual void BuyTicket() const { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() const { cout << "买票-半价" << endl; }
};
void Func(const Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person p;
	Student s;
	Func(p);
	Func(s);
	return 0;
}

我们对其进行分析:

我们其实可以对他们用下图代替,即用下图来表达:

观察上图,我们可以发现,所谓的重写,其实只是把虚表里面的地址给覆盖了,导致的调用了不同的函数,所以重写语法层的概念,覆盖原理层的理念。

对于子类的虚表,我们也可以这样理解,我们把父类的虚表给拷贝下来了,把重写的覆盖上去了。

现在,我们知道了多态是如何实现的了,如果是父类对象,进行调用的时候自然就是调用他的虚表内的函数,如果调用的是子类对象,使用指针或、引用进行切片的话,本质上面还是指向子类,只不过指向子类中的父类的那一部分而已,而我们这里的虚表中的地址已经被覆盖,调用的是子类重写的,所以自然就调用了不同的函数。

根据之前的知识,我们可以得出以下结论:

如果是普通的调用的话,那么它在编译的时候地址已经被确定了。如果符合多态的话,运行时到指向对象的虚函数表中找调用对象的地址。不是在编译时候就确定了地址了。

所以在多态时我们也不管他是子类还是父类,即便是子类,经过切片后,也是一个父类。我们只需要找到对应的虚表中的地址就可以了。

二、通过一道题加强对多态的理解

讲完了这个,我们可以来看看这道很经典的题目:

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()
{
	B* p = new B;
	p->test();
	return 0;
}

让我们猜猜答案会是什么呢?

首先这个代码没有编译错误,这道题的运行结果为:

看到这里,有些同学肯定已经蒙了,这是为什么呢?为什么是这么一个结果呢?

我们现在来分析一下代码

我们定义了一个派生类的指针,指向了B对象。然后我们现在想用这个派生类的指针去调用test函数,这里是可以去调用的,因为子类继承了父类,在test函数里面,只有一个功能就是调用func函数,注意,这里的func函数是由this指针来进行调用的,只不过是this指针隐藏了。我们知道,多态调用有两大条件:父类的指针或引用去调用虚函数,这个虚函数必须是重写的虚函数。那么这里的this指针是父类的指针吗?答案是这里的this指针确实是父类的指针,而不是派生类的指针!为什么是A*父类的指针呢?因为这里的func函数是继承下来的。这里的继承并不是单纯的将test函数在派生类生成了一份,编译器不会那样做的。继承的对象模型是这样做的,它的对象模型分为两部分,一部分是将父类的整体当成一个成员给拿下来,这里父类会自己内存对其等操作,然后另一部分就是自己的本来的成员,经过与父类对象进行内存对齐以后,整个进行建模。然后这些成员函数它都是在代码段的,它并不会生成多份的。编译的时候是检查语法的,先去派生类里面去找,找不到再去父类里面去找。 所以test不会有两份,所以这里只能是A*指针了。这样就满足了多态的第一个条件了。或者说这里发生一个切片,B*指针切片给了A*类型的指针。第二个条件是虚函数的重写,那么这个func满足虚函数的重写吗?其实是满足的,首先有基类和派生类里面都有func函数,这两个函数满足虚函数加三同的条件,注意形参的类型相同指的是类型的相同,有没有缺省参数,缺省参数是多少跟他们没有任何关系,即便是形参名字不同也是无所谓的。所以现在满足了多条的条件,已经是多态的调用了。我们知道多态的调用看的是指向的对象是哪里。而这里我们的A*的指针是由B*的指针切片得到的,所以这里实际指向的是一个派生类,那么自然就调用的是派生类的func了此时我们以为得到了正确答案B->0,实则不然,我们要注意,多态改变的是函数的实现,虚函数加上三同只是可以告诉我们说这个构成了多态。换言之,多态在调用的时候,前面的部分,即返回值形参函数名这些看的是基类的部分,而实现的部分看的是多态的调用,即指向的对象的那一部分。而在这里,形参使用基类中的1,实现打印B。所以最终结果为B->1。

因此我们将派生类中的缺省参数给去掉也是没有任何问题的:

甚至于形参的名字都可以直接换:

所以说,虚函数的重写,重写的只是实现,那一个壳用的还是基类的。这里也印证了为什么派生类可以不加virtual。因为只是重写的实现。

如果我们将test这个函数换在了B类里面,即派生类里面,会怎么样呢?

class A
{
public:
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
};
class B : public A
{
public:
	void func(int val = 0) { std::cout << "B->" << val << std::endl; }
	virtual void test() { func(); }
};
int main()
{
	B* p = new B;
	p->test();
	return 0;
}

运行结果:

为什么呢?因为这里this指针和p一样了都是派生类的指针类型,这已经不构成多态的条件了,所以是一个普通的调用,就直接看的是派生类中的这个函数了所以结果为B->0。

三、解析为什么必须是父类的指针或引用

多态的条件:

  1. 基类的指针或引用去调用虚函数
  2. 被调用的函数必须是重写的虚函数

请大家思考

  1. 为什么必须是父类的指针或引用?
  2. 子类的指针或引用为什么不可以呢?
  3. 为什么不能是父类的对象呢?

第一个问题我们之前讲过吧!因为只有父类的指针才可以指向子类和父类,如果是子类的指针的话就只能指向子类了,不能指向多种形态了。这个问题很简单。

第二个问题:我们知道对象的切片和指针与引用的切片是有一些不同的,我们先要知道对象切片和指针切片的差异是什么。为了演示这个差异,我们看一下这个代码:

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;
};
void Func(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person p;
	Student s;

	return 0;
}

我们看看这两个类的内部:

此时我们还是比较容易理解的,这里两个对象,分别有他们自己的虚函数表。这里的虚函数表严格来说应该是一个指针,指向着虚函数表。

我们知道下面的三种方式都是切片,那么他们的差异究竟在哪里呢?

ps = st;
Person* ptr = &st;
Person& ref = st;

首先毋庸置疑的是,如果是指向父类的指针,那么它指向的是一个父类的对象,看到的自然就是父类的虚表了。

如果是指向子类的指针或引用的话,那么它指向的是一个子类中的父类的那一部分,看到的其实是子类中的虚表,这个虚表是经过虚函数重写覆盖过的。

所以说指针和引用的切片他们是不存在任何的拷贝的问题的。

而对象的切片就存在拷贝的问题了。

当我们使用对象的切片的时候,子类中的父类部分的成员变量肯定是都会被拷贝过去的,但是虚表会被拷贝过去吗?我们可以测试一下

为了方便我们观察,我们可以提前先修改一下派生类中_a的值

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
	virtual void Func1() {};
	virtual void Func2() {};
	int _a = 0;
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
protected:
	int _b = 1;
};
void Func(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person p;
	Student s;
	s._a = 10;
	p = s;
	Person* ptr = &s;
	Person& ref = s;

	return 0;
}

然后我们在使用对象的切片,如下图所示,是未切片的时候

如下所示,是切片发生之后

我们已经发现,对象的切片,并不会改变虚表,所以虚表是不会进行拷贝的

那么为什么不拷贝虚表呢?拷贝虚表会带来什么问题呢?

我们可以这样思考一下,假如我们将派生类中的虚表给拷贝过去了,那么我们使用ps这个父类对象给取出它的地址以后,使用这个指针去调用它里面的函数的话,就会反而调用了派生类中的函数。这个明显有点奇怪。因为指向父类的居然调用了子类的函数。这明显不符合多态的要求。毕竟多态是指向什么类就调用什么类的。这样做显然就会乱套的。

所以我们得到一个结论:子类赋值给父类对象切片,不会拷贝虚表。如果拷贝虚表,那么父类对象虚表中的究竟是父类虚函数还是子类虚函数就不知道了,因为我们并不知道究竟这个对象有没有被赋值切片过。

上面这个结论,也就回答了我们前面的问题,为什么多态的条件不能是父类的对象。

四、解析为什么是虚函数的重写/覆盖?

在前面我们也已经提到过,在语法层面称之为重写,重写的是它的实现。所以有时候我们也会提出一个概念,普通的函数的继承称为实现继承,而多态,虚函数的重写,其实就是一个接口继承,然后重写它的实现

在原理上就是说将父类的虚函数表给拷贝下来,然后将子类中重写的部分给覆盖。

其次,因为只有完成了虚函数的重写,那派生类的虚表里面才能是派生类的虚函数。这样的话,这个基类指针才能做到指向父类调用父类,指向子类调用子类。

五、虚函数表的总结

  1. 派生类对象st中也有一个虚表指针,st对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在这一部分的,另一部分是自己的成员。
  2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现BuyTicker完成了重写,所以d的虚表中存的是重写的Student::Buyticker,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法
  3. 另外Func1和Func2继承下来后是虚函数,所以放进了虚表,如果Func2不是虚函数,那么它也继承下来了,但是因为不是虚函数,所以不会放进虚表
  4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr 。(注意不是所有的编译器都会给的,g++编译器就没有给,而且有时候vs的编译器有一些问题,不会给这个nullptr,这时候我们可以自己清理一下解决方案,然后重新编译一下就有了,这里算是一个编译器的bug)
  5. 派生类的虚表生成:

    a. 先将基类中的虚表内容拷贝一份到派生类虚表中

    b. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数

    c. 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后(注意:c这个小点中,虽然它会将这个添加到派生类虚表的最后,但是我们的监视窗口有时候是看不见的)

  6. 虚表应该存储在常量区/代码段,虚函数也是存储在常量区的

我们可以来分析验证一下6,

假设我们不知道它们存在哪里,我们可以先用排除法:

首先我们就可以排除的是堆区,因为堆区还需要new和delete一下,编译器基本上不会这样做的

然后我们还可以排除的是栈区,因为如果是存在栈区的话,那如果是两个栈帧的话,里面的虚表的地址肯定是不一样的,而我们经过下面的测试,发现地址是一样的,也就是说他们共用虚表,所以可以排除栈区。当然其实也不能百分之百排除掉栈区,因为万一存储在main函数的栈帧中呢?但是大概率还是不会存储在main中的。

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
	virtual void Func1() {};
	virtual void Func2() {};
	int _a = 0;
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
	virtual void Func3() {};
protected:
	int _b = 1;
};
void test()
{
	Person p1;
	Student s2;
}
int main()
{
	Person p;
	Student s;
	test();
	s._a = 10;
	p = s;
	Person* ptr = &s;
	Person& ref = s;

	return 0;
}

同时上面的情形还说明了一件事,同类型的对象共用虚表

然后我们就可能会去猜测是静态区中存储着虚表,实际上不是的,虽然说网上的很多答案都是静态区,不过这个答案其实是错误的。

我们可以使用如下代码去验证:

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

	int _a = 0;
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
	virtual void Func3() {};
protected:
	int _b = 1;
};

int main()
{
	int a = 0;
	printf("栈区:%p\n", &a);

	int* p = new int;
	printf("堆区:%p\n", p);

	static int b = 0;
	printf("静态区(数据段): %p\n", &b);

	const char* str = "hello world";
	printf("常量区(代码段): %p\n", str);

	Person ps;
	printf("Person: %p\n", *(int*)&ps);
	Student st;
	printf("Student: %p\n", *(int*)&st);

	return 0;
}

我们先来解释一下这段代码,前面都很简单,最后两个打印的时候,由于对象里面是没有虚表的,但是有一个虚表指针,并且这个指针就是第一个成员变量,所以我们ps的地址就是虚表指针的地址,然后我们为了可以直接用这个虚表指针的地址去打印出来虚表所在的地址,于是我们就对其进行强制类型转换为int*,因为我们的指针是四字节的。然后我们直接解引用,就可以拿到这个虚表指针所指向的值了。由于这个虚表指针本身就是一个二级指针,里面存储的就是一个地址,这个地址所指向的就是虚函数所存储的地址了。

运行结果:

我们对比后发现,与常量区,即代码段的数值最为接近。所以虚表应该存储在常量区/代码段

那么虚函数存储在哪里呢?

如果直接打印地址的话,恐怕并不好打印,有点繁琐,我们不如直接在监视窗口里面观察

可以注意到,虚函数显然距离常量区更近一些。所以也是存储在常量区的.

六、动态绑定与静态绑定

我们有时候又将多态分为静态的多态与动态的多态

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载。
  2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

所谓静态的多态,一般是指编译时的多态,也就是函数重载

例:

int main()
{
	int i = 1;
	double d = 1.1;
	cout << i << endl;
	cout << d << endl;
	return 0;
}

即不同的对象调用不同的函数,这些是在编译时候就确定好了的。通过函数名修饰规则等,来匹配不同的函数。我们也将之称为静态绑定

静态绑定在程序编译期间确定了程序的行为。它与普通的调用是一样的,在编译时就确定了地址

如下所示,现在所处的就是一个普通的调用。它在编译时就确定好了地址。

而下面这个则是多态的调用,编译器也不知道调用的到底是谁,反正就是通过一系列方法将这里面的函数给取出来去调用

这里也就是动态的多态,即运行时的多态,他是通过继承,虚函数重写实现的多态。

所以,满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。

七、菱形虚拟继承下的虚函数表

此处属于拓展内容,感兴趣的可以看看,我也不多做讲解

class A
{
public:
	virtual void func1()
	{}
	int _a;
};

class B :virtual public A
{
public:
	virtual void func1()
	{}
	virtual void func2()
	{}
	int _b;
};

class C :virtual public A
{
public:
	virtual void func1()
	{}
	virtual void func3()
	{}
	int _c;
};

class D :public B, public C
{
public:
	virtual void func1()
	{}
	virtual void func4()
	{}
	int _d;
};

int main()
{
	D d;
	d._b = 1;
	d._c = 2;
	d._d = 3;
	d._a = 4;
	return 0;
}

虚表(虚函数表):存储虚函数地址

虚基表:存储偏移量

注意:

  1. D类中必须重写func1,避免B和C类多重继承时重写的歧义性
  2. 虚拟继承中,重写的func1位于A部分虚表,而B和C类中未重写的虚函数,分别位于B和C部分的虚表
  3. D类中新增的虚函数,放在第一个继承类部分的虚表(即B部分虚表)
  4. 虚基表中(总共两个位置),第一位置记录距离虚表指针的偏移量,第二位置记录距离A部分的偏移

实际上我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。

  • 25
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值