大家好!这次逗比老师要和大家分享的是C++中的成员函数,我们会深入解析C++处理对象成员时的方式,还有关于成员函数指针、虚函数表等问题的深入研究。
在介绍其他问题之前,咱们先来研究一下,一个C++对象在内存中的存储布局。首先,如果是POD类型的对象,那么布局方式和C中的结构体相同,按照定义的顺序排布所有成员,并且会在适宜的时候进行内存对齐。例如下面例程我们写了一个简单的用来打印一个对象内部结构(十六进制方式)的代码:
#include
#include
class C1 {
public:
char m1;
// pad 7 Bytes
uint32_t m2[5];
};
template
void ShowMemory(const T &ref, const std::string &name = "no name") {
std::cout << "=====begin=====" << std::endl;
auto base = reinterpret_cast(&ref);
auto size = sizeof(typename std::remove_reference::type);
std::cout << "name: " << name << std::endl;
std::cout << "size: " << size << " Byte(s)" << std::endl;
std::cout << " |";
for (int i = 0; i < 16; i++) {
std::cout << std::setw(2) << std::hex << i << "|";
}
std::cout << std::endl;
int i = 0;
for (const uint8_t *ptr = base; ptr < base + size; ptr++) {
if (i % 16 == 0) {
std::cout << " " << std::hex << i / 16 << "|";
}
i++;
std::cout << std::setw(2) << std::setfill('0') << std::hex << uint16_t{*ptr} << "|";
if (i % 16 == 0) {
std::cout << std::endl;
}
}
std::cout << std::endl << "======end======" << std::endl;
}
#define SHOW(obj) ShowMemory(obj, #obj)
int main(int argc, const char * argv[]) {
C1 c1;
c1.m1 = 44;
c1.m2[0] = 88;
SHOW(c1);
return 0;
}
示例的输出结果如下:
=====begin=====
name: c1
size: 24 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|2c|00|00|00|58|00|00|00|00|00|00|00|00|00|00|00|
0|00|00|00|00|00|00|00|00|
======end======
相信这一部分大家都很熟悉了,不再啰嗦。
接下来我们要研究的是,非POD类型中,C++到底都“偷偷”在对象中做了什么。首先我们先看一下简单继承的方式,假如有B继承自A,那么B中是如何布局的呢?请看例程:
// ShowMemory相关内容省略,参考之前的例程即可
class A {
public:
uint16_t m1, m2;
uint8_t m3;
};
class B : public A {
public:
uint16_t m4;
};
int main(int argc, const char * argv[]) {
B b;
b.m1 = 0x1234;
b.m2 = 0x4567;
b.m3 = 0xef;
b.m4 = 0x789a;
SHOW(b);
return 0;
}
输出如下:
=====begin=====
name: b
size: 8 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|34|12|67|45|ef|00|9a|78|
======end======
看得出,地址0x00和0x01是m1,0x02和0x03是m2,0x04是m3,然后0x05是一个字节的内存对齐。也就是说,0x00~0x05其实就是一个完整的A类型对象,也就是父类集成到的内容。然后0x06和0x07是m4,也就是后面排的是子类的扩展内容。
我们知道,数据类型仅仅是处理数据的方式,然而数据本身都只是相同的二进制数罢了,如果知道了一个对象的实际内存布局,那么我们其实也可以反过来直接构造一个对象。请看下面历程:
// 省略A和B的定义,请参考上面历程
int main(int argc, const char * argv[]) {
// 直接构造二进制数据
uint8_t data[] = {0x34, 0x12, 0x67, 0x45, 0xef, 0x00, 0x9a, 0x78};
// 用对象方式解析数据
B *ptr = reinterpret_cast(data);
// 尝试读取m2和m4
std::cout << std::hex << ptr->m2 << ", " << ptr->m4 << std::endl;
return 0;
}
输出结果如下:
4567, 789a
看起来,通过二进制数据来反向构造对象,到目前为止还是可行的。
请大家先消化上面的内容,我们再一起来往下看。
我们了解到,C++其实本质还是C语言,只不过做了很多语法糖,使得语法更为高级,更加适合用高等思维去设计。但语法并不改变语义,C++的高级语法其实都可以等价翻译为C语言语法,例如函数重载,其本质是编译器在函数名前后加上了前后缀用以区分的。
那么成员函数也是一样的,虽然我们把它写在类当中,但本质上,它仍是函数,和普通函数一样,它的指令也会存入一块内存,我们也可以设法找到这片内存。
先来看一个静态成员函数的例子:
class C1 {
public:
static void test() {std::cout << "C1::test" << std::endl;}
};
int main(int argc, const char * argv[]) {
// 静态成员函数指针
void (*pf1)() = C1::test;
// 打印地址的值
std::cout << reinterpret_cast(pf1) << std::endl;
// 直接调用
pf1();
return 0;
}
执行结果:
0x100003074
C1::test
这里的0x100003074其实就是C1::test函数保存的跳转地址。所以这里我们看到,其实静态成员函数就是普通的函数而已,语义上来说,和写在外面的函数没什么区别,这里的类名其实与命名空间几乎无异了。只是语法上来说,它在C1内,那我们自然是要写和C1相关的内容。
但如果是非静态成员函数呢?请看例程:
class C1 {
public:
int m1;
void test(int a) {std::cout << "C1::test, a=" << a << ", m1=" << m1 << std::endl;}
};
int main(int argc, const char * argv[]) {
// 定义对象
C1 c1;
// 成员赋值
c1.m1 = 1234;
// 成员函数指针
void (C1::*pf1)(int) = &C1::test;
// pf1的长度
std::cout << sizeof(pf1) << std::endl;
// 调用
(c1.*pf1)(5);
return 0;
}
输出如下:
16
C1::test, a=5, m1=1234
非静态成员函数指针的用法大家应该不陌生,但似乎让我们很诧异的是这个16,照理讲,在64位环境下,指针的大小都是8字节,可pf1却很个性地来了个16,这是为什么?
先不急,我们还是把pf1的二进制内容先打印出来看看:
int main(int argc, const char * argv[]) {
C1 c1;
c1.m1 = 1234;
void (C1::*pf1)(int) = &C1::test;
SHOW(pf1);
return 0;
}
/* 输出结果:
=====begin=====
name: pf1
size: 16 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|80|28|00|00|01|00|00|00|00|00|00|00|00|00|00|00|
======end======
*/
后面一长串都是0,而前面这部分看上去有点像是地址,不免让人猜测,是否前8字节才是真正的函数地址呢?我们来做个实验便好:
class C1 {
public:
int m1;
void test(int a) {std::cout << "C1::test, a=" << a << ", m1=" << m1 << std::endl;}
void test2() {std::cout << "C1::test2" << std::endl;}
};
int main(int argc, const char * argv[]) {
C1 c1;
c1.m1 = 1234;
void (C1::*pf1)(int) = &C1::test;
void (C1::*pf2)() = &C1::test2;
auto test_ppf = reinterpret_cast(&pf2);
(*test_ppf)();
return 0;
}
/*输出结果:
C1::test2
*/
(这里怕有些读者看晕,我稍微多解释一下。由于void (C1::*)()这种类型是16字节长度的,并不是普通的指针,因此我们不能直接转换成void *或普通函数指针。而我们现在要做的是把pf2的前8个字节拿出来,再按照一个普通的函数指针去读取。因此,我们先取pf2的地址,然后把这个地址按照二级指针进行解指针,得到一个指针,而这个指针的值其实就是pf2的前8个字节。所以刚才那几行代码如果详细一点来写就是这样:
void (C1::*pf2)() = &C1::test2;
void *ppf2 = reinterpret_cast(&pf2); // ppf2是pf2的地址
// 但此时ppf2应该是个二级指针,解指针后应该得到一个8字节的数
uintptr_t pf_addr = *reinterpret_cast(ppf2);
// pf_addr的值,应该就是我们想到的函数的地址的值了,还需要再转换成函数指针类型
void (*pf)() = reinterpret_cast(pf_addr);
// 按照函数方式调用
pf();
转义之后相信大家应该更容易看得懂了。)
果然如此,前8个字节解出来的地址,还真的是个可调用的函数。但到目前为止我们都没有出现任何问题,是因为C1::test2是无参的,并且内部也与成员变量无关。如果把相同的操作用给C1::test的话就会core dump,有兴趣的读者可以自行尝试。
既然是非静态的成员函数,我们都只要正常操作都是用对象来调用的,这个对象会作为函数的一个隐藏参数(也就是this)来传入,所以,我们其实少传了一个this参数。例如C1::test的操作应该是这样的:
class C1 {
public:
int m1;
void test(int a) {std::cout << "C1::test, a=" << a << ", m1=" << m1 << std::endl;}
};
int main(int argc, const char * argv[]) {
C1 c1;
c1.m1 = 1234;
void (C1::*pf1)(int) = &C1::test;
// 对象作为第一个参数传进去,其他参数跟在后面,即可转换为普通函数
auto test_ppf = reinterpret_cast(&pf1);
(*test_ppf)(&c1, 5);
// 引用其实本质是指针的语法糖,所以也可以改写成引用类型
auto test_ppf2 = reinterpret_cast(&pf1);
(*test_ppf2)(c1, 5);
return 0;
}
/*调用结果:
C1::test, a=5, m1=1234
C1::test, a=5, m1=1234
*/
看来确实是这样了,pf1的前8个字节真的就是一个普通的函数,只不过有一个隐藏的this参数罢了。我们也就把obj->func(arg)的形式,成功改写成了func(obj ,arg)的形式。那后8个字节到底是干什么的呢?先别急,后面就知道了。在解释后8个字节的作用之前,我们不妨先换换脑子,看另一个问题。
这个小标题可能会让读者有点摸不着头脑,不过没关系,很快你就会明白,我们先来看一段例程:
class C1 {
public:
int m1;
// 为了方便观察,这里我用十六进制打印m1
void test(int a) {std::cout << "C1::test, a=" << a << ", m1=" << std::hex << m1 << std::endl;}
};
int main(int argc, const char * argv[]) {
// 这是随意写的一段数据
uint8_t data[] = {0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc};
void (C1::*pf1)(int) = &C1::test;
// 注意这里,我将隐藏参数改为了void *
auto test_ppf = reinterpret_cast(&pf1);
auto f = *test_ppf;
f(data, 8);
return 0;
}
/*输出结果:
C1::test, a=8, m1=78563412
*/
这通操作相当大胆,f是C1::test所对应的实际函数,照理说,第一个参数是要传一个C1类型的对象的,但此时我传入了一个随意的二进制数据,程序竟然可以正常运行。并且我们观察运行结果,0x78563412正好是data的前4个字节。这也就是说,程序把data当做了C1类型来处理,取的m1,就是取这个对象(或数据)的前4个字节,并且当做整数来处理。
为了验证这个说法,我们不妨再多定义几个变量:
class C1 {
public:
int m1;
char m2;
short m3;
void test(int a) {std::cout << "C1::test, a=" << a << ", m1=" << std::hex << m1 << ", m2=" << m2 << ", m3=" << m3 << std::endl;}
};
int main(int argc, const char * argv[]) {
// 这是随意写的一段数据
uint8_t data[] = {0x12, 0x34, 0x56, 0x78, 0x3c, 0xbc, 0x11, 0xaa, 0xcc, 0x55};
void (C1::*pf1)(int) = &C1::test;
// 注意这里,我将隐藏参数改为了void *
auto test_ppf = reinterpret_cast(&pf1);
auto f = *test_ppf;
f(data, 8);
return 0;
}
/*输出结果:
C1::test, a=8, m1=78563412, m2=<, m3=aa11
*/
没问题,成员都是按照对首地址的偏移,以及定义的类型来解析的,比如这里m2,应当取的是0x3c所对应的ASCII码,自然是'<'。
那么此时我们在回头看一眼这一节的小标题,有没有恍然大悟呢?
我们再来看看,如果一个类(或父类)拥有虚函数,会变成什么样。请看下面例程:
// 省略SHOW相关代码,请参考前面例程
class C1 {
public:
int m1 = 0x1234;
virtual void test() {std::cout << m1 << std::endl;}
};
int main(int argc, const char * argv[]) {
C1 c1;
SHOW(c1);
return 0;
}
/*输出结果:
=====begin=====
name: c1
size: 16 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|38|40|00|00|01|00|00|00|34|12|00|00|00|00|00|00|
======end======
*/
看起来m1跑到了0x08的位置,那么0x00~0x07位置应当就是虚函数表指针了。将这个指针解开以后,就可以得到虚函数表,虚函数表其实就是一个指针数组,每一个元素指向一个函数。为了验证这个说法,我们不妨再做个实验,请看例程:
class C1 {
public:
int m1 = 0x1234;
virtual void test() {std::cout << std::hex << m1 << std::endl;}
virtual void test2() {std::cout << "test2" << std::endl;}
};
int main(int argc, const char * argv[]) {
C1 c1;
// 由于虚函数表指针是最初始的成员,偏移量为0,所以和对象首地址相同
void *pvfl = static_cast(&c1);
// 解pvfl应当得到一个数组,但是由于无法确定数组大小(也就是虚函数个数),因此用数组元素指针偏移来完成
void **vfl = *static_cast(pvfl); // vfl是虚函数表,也就是个数组,里面的元素都是指针,所以vf1是void **类型,而pvfl是这个数组的指针,所以pvf1是void ***类型(如果实在想不通,把最里面的一层void *定义为func_t,vfl就是func_t[]类型,然后pvfl就是func_t (*)[]类型,所以*pvfl就是func_t[],再把数组替换成指针,把func_t替换成void *得到前面代码)
// 尝试取出第一个元素
void *vf1 = vfl[0];
// 将这个元素转化为函数指针,然后调用
void (*f1)(C1 &) = reinterpret_cast(vf1);
f1(c1);
return 0;
}
/*调用结果:
1234
*/
我们成功通过虚函数表访问到了成员函数。验证一下,我们来尝试取出test2对应函数地址:
class C1 {
public:
int m1 = 0x1234;
virtual void test() {std::cout << std::hex << m1 << std::endl;}
virtual void test2() {std::cout << "test2" << std::endl;}
};
int main(int argc, const char * argv[]) {
C1 c1;
void *pvfl = static_cast(&c1);
void **vfl = *static_cast(pvfl);
void *vf2 = vfl[1];
void (*f2)(C1 &) = reinterpret_cast(vf2);
f2(c1);
return 0;
}
/*调用结果:
test2
*/
没有问题,看来虚函数表,就是普通函数指针的指针,正常按照指针大小偏移即可。
刚才我们用通过手动来控制指针偏移,找到了对应的虚函数并调用。编译器也可按照同样的方式,在成员定义列表中找到虚函数的位置,数出它是第几个,然后去虚函数表中找。但倘若我把虚函数的函数指针单独拿出来,该怎么办呢?(因为此时没法通过变量名来判断这是第几个虚函数了。)玄机,就在函数指针当中。
请看下面例程:
// 省略SHOW相关实现,请参考前面例程
class C1 {
public:
int m1 = 0x1234;
virtual void test() {std::cout << std::hex << m1 << std::endl;}
virtual void test2() {std::cout << "test2" << std::endl;}
};
int main(int argc, const char * argv[]) {
void (C1::*pf1)() = &C1::test;
SHOW(pf1);
return 0;
}
/*调用结果:
=====begin=====
name: pf1
size: 16 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|01|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|
======end======
*/
我们可以看到,虚函数的函数指针中保存得不再是实际的函数地址(因为实际的保存在虚函数表中),而是字节的偏移量,注意这里偏移量是1起始,因此实际在虚函数表中的偏移量比这个数值少1(主要是由于0用来表示空指针了)。
验证一下,我们取test2即可:
class C1 {
public:
int m1 = 0x1234;
virtual void test() {std::cout << std::hex << m1 << std::endl;}
virtual void test2() {std::cout << "test2" << std::endl;}
virtual void test3() {}
};
int main(int argc, const char * argv[]) {
void (C1::*pf2)() = &C1::test;
SHOW(pf2);
return 0;
}
/*调用结果:
=====begin=====
name: pf2
size: 16 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|09|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|
======end======
*/
OK,如果有继承关系会怎样呢?
class C1 {
public:
int m1 = 0x1234;
virtual void test() {std::cout << std::hex << m1 << std::endl;}
virtual void test2() {std::cout << "test2" << std::endl;}
};
class C2 : public C1 {
public:
virtual void test3() {}
};
int main(int argc, const char * argv[]) {
// 这里一定不可以用auto,因为C2中没有override这个函数,所以auto会推导出void (C1::*)()而不是void (C2::*)()
void (C2::*pf1)() = &C2::test;
SHOW(pf1);
void (C2::*pf2)() = &C2::test2;
SHOW(pf2);
void (C2::*pf3)() = &C2::test3;
SHOW(pf3);
return 0;
}
/*执行结果:
=====begin=====
name: pf1
size: 16 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|01|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|
======end======
=====begin=====
name: pf2
size: 16 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|09|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|
======end======
=====begin=====
name: pf3
size: 16 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|11|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|
======end======
*/
也就是说,继承时,父类的虚函数表也会继承过来,新的虚函数会续写在父类的虚函数表之后。
单继承的,虚函数表顺延续写看起来理所应当,可多继承呢?
多继承时,C++会将第一个继承类作为主父类,而其他的父类虚函数表将单独继承,不再合并。也就是说,如果一个类有N个父类的话,就会有N个虚函数表。
为了验证,请看例程:
struct A {
uint8_t pad[4] {1, 2, 3, 4};
virtual void f1() {}
};
struct B {
uint8_t pad[8] {1, 2, 3, 4, 5, 6, 7, 8};
virtual void f2() {}
};
struct C : A, B {};
int main(int argc, const char * argv[]) {
C c;
SHOW(c);
return 0;
}
/*执行结果:
=====begin=====
name: c
size: 32 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|50|40|00|00|01|00|00|00|01|02|03|04|00|00|00|00|
1|68|40|00|00|01|00|00|00|01|02|03|04|05|06|07|08|
======end======
*/
可以看到,0x00~0x07是一个虚函数表指针,也是主的(C和A拼接后的),而0x10~0x17是另一个虚函数表(从B直接继承下来的),读者可以自行验证该说法。
接下来我们做一个操作,在三个类中的函数里分别打印出this,请看例程:
struct A {
uint8_t pad[4] {1, 2, 3, 4};
virtual void f1() {std::cout << "f1, this=" << this << std::endl;}
};
struct B {
uint8_t pad[8] {1, 2, 3, 4, 5, 6, 7, 8};
virtual void f2() {std::cout << "f2, this=" << this << std::endl;}
};
struct C : A, B {
int m = 5;
virtual void f3() {std::cout << "f3, this=" << this << std::endl;}
};
int main(int argc, const char * argv[]) {
C c;
c.f1();
c.f2();
c.f3();
SHOW(c);
return 0;
}
/*调用结果:
f1, this=0x16fdff448
f2, this=0x16fdff458
f3, this=0x16fdff448
=====begin=====
name: c
size: 32 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|50|40|00|00|01|00|00|00|01|02|03|04|00|00|00|00|
1|70|40|00|00|01|00|00|00|01|02|03|04|05|06|07|08|
2|05|00|00|00|00|00|00|00|
======end======
*/
this指针的不同,其实也证实了上面的说法,A和C中函数都是正常的this(实际对象的首地址),而B中的函数打印出的this却发生了偏移。其实,C++的多继承,从第二个父类开始,就会转换成类的组合来处理。相当于在C类中先放了一个B的对象,因此我们观察对象c的内存布局,首先0x00~0x0f是从A类继承来的内容,然后0x10~0x1f是一个完整的B,最后0x20~0x27是C中新增的成员。
由于C的虚函数直接续写在了从A继承来的虚函数表后面,因此,这两个类中的虚函数传入的this都是对象的首地址,而B类中的虚函数的this则要传入C类中B类继承来位置的首地址,可以看得出偏移量是0x10,正好上面验证f2的this比f1和f3的this向后偏移了0x10。
现在我们再来打印一下三个函数指针:
struct A {
uint8_t pad[4] {1, 2, 3, 4};
virtual void f1() {std::cout << "f1, this=" << this << std::endl;}
};
struct B {
uint8_t pad[8] {1, 2, 3, 4, 5, 6, 7, 8};
virtual void f2() {std::cout << "f2, this=" << this << std::endl;}
};
struct C : A, B {
int m = 5;
virtual void f3() {std::cout << "f3, this=" << this << std::endl;}
};
int main(int argc, const char * argv[]) {
void (C::*p1)() = &C::f1;
SHOW(p1);
void (C::*p2)() = &C::f2;
SHOW(p2);
void (C::*p3)() = &C::f3;
SHOW(p3);
return 0;
}
/*调用结果:
=====begin=====
name: p1
size: 16 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|01|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|
======end======
=====begin=====
name: p2
size: 16 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|01|00|00|00|00|00|00|00|10|00|00|00|00|00|00|00|
======end======
=====begin=====
name: p3
size: 16 Byte(s)
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
0|09|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|
======end======
*/
终于,成员函数指针神秘的高8字节作用解开了,就是这个this的偏移量,这里的0x10表示要向后偏移16字节。这就是成员函数指针是2个指针长度的原因所在了,第8字节是函数指针,高8字节是this的偏移量。
总结,完整的虚函数指针的调用方式如下:
1.取低8字节,表示虚函数表的偏移量
2.取高8字节,表示this的偏移量
3.根据this偏移量找到虚函数表
4.根据虚函数表偏移量找到函数
5.将调用者向后偏移对应的位置,作为实际调用者,传入函数的第一个参数中
例如:(obj.*vf1)() 【obj是对象,vf1是一个虚函数指针】
1.取vf1低8字节,记为f1
2.取vf2高8字节,记为adj
3.将&obj向后偏移adj字节,这是虚函数表指针,记为vpfl
4.vpfl向后偏移f1 * 指针大小,这是实际的函数指针,记为rf
5.调用rf,第一个参数是&obj偏移adj字节,其他参数递补。
C++确实很难,因为它用了很基础的C作为底层支撑,却提供了很多高级的语法和功能,但如果我们可以把握本质,揭开它神秘面纱以后,发现其实也不过如此。
关于C++成员函数指针的相关问题就讲解到这里,如果读者有疑问,欢迎留言!