数据成员指针、函数成员指针

1. 数据成员指针

对于普通指针变量来说,其值是它所指向的地址,0表示空指针。

而对于数据成员指针变量来说,其值是数据成员所在地址相对于对象起始地址的偏移值,空指针用-1表示。例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct X {
    int a;
    int b;
};
#define VALUE_OF_PTR(p)     (*(long*)&p)
int main() {
    int X::*p = 0;  // VALUE_OF_PTR(p) == -1
    p = &X::a;      // VALUE_OF_PTR(p) == 0
    p = &X::b;      // VALUE_OF_PTR(p) == 4
    return 0;
}

2. 函数成员指针

函数成员指针与普通函数指针相比,其size为普通函数指针的两倍(x64下为16字节),分为:ptr和adj两部分。

(1) 非虚函数成员指针

ptr内容为函数指针(指向一个全局函数,该函数的第一个参数为this指针),adj内容为该函数使用的this指针与默认的类内this指针的偏移值,在非虚成员函数中,adj固定为0.例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
extern "C" int printf(const char*, ...);
struct B {
    void foo() {  printf("B::foo(): this = 0x%p\n", this); }
};
struct D : public B {
    void bar() { printf("D::bar(): this = 0x%p\n", this); }
};
void (B::*pbfoo)() = &B::foo; // ptr: points to _ZN1B3fooEv, adj: 0
void (D::*pdfoo)() = &D::foo; // ptr: points to _ZN1B3fooEv, adj: 0
void (D::*pdbar)() = &D::bar; // ptr: points to _ZN1D3barEv, adj: 0
extern "C" void _ZN1B3fooEv(B*);
extern "C" void _ZN1D3barEv(D*);
#define PART1_OF_PTR(p)     (((long*)&p)[0])//(long*)&p 将函数指针的前半部分转换成long型指针,才能进行输出
#define PART2_OF_PTR(p)     (((long*)&p)[1])
int main() {
    printf("&B::foo->ptr: 0x%lX\n", PART1_OF_PTR(pbfoo));
    printf("&B::foo->adj: 0x%lX\n", PART2_OF_PTR(pbfoo));    // 0
    printf("&D::foo->ptr: 0x%lX\n", PART1_OF_PTR(pdfoo));
    printf("&D::foo->adj: 0x%lX\n", PART2_OF_PTR(pdfoo));    // 0
    printf("&D::bar->ptr: 0x%lX\n", PART1_OF_PTR(pdbar));
    printf("&D::bar->adj: 0x%lX\n", PART2_OF_PTR(pdbar));    // 0
    D* d = new D();
    d->foo();
    _ZN1B3fooEv(d); // equal to d->foo()
    d->bar();
    _ZN1D3barEv(d); // equal to d->bar()
    return 0;
}

(2) 虚函数成员指针

ptr部分内容为虚函数对应的函数指针在虚函数表中的偏移地址加1(之所以加1是为了用0表示空指针),而adj部分为当前类的this指针的偏移字节数(因为类存在继承关系,当前类的this指针偏移字节数不再为0,要跳过父类所占的字节数)。例:

说明:

  1. A和B都没有基类,但是都有虚函数,因此各有一个虚函数指针(假设为vptr)。

  2. C没有重写继承自A和B的虚函数,因此在C的虚函数表中存在A::foo和B::bar函数指针(如果C中重写了foo(),则C的虚函数表中A::foo会被替换为C::foo)。

  3. C中有两个虚函数指针vptr1和vptr2,相当于有两张虚函数表。

  4. A::foo(C::foo)、B::Bar(C::bar)都在虚函数表中偏移地址为0的位置,因此ptr为1(0+1=1)。而C::quz在偏移为4的位置,因此ptr为5(4+1=5)。

  5. 当我们使用pc调用C::bar()时,如:“(pc->*pcbar)()”,实际上调用的是B::bar()(即_ZN1B3barEv(pc)),pc需要被转换为B类型指针,*因此需要对this指针进行调节(通过B::bar()函数中的adj部分)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
