[关闭]
@zsh-o 2018-04-12T15:56:23.000000Z 字数 2394 阅读 2067

C++ 类的内存布局 与 继承的虚函数机制

编程 C++


类与实际运行时的对象的关系是,在程序运行时类会被初始化成一个默认的对象,也就是说类会被预先加载到内存中,当声明具体的对象的时候再把该内存拷贝到一个新的地方,根据参数修改初始化的值(构造函数)【“实例化一个对象就是调用一个函数”】
image.png-85.4kB
就像这个,我们声明的是一个父类的对象,指针c1c2所指的内存地址是相同的,但其类型不同,意思是,c1指针是把c1指向的地址解析为C1类型,c2是把c2指向的地址解析成C2类型,解析的意思也就是根据类中的成员的类型和偏移地址不同,把不同的偏移地址按照相应的类型进行转化
C++的内存模型为
image.png-222.9kB
可以看出来,C++继承的内存布局是依据父类->子类的顺序来的,因此,上面的父类C1的对象c1转换为子类c2的对象,也就是c1内存后面的一部分内存被转化为了子类C2的专有成员,因为该内存没有被初始化,所以c2->a = -1414812757

而带有虚函数的类,采用虚函数表来完成,加虚函数与不加虚函数的区别:
image.png-124.9kB
先说下C++中对函数(非inline)的处理,首先是实例化的对象的内存分配中不包括函数的内存也不包括函数的指针,也就是说具体的实例化的对象中不包括任何关于函数(这里不表示虚函数)的任何信息,而函数的调用是用当前类型中保存的函数的入口地址,调用该函数的时候用栈保存各种上下文环境(返回地址参数列表等),然后跳转到该函数的入口地址来执行,也就是说跟对象具体由哪个类实例化来的没什么关系,只是跟当前地址所表示的类型有关,就像c12 = (C12 *)c11,这时候把c11转换为了类型C12,那么c12这个指针的类型就是C12,那么这时候执行c12->run(),执行的是类C12中的函数,而且这时c12的c->b=1,原因就是上面所说,类C12和类C11的结构相同,那么各个成员(不包括函数)在内存中的偏移是相同的,所以这时候的c12是把子类C2中的C11部分转换为了类C12(因为这时候两个指针都是指向c2这个实例化对象的始地址)其内存分配如下所示
image.png-55.8kB
如果这样设计类的话,调用函数的时候必须每次指明指针的类型,但指针的类型均是在编译期间决定的。假设一个现在很常见的场景,我们有一个基类三个子类,如果按照上面的方法,我们要访问子类中的函数就必须首先要有一个子类类型的指针,但这样是很不灵活的,例如我们需要把三个子类的对象一起处理,然后只需要用一个类型为父类的指针就可以实现操作每一个成员函数,但可以这样保存例如把它们都保存到一个vector<Base*>里面,但当调用函数的时候,就需要有具体的每一个子类对象的指针,但在这里我们并不知道每一个具体的对象的具体是由哪一个子类实例化来的,我们只知道他们的基类类型为Base,也就只能调用基类的函数,这显然不是我们想要的,我们想要的是用基类类型的指针也能调用子类的函数。人们想到的解决方法就是虚函数(virtual)这也是C++多态的精髓,那么具体是怎么解决这个问题的呢,方法很简单,只需要在对象的内存分配上加上指定函数的地址,然后用父类调用的时候就调用该地址指向的函数就可以了,所以加了一个virtual关键字(虚函数)表明此函数是多态的,然后在分配内存时多分配了一个叫做虚函数列表指针的指针空间,该指针指向一个保存实例化该对象的类的虚函数列表。因为该对象实例化的时候我们是知道该对象的类型的(new + 构造函数关键字)所以这个虚函数列表每一个类只需要有一份就可以了,而指向该地址的指针需要每一个实例化的对象都需要存在一个。而虚函数列表是这样设计的:每一个父类的虚函数列表指针是单独分开的(有个父类就有个虚函数列表指针),子类中同名的虚函数会覆盖掉父类的虚函数列表中的虚函数,也就是说列表中基类的同名的虚函数是该实例化的子类的函数,那么这时候内存分配为下图(VFTP表示虚函数列表指针)(分别为实例化C11C12C2时的内存布局)
image.png-113.4kB
这里需要注意的是,当前类的虚函数与继承的第一个父类的虚函数合并到一个虚函数列表里面了,当前类与父类中的同名虚函数会被替换成当前类的虚函数,因此,用父类的指针去调用该函数时执行的仍然是此子类的函数(会按照上面的偏移转换的方式进行类型转换)。而当调用无同名的虚函数时执行的为第一个基类的函数(也就是根据偏移得到的函数—这也说明了C++在执行期间只有偏移没有名称)
c12->run1()执行的是C11run1(),如果把类C12中的run1()改名为run2(),执行的仍然是C11run1(),这也就说明了类型中成员的值是由该指针的类的成员偏移决定的,与名称无关

然后再说最后一个问题,既然成员的值是由偏移和类型决定的,那么指针类型的内存结构与第一个基类内存结构不同会如何。这种情况很容易造,也很常见,只需要在上面类C12中添加一个虚函数run2(),然后调用该虚函数即可,此时C12的内存布局如下
image.png-72.9kB
这时候调用c12->run2(),然后出现了运行时错误,因为此时的run2()的地址不知道指向哪,这也就是为什么运行时错误总是发生在函数处的原因。还有一点要说的是我们只能用指针获得该指针类型中的成员,也就是说c11无法得到run2()函数的指针,虽然c11指针指向的内存为C2类型的(包含run2()函数),但c11指针的类型却是C11,这样也就保证了C++编译器能够发现一些语义错误(不能获取类中没有的成员)而具体的该段内存究竟是什么实例化来的是由运行时决定的

另一个非常非常常见的例子是用基类实例化对象,类型转换为子类的类型,然后调用父类中不存在的函数,这也会发生运行时错误,而调用调用父类中不存在的变量或者偏移结构不同时,变量的值是不可预知的。

Reference

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注