经过之前的学习我们知道了,继承能够实现多态的原理就是,在继承的父类和子类中各自存在一个虚表,父类和子类的虚表中各自储存了自己的虚函数,不同的点就是如果我们完成了虚函数的重写,那么子类(派生类)虚表中的那个虚函数地址是重写后的虚函数的地址。所以我们虚函数重写还有一个名字就是虚函数的覆盖,形象一点的说,那么虚函数的重写,就是子类将重写后的虚函数的地址,覆盖在了为重写前的虚函数的地址上。当然这只是一个形象的说法而已,底层不一定就是这样做的。下面我们再来深入的理解一下多态的实现。
首先我们知道正是因为虚表的存在所以才存在了多态,那么我们思考一下,虚表以及虚函数是存在哪一个区域的呢?是栈区?还是堆区?还是其它的区域?首先虚函数和普通函数一样都是存在了代码段,同时把虚函数的地址存了一份到虚表中。
那么虚函数表呢?是在静态区吗?还是在栈区呢?
首先虚函数表肯定是不能存在于栈中的,如果在栈中如果离开了某一作用域虚表就会被销毁,但是我们经过之前的学习是知道了一件事情的,那就是一个类的所用对象公用一张虚表,如果从某一作用域出来后,虚表就被销毁了,如果此时还存在这个类的对象,那就说不通了。
那么虚函数表是在静态区吗?因为这个和静态成员变量的性质是非常的相像的。那么究竟是在哪里呢?我们想办法去验证一下。
即我们打印出栈区,堆区,静态区中某一个变量的地址,最后让其和虚表的地址相比较。
class Base {
public:
virtual void func1()
{
cout << "Base::func1" << endl;
}
virtual void func2()
{
cout << "Base::func2" << endl;
}
private:
int a;
};
int main()
{
Base b1;
Base b2;
static int a = 0;//静态区的变量
int b = 1;//栈区的变量
char* p = new char;//堆区的地址
const char* c = "hello world";//代码段的地址
//下面使用printf便于打印地址
printf("静态区:%p\n", &a);
printf("栈:%p\n", &b);
printf("堆:%p\n", p);
printf("代码段:%p\n", c);
return 0;
}
但是现在存在一个问题就是我们如何打印虚函数表呢?
我们分析一下我们知道虚函数表肯定是存在于一个具有虚函数的类对象的头四个字节,而一个虚函数表说白了就是一个函数指针数组,也即数组。所以我们可以像下面这样做:
int main()
{
Base b1;
Base b2;
static int a = 0;//静态区的变量
int b = 1;//栈区的变量
char* p = new char;//堆区的地址
const char* c = "hello world";//代码段的地址
//下面使用printf便于打印地址
printf("静态区:%p\n", &a);
printf("栈:%p\n", &b);
printf("堆:%p\n", p);
printf("代码段:%p\n", c);
printf("虚函数表:%p", *((int*)&b1));//如何取出头四个字节呢?首先将b1强转为int然后
//再取地址的操作是不可取的,
//因为类型转换只能够用于有一定关联的类型之间,例如int,char,double等都是表现的数据大小,
//但是指针和int不可以转换
// 这是为什么呢?因为指针是一个地址,而int是一个大小,自然就不能进行转换。
//而这里使用的方法是先将b1的地址取出来,然后将其转化成int*(4个字节),刚好就能拿到储存着虚函数表的地址,然后对这个地址解引用就能够得到虚函数表
return 0;
}
上面的注释画成图像表示如下:
一开始的这个p是一个base的类型,此时它指向的就是上图中的全部,但是如果我们将其转化成int,之后,这个p指向的就是上图中的虚函数表的那一部分,然后我们对此时的p解引用自然就能拿到虚函数表的地址。
运行结果:
然后我们能够看到这个虚表和代码段是和代码段更接近的,下面我们把虚函数的地址一起加上看一看。
但是下面的这种取法是错误的
首先函数地址并不会储存在对象中,并且我们在调用函数的时候,是传递了一个隐藏的this指针的,所以才能够取调用函数。
这里要拿到虚函数的地址你可以去虚表中取这是一个方法,还有一个方法:首先我们知道的就是函数名就是函数的地址,再其次成员函数肯定是保存在对应的类域中的,所以这里我们可以这么写:
但是这么写依旧不行,因为语法规定,成员函数你要取地址,必须加一个取地址符号
这样才能够取到,普通函数直接使用函数名就可以。
下面是运行结果:
首先这一堆东西,可以确定的是普通函数和虚函数是放在一起的。然后再能确定的就是虚函数表是放在常量区的。
所以严格来说我们认为虚表储存在常量区(代码段),存在这一个地方也很好理解,因为虚表在我们编译好之后,是不允许我们修改的。
这里我们要思考的问题就是一个类中的虚函数都会放到虚表中吗?这句话是正确的吗?即虚函数的地址一定会被放到虚函数表吗?
下面是我们要验证的代码:
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
private:
int a;
};
class Derive :public Base {
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
virtual void func4() { cout << "Derive::func4" << endl; }
private:
int b;
};
//虚函数的地址一定会放到虚函数表中吗
int main()
{
Base b;
Derive c;
return 0;
}
首先我们使用监视来验证一下:
此时Base的对象中的func1和func2都放到了虚函数表中。
下面我们来看Derive的对象呢?
可以看到Derive的对象中存在一张虚函数表,其中func1被Derive完成了重写。然后还有一个func2的虚函数地址是父类的,所以从监视窗口我们得出的结论是并不是所有的虚函数都会放到虚函数表当中,真的吗?
那么假设我这里还存在一个类(X)继承了Derive然后再重写了fun3呢?
难道func3和func4就不放了吗?
我们再次使用监视窗口查看一下:
居然还是没有放func3和func4,如果func3和func4真的没有放到虚表当中是存在很大的问题的,因为虽然此时的Derive虽然只是Base的子类,但是他也是X的父类,所以Derive和X之间是可能发生多态的,例如上面的这种情况,此时X重写了func3,如果此时存在一个但是如果此时使用Derive的指针去指向X的对象,那么就会造成多态调用,如果func3不再虚表中此时是会报错的。但是这里是可以运行的。
依旧完成了多态,所以这里我们应该怀疑使用监视窗口是不准确的,我们这里必须使用内存窗口去验证。
那么我们使用内存窗口去看谁呢?首先Base对象不用看因为在监视窗口中Base的虚表是完全的。我们去看Derive对象的虚表。
我们首先来看前2位的地址(看最后的4位)1230,然后监视窗口显示的也是1230,下一个是1113(监视窗口),内存窗口也是1113。也对上了。按理来说 Base应该存在有4个虚函数地址,继承了父类的2个,重写了父类的一个func1,然后增加了自己的func3和func'4所以应该存在4个才对。所以我们应该高度怀疑这后面的两个也应该算是地址。
下面我们来看x
x按道理应该也存在4个虚函数地址,应为x继承了Derive,然后自己只是重写fun3,并没有新增函数。所以我们也应该高度怀疑下面的两个也应该是地址。
但是我们现在还是不能确定这后面的两个东西就是地址。
那么下面我们就使用最终的验证方法,我们来打印虚表。
首先我们知道所谓的虚表也就是一个函数指针数组,现在我们能够找到虚表(使用上面的方法),然后虚表是一个函数指针数组,我们去调用虚表里面储存的函数(通过函数指针来调用函数),因为我这里设置的函数都是void返回值,函数的参数都为空(对象调用增加this指针),然后函数的功能也很简单,并且能够让我们分辨是什么函数。所以我们就可以使用这种方法去验证。示意图如下:
首先我们要给一个函数指针重命名。这里可以复习一下函数指针重命名的方式,以及定义变量的方式
这里只需要记住就好,函数指针和数组指针的重命名和定义的方式都很特殊。
我们typedef之后有一个好处就是可以像普通类型一样去定义变量了。
下面我们就来打印一下Base中虚表的内容:
void PrintVFT(VFUNC a[])//打印虚表的函数
//这里和访问普通数组的方式并没有什么不同,只不过这就是一个函数指针
//数组而已
{
for (size_t i = 0;a[i]!=0;i++)
{
printf("[%d]:%p\n", i, a[i]);//打印虚表中的内容
}//再Linux中不能使用这种方式来判定虚函数表的结尾,只能写死,因为再Linux中,虚函数表
//的最后一位不是null或是0
printf("\n");
}
int main()
{
Base b;
PrintVFT((VFUNC*)*((int*)&b));//这里就是将b的头四个字节
//拿出来
//然后强转位int*,解引用这个int*,拿到虚函数表的地址,
// 但是此时
//是int类型的数据,我们强转位VFUNC*即可
return 0;
}
在运行结果的时候可能会出现下面的这种情况:
这是因为编译器底层对于虚表的结尾没有处理完全,所以出现的情况,这个时候只需要清理一下解决方案资源管理器,然后再重新编译运行就可以了。
此时的结果就正确了。
下面我们再加上Derive和X的对象再来打印一下
然后发现又不对了,再次清理重新编译一下:
此时就正确了。
当然也有可能是你的代码的问题。但是这样和我们使用内存窗口查看是没有任何的区别的。所以这里我们这里需要去使用函数的地址来调用对应的函数
使用下面的代码:
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
private:
int a;
};
class Derive :public Base {
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
virtual void func4() { cout << "Derive::func4" << endl; }
private:
int b;
};
//虚函数的地址一定会放到虚函数表中吗
class X :public Derive
{
virtual void func3()
{
cout << "X::func3" << endl;
}
};
typedef void (*VFUNC)(); //给一个函数指针重命名,这个是正确的方法
void PrintVFT(VFUNC* a)//打印虚表的函数,这里的形参应该是一个函数指针数组,但是因为在传参的时候,是没有函数指针数组这个选项的
//所谓的函数指针数组,本质也就是一个函数指针的指针,即第一个函数指针的地址,因为数组名也就是数组第一个元素的地址。
//这里和访问普通数组的方式并没有什么不同,只不过这就是一个函数指针
//数组而已
{
for (size_t i = 0;a[i]!=0;i++)
{
printf("[%d]:%p->", i, a[i]);//打印虚表中的内容
VFUNC f = a[i];
f();//我这里使用一个函数指针去掉用一下
//(*a[i])();
//方法二:
//a[i]();
}
printf("\n");
}
int main()
{
Base b;
PrintVFT((VFUNC*)(*((int*)&b)));//这里就是将b的头四个字节
//拿出来
//然后强转位int*,解引用这个int*,拿到虚函数表,
// 但是此时
//是int类型的数据,我们强转为VFUNC*即可,记住我们上面说的是地址不能强转为整型,但是整型可以强转为地址。
// 因为上面的函数需要的是函数指针数组
// 这里能够
//下面我们再拿Derive和X来打印一下:
Derive c;
PrintVFT((VFUNC*)*((int*)&c));
X x;
PrintVFT((VFUNC*)*((int*)&x));
return 0;
}
所谓的函数指针数组,本质也就是一个函数指针的指针, 即第一个函数指针的地址,因为数组名也就是数组第一个元素的地址。
所以我们在PrintfVTF()那里强转成的是一个函数指针的指针。因为函数指针数组的数组名也就是数组第一个元素的地址,也就是函数指针的指针。
运行结果:因为我们之前已经在类中为每一个函数打印的信息,做了处理,所以我们这里能够很快的看出对应虚函数表中的虚函数到底是哪一个。
可以看到Base对象的虚函数表中含有两个虚函数,打印结果也是正确的正好就是Base中的那两个虚函数。
而Derive对象也是正确的它的虚函数表中含有的是父类的1没有被重写的虚函数,以及自己的两个虚函数,最后还有一个被重写的虚函数。而X中则含有的是重写的func3函数,其余继承Derive类的虚函数。
由此我们才能得出结论:
所有的虚函数一定会被放到虚表中。
当然这里我们只是为了验证这个结论,才使用函数指针去调用成员函数的,在平时调用成员函数时不需要使用这么复杂的方法。
这样转是无法调用的,因为&d是这个对象的地址,而我们要的是对象的头,四个字节指向的那个虚函数表的地址。
使用上面的那个调用方法等于把对象的头四个字节当成了虚函数表,将对象的头四个字节当成虚函数表,自然是无法打印出任何的东西的。
所以我们这里需要取b对象中的头四个字节(强转为int*),然后解引用这四个字节,就能够得到虚函数表中第一个元素的内容,也就是函数指针,但是因为我们这里是强转成的int*,所以这里解引用得到的是一个int的数据。然后我们这里的打印虚表函数需要的是一个函数指针数组(本质就是函数指针的指针),所以这里我们需要将int的数据强转为VFUNC*(这里可行,但是地址转为整型不可行)。
以上都是我在32位上才能跑的,如果是在64位下的话,上面的代码就无法跑了,因为64位下,指针和虚函表指针的大小都是8字节,所以如果是64位的话这里就要修改一下:
但是如果你想要写一个32位64位下都能跑的代码的话使用条件编译是一个不错的放大,当然如果你使用long long*,来强转在32位下其实也可以跑,因为在32位的时候,发生了截断,他将8个字节大下的long long*,截断成了4个字节大小。但是可能会出现丢失数据从而导致虚函数表无法找到的错误。
了解以上的这些都是为了能够更加深入的理解虚函数表,同时我们也要知道c/c++语言是比较暴力的你只需要拥有函数的地址,就能够通过函数的地址去调用函数。
上面我们看到的都是单继承的虚表,那么如果是多继承呢?多继承有几个虚表呢?
使用代码:
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;
};
对于Base 1和Base 2 我们都可以不用看了,因为Base1和Base2就只是一个普通类而已,我们要关注的是Derive。
首先我们判断Derive有几个虚表。
可以看到有两个虚表,因为Derive 继承了Base1,Base1有一个虚表,Derive又继承了Base2,Basse2又存在一个虚表。所以Derive存在两个虚表。这里的Derive重写了func1。然后这里Derive存在一个自己的func3,那么这个func3放在哪一个虚表呢?还是两个虚表都放呢?
此时我们不确定,我们只能打印出来,vs的监视窗口无法看出来,因为在vs的监视窗口里面,派生类的虚函数都没有往虚表里面放
此时的Derive的对象模型如下:
我们打印第一张虚表很好打印:
这样就能打印。但是如果我想打印第二张虚表改如何打印呢?
一个很简单的方法利用切片,将d中的Base2拿出来在使用上面的方法即可:
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;
};
int main()
{
Derive d;
PrintVFT((VFUNC*)*((int*)&d));
Base2* p = &d;//利用切片将d的地址中Base2*的地址拿出来,
PrintVFT((VFUNC*)(*(int*)p));//然后在打印这个继承于Base2的虚表
return 0;
}
也可以使用这种方法
运行结果:
然后我们就能够看到fun3只在第一张虚表,也就是第一个继承类的虚表中。对于虚表的数量你可以这么认为你继承了几个具有虚表的父类,你就含有几个虚表。
为什么要存在两张虚表?因为我们可能会出现下面的这种调用方式:
我可能使用Base1*去调用,还可能使用Base2* 去调用。除此之外还存在 一个很玄妙的一点。
这里我们的Derive重写两个func1(Base1和Base2),首先我们可以肯定的是重写的func1肯定是相同的func1,但是在两张虚表中,虚函数的地址居然不一样,这就很奇怪了。下面调用的时候,肯定最后还是调用的同一个函数。
但是为什么虚函数表里面的地址不一样呢(调的是同一个函数)?、
这里和指针的偏移有关系。
下面我们通过汇编和画图来理解:
首先p1是从开头指向的位置开始调用,而p2则是从图中指向的位置开始往下调用。
下面我们来观察汇编
首先这里就是去虚表里面取地址,其实这里eax里面的地址并不真正的就是这个虚函数开始的地址,而是jump指令的地址(vs编译器上)。
jmp后面跟的才是真正的函数地址。
然后才是开始执行func1.此时的p1我们可以认为是正常的调用func1的。
下面我们来看一下p2的调用
这里首先p2也是在第二张虚表里面寻找地址,可以看到的是这里的地址和上面的那个地址是不一样的。
然后继续是jmp指令,这里的jmp指令和上面的jmp指令很明显不是同一个,一个跳的是2840,一个跳的是1DE2
然后jmp跳到了这里:
此时这里做了一件事情,让ecx里面的内容-8(这里不会跳跃,因为指令是sub(-)),之后再次jmp。
这个时候才终于到位跳到了func1的函数位置,对比上面我们就知道2840正是func1函数开始的地址。
这里可以理解成两个人去同一个地方但是,一个人走的路近(p1调用)而另外一个人走的路很远(p2调用)。走的路线不一样。
那么这是为什么呢?
我们总结的看,其实其它的地方没有什么差别唯一的差别也就是p2调用的时候,多跳了几步。以及在有一次跳跃前让(ecx里面的内容减去了一个8),那么ecx里面储存的是什么呢?ecx存的是调用成员函数时传递的this指针。
p1调用的时候,ecx里面存的时p1,而p1指向的Base1的开始,同时也是整个对象的开始,所以它没动。
但是p2调用的时候,ecx里面存的是p2,而p2指向的并不是整个对象的开始,而让ecx减去一个8,目的就是让ecx里面的this指针回到整个对象开始的地方。为什么要减回去呢?
因为func1是Derive的成员函数
那么func1的this指针自然也就是Derive*了。而我们在使用这个this指针的时候,可能会去访问Derive中的变量(自己的和继承得来的)
为了能让这个this指针能够访问整个对象的内容,自然要指向对象的开始了。Base1调用的时候没有转是因为Base1指针刚好就是指向的开始,但是Base2指针没有指向开始,所以Base2要减回去。
由此我们能够知道本质让ecx里面的内容减去8,是为了修正this指针。
以上的知识作为了解即可,但是我们需要具有分析这种问题的能力。
代码使用如下
class A
{
public:
virtual void func1()
{
cout << "A::func1" << endl;
}
public:
int _a;
};
class B : public A
//class B : virtual public A
{
public:
virtual void func1()
{
cout << "B::func1" << endl;
}
virtual void func3()
{
cout << "B::func3" << endl;
}
public:
int _b;
};
class C : public A
//class C : virtual public A
{
public:
virtual void func1()
{
cout << "C::func1" << endl;
}
virtual void func5()
{
cout << "C::func5" << endl;
}
public:
int _c;
};
class D : public B, public C
{
public:
virtual void func1()
{
cout << "D::func1" << endl;
}
virtual void func2()
{
cout << "D::func2" << endl;
}
public:
int _d = 1;
};//首先我们就使用一个没有使用虚继承的菱形继承来看
首先我们思考第一个问题如果建立了一个d对象,那么d中存在几张虚表。
答案是两张,因为菱形继承说白了也就是多继承。根据之前说的结论:对于虚表的数量你可以这么认为你继承了几个具有虚表的父类,你就含有几个虚表。
下一个问题如果我在D中增加了一个func2(我在上面的代码中已经增加了)。
那么这个虚函数要放在哪里
答案是放在继承自B的那张虚表(放在第一张虚表)。
那么我们修改一下问题:如果是菱形虚拟继承存在几张虚表?
我们来通过内存窗口查看一下:
(这里的监视窗口没有增加func3和func5)在B和C中没有新增函数
此时存在两张虚表,此时的A有一张虚表就是上图中蓝色的部分(这里我在内存窗口中输入了&b),此时
还有一张虚表如下图蓝色部分所示:
(这里的监视窗口没有增加func3和func5)在B和C中没有新增函数
这一张虚表是D的。那么这里为什么D要有一张单独的虚表呢?因为我们这里给D增加了一个的虚函数func2,如果不是虚拟继承D的func2本来是要放到B虚表里面的,但是B没有独立的虚表(为什么呢?因为B和C共享了A而A中存在一份虚表)。这里就已经很复杂了。那么如果我又在B中新增加一个func3
而在C中增加一个func5
那么此时对应的虚表还会增加,我们在上面看到的都是B和C中没有虚函数场景。那么现在B和C中新增加了虚函数,现在我们再来通过内存窗口查看一下:
此时就存在3张虚表了,但是A的这张虚表,B不能往里面放自己的虚函数,C也不能往里面放自己的虚函数,因为B和C是共享A的。所以这里B和C只能各自创建一份属于自己的虚表。所以此时的B和C中各有一张虚基表还有一张虚表。那么哪一张是虚基表,哪一张是虚表呢?
我们这里也能知道在虚基表中第一个储存的是当前位置距离虚函数表的位置,第二个值记录的是距离A的位置。
这里我们还没有把多态加上都已经比较复杂了,所以一般不要使用菱形继承!!!
下图:
对于第10,11,2,3问题在我的上一篇博客中说明了。
希望这篇博客能对您有所帮助,如果您觉得写的不好请见谅,如果发现了任何的错误欢迎指出。