extern "C" int printf(const char*, ...);
struct A {
    virtual void foo() { printf("A::foo(): this = 0x%p\n", this); }
};
struct B {
    virtual void bar() { printf("B::bar(): this = 0x%p\n", this); }
};
struct C : public A, public B {
    virtual void quz() { printf("C::quz(): this = 0x%p\n", this); }
};
void (A::*pafoo)() = &A::foo;   // ptr: 1, adj: 0
void (B::*pbbar)() = &B::bar;   // ptr: 1, adj: 0
void (C::*pcfoo)() = &C::foo;   // ptr: 1, adj: 0
void (C::*pcquz)() = &C::quz;   // ptr: 5, adj: 0
void (C::*pcbar)() = &C::bar;   // ptr: 1, adj: 8
#define PART1_OF_PTR(p)     (((long*)&p)[0])
#define PART2_OF_PTR(p)     (((long*)&p)[1])
int main() {
    printf("&A::foo->ptr: 0x%lX, ", PART1_OF_PTR(pafoo));   // 1
    printf("&A::foo->adj: 0x%lX\n", PART2_OF_PTR(pafoo));   // 0
    printf("&B::bar->ptr: 0x%lX, ", PART1_OF_PTR(pbbar));   // 1
    printf("&B::bar->adj: 0x%lX\n", PART2_OF_PTR(pbbar));   // 0
    printf("&C::foo->ptr: 0x%lX, ", PART1_OF_PTR(pcfoo));   // 1
    printf("&C::foo->adj: 0x%lX\n", PART2_OF_PTR(pcfoo));   // 0
    printf("&C::quz->ptr: 0x%lX, ", PART1_OF_PTR(pcquz));   // 5
    printf("&C::quz->adj: 0x%lX\n", PART2_OF_PTR(pcquz));   // 0
    printf("&C::bar->ptr: 0x%lX, ", PART1_OF_PTR(pcbar));   // 1
    printf("&C::bar->adj: 0x%lX\n", PART2_OF_PTR(pcbar));   // 4
    return 0;
}

VPTR 和 VTABLE 和类对象的关系

每一个具有虚函数的类都有一个虚函数表VTABLE,里面按在类中声明的虚函数的顺序存放着虚函数的地址,这个虚函数表VTABLE是这个类的所有对象所共有的,也就是说无论用户声明了多少个类对象,但是这个VTABLE虚函数表只有一个。

如果该类是基类且含有virtual函数,那么该基类实例化对象包含1个虚表指针vptr.

如果该类是子类且有n个包含虚函数的基类,那么该子类实例化对象包含n个虚表指针vptr(子类本身的虚函数填充到主基类的虚函数表中)

注意

  1. 虚函数表是class specific的,也就是针对一个类来说的,这里有点像一个类里面的staic成员变量,即它是属于一个类所有对象的,不是属于某一个对象特有的,是一个类所有对象共有的。

  2. 虚函数表是编译器来选择实现的,编译器的种类不同,可能实现方式不一样,就像前面我们说的vptr在一个对象的最前面,但是也有其他实现方式,不过目前gcc 和微软的编译器都是将vptr放在对象内存布局的最前面。

  3. 虽然我们知道vptr指向虚函数表,那么虚函数表具体存放在内存哪个位置呢,虽然这里我们已经可以得到虚函数表的地址。实际上虚函数指针是在构造函数执行时初始化的,而虚函数表是存放在可执行文件中的。

    虚函数表vtable在Linux/Unix中存放在可执行文件的只读数据段中(rodata),这与微软的编译器将虚函数表存放在常量段存在一些差别。

虚函数的实现的基本原理

1. 概述

简单地说,每一个含有虚函数(无论是其本身的,还是继承而来的)的类都至少有一个与之对应的虚函数表,其中存放着该类所有的虚函数对应的函数指针。例:

其中:

  1. B的虚函数表中存放着B::foo和B::bar两个函数指针。

  2. D的虚函数表中存放的既有继承自B的虚函数B::foo,又有重写(override)了基类虚函数B::bar的D::bar,还有新增的虚函数D::quz。

提示:为了描述方便,本文在探讨对象内存布局时,将忽略内存对齐对布局的影响。

2. 虚函数表构造过程

从编译器的角度来说,B的虚函数表很好构造,D的虚函数表构造过程相对复杂。下面给出了构造D的虚函数表的一种方式

