题记:
有一些问题,每过一段时间就有新的理解...
聚合对象结构体和类
聚合对象,就是一系列的基本数据类型的组装,在内存中像线型一样紧密摆放,比如结构体就是典型的代表:
typedef struct tagPerson{ int age; char address[128]; }PERSON;
如果我们对C这套东西很了解,能知道在这个语言中,基本上的数据类型就是1,2,4,8,或者它们之间的组合。
比如,char 1字节,int 4字节,指针(任何数据类型的)占4字节,double 8字节。在这些类型中,特别注意指针是4字节。
所以聚合对象,就是基本对象的组合,如果我们基本对象熟练,那么聚合对象自然就熟练。
类和结构体从内存结构来说几乎一样,只相当于把名字struct换成class。因此,类也可认为是聚合对象。只不过,语言设计者又给它添加了构造析构等方法。
因此,把类等价类比于结构体是认识提升的第一步。
方法初探
写完类的基础,再看类的方法。一个类的方法是在编译期或者说运行之后就已经固定了,我们认为它就是固定的,比如:
class Person{ public: void show(){ printf("hello\n");} };
实例一个对象,并调用:
Person p;
p.show();
对象p调用方法,这个show()方法确定无疑在内存中处在一个固定的位置,我们常常说,方法是通用的,图示:
对象有若干个,但是方法只有一个。只要进入时限定对象,就可以通用,比如对象a进入,就将a的首地址传进去,对象b进入就将b的首地址传入,这样就可以区别开。
调用父类的方法
我们可以认为所有的一切都是事先安排好的,这其中都是编译器在背后做的工作。举例:
class Person{ public: void GetAge(){} }; class Employee:public Person{ public: void GetInfo(){ //调用GetAge()方法 GetAge(); } void GetAge(){} }; Employee e; e.GetInfo();
类Employee的GetInfo方法调用GetAge(),由于不存在虚函数,而且层次又这么清楚,所以每个类方法没有任何理由是动态的,也就是所有的方法都是固定位置确定无疑的。编译器编译三个方法:
Person::GetAge()
Employee::GetInfo()
Employee::GetAge()
因此,GetInfo()里面的方法,在编译期就能确定调用的是Employee::GetAge(),这就是上面所说的,一切都是事先安排好的。有的人说,我会强转大法,如下:
Employee per; Person* p=(Person*)&per; p->GetAge();
将雇员地址强转为Person*,编译器一眼就看到这是确定的,这是调用Person::GetAge()方法,所以我们的结论成立
结论:所有的方法都是事先安排好的
类派生的内存结构
普通的方法理解之后,我们再看下类的内存结构,它非常类似于套娃,如图所示:
|
class A{ public: int m_a; }; class B:public A{ public: int m_b; }; class C:public B{ public: int m_c; }; class D:public C{ public: int m_d; }; |
对于派生终端的子类D来说,它将会继承上面的所有类成员,并且最高层的类成员排在内存靠前位置,如下:
虚表指针
如果没有虚方法,那么内存布局就像上图所示,非常明了。一旦有虚方法之后,对象的起始4字节就被虚表指针占用了(前面我们假设指针占用4字节),就变成如下样式:
就好比虚表指针在说:都让开,前面4字节必须由我来占用。怎么验证呢?其实也比较简单,我就把这个对象的首地址前4字节强转出来,看看长的什么样:
class AA{ public: virtual void Show(){} }; class V:public AA{ public: V(){this->a=10;} private: int a; }; int main(int argc,char* argv[]){ V v; int* p=(int*)&v; printf("%p\n",*p); //注意这是取的是对象首地址四字节存储的值;如果没有虚方法,打印结果应该是10; printf("%d\n",*(p+1)); //将指针向前推四个字节,打印出10; return 0; }
按照内存结构,开始应该打印出10,结果是0x00402020(可以看出是一个地址值),然后将指针向前推了4字节,才得到10。说明前面四字节确实是虚表地址。
(这个例子也说明,类的成员访问控制仅仅是编译层面进行,实际简单绕一下就过去了)
虚表结构
从上面的实证中,我们知道对象前四个字节确实是虚表地址,有时候,学习就是这样,必须确定无疑拿出一个结果出来,我们真正用眼睛看到了才能真正理解。
下面我们接着说虚表结构,其实是一个指针数组,而这些指针就是函数地址。如下图所示:
假如我们打开编辑器进行debug,定位到0x00402020,就会看到类似下面这样的地址:
10 22 40 00 1A 22 40 00,(每个机器可能都不一样,这里仅做示例)
可以看出,这是地址,而虚表就是数组,由于指针是四字节,所以每四个字节为一组向前填充,这一步验证了虚表的概念
虚函数指针的填充
有的人会问,我没有在任何地方做,或者看到虚表指针被处理,那么它在哪个地方做了处理呢?答案是,编译器在构造方法中进行的。
因此会有结论:
1. 如果派生中存在虚函数定义,那么一定会有一个构造方法。
2. 如果用户没写,那么编译器会默认定义一个
3. 如果用户写了构造函数,即使什么都没做,编译器也在这个构造方法中悄悄的将虚表指针正确部署起来
虚函数的部署
为了理解这个问题,我们做个简单的例子,假如有两个类A和B,类A有一个虚方法,如下所示:
class A{ public: virtual void Show(){ printf("父类A"); } }; class B:public A{ public: };
由于子类B没有重写,因此原样将A继承过来了,虚表示意图如下:
在前面说明类方法时说过,每个类的方法都是固定的,因为它没有动态的理由,因此编译器首先在内存中部署了一个函数A::Show(),它和一般的方法并没有什么不同,只是因为
它是虚方法。假定它的入口地址是0x1000,如上图所示,就将该地址登记在虚表中。(注意,这部分都在说类B)
虚方法被重写如何呢?
假如B重写了类A的虚方法呢?对不起,A::Show()退下去,如图所示:
可以看到,A::Show()被排挤出去了,只留下B::Show(),因为只要写了,编译器足够聪明的知道你重写了。想一想,如果这一步不能确定,它怎么能正确的安装虚表呢?
这一步关键在于,要知道A::Show()并不是被继承过来了,而是被排挤出去了,现在只有B::Show()在虚表中。
好了,再次验证一句话:原来一切都是安排好的。
多态
当编译器安排好之后,如何实现多态呢?我们再举一例说明:
class Base{ public: virtual void Show(){ printf("父类Base\n"); } }; class A:public Base{ public: virtual void Show(){ printf("A类Show\n"); } }; class B:public Base{ public: }; void test(Base* base){ base->Show(); } int main(int argc,char* argv[]){ A a; B b; test(&a); test(&b); return 0; }
这个例子比较简单,A重写父类,B没有重写,根据前面的说明,我们知道A的虚表也被重写,B的虚表相当于继承过来,这一步是编译器干的。
因为编译器明确知道一切信息,所以虚表才能正确部署。
当调用test方法时,虽然形式上用的是基类的指针,但是我们注意到,这里实质传进去的是派生类对象的指针。
那么编译器会关心这个指针是谁吗?不,它一点都不关心,因为虚表已经确定了。
由于虚表指针是在对象首地址的前4字节,所以编译器不管你传什么指针进来,它第一步就去取虚表地址
void test(Base* base){ base->Show(); }
为什么呢?因为代码是在调用虚方法,编译结果即如此,所以在这一步中,base实参无论是A指针或B指针或Base指针都无所谓,
因为能调用虚方法的对象,前4字节必定是虚表地址。
当取到虚表地址之后,由于虚表已经固定,所以Show()方法的地址是可以精确计算出来的。
在我们这个例子中,虚方法Show确定是在第一行中,无论对于A重写来说,对于B继承来说,或原类Base,第一个虚方法地址必定是Show,所以这一步能取到Show方法地址。
最后一步,我们把对象地址push入栈,首地址确定即对象确定。
所有操作,都对传进来的指针是谁没有一点限定!
原来,一切早已安排好
- - 完结撒花 2022-4-8号 夜 - -