企业形象成品网站,扬中三茅镇,建设自己网站教程,wordpress $show_date前言
本博客主要介绍C 当中 多态语法的实现原理#xff0c;如果有对 多态语法 有疑问的#xff0c;请看下面这篇博客#xff1a; 探究#xff0c;为什么多态的条件是那样的#xff08;虚函数表#xff09; 首先#xff0c;调用虚函数必须是 父类的 指针或 引用#xf…前言
本博客主要介绍C 当中 多态语法的实现原理如果有对 多态语法 有疑问的请看下面这篇博客 探究为什么多态的条件是那样的虚函数表 首先调用虚函数必须是 父类的 指针或 引用不能是子类的。这是因为子类当中有父类部分而父类当中只有自己。我们使用父类指针当指向对象是父类的时候指针类型也是相匹配的就会调用父类的函数如果指向那个对象是子类的但是子类当中构造了父类就会发生切片因为指针类型是父类的指针只会访问父类的那一部分。
但是如果指针类型是子类的就不行了因为子类虚函数表当中只有自己虚函数的地址。父类指针可以指向子类和父类但是子类指针只能指向子类。 为什么不能是父类的对象而必须是指针或引用呢 因为 指针的切片和 对象的切片做造成的结果是不一样的。
如果是指针切片在创建派生类的时候子类当中父类的虚函数表是先从父类当中拷贝一份到在子类当中父类的虚函数表然后如果子类当中重写了虚函数再把子类当中重写虚函数地址直接覆盖在虚函数表中之前父类中对应的虚函数地址位置。 拷贝之后如下所示 所以才会有指向父类调用父类的虚函数指向子类调用子类的虚函数只不过在指针看来看到的都是对象对象。一个是之间看到父类对象一个是切片之后看到了子类当中父类对象。所以说指针的切片不考虑拷贝的问题就可以理解为他只是把原本就有的部分切片出来给指针看到。 而对象的切片像上述的例子 ps st相当于是把子类对象拷贝到 ps 当中因为ps 是父类的指针 而 st 是子类的指针这时候就会发生切片。把切片出来的子类当中的父类拷贝到 ps 当中。
在子类的父类当中有两个部分首先 _a 肯定是会拷贝到 ps 当中但是虚函数表会不会拷贝呢
我们先来看拷贝之后会发生什么。如果我们把子类当中的虚函数表拷贝到父类当中的虚函数表当中那么当指针指向父类的时候此时应该调用父类的函数但是此时父类当中的虚函数表存储的是子类的虚函数地址因为刚刚假设是直接拷贝那么此时就会去调用子类的虚函数这部乱套了吗这肯定不会是我们所期望的我们肯定期望父类指针指向父类对象 就去调用父类的虚函数。
所以此时肯定不能 把 子类当中的 虚函数表拷贝到 父类当中的虚函数表当中。在实际当中子类拷贝给父类编译器也没有拷贝虚函数表和我们刚刚所想是一样的。
所以说上述就是我们不能使用父类对象调用虚函数的原因如果使用对象调用就要进行赋值拷贝开空间而新开出来的父类就需要重新构建虚函数表而又不能直接拷贝原本的虚函数表原本子类和父类构建的重写关系可能会乱套 这里提一嘴我们普通继承比如 A类 继承 B类这种称之为 实现继承。而 上述的多态继承称之为 接口继承。 至于为什么需要虚函数的重写上述给出的过程也可以证明了因为只有是虚函数重写在子类当中的虚函数表当中才会 有 子类新重写的虚函数地址。而调用虚函数的 父类指针 或 引用只需要“无脑的” 从 父类当中的虚函数表 找到这个虚函数地址调用这个虚函数就行了。 关于虚函数表的一些问题 派生类对象d中也有一个虚表指针d对象由两部分构成一部分是父类继承下来的成员虚表指针也就是存在部分的另一部分是自己的成员。 基类b对象和派生类d对象虚表是不一样的这里我们发现BuyTicket完成了重写所以Student的虚表中存的是重写的Student::BuyTicket所以虚函数的重写也叫作覆盖覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法覆盖是原理层的叫法.虚函数表当中只放虚函数的地址如果不是虚函数该函数的地址是不会放到虚函数表当中的派生类对于父类当中的虚函数表是先拷贝然后在把派生类重写的虚函数地址进行直接覆盖如果在派生类当中自己有虚函数就按照派生类的声明次序写在虚函数表当中注VS的调试窗口下不会显示派生类当中的虚函数不是重写的但是我们打开内存窗口可以看到派生类虚函数的地址。也就是说在派生类中父类的虚函数表当中有三个部分从父类拷贝下来父类的虚函数子类重写之后覆盖的虚函数子类当中的虚函数虚函数表的本质是一个 存放 函数指针的指针数组一般情况下这个数组在最后放了一个 nullptr0但是这个看不同的编译器在VS下就给了但是在 g 当中没给。虚函数表是存储在 代码段也就是常量区当中的。 虚函数表存储在代码段的验证
C当中数据存储位置大概有以下几个地方栈 堆 数据段静态区 代码段常量区。首先排除的是 堆 因为堆是拿给我们动态开辟空间的而虚函数表是由编译器生成的所以不可能是编译器 什么 malloc new出来的。栈也不可能因为同类型的对象共用一个虚函数表一表多用比如 Person ps1 和 Person ps2这两个对象是共用一个虚函数表的不管这两个对象分别构造在任意位置而栈上的空间一般是跟着栈帧走的不能单独开空间。而且如果存储在栈上也有一个问题就是函数执行结束战争销毁存储在这个栈帧当中的虚函数表也要进行销毁那么当下一个同类型的对象构造的时候虚函数表难道要重新进行构造吗肯定是不行的。除非是 main 函数栈帧我们可以来验证一下我们用 分别存储在上述四个存储位置的 四个数据分别打印他们的地址这样我们可以大概的看出这个四个存储位置的地址区间在打印对象当中虚函数表的地址用强转类型int* 这样解引用的话只会访问 4 个字节的内容因为这个例子的数据量很小地址最接近的我们可以认为虚函数表就存储在那个 存储位置 我们发现虚函数表 和 常量区 存储位置地址最接近。
VS当中 虚函数表最后的 nullptr有时候编译器在你调试的时候修改一些代码编译器可能不会给nullptr但是清理一下重新生成解决方案之后就会有了; 验证派生类的虚函数表当中VS调试窗口看不到的派生类的虚函数地址
class Person
{
public:virtual void Func1(){cout Person::Func1() endl;}virtual void Func2(){cout Person::Func2() endl;}};class Student : public Person
{
public:virtual void Func3(){cout Student::Func3() endl;}
};
如上例子所示在子类 Student 中的虚函数表除了存储父类 Person 的两个虚函数之外还要存储 子类 当中的 虚函数--func3的地址但是 这个 func3 函数的地址在VS的调试窗口上是不会显示的但是在内存当中除了 有父类 当中两个虚函数的地址还多出来一个地址我们怀疑这个地址的空间存储的就是 func3函数的地址
调试窗口子类虚函数表没有func3地址 内存窗口当中多出一个地址 上述说过虚函数表其实就是函数指针数组这个数组当中存储的是每一个虚函数的指针所以我们可以利用C 当中的函数指针来帮助我们验证这个地址是不是 func3函数的指针。
我们可以在虚函数表数组当中找到这个地址然后用这个地址调用这个地址的函数看是不是func3。
函数指针语法转自博客c 函数指针_c指针函数_Alpha205的博客-CSDN博客
double (*pf)(int); // 指针pf指向的函数 输入参数为int,返回值为double
这样不太好看我们可以typedef一下
typedef void(*FUNC_PTR) ();
在数组当中找到 这个地址然后调用这个地址上的 函数
typedef void(*FUNC_PTR) ();void PrintVFT(FUNC_PTR* table)
{for (size_t i 0; table[i] ! nullptr; i){printf([%d]:%p\n, i, table[i]);}printf(\n);
}int main()
{Person ps;Student st;int vft1 *((int*)ps);PrintVFT((FUNC_PTR*)vft1);int vft2 *((int*)st);PrintVFT((FUNC_PTR*)vft2);return 0;
}
我们拿输出结果看和内存对比地址是否相同来验证我们当前取出来的地址是否正确 我们发现是完全吻合的。
然后我们在把 疑似 func3函数的函数指针拿出来调用看这个地址是不是 func3函数的地址
//打印虚函数表当中 虚函数地址的函数
void PrintVFT(FUNC_PTR* table)
{for (size_t i 0; table[i] ! nullptr; i){printf([%d]:%p\n, i, table[i]);FUNC_PTR func table[i];func();}printf(\n);
}
输出
[0]:00FC1357
Person::Func1()
[1]:00FC12AD
Person::Func2()[0]:00FC1357
Person::Func1()
[1]:00FC12AD
Person::Func2()
[2]:00FC12D0
Student::Func3()
此时我们就验证了那个多出来的地址就是 Func3函数的地址。 以上验证方式需要注意的点 首先这个程序是在 X86 也就是在 32位环境下执行的也就是说我们在寻找虚函数表的首地址的时候是寻找对象前4字节的存储的数据这个数据就是虚函数表的地址。如果是 64 位环境的话应该是取 对象的前8个字节。上述写的PrintVFT这个函数中的循环是以 nullptr0作为循环的终止条件的因为在VS当中的虚函数表在最后会以 nullptr 来结尾。但是有时候我们不注意在调试时候修改代码可能就不会以 nullptr 来结尾了这时候我们需要重新生成解决方案。在Linux中也就是在g环境下虚函数表不是以 nullptr 结尾的这时候的循环只能写死了。 看到上述的验证我们应该注意了VS当中的监视窗口有时候可能不靠谱而内存当中是绝对靠谱的。 函数指针函数地址在使用的时候需要注意如果你知道函数的地址不管这个函数受哪一个权限修饰符修饰就算是使用 private 修饰照样可以访问。 因为此时你都已经知道了这函数的地址使用函数指针来调用函数是直接在代码段当中找到这个函数然后调用。 还有一个原因是权限的限定只是在语法层次来限定不是在运行层当中进行限制的。这里的函数指针直接跳过了语法层次直接在语法层次来进行寻找函数调用。 动态绑定和静态绑定动态多态和静态多态 其实多态这一现象不止发生在对象当中在函数的当中时常发生。如下例子
int a 1;
double b 1;cout a endl;
cout b endl; 库函数当中的 cout 流插入之所以实现自动判别类型其实底层实现就是使用 函数重载。当我们传入不同类型的参数的时候编译器就会自动的去寻找参数列表对应的函数来调用。
上述这种用函数重载来实现的多态就叫做静态多态。
而我们上述的多态也就是使用继承虚函数来实现的多态就是动态多态。 多继承当中的虚函数表 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;
};
我们先来计算 Deribve d 这个对象的大小是多少sizeofd 20Base1的虚表指针 b1 Base2的虚表指针 b2 d1 20。 派生类是没有自己的虚表的因为派生类在构造之前需要先构造其父类它的父类当中就有虚表而且这个虚表还是子类修改过的重写就覆盖地址。 多继承当中派生类当中创建的多个父类当中都有虚表我们可以认为这些虚表都是属于这个派生类的因为这些虚表当中如果派生类对其中的虚函数进行了重写那么都是对这个虚表进行了修改的就算这个表当中没有也不影响子类调用函数。 那么在子类当中func3这个函数没有重写但是是虚函数那么也要放进虚表当中但是紫烈继承了两个父类此时有两个虚表究竟是放到哪一个虚表当中的呢
要得到上述问题的答案我们还是要进行虚表当中虚函数的打印打印过程和上述一样唯一不一样的是base2父类不在 d 对象当中的第一位置Base1当中的虚表好弄因为是Base1是在第一位置。所以此时我们要像去Base1一样先取出第一位置的地址然后加上 sizeofbase2因为 所用的指针是 d 类型指针所以此时还需要把 d 指针强转为 char* 使得我们加上 sizeofbase2是一个字节一个字节加的。具体代码如下所示
Derive d;
int vft2 *((int*)( (char*)d sizeof(Base1)));
这样就可以取出d对象当中 Base2 父类当中的虚函数表的地址了。
还有一个更好的方法使用 Base2 类型的指针指向子类对象d这样就会发生切片Base2类型的指针直接指向 d 对象当中的 Base2 首地址。虚函数就在首地址处直接按照4个字节大小取出就好
Derive d;
Base2* ptr d;
int vft2 *((int*)ptr);
void PrintVTable(VFPTR vTable[])
{cout 虚表地址 vTable endl;for (int i 0; vTable[i] ! nullptr; i){printf( 第%d个虚函数地址 :0X%x,-, i, vTable[i]);VFPTR f vTable[i];f();}cout endl;
}int main()
{Derive d;int vTableb1 (*(int*)d);PrintVTable((VFPTR*)vTableb1);int vTableb2 (*(int*)((char*)d sizeof(Base1)));PrintVTable((VFPTR*)vTableb2);return 0;
}
输出 虚表地址00A69B94第0个虚函数地址 :0Xa61244,-Derive::func1第1个虚函数地址 :0Xa612e9,-Base1::func2第2个虚函数地址 :0Xa61230,-Derive::func3虚表地址00A69BA8第0个虚函数地址 :0Xa61357,-Derive::func1第1个虚函数地址 :0Xa610b9,-Base2::func2
通过上述输出结果发现func3函数的地址是存在 第一个 父类对象的虚函数表当中的。 我们还看到上述在 Base1 当中的 func1函数的地址 和 在 Base1 当中的 func1函数的地址是不一样的 我们先来模拟用两个分别指向 Base1 和 Base2 的指针来调用func1函数来看看
int main()
{Derive d;Base1* pb1 d;pb1-func1();Base2* pb2 d;pb2-func1();return 0;
}
输出结果是一样的
Derive::func1
Derive::func1
虽然两次函数调用的结果是一样的但是两次调用函数的地址是不一样的。
我们来查看反汇编来看看编译器在这里究竟干了些什么为什么要这样做 上述就是我们在main函数的当中写的代码所转化的反汇编截图。
过程描述
首先是 Base1 指针调用 func1call指令调用函数call指令当中 eax寄存器 存储的地址是 jmp的地址因为在VS当中调用函数之前要先走一趟jmp而jmp跳跃到的地址才是调用函数真真的地址我们发现Base1 指针调用 func1函数就是直接跳到子类重写的 func1函数地址来进行调用的。 然后我们来看 Base2 指针调用func1的过程同样在call指令开始查看发现此时 eax寄存器当中存储的地址和 Base1当中 eax存储的地址不一样了。 也就是说此时call之后执行的 jmp 指令也不会是之前的那个指令了 此时的jmp 跳到了另一个指令位置当中此时就只有 sub 和 jmp这两个指令首先执行 sub 这个指令这个指令是 减 的意思意思是 寄存器 ecx 当中的值 减 8。而 ecx 当中存储的值是 this 指针的值也就是说sub 指令是让 this 指针 减8。 然后 接下来执行的 jmp指令 就和 Base1 当中的 jmp 指令地址一样的了也就是执行的是一个指令此时就跳到了 func1子类重写的地址处进行执行。我们发现Base2也是跳到 子类重写的函数当中进行调用 func1函数的。 既然两处最后都是跳到 子类当中对 func1函数重写位置进行 调用的那么 Base2 指针调用的 func1函数为什么要多执行这几步绕一圈在执行呢 其实不难发现Base2 指针调用的 func1函数过程多执行的几步当中jmp指令都不重要重要的是执行的 那个 sub 指令。这个指令对当时的 this 指针进行了修改那么为什么要对当中的 this 指针进行修改呢 首先我们要知道此时的this指针指向的是什么。此时的this指针谁调用的谁就是 this 指针很明显此时的this指针是 pb2。而此时的pb2 指向的是 d 子类对象当中的 Base2 这个父类对象也就是说此时的this 指针指向的是 Base2 这个对象。 但是我们此时调用的 func1函数进行了重写所以 func1 函数的实现是在 子类当中而不在Base2 当中当 func1当中调用了 子类当中的成员函数或成员变量我们知道调用成员是需要 this 指针来调用的如果此时this 指针还是指向 Base2就出大问题了 所以此时编译器就对this指针进行了修改 ecx 存储的是 this指针在最开始是从 ptr2 拷贝过来的this指针的值ptr2 存储的是 Base2 对象的指针这肯定是不对的所以此时编译器才饶了一圈来修改this指针指向的位置。 那为什么 Base1 类型的指针ptr1调用 func1函数就没有这样饶圈而是直接跳到 func1函数实现位置调用呢 其实是也 ptr1 指针指向位置很特殊他就是 子类对象 d 的首地址就是 d 对象的 this指针应该指向的地方所以此时编译器不需要对this 指针进行修改。 当然上述是基于 VS 当中的编译器做到事情其他编译器不好说。
除了上述方法我们还可以让两个虚表当中的 func1函数的地址是一样的而且同样的可以修改this 指针。
就是不要 那 ptr2 来赋值 ecx 就算要拿 ptr2 来赋值给 ecx 在下一行指令就把 ecx 当中的值向上述一样 减8 就行了。