提示:该过程是由编译器完成的,因此也可以说:虚函数替换过程发生在编译时。

3. 虚函数调用过程

以下面的程序为例:

编译器只知道pb是B*类型的指针,并不知道它指向的具体对象类型 :pb可能指向的是B的对象,也可能指向的是D的对象。

但对于“pb->bar()”,编译时能够确定的是:此处operator->的另一个参数是B::bar(因为pb是B*类型的,编译器认为bar是B::bar),而B::bar和D::bar在各自虚函数表中的偏移位置是相等的。

无论pb指向哪种类型的对象,只要能够确定被调函数在虚函数中的偏移值,待运行时,能够确定具体类型,并能找到相应vptr了,就能找出真正应该调用的函数。

虚函数指针中的ptr部分为虚函数表中的偏移值(以字节为单位)加1。

B::bar是一个虚函数指针, 它的ptr部分内容为9,它在B的虚函数表中的偏移值为8(8+1=9)。

当程序执行到“pb->bar()”时,可以利用vptr找到虚函数表vtbl,利用虚函数指针找到对应的函数:

  1. 如果pb指向B的对象,可以获取到B对象的vptr,加上偏移值8((char*)vptr + 8),可以找到B::bar。

  2. 如果pb指向D的对象,可以获取到D对象的vptr,加上偏移值8((char*)vptr + 8) ,可以找到D::bar。

  3. 如果pb指向其它类型对象…同理…

4. 多重继承

当一个类继承多个类,且多个基类都有虚函数时,子类实例化对象中将包含多个虚函数表的指针(即多个vptr),

例:

其中:D自身的虚成员函数放在(D类中生成的)拷贝B基类的虚函数表中。因此也称B为D的主基类(primary base class)。

虚函数替换过程与前面描述类似,只是多了一个虚函数表,多了一次拷贝和替换的过程。

虚函数的调用过程,与前面描述基本类似,区别在于基类指针指向的位置可能不是派生类对象的起始位置,内存布局中,其父类布局依次按声明顺序排列。以如下面的程序为例:

5. 重复继承

每个类都有两个变量,一个是整形(4字节),一个是字符(1字节),而且还有自己的虚函数,自己overwrite父类的虚函数。如子类D中,f()覆盖了超类的函数, f1() 和f2() 覆盖了其父类的虚函数,Df()为自己的虚函数。

下面是对于子类实例中的虚函数表的图:

我们可以看见,最顶端的父类B其成员变量存在于B1和B2中,并被D给继承下去了。而在D中,其有B1和B2的实例,于是B的成员在D的实例中存在两份,一份是B1继承而来的,另一份是B2继承而来的。所以,如果我们使用以下语句,则会产生二义性编译错误:

D d;
d.ib = 0;               //二义性错误
d.B1::ib = 1;           //正确
d.B2::ib = 2;           //正确

注意,上面例程中的最后两条语句存取的是两个变量。虽然我们消除了二义性的编译错误,但B类在D中还是有两个实例,这种继承造成了数据的重复,我们叫这种继承为重复继承。重复的基类数据成员可能并不是我们想要的。所以,C++引入了虚基类的概念。

钻石型虚继承

虚拟继承的出现就是为了解决重复继承中多个间接父类的问题的。钻石型的结构是其最经典的结构。也是我们在这里要讨论的结构:

上述的“重复继承”只需要把B1和B2继承B的语法中加上virtual 关键,就成了虚拟继承,其继承图如下所示:

由于有了间接性和共享性两个特征,所以决定了虚继承体系下的对象在访问时必然会在时间和空间上与一般情况有较大不同。

  1. 时间:在通过继承类对象访问虚基类对象中的成员(包括数据成员和函数成员)时,都必须通过某种间接引用来完成,这样会增加引用寻址时间(就和虚函数一样),其实就是调整this指针以指向虚基类对象,只不过这个调整是运行时间接完成的。

  2. 空间:由于共享虚基类所以不需要再派生类中保存多份虚基类虚函数表的拷贝,这样相比多重继承节省空间。虚拟继承与普通继承不同的是:

    1. 虚拟继承可以防止出现diamond继承时,一个派生类中同时出现了两个基类的虚函数表。它需要多出一个指向基类的指针vbtr。

    2. 另外如果派生类本身也有自身的虚成员函数,那么派生类还要新建一个关于自身的虚函数表,并在实例化的对象中增加一个指向自身虚函数表的指针。

