读者若是以为我文章还不错的,但愿能够多多支持下我,文章能够转发,可是必须保留原出处和原做者署名。更多内容请关注个人微信公众号:cpp手艺人。ios
咱们讨论前提都是在windows 10 vs2013 debug模式下c++
1.虚函数指针和虚表在哪里? |
2.咱们如何手动调用虚函数? |
3.为何只有在子类以父类的引用或者指针的形式才能出现多态? |
4.虚函数的调用为何效率相比普通的成员函数较低?又具体低了多少? |
5.为何构造函数和析构函数尽可能不要调用虚函数? |
6.纯虚函数究竟是什么?为何禁止我调用?有什么办法能够绕编译器? |
7.看码说话windows
class MemsetObj {
public:
MemsetObj()
{
memset(this, 0, sizeof(MemsetObj));
cout << "memsetobj ctor" << endl;
}
void test() { cout << "memset obj test" << endl; }
virtual void virtual_func() { cout << "virtual test" << endl; }
};
// 1.
MemsetObj obj;
obj.virtual_func();
// 2.
MemsetObj *pobj = new MemsetObj();
pobj->virtual_func();
// 这两种方式调用有问题?什么问题?
复制代码
这里我解释下经常使用的和常见的一些指令,更多的须要你们本身课后学习。设计模式
只有用virtual声明类的成员函数,称之为虚函数。数组
就是一句话:实现多态的基石 实现多态的三大步:缓存
- 存在继承关系
- 重写父类的virtual function
- 子类以父类的指针或者是引用的身份出现
相信不少人都能说出来其中实现关键原理,就是两点:虚函数表指针(vptr),虚函数表(vftable)安全
咱们把对象从首地址开始的4个字节,这个位置咱们称之为虚函数表指针(vptr),它里面包含一个地址指向的就是虚函数表(vftable)的地址。微信
虚函数表说白了就是里面是一组地址的数组(就是函数指针数组),他所在的位置就是虚函数表指针里面所存储的地址,它里面所包含的地址就是咱们重写了父类的虚函数的地址(没有重写父类的虚函数那么默认的就是父类的函数地址)。ide
class Base {
public:
int m_a = 0;
virtual void f() { cout << "Base::f()" << endl; }
virtual void g() { cout << "Base::g()" << endl; }
virtual void h() { cout << "Base::h()" << endl; }
// virtual ~Base() { cout << "~Base" << endl; }
};
class BaseOne {
public:
int m_b = 0;
virtual void i() { cout << "BaseOne::i()" << endl; }
virtual void j() { cout << "BaseOne::j()" << endl; }
virtual void k() { cout << "BaseOne::k()" << endl; }
// virtual ~ BaseOne () { cout << "~ BaseOne " << endl; }
};
class Derive : public Base
{
public:
int m_d = 0;
virtual void g() { cout << "Derive::g()" << endl; }
// virtual ~ Derive () { cout << "~ Derive " << endl; }
};
class MultiDerive: public BaseOne, public Base
{
public:
int m_e = 0;
virtual void h() { cout << "MultiDerive::h()" << endl; }
virtual void k() { cout << "MultiDerive::k()" << endl; }
virtual void m() { cout << "MultiDerive::m()" << endl; }
// virtual ~ MultiDerive () { cout << "~ MultiDerive " << endl; }
};
复制代码
若是代码看的累,咱们直接看图(这里我没有用UML图表示):函数
既然咱们知道vptr的位置,咱们开始尝试手动调用虚函数
typedef void (*Fun)();
void test_multi_inhert_virtual_fun() {
MultiDerive multiderive;
// 从对象的首地址4个字节,获取vptr的地址,x86平台下指针都是4个字节
long *pbaseone_tablepoint = (long *)(&multiderive);
// 对vptr指针解引用操做获取vftable的地址
long *baseone_table_address = (long *)*(pbaseone_tablepoint);
for(int i = 0; i < 4; ++i) {
cout << std::hex << "0x" << baseone_table_address[i] << endl;
}
((Fun)(baseone_table_address[0]))();
((Fun)(baseone_table_address[1]))();
((Fun)(baseone_table_address[2]))();
/* 打印的结果 BaseOne::i() BaseOne::j() MultiDerive::k() */
// 获取第二个虚函数表指针的位置,从基址开始的地址+第一个继承对象的大小
long *pbase_tablepoint =
(long *)(reinterpret_cast<char *>(pbaseone_tablepoint)+sizeof(BaseOne));
long *base_table_address = (long *)(*pbase_tablepoint);
for(int i = 0; i < 4; ++i) {
cout << std::hex << "0x" << base_table_address[i] << endl;
}
((Fun)(base_table_address[0]))();
((Fun)(base_table_address[1]))();
((Fun)(base_table_address[2]))();
/*打印的结果 Base::f() Base::g() MultiDerive::h() */
}
复制代码
对于不想看代码的同窗,我画了一张图
对于这张图里面的一些强转我作些解释:
函数 | 打印结果 |
---|---|
((Fun)(baseone_table_address[0]))(); | BaseOne::i() |
((Fun)(baseone_table_address[1]))(); | BaseOne::j() |
((Fun)(baseone_table_address[2]))(); | MultiDerive::k() |
((Fun)(base_table_address[0]))(); | Base::f() |
((Fun)(base_table_address[1]))(); | Base::g() |
((Fun)(base_table_address[2]))(); | MultiDerive::h() |
从表中咱们能够看出来,其实这个vptr和vftable也没啥神秘的地方,就是一个指针指向装有函数指针的数组。数组里面的内容若是子类没有override,那么默认值就是父类的虚函数地址,不然就是子类本身的函数(表中红色部分)
咱们对类的声明代码稍做修改,作成独立的cpp文件
/**************************************************************************** ** ** Copyright (C) 2019 635672377@qq.com ** All rights reserved. ** ****************************************************************************/
#include <iostream>
using std::cout;
using std::endl;
class BaseOne {
public:
int m_b = 0;
virtual void i() { cout << "BaseOne::i()" << endl; }
virtual void j() { cout << "BaseOne::j()" << endl; }
virtual void k() { cout << "BaseOne::k()" << endl; }
};
class Base {
public:
int m_a = 0;
virtual void f() { cout << "Base::f()" << endl; }
virtual void g() { cout << "Base::g()" << endl; }
virtual void h() { cout << "Base::h()" << endl; }
};
class MultiDerive :public BaseOne, public Base
{
public:
int m_e = 0;
virtual void h() { cout << "MultiDerive::h()" << endl; }
virtual void k() { cout << "MultiDerive::k()" << endl; }
virtual void m() { cout << "MultiDerive::m()" << endl; }
};
int main() {
return 1;
}
复制代码
在windows菜单中找到vs2013命令行工具
cd 到你所在cpp文件的目录中好比我:cd /d j:\code\polymorphism_virtual\source
执行命令 cl /d1 reportSingleClassLayoutMultiDerive analysis_virtual_by_tools.cpp 注意MultiDerive表示你想导出来的类布局
从导出来的数据中能够看出:
1.MultiDerive大小、内存布局、以及成员变量的偏移、vftable的寻址
2.你们可能看到红色标注的-8有点奇怪,这个就是计算第二个vptr的偏移地址,计算方式是首地址-第二个vptr的所在的地址=-8,他们之间的距离就是一个BaseOne的大小。
这种方式比较简答,就是启动VS,下个断点,将鼠标移动到MultiDerive实例上,能够看到详细的信息。
从这里你可能发现一个问题,子类本身的虚函数这里没有体现出来,因此建议cl.exe工具验证虚表。
到这里咱们能够画图上述代码中类的内存布局图:
从Derive和MultiDerive内存图咱们发现一些规律:
1.继承的体系越复杂,子类的体积越大。
2.子类中普通成员顺序按照继承的前后顺序来的。
3.多重继承,子类中含有多个vptr,分别指向不一样的vftable。
4. vftable中的虚函数地址和在类中声明的顺序一致。
5.若是子类override父类中虚函数,那么子类vftable中就会替换原来父类的虚函数。
6.若是子类本身含有额外的虚函数,则会附加到第一个vftable中。
7. vftable中的最后一个值可能为0x0,有时候并非为0,上图中红色字部分。
8.子类有vftable,同时父类也有一份vftable,两个vftable没有关联。每一个实例化子类都共享一个vftable,一样父类全部实例化对象也共享一份vftable,很是相似类的静态变量
眼尖的同窗可能注意到探索虚表位置的代码,我注释掉了父类中全部的虚析构函数。我本想拿到虚表地址,就拿到了虚析构函数的地址,我用函数指针强制转换就能够调用,可是我一调用就崩溃。好,废话不说,反汇编走起来。
; base.~Base();
; 传递参数0
002D99EF push 0
; 传递this到ecx
002D99F1 lea ecx,[base]
; 调用一个相似析构函数的函数
002D99F4 call virtual_fun_table::Base::`scalar deleting destructor' (02D16C2h)
复制代码
看了反汇编代码,会惊讶的发现,竟然push 0了,说明编译器帮咱们传递一个参数,那咱们就本身手动也传递一个,你同时发现了这里并非直接调用Base的析构函数(代理析构函数)。
typedef void (*DctorFun)(int);
Base base;
base.~Base();
long *vptr = (long *)(&base);
long *vftable = (long *)(*vptr);
((Fun)(vftable[0]))();
((Fun)(vftable[1]))();
((Fun)(vftable[2]))();
((DctorFun)(vftable[3]))(0); ; 调用Base的虚析构函数
复制代码
恩,应该没有问题了,开心的跑起来,VS F5跑起来。但仍是抛出异常了… 想不出来啥缘由,仍是看反汇编吧,不断的跟啊跟啊,发现崩溃在~Base函数里面。
01276EFF pop ecx
01276F00 mov dword ptr [this],ecx
01276F03 mov eax,dword ptr [this]
01276F06 mov dword ptr [eax],1282BD8h
复制代码
这里拿到ecx,而后又一次赋值虚表地址1282BD8h。可是咱们是直接调用虚函数,没有push ecx,因此这里拿到的ecx值是个不肯定的值就至关于一个野指针问题。 咱们知道了为何崩溃的缘由,可是仔细想一想这里为何还须要再一次从新赋值虚表地址,在构造函数的时候不是已经初始化好虚表的地址了? 试想下这样的情形:Derive继承Base,在调用Derive析构函数时,先是调用Derive的析构函数,再Base调用的析构函数。若是在调用Base的析构函数时不重置虚表的话,那么Base中可能出现间接的调用Derive虚函数,而此时Derive的已经执行过析构函数,里面的数据已是不可靠的,存在安全隐患。 同时得出结论析构函数和构造函数中调用虚函数并无多态的特性。 关于间接的调用虚函数说明: 咱们知道,在构造函数中直接调用虚函数的话,是没有多态的特性,可是若是咱们写一个函数,这个函数再去调用虚函数,这个时候生成的汇编代码,会根据虚表找函数地址,就有了多态的特性。有兴趣的同窗能够本身反汇编验证下。
1.对象初始化时
2.拷贝构造调用
咱们从汇编的角度看,在对象初始化时如何设置vftable的地址。
在vs2013 在调试状态下,右键转到反汇编就能够看到汇编代码
// c++代码
MultiDerive multiderive;
复制代码
这下面三个都是汇编代码
; MultiDerive multiderive;
; multiderive的地址放到ecx寄存器中
00FC9CD8 lea ecx,[multiderive]
; 调用MultiDerive构造函数
00FC9CDB call virtual_fun_table::MultiDerive::MultiDerive (0FC12BCh)
复制代码
; 跳转到 virtual_fun_table::MultiDerive::MultiDerive:
00FC12BC jmp virtual_fun_table::MultiDerive::MultiDerive (0FC4180h)
复制代码
00FC4180 push ebp
00FC4181 mov ebp,esp
00FC4183 sub esp,0CCh
00FC4189 push ebx
00FC418A push esi
00FC418B push edi
00FC418C push ecx
00FC418D lea edi,[ebp-0CCh]
00FC4193 mov ecx,33h
00FC4198 mov eax,0CCCCCCCCh
00FC419D rep stos dword ptr es:[edi] ; 从00FC4180~00FC419D都是作函数调用过程初始化准备
00FC419F pop ecx ; 拿到this指针
00FC41A0 mov dword ptr [this],ecx
00FC41A3 mov ecx,dword ptr [this]
00FC41A6 call virtual_fun_table::BaseOne::BaseOne (0FC1235h) ; 调用BaseOne的构造函数
00FC41AB mov ecx,dword ptr [this]
00FC41AE add ecx,8 ; 调整this指针偏移,并将this指针传递给调用Base构造函数
00FC41B1 call virtual_fun_table::Base::Base (0FC15D2h)
00FC41B6 mov eax,dword ptr [this]
00FC41B9 mov dword ptr [eax],0FD1B54h ; 将vftable的地址设置到第一个vptr中
00FC41BF mov eax,dword ptr [this]
00FC41C2 mov dword ptr [eax+8],0FD1B6Ch ;跳过8字节,设置第二个虚表地址放到第二个vptr中
00FC41C9 mov eax,dword ptr [this]
00FC41CC mov dword ptr [eax+10h],0
00FC41D3 mov eax,dword ptr [this]
00FC41D6 pop edi
00FC41D7 pop esi
00FC41D8 pop ebx
00FC41D9 add esp,0CCh
00FC41DF cmp ebp,esp
00FC41E1 call __RTC_CheckEsp (0FC1488h)
00FC41E6 mov esp,ebp
00FC41E8 pop ebp
00FC41E9 ret
复制代码
关于这里的汇编代码作下说明:
这个编译器在编译期间就已经初始化好了,为每一个类肯定好了虚表里面对应的内容。
前面,咱们详细介绍了虚函数的实现细节,可是子类为何以父类引用或指针的身份出现就会有多态的特性,总感受还有一层窗户纸没有捅破。 上代码,咱们看下面的这段代码
; MultiDerive multiderive; c++代码
01289FC8 lea ecx,[multiderive]
01289FCB call virtual_fun_table::MultiDerive::MultiDerive (012812BCh)
; multiderive.m(); c++代码
; 注意看这里以普通的身份调用虚函数,就两行汇编代码,直接调用传递this指针
01289FD0 lea ecx,[multiderive]
01289FD3 call virtual_fun_table::MultiDerive::m (012812F8h)
; MultiDerive *multiderive2 = new MultiDerive(); c++代码
; 这里从0x01289FD8~到0x0128A014主要是进行了申请堆内存
; 申请成功了调用构造函数,申请失败跳过构造函数
01289FD8 push 14h
01289FDA call operator new (01281587h)
01289FDF add esp,4
01289FE2 mov dword ptr [ebp-13Ch],eax
01289FE8 cmp dword ptr [ebp-13Ch],0
01289FEF je virtual_fun_table::test_multi_inhert_virtual_fun+64h (0128A004h)
01289FF1 mov ecx,dword ptr [ebp-13Ch]
01289FF7 call virtual_fun_table::MultiDerive::MultiDerive (012812BCh)
01289FFC mov dword ptr [ebp-144h],eax
0128A002 jmp virtual_fun_table::test_multi_inhert_virtual_fun+6Eh (0128A00Eh)
0128A004 mov dword ptr [ebp-144h],0
0128A00E mov eax,dword ptr [ebp-144h]
0128A014 mov dword ptr [multiderive2],eax
; multiderive2->m(); c++代码
; 这里是咱们须要看的重点内容
; 将multiderive2的首地址,放到eax寄存器
0128A017 mov eax,dword ptr [multiderive2]
; eax就是咱们以前说的vptr,对vptr解引用获取到vftable地址放到edx
0128A01A mov edx,dword ptr [eax]
0128A01C mov esi,esp
; thiscall方式调用,经过ecx传递this指针地址
0128A01E mov ecx,dword ptr [multiderive2]
; edx里面存放了vftable的地址+偏移地址
; 还原成高级语言就是数组取值 eax = vftable[0CH],每一个函数指针是4个字节,4*3=12byte
; eax中就是存放了虚函数m的地址
0128A021 mov eax,dword ptr [edx+0Ch]
0128A024 call eax
0128A026 cmp esi,esp
0128A028 call __RTC_CheckEsp (01281488h)
复制代码
咱们简化下来对比下代码
multiderive.m(); | 01289FD0 lea ecx,[multiderive] 01289FD3 call virtual_fun_table::MultiDerive::m (012812F8h) |
multiderive2->m(); | 0128A017 mov eax,dword ptr [multiderive2] 0128A01A mov edx,dword ptr [eax] 0128A01C mov esi,esp 0128A01E mov ecx,dword ptr [multiderive2] 0128A021 mov eax,dword ptr [edx+0Ch] 0128A024 call eax |
若是不是引用或者指针,虚函数的调用则是直接寻址,2行汇编代码搞定。
若是是指针或者是引用,看出虚函数的寻址并非那么简单的。先是找到vptr再找到vftable在加上偏移地址(偏移量是在编译器就已经肯定的)最后才是真正的函数调用地址,用了6行代码。
如今你能理解为何虚函数的调用效率比较慢了吧。
若是咱们在虚函数原型的后面加上=0(virtual void func()= 0),同时这个函数是没有实现的。
有纯虚函数的类表示这是一个抽象类,既然是抽象的,那么确定就是不能实例化。关于抽象类不能实例化能够从逻辑上理解也是合理的,好比说:动物,老虎,狮子,人都是动物。可是你说动物没人能理解你说的动物到底指的是什么东西。
由纯虚函数的引出了抽象类,抽象类的出现是为了解决什么问题?
抽象类就是为了被继承的,它为子类实例化提供蓝图。在相关的组织继承层次中,它来提供一个公共的根。其余相关子类都是这里衍生出来。
它与接口的区别是什么?
接口是对动做的抽象,抽象类是对根源的抽象。好比说人,有五官,有其余属性。可是吃这个动做应该定义为接口更合适。由于其余动物也有吃的动做。
// #define test_call_abstract_virtual_fun 1
class AbstractBase {
public:
#ifdef test_call_abstract_virtual_fun
AbstractBase() { CallAbsFunc(); }
void CallAbsFunc() { AbsFunc(); }
#else
AbstractBase() { }
#endif // test_call_abstract_virtual_fun
virtual void AbsFunc() = 0;
virtual void AbsFunc2() = 0;
};
class Child : public AbstractBase
{
public:
Child() { AbsFunc(); }
void AbsFunc() { cout << "" << endl; }
void AbsFunc2() { cout << "" << endl; }
};
void test_abstract_virtual_fun() {
// 由于抽象类不能直接实例化,经过子类实例化,反汇编找到AbstractBase找到构造函数
AbstractBase *child = new Child();
child->AbsFunc();
}
复制代码
在24行代码处下断点->启动vs->右键菜单->反汇编->快捷键F11单步调试。 反汇编的代码比较多,我就挑出重点的代码画图解释下:
在反汇编的时候,咱们拿到了虚表的地址0x0F82D98,咱们把这个地址放到vs2013中的内存窗口中。根据咱们前面的学到的知识,就知道AbstractBase应该有两个虚函数,那么表中应该有两个函数指针,以下图所示。可是你会惊讶的发现表中的两个函数指针都是0x00f714ab。
如今咱们很好奇,为何虚表中的内容的是同样的。还有纯虚类的虚函数都没有任何的实现的,为何虚表中还有内容。还有这个地址究竟是个什么?干啥的?
那我就在想,若是我能够拿到这个地址直接转成函数指针,经过函数指针调用就能够了。
方案一:在纯虚父类构造函数中,直接调用纯虚函数,编译失败error LNK2019: 没法解析的外部符号(由于纯虚函数没有实现,直接调用没有任何意义)。方案否决。
方案二:尝试拿到纯虚父类的vftable地址,可是发现纯虚父类不能实例化。方案否决。
方案三:尝试在子类构造函数,拿到this指针的,在根据this指针拿到虚表地址。反汇编的代码看编译器的代码先于个人代码执行,就是说等到了执行个人代码时候,这个this指针已经不是纯虚父类的vftable,而是子类的vftable了。 貌似进入死胡同,没有方案了。
问题回到刚开始时候,我如今是怎么拿到纯虚父类的vftable。我是实例化了子类,而后反汇编F11一步步跟过去的。就是说我是间接的经过子类去获取纯虚父类的vftable,等等,是否是有思路了。
在上面的代码将宏放开#define test_call_abstract_virtual_fun 1,编译经过ok,接下来能够愉快的玩耍了。 代码整理好,反汇编走起来。我把重要的调用过程画图表示出来,便于理解。
从纯虚函数的调用过程来看,调用纯虚函数->__purecall->call 0FE51470(19h) ->抛出异常
如今咱们大概的猜一猜上面提出的疑问了:
1.纯虚函数的确是没有实现的,而虚表的内容时编译器塞进去的。
2.纯虚函数原本就是不能让咱们调用的,咱们如今经过某种手段绕过编译器了。若是咱们直接调用纯虚函数,编译器可以检查出来,会报错error LNK2019: 没法解析的外部符号。而若是咱们间接的调用了纯虚函数,编译器也无能为力,可是编译器仍是道高一尺,它知道本身可能在编译期间解析不出来,因此编译器就在虚表中插入__purecall函数,你有几个虚函数我就插入几个__purecall函数,当你在运行时调用,我就让你调用__purecall抛出异常。让你不能调用你就是不能调用,强行调用我就给你shutdown。如今可以解释为何虚表的内容是同样的。
天使与魔鬼是并存的,虚函数在带来超强的多态特性,可是不可避免的带来了其余缺点。
1.间接寻址形成的效率慢,在怎么实现多态特性上汇编角度能够看出来,引入时间复杂度。
2.继承关系带来的强耦合关系,父类动子类可能地动山摇,对象关系复杂度上升。
3.体积的增长,尤为是多继承时体现的更明显,引入额外的空间复杂。
可是在软件开发的角度看大大下降软件的开发周期和维护成本,总的来讲瑕不掩瑜。与带来的多态特性相比,我以为仍是值得。
在多态中,咱们经过对象->虚表指针->虚表->虚函数,最终找到咱们想要的函数。简化的看对象->(中间操做)->虚函数,对象通过中间的一系列操做获得虚函数。这种思路称为间接思惟,当咱们想要某种东西的时候,可能无法直接获取或者是直接获取的成本过高,可是经过间接轻松的获取。这种思路随处可见,好比:我要吃饭要经过钱去等价交换,我去公司上班经过地铁过去,计算机中的缓存做用。
关于实现多态的特性,网上还有利用模板的编译期多态的特性,有兴趣同窗能够搜一下。
设计模式的里面的套路,就是基于的虚函数多态的特性。
《C++深刻理解对象模型》 《C++反汇编与逆向分析》