虚函数的定义
- 虚函数是指在基类内部声明的成员函数前面添加关键字 virtual 指明的函数
- 虚函数存在的意义是为了实现多态,让派生类能够重写(override)其基类的成员函数
- 派生类重写基类的虚函数时,可以添加 virtual 关键字,但不是必须这么做
- 虚函数是动态绑定的,在运行时才确定,而非虚函数的调用在编译时确定
- 虚函数必须是非静态成员函数,因为静态成员函数需要在编译时确定
- 构造函数不能是虚函数,因为虚函数是动态绑定的,构造函数创建时需要确定对象关系。
- 析构函数一般是虚函数
- 虚函数一旦声明,就一直是虚函数,派生类也无法改变这一事实
虚函数工作原理
虚函数表 + 虚表指针
- 编译器在含有虚函数的类中创建一个虚函数表,称为 vtable,这个vtable用来存放虚函数的地址。另外还隐式的设置了一个虚表指针,称为vptr,这个vptr指向了该类对象的虚函数表。
- 派生类在继承基类的同时,也会继承基类的虚函数表。
- 派生类重写(override)了基类的虚函数时,则会将重写后的虚函数的地址替换掉由基类继承而来的虚函数表中对应虚函数地址。
- 若派生类没有重写,则由基类继承而来的虚函数的地址将直接保存在派生类的虚函数表中。
示例
class A
{
public:int a;int function() {return this->a;}virtual int vfunction() {return a;}
};void vCallFunction(A* obj) {obj->vfunction();
}void callFunction(A* obj) {obj->function();
}调用:A a;vCallFunction(&a);callFunction(&a);汇编解析,以说明 只有在运行时才能确定调用的是基类的虚函数还是派生类的虚函数:1. 静态绑定,不是虚函数,目标地址在编译阶段就确定了。
00007FF679471030 <test1. | 48:894C24 08 | mov qword ptr ss:[rsp+8],rcx | test1.cpp:35
00007FF679471035 | 48:83EC 28 | sub rsp,28 |
00007FF679471039 | 48:8B4C24 30 | mov rcx,qword ptr ss:[rsp+30] | test1.cpp:36
00007FF67947103E | E8 BDFFFFFF | call <test1.public: virtual int __cdecl A::vfunction(void)> |
00007FF679471043 | 48:83C4 28 | add rsp,28 | test1.cpp:37
00007FF679471047 | C3 | ret |2. 动态绑定,只能根据 rdx 的值来确定函数位置
00007FF679471010 <test1. | 48:894C24 08 | mov qword ptr ss:[rsp+8],rcx | test1.cpp:31
00007FF679471015 | 48:83EC 28 | sub rsp,28 |
00007FF679471019 | 48:8B4424 30 | mov rax,qword ptr ss:[rsp+30] | test1.cpp:32
00007FF67947101E | 48:8B00 | mov rax,qword ptr ds:[rax] |
00007FF679471021 | 48:8B4C24 30 | mov rcx,qword ptr ss:[rsp+30] |
00007FF679471026 | FF10 | call qword ptr ds:[rax] |
00007FF679471028 | 48:83C4 28 | add rsp,28 | test1.cpp:33
00007FF67947102C | C3 | ret |当类A有虚函数的时候它就会偷偷生成一个隐藏成员变量,它存放着虚函数表的位置,根据偏移就可以找到实际上的 vfunction 的地址,将其存在寄存器 rax 里面,随后 call[rax] 就正常调用了。
注意:
每个类都只有一个虚函数表,该类所有的对象共享这个虚函数表,而不是每个实例化对象都分别由一个虚函数表。
c++ 类的多态性是通过虚函数来实现的,如果基类通过引用或指针调用的是虚函数时,我们并不知道执行该函数的对象是什么类型的,只有在运行时才能确定调用的是基类的虚函数还是派生类的虚函数,这就是运行时多态。
虚函数表和虚函数表指针创建的时机
当我们发现某一个具体的类当中,存在 virtual 这样的字段,就会为这个类去生成虚函数表。它的内容在编译期就已经生成确定了。虚函数表存储的位置是在全局数据区的只读数据段 ,虚函数表是存放虚函数的地址的数组。
当我们为类去构建对象的时候,在构造函数中。将虚函数表的地址赋值给对象的 vptr (存放对象首地址)
继承下,虚函数表指针的复制过程:
继承下,先会调用基类的构造函数,先将基类的虚函数表地址赋值给vptr,接着调用子类的构造函数的时候又将子类的虚函数表地址赋值给vptr(这是覆盖的行为)。
什么函数不能是虚函数 为什么(重点)
- 不能被继承的函数
- 不能被重写的函数
- 普通函数 普通函数不属于成员函数 是不能被继承的 普通函数只能被重载 不能被重写 因此声明为虚函数没有意
义 因为编译器会在编译时绑定函数 而多态体现在运行时绑定 通常通过基类指针指向子类对象实现多态 - 友元函数 友元函数不属于类的成员函数 不能被继承 对于没有继承特性的函数没有虚函数的说法
- 构造函数 构造函数是用来初始化对象的 假如子类可以继承基类构造函数 那么子类对象的构造将使用基类的构造
函数 而基类构造函数并不知道子类有什么成员 显然是不符合语义的 从另外一个角度讲 多态是通过基类指针指
向子类对象来实现多态的 在对象构造之前并没有对象产生 因此无法使用多态特性 这是矛盾的 因此构造函数不
允许继承 - 内联成员函数 内联函数就是为了在代码中直接展开 减少函数调用花费的代价 也就是说内联函数是在编译时展开
的 而虚函数是为了实现多态 是在运行时绑定的 因此内联函数和多态的特性相违背 - 静态成员函数 首先静态成员函数理论是可继承的 但是静态成员函数是编译时确定的 无法动态绑定 不支持多态因此不能被重写
汇编角度看类
类大小的计算
class Person {private:int age; uint64_t num;
};
大小:
printf("%d", sizeof(Person)); // 16
这里只有类的成员,按照结构体算法,直接就是16
进阶算法,当添加一个成员函数时,计算大小:
class Person {void function(){printf("hello world");};
private:int age; uint64_t num;
};
printf("%d", sizeof(Person)); // 16
由此可见:类中的成员函数是不占用类对象内存空间的
为了验证以上说法,我们删除一个8位的成员变量,此时只剩下 int age 也就是4字节成员变量。
class Person {void function(){printf("hello world");};
private:int age; // 4
};
练一练,计算如下大小:
class Person {
public:virtual int getAge() { //虚函数定义return age;}
private:int age;
};
大小:
printf("%d", sizeof(Person)); // sizeof(Person) = 16 (64-bit)
这里为什么是16 字节呢,在这里由于出现了虚函数,那么编译器会初始化虚表指针
在 64 位的情况下指针占8个字节,对齐成员变量的话,就是16字节,是不是很简单。
对整体类逆向分析:
class Person {
public:
virtual int getAge() { //虚函数定义
return age;
}
virtual void setAge(int age) { //虚函数定义
this->age = age;
}
private:
int age;
};
int main(int argc, char* argv[]) {
Person person;
return 0;
}
反汇编:
------------------------ main ------------------------
00007FF76A17117C | 48:8D4C24 20 | lea rcx,qword ptr ss:[rsp+20] ;获取对象首地址
00007FF76A171181 | E8 1A000000 | call 0x00007FF76A1711A0 ;调用构造函数:<test1.public: __cdecl Person::Person(void)>------------------------ 构造函数(Person::Person())------------------------
00007FF76A1711A0 < | 48:894C24 08 | mov qword ptr ss:[rsp+8],rcx
00007FF76A1711A5 | 48:8B4424 08 | mov rax,qword ptr ss:[rsp+8] ; this 指针 rax = 0000004AF30FF7B0
00007FF76A1711AA | 48:8D0D CF200000 | lea rcx, ds:[0x00007FF76A173280] ; 给 虚函数表指针 <const Person::`vftable'> 地址 rcx = 00007FF76A173280
00007FF76A1711B1 | 48:8908 | mov qword ptr ds:[rax],rcx ; 取虚表的首地址,保存至虚表指针中
00007FF76A1711B4 | 48:8B4424 08 | mov rax,qword ptr ss:[rsp+8]; 返回对象首地址,rax = 0000004AF30FF7B0
00007FF76A1711B9 | C3 | ret 内存<test1.const Person::`vftable'> :
00007FF76A173280 <public: virtual int __cdecl Person::getAge(void)> 00007FF76A171120 ..j÷... test1.public: virtual int __cdecl Person::getAge(void)
00007FF76A173288 <public: virtual void __cdecl Person::setAge(int)> 00007FF76A171130 0..j÷... test1.public: virtual void __cdecl Person::setAge(int)
地址所在区段:
地址=00007FF76A173000
大小=0000000000002000
页面信息=".rdata"
当前类由于存在虚函数,那么编译器为 Person 生成了默认构造函数。该默认构造函数首先取得虚表的首地址,然后赋值到虚表指针中。
查看 内存 可见,虚表指针中存放了两个函数地址,分别是虚函数 getAge 和虚函数 setAge 的地址。因此,得到虚表指针就相当于得到了类中所有虚函数的首地址。
因为虚表信息在编译后会被链接到对应的执行文件中,所以获得的虚表地址是一个相对固定的地址。虚表中虚函数的地址排列顺序因虚表函数在类中的声明顺序而定,先声明的虚函数的地址会被排列在虚表靠前的位置。第一个被声明的虚函数地址在虚表首地址处。
在虚表指针初始化的过程中,对象执行了构造函数后,就得到了虚表指针,当其他代码访问这个对象的虚函数时,会根据对象的首地址,取出对应的虚表元素,当函数被调用时,会间接访问虚表,得到对应的虚函数首地址并调用执行。这种调用方式是一个间接的调用过程。需要多次寻址才能完成。
Person person;person.setAge(0x11);printf("Age = 0x%X", person.getAge());
汇编:
00007FF6A339125C | 48:8D4C24 20 | lea rcx,qword ptr ss:[rsp+20] | test1.cpp:89
00007FF6A3391261 | E8 4A000000 | call <test1.public: __cdecl Person::Person(void)> |
00007FF6A3391266 | BA 11000000 | mov edx,11 | test1.cpp:90
00007FF6A339126B | 48:8D4C24 20 | lea rcx,qword ptr ss:[rsp+20] |
00007FF6A3391270 | E8 9BFFFFFF | call <test1.public: virtual void __cdecl Person::setAge(int)> |
00007FF6A3391275 | 48:8D4C24 20 | lea rcx,qword ptr ss:[rsp+20] | test1.cpp:91
00007FF6A339127A | E8 81FFFFFF | call <test1.public: virtual int __cdecl Person::getAge(void)> |
00007FF6A339127F | 8BD0 | mov edx,eax |
00007FF6A3391281 | 48:8D0D F81F0000 | lea rcx,qword ptr ds:[7FF6A3393280] | 00007FF6A3393280:"Age = 0x%X"
00007FF6A3391288 | E8 13FEFFFF | call <test1.printf> |
上述通过虚表间接寻址访问的情况,只有在使用对象的指针或引用,调用虚函数的时候才会出现。当直接使用对象调用自身虚函数时,没必要查表访问,因为已经明确调用的是自身成员函数,根本没有构成多态性。查询虚表只会画蛇添足,降低程序执行效率,所以将这种情况处理为直接调用。
析构函数操作:
添加析构代码:~Person() { printf("~Person() \n");}汇编:
00007FF6DCBB1230 < | 48:894C24 08 | mov qword ptr ss:[rsp+8],rcx | test1.cpp:85, [rsp+08]:"Age = 0x%X \n"
00007FF6DCBB1235 | 48:83EC 28 | sub rsp,28 |
00007FF6DCBB1239 | 48:8B4424 30 | mov rax,qword ptr ss:[rsp+30] | [rsp+30]:拿到 this 指针也是虚表的位置
00007FF6DCBB123E | 48:8D0D 63200000 | lea rcx,qword ptr ds:[<const Person::`vftable'>] |将当前类虚表首地址赋值到虚表指针中
00007FF6DCBB1245 | 48:8908 | mov qword ptr ds:[rax],rcx |
00007FF6DCBB1248 | 48:8D0D 31200000 | lea rcx,qword ptr ds:[<"~Person() \n"...>] | 00007FF6DCBB3280:"~Person() \n"
00007FF6DCBB124F | E8 4CFEFFFF | call <test1.printf> |
00007FF6DCBB1254 | 48:83C4 28 | add rsp,28 |
00007FF6DCBB1258 | C3 | ret |
在汇编中识别析构函数的条件是,写入虚表指针,对象的虚表指针可能是有效的,已经指向了正确的虚函数表,将对象的虚表指针重新赋值后,其指针可能指向了另一个虚表,虚表内容不一定和原来的意义。