空类之间的虚继承

1
2
3
4
5
6
class X {};
class R {};
class Y : public virtual X{};
class Z : public virtual X,public virtual R{};
class A : public Y, public Z {};
class B : public X,public R{};
X:1		空类仅有一个char
Y:4		虚继承包含指向父类的指针
Z:8		包含两个指向父类的指针
A:12	直接继承X类和Z类的所有大小
B:1		多重继承空类,大小仍为1字节

带虚函数类之间的虚继承

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
第一种情况:         第二种情况:          第三种情况            第四种情况:
class a           class a              class a              class a
{              {                {                 {
 virtual void func(){}; virtual void func(){};    virtual void func(){};       virtual void func(){};
};              };                  char x;              char x;
class b:public virtual a   class b :public a           };                };
{              {                class b:public virtual a      class b:public a
    virtual void foo();        virtual void foo();     {                 {
};              };                  virtual void foo();        virtual void foo();
                               };                };

如果对这四种情况分别求sizeof(a), sizeof(b)。结果是什么样的呢?下面是输出结果:(在32位机vc6.0中运行)

第一种:4,12 
第二种:4,4
第三种:8,16
第四种:8,8

不同操作系统,不同位数,不同编译器结果基本都不一样,一般基类都没问题。对于子类:

第一个:vfptr(b:foo)+vbptr+vfptr(a:func)=12
第二个:vfptr(a:func, b:foo)=4
第三个:vfptr(b:foo)+vbptr+vfptr(a:func)+x(对齐为四个字节)=16
第四个:vfptr(a:func, b:foo)+x(对齐为四个字节)=8

这里没有类之间的函数覆盖,如果有会更麻烦一点。

想想这是为什么呢?

因为每个存在虚函数的类都要有一个4字节的指针指向自己的虚函数表,所以每种情况的类a所占的字节数应该是没有什么问题的,那么类b的字节数怎么算呢?看“第一种”和“第三种”情况采用的是虚继承,那么这时候就要有这样的一个指针vbptr,这个指针叫虚类指针,也是四个字节,所以类b的字节数就求出来了。而“第二种”和“第四种”情况则不包括vbptr指针。

获取虚表地址和虚函数地址

通过虚表的方式

这种方式的应用环境是通过类对象的指针或引用来调用虚函数

简单说一下虚表的概念:在一个类中如果有虚函数,那么此类的实例中就有 一个虚表指针指向虚表,这个虚表是一块儿专门存放类的虚函数地址的内存。

C++的编译器应该是保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下)。 这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。

注意:

因为每个存在虚函数的类在内存中有且只保存一张虚函数表,所以为了节省空间,虚表和虚表中的函数都是以指针形式存放在类内的。

d首地址前4字节是虚表指针的地址,需要解引用才是虚表指针的内容。虚表指针的内容为虚表的地址。

同理,虚表的地址前4字节是指向第一个虚函数的指针的地址,需要解引用才是第一个虚函数的地址。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include<cstdio>
#include<iostream>
using namespace std;
class Base {
public:
    virtual void f() { cout << "Base::f" << endl; }
    virtual void g() { cout << "Base::g" << endl; }
    void h() { cout << "Base::h" << endl; }
};

typedef void(*Fun)(void);//通用函数指针
    int main()
    {
    Base b;

    printf("vtb Address:%p\n", *(int *)&b);
    printf("virtual void f() Address:%p\n", *(int *)*(int *)&b);
    printf("virtual void g() Address:%p\n", *((int *)*(int *)(&b) + 1));
    Fun pfun = (Fun)*((int *)*(int *)(&b));//vitural f();//将地址转换为函数指针
    printf("f():%p\n", pfun);
    pfun();
    pfun = (Fun)(*((int *)*(int *)(&b) + 1));//vitural g();
    printf("g():%p\n", pfun);
    pfun();
    }

解析第一行代码:

printf("vtb Address:%p\n", *(int *)&b);
  1. &b代表基类对象b的起始地址

  2. (int *)&b 强转成int *类型,为了后面取b对象地址的前四个字节,前四个字节是虚表指针的地址

  3. *(int )&b 再加上*从虚表指针的地址中取出虚表指针,即vptr虚表地址,因为int型是4个字节,直接输出前4个字节的内容,即虚表地址

解析下一行代码:

printf("virtual void f() Address:%p\n", *(int *)*(int *)&b);

根据上面的解析我们知道*(int *)&b是vptr,即虚表地址.并且虚表是存放虚函数指针地址的集合。

一维数组中第一个元素的地址和数组首地址是一样的,那么虚函数表中指向第一个虚函数的指针的地址和虚函数表的地址一致,因此

(int *)*(int *)&b

虚表的地址本身已经解引用,成为了数值,若想通过虚表地址的解引用获得第一个函数指针,就必须将虚表的地址强制转换成(int *)形式,这才是虚表的第一个函数指针所在的地址.

所以*(int *)*(int *)&b就是虚表的第一个元素指针的内容.即f()的地址.

那么接下来的取第二个虚函数地址也就依次类推.始终记着vptr指向的是一块内存, 这块内存存放着虚函数指针的地址,这块内存就是我们所说的虚表.

VC下通过vcall thunk的方式

这种方式对应的应用环境是通过类成员函数的指针来调用虚函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include<iostream>  
#include<stdio.h>  
using namespace std;  

class Base {

public:
virtual void f() { cout << "Base::f" << endl; }

virtual void g() { cout << "Base::g" << endl; }

virtual void h() { cout << "Base::h" << endl; }

};  

typedef void(Base::*Fun)(void);   
Fun pFun = NULL;   

int main()  
{  
Base b;  
printf("%p/n",(int*)*(int*)*(int*)(&b));  

pFun = &Base::f;  
printf("%p/n",pFun);  
return 0;  
}  

不要试图寻找虚函数的真正地址

同是虚函数f的地址,为啥打印出来就不一样呢?

其实,事实的真相并不像你想象的那样,(int)(int)(int*)(&b)是虚函数表中的虚函数f地址,但是它并不是虚函数f真正的地址,而是编译后程序符号表段中的该函数索引的地址,通过符号表中的索引找到虚函数f对应的表项,其中有一项就是存储这其真正的函数地址

答案其实也很简单,符号表段是程序编译连接后产生的一个段,用于标识程序中全局静态变量、函数等符号和其真实地址之间的映射,就像代码段、数据段一样是程序编译连接后的一部分!

通过虚函数表获取的函数指针是函数符号的映射地址,也可认为是函数真正的调用地址。但通过函数指针获取的地址又是什么呢?

总的来说,函数指针pFun所指向的也并不是虚函数f的入口地址,而是编译器做了一个令人意想不到的处理,在代码编译后,会针对每个函数指针的类型,定义各自的调用函数,而且每个调用函数也不是真的指向原函数的真正的入口地址。

原理:类的成员函数指针和普通函数指针不一样,成员函数指针是一个结构体指针,里面包含了偏移量,标志(是否是虚函数),真实地址等。

怎么样?经过上面的分析,是否让你大跌眼镜!所有的一切均和起初预想的完全不一样!原因是你对程序编译想象的太简单了!其实,编译器为了优化和实现c++的某些特性,做了你根本想象不到的事情!而且不同的编译器编译的策略也是不一样的,所以这个代码如果用g++编译,可能又有另一番奇特景象!

综上所述,虚函数表中所指向的函数地址和函数指针所指向的地址都不是该函数的真正入口地址,虚函数表所指向的函数地址是该函数的符号地址;而函数指针所指向的地址则是编译器为了满足函数指针类型定义而生成的函数指针的调用地址。总之,他们是无法进行比较的!

如果你还是锱铢必较,请省点力气,因为里面的模型很是复杂,不同的编译器处理方式也不一样!

参考:

http://blog.csdn.net/haoel/article/details/3081385

http://www.cnblogs.com/malecrab/p/5572730.html

http://www.cnblogs.com/BeyondAnyTime/archive/2012/06/05/2537451.html

http://www.cnblogs.com/malecrab/p/5572119.html