C++继承关系中,深度解析类内存布局与多态的实现
类内存布局,有如下特征。
- 派生类对象包含完整的基类对象
- 派生类中基类的内存按继承顺序排列
- 派生类新增成员追加在基类对象的成员之后
详情可通过下面的示例体会。
一,使用开发人员命令提示工具,打印类内存布局
示例1的继承关系如下所示。
class N1
{int m_A;
};class N2
{int m_A;
};class N3 : public N1, public N2
{int m_C;
};
现获取派生类N3在内存中的布局。
① 打开 开发人员命令提示符工具。
② 跳转盘符到cpp文件所在的盘
③ 跳转路径到cpp文件所在的路径
④ 打印出此路径下的所有文件
⑤ 报告单个类N3的内存布局
命令为:
cl /d1 reportSingleClassLayoutN3 ConsoleApplication1.cpp
N3类的内存布局为:
ConsoleApplication1.cppclass N3 size(12):+---0 | +--- (base class N1)0 | | m_A| +---4 | +--- (base class N2)4 | | m_A| +---8 | m_C+---
可见,N3的大小为12字节,有两个基类N1(起始偏移量是0)和N2(起始偏移量是4)。
二,类内存布局的特点
1,类内部无任何非静态成员变量时,其大小为1字节
继承关系和内存布局如下所示。
class N1
{
};class N2
{
};class N3 : public N1, public N2
{
};
ConsoleApplication1.cppclass N3 size(1):+---0 | +--- (base class N1)| +---1 | +--- (base class N2)| +---+---
N3的内存大小为1字节。类内部无任何非静态成员变量时,其大小为1字节。
2,只有非静态的成员变量才会存储在类对象上
①、某示例的继承关系和内存布局如下所示。
class N1
{int m_A;
};class N2 : public N1
{int m_A;
};class N3 : public N1, public N2
{int m_C;
};
ConsoleApplication1.cppclass N3 size(16):+---0 | +--- (base class N1)0 | | m_A| +---4 | +--- (base class N2)4 | | +--- (base class N1)4 | | | m_A| | +---8 | | m_A| +---
12 | m_C+---构造N3对象时,先构造N1,再构造N1,再构造N2,再构造N3。
②、某示例的继承关系和内存布局如下所示。
class N1
{int m_A;void M1() {}static void M2() {}
};class N2 : public N1
{int m_A;static float m_E;
};class N3 : public N1, public N2
{int m_C;static int m_F;void M1() {}static void M2() {}
};
class N3 size(16):+---0 | +--- (base class N1)0 | | m_A| +---4 | +--- (base class N2)4 | | +--- (base class N1)4 | | | m_A| | +---8 | | m_A| +---
12 | m_C+---
对比上面两示例,可知,只有非静态的成员变量才会存储在类对象上,静态成员变量、成员函数,都不会存储在类对象上。
3,一个复杂继承关系的说明
菱形继承,继承关系如下所示。
class N1
{
public:int m_A;void M1() {}
};class N2 : public N1
{
public:float m_A;void M1() {}
};class N3 : public N1
{
public:std::string m_A;static void M1() {}
};class N4 : public N3, public N2
{
};
N4的内存布局为:
ConsoleApplication29.cppclass N4 size(36):+---0 | +--- (base class N3)0 | | +--- (base class N1)0 | | | m_A| | +---4 | | ?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@ m_A| +---
28 | +--- (base class N2)
28 | | +--- (base class N1)
28 | | | m_A| | +---
32 | | m_A| +---+---
①、在一个类的内部,成员名称不能重复。但是在继承关系中,子父类的成员名称可以重复。
②、当基类中有多个同名成员时(在上图所示的基类中,有多个同名成员,而不论同名成员分属于哪个类),使用派生类对象访问该成员时,需明确要访问的成员是谁,否则,编译器会报错。如下图。
正确的访问方式是,指定访问变量的作用域:
int main()
{N4 n4;n4.N2::m_A = 1;n4.N2::N1::m_A = 2;n4.N3::m_A = 4;std::cout << n4.N2::m_A << " " << n4.N2::N1::m_A << " " << n4.N3::N1::m_A << " " << n4.N3::m_A;
}
③、当派生类中有与基类同名的成员时,使用派生类对象直接访问成员,是被允许的,此时,通过派生类对象可以直接访问在派生类中直接定义的成员。
比如,修改N4的定义为:
class N4 : public N3, public N2
{
public:std::string m_A;
};
可直接通过N4的对象访问m_A的成员。
int main()
{N4 n4;n4.N2::m_A = 1;n4.N2::N1::m_A = 2;n4.N3::m_A = 4;n4.m_A = "23";std::cout << n4.N2::m_A << " " << n4.N2::N1::m_A << " " << n4.N3::N1::m_A << " " << n4.N3::m_A << " " << n4.m_A;
}
④、对于非静态的成员函数,规则也同上述的所说的成员变量。因为成员函数涉及到this指针的指向,需要明确。(即:基类中多个成员函数时,调用函数时,既需要明确函数地址,也需要明确是哪个this指针。否则编译器就会报不明确的错误。如果派生类自身有该函数定义时,可以使用派生类对象直接访问该函数)。
⑤、静态成员函数也是如此,但只需要明确函数地址。当函数地址不明确时,会报不明确的错误,但不会因this指针不明确而报错,因为静态函数不涉及this指针。
⑥、首先在派生类中查找成员,没找到,然后在基类中查找。基类中找到多个时,报不明确的错误。
⑦、static_cast转换
int main()
{N4 n4;// 由于N4对象中有多个N1对象,编译器不知道要获取哪个N1的地址// 下面代码会报错:N1不明确N1* n1Ptr = static_cast<N1*>(&n4);// 先转换成N3的指针,再转换为N1指针,此时转换为的N1是明确的N1* n1Ptr = static_cast<N1*>(static_cast<N3*>(&n4));
}
4、继承关系中的this指针
①、下面的代码中,在main函数中,调用M1()函数时,传递this指针时,是n3对象的指针吗?
#include <iostream>class N1
{
public:int m_A;void M1() {}
};class N2
{
public:float m_A;
};class N3 : public N2, public N1
{std::string m_A;
};int main()
{N3 n3;n3.M1();
}
N3的内存分布:
class N3 size(32):+---0 | +--- (base class N2)0 | | m_A| +---4 | +--- (base class N1)4 | | m_A| +---8 | ?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@ m_A+---
当通过派生类对象调用基类成员函数时,编译器会自动将基类子对象的地址作为 this 指针传递。
调用 n3.M1() 时,this 指针指向 N3 对象中 N1 子对象的地址,即&n3偏移4字节后的地址: &n3 + sizeof(N2)。
当调用 n3.M1()
时,编译器实际会自动计算N1子对象的地址:
// 伪代码
N1::M1(static_cast<N1*>(&n3)); // 自动计算 N1 子对象地址
②更复杂的代码说明
以下面的代码为例:
// ConsoleApplication29.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//#include <iostream>class N1
{
public:int m_A;void M1() {}
};class N2
{
public:float m_A;void M1() {}
};class N3 : public N2, public N1
{
public:std::string m_A;void M1() {}
};class N4 : public N3
{
public:static void M1() {}
};int main()
{N4 n4;n4.M1();n4.N3::M1();n4.N3::N2::M1();n4.N3::N1::M1();
}
N4的内存布局为:
class N4 size(32):+---0 | +--- (base class N3) -> n4.N3::M1()的this指针的指向0 | | +--- (base class N2) -> n4.N3::N2::M1()的this指针指向0 | | | m_A| | +---4 | | +--- (base class N1) -> n4.N3::N1::M1()的this指针指向4 | | | m_A| | +---8 | | ?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@ m_A| +---+---构造顺序,与内存的排布顺序相同,先构造N2,再构造N1,再构造N3,再构造N4。
上述代码,执行了四次M1()函数。四次调用的函数地址都不相同。
n4.M1(); 执行的是静态函数,没有this指针。其他三次的调用都是对象的成员函数,调用时都需要this指针。this指针的指向如下说明:
调用方式 | this 指针指向 | 地址偏移 | 函数类型 |
---|---|---|---|
n4.M1() | 无 this 指针 | - | 静态函数 |
n4.N3::M1() | N3 子对象的起始地址 | 0(this地址为&n4+0) | 非静态 |
n4.N3::N2::M1() | N3 中 N2 子对象的地址 | 0(this地址为&n4+0) | 非静态 |
n4.N3::N1::M1() | N3 中 N1 子对象的地址 | 4(this地址为&n4+4) | 非静态 |
如上表,n4.N3::M1(); n4.N3::N2::M1(); 两次调用的函数地址不同,但是this指针相同,都是偏移量为0的地址。n4.N3::N1::M1(); 的调用时的this指针,为base class N1的起始地址。
N4对象包含了多个子对象。各个子对象的指针获取方式为:
子对象类型 | 访问方式 | 内存偏移 |
---|---|---|
N4 自身 | &n4 | 0 |
N3 | static_cast<N3*>(&n4) | 0 |
N2 (通过N3) | static_cast<N2*>(static_cast<N3*>(&n4)) | 0 |
N1 (通过N3) | static_cast<N1*>(static_cast<N3*>(&n4)) | 4 |
N2 (直接) | static_cast<N2*>(&n4) | 12 |
5,多角度解读继承关系
- 派生类对象(如 N4)是一个单一完整的对象,在内存中占据连续的空间。
- 派生类对象内部会包含多个基类子对象,这些子对象是派生类对象的一部分。它们共享同一块内存区域的不同部分。
当通过不同基类接口操作对象时,会产生不同的 this 指针,这些 this 指针指向同一个物理对象的不同子对象部分。不是说"有多个 this 指针",而是同一个对象在不同上下文中呈现不同的地址视角。
三、虚继承
1,使用虚继承解决菱形继承的问题
如下代码,是典型的菱形继承的问题,并通过虚继承的方式,只在内存中,创建一份N1的实例。
#include <iostream>
#include <string>class N1
{
public:int m_A;void M1() {}
};class N2 : public virtual N1
{
public:int m_A;void M1() {}
};class N3 : virtual public N1
{
public:int m_A;void M1() {}
};class N4 : public N3, public N2
{
public:float m_A;void M1() {}
};
对应的内存布局为:
D:\Project\ConsoleApplication1\ConsoleApplication1>cl /d1 reportSingleClassLayoutN4 ConsoleApplication1.cpp
用于 x86 的 Microsoft (R) C/C++ 优化编译器 19.43.34810 版
版权所有(C) Microsoft Corporation。保留所有权利。ConsoleApplication1.cppclass N4 size(24):+---0 | +--- (base class N3)0 | | {vbptr} // x86编译器,虚基表指针是4字节。指针指向N4::$vbtable@N3@4 | | m_A| +---8 | +--- (base class N2)8 | | {vbptr} // 指向N4::$vbtable@N2@
12 | | m_A| +---
16 | m_A+---+--- (virtual base N1)
20 | m_A+---N4::$vbtable@N3@:0 | 0 // 第一个0,表示是虚基表的第一个条目。此条目偏移量为0,没有实际意义。为了兼容早期的MSVC版本的格式。1 | 20 (N4d(N3+0)N1) // 20表示偏移量N4::$vbtable@N2@:0 | 01 | 12 (N4d(N2+0)N1) // 12表示偏移量
vbi: class offset o.vbptr o.vbte fVtorDispN1 20 0 4 0
Microsoft (R) Incremental Linker Version 14.43.34810.0
Copyright (C) Microsoft Corporation. All rights reserved./out:ConsoleApplication1.exe
ConsoleApplication1.obj
N4的内存布局:基类子对象、派生类自身成员、虚基类子对象。
虚基表指针:类实例化的对象只存储虚基表指针,不存储虚基表。虚基表指针在成员变量的起始位置(基类子对象之后,自身成员变量之前),每个vbptr指向它自己的虚基表vbtable。
虚基表:虚基表与对象内存不是连续的,它不在对象内存中,它是在编译时静态创建的,它存储在可执行文件的只读数据段(.rdata)中。所有对象的实例指向的虚基表的地址是相同的,它是按类创建的,它独立于对象实例,它是全局共享的。
N4::$vbtable@N3@:N4表示这个表属于N4类,$vbtable表示这是一个虚基表,@N3@表示这个虚基表专门为N4中的N3子对象服务的。所有的N4的对象共享着一个虚基表。
20 (N4d(N3+0)N1):20表示地址偏移量。N4是上下文类,表示最终的派生类。d:表示数据类型是偏移量(delta偏移量)。起始子对象是N2。N2+0表示虚基表指针的位置,即在N2子对象内偏移0字节的位置。目标虚基类是N1。完整的含义是:在 N4 对象中,从 N2 子对象的起始位置(加上0字节偏移)到虚基类 N1 的偏移量是20。
2,更复杂实示例的说明
继承关系:
#include <iostream>
#include <string>class N1
{
public:int m_A;void M1() {}
};class N2 : public virtual N1
{
public:int m_A;void M1() {}
};class N3 : virtual public N1, public virtual N2
{
public:int m_A;void M1() {}
};class N4 : public N3, public N2
{
public:float m_A;void M1() {}
};
内存布局为:
D:\Project\ConsoleApplication1\ConsoleApplication1>cl /d1 reportSingleClassLayoutN4 ConsoleApplication1.cpp
用于 x86 的 Microsoft (R) C/C++ 优化编译器 19.43.34810 版
版权所有(C) Microsoft Corporation。保留所有权利。ConsoleApplication1.cpp
ConsoleApplication1.cpp(25): warning C4584: “N4”: 基类“N2”已是“N3”的基类
ConsoleApplication1.cpp(11): note: 参见“N2”的声明
ConsoleApplication1.cpp(18): note: 参见“N3”的声明class N4 size(32):+---0 | +--- (base class N3)0 | | {vbptr} // 指向N4::$vbtable@N3@4 | | m_A| +---8 | +--- (base class N2) // 直接的继承类N2。直接继承的子类对象,可能有多个,虚继承的子类对象只会有一个。由于N2也有虚继承的基类,那么它的每个对象(直接继承的多个和最多一个虚继承对象,都会有自己的虚继承表)8 | | {vbptr} // 指向N4::$vbtable@:
12 | | m_A| +---
16 | m_A+---+--- (virtual base N1)
20 | m_A+---+--- (virtual base N2) // 虚继承类N2
24 | {vbptr} // 指向N4::$vbtable@N2@
28 | m_A+---N4::$vbtable@N3@:0 | 01 | 20 (N4d(N3+0)N1) // N3有两个虚继承基类,虚基类表地址是N3+0,表中有两个虚基类N1和N2的偏移量2 | 24 (N4d(N3+0)N2)N4::$vbtable@: // N4直接继承的第二个基类是N2,这是N2的虚基类表0 | 01 | 12 (N4d(N2+0)N1)N4::$vbtable@N2@: // 虚基类N2的虚继承表0 | 01 | -4 (N4d(N2+0)N1)
vbi: class offset o.vbptr o.vbte fVtorDispN1 20 0 4 0N2 24 0 8 0
Microsoft (R) Incremental Linker Version 14.43.34810.0
Copyright (C) Microsoft Corporation. All rights reserved./out:ConsoleApplication1.exe
ConsoleApplication1.obj
四、虚函数
子类指针可以转换为父类指针,因为子类对象包含父类子对象,可以通过指针偏移的方式将指针转换为父类指针。
虚函数会在类内部生成虚函数表指针,虚函数表指针指向虚函数表。虚函数表是编译器在编译时生成的静态数据结构,它存储在程序的可执行文件中,并在程序加载时映射到内存的只读数据段。所有同类型的类对象共享同一个虚函数表。
在有虚函数的类及其派生类中,每个类都会创建自身的虚函数表。
- 若派生类未重写基类虚函数,派生类的虚函数表中保留基类函数的地址(指向基类实现)。
- 若派生类重写了基类虚函数,派生类的虚函数表中对应条目更新为派生类函数的地址(覆盖基类地址)。
- 若派生类新增虚函数,新虚函数的地址会追加到虚函数表末尾。
下面根据代码做详细说明,虚函数代码如下:
#include <iostream>
#include <string>class N1
{
public:int m_A;virtual void M1() = 0;virtual void M2() = 0;
};class N2 : public N1
{
public:int m_A;void M1() override { std::cout << "&N2::M1()" << std::endl; }void M2() override { std::cout << "&N2::M2()" << std::endl; }
};class N3 : public N2
{
public:int m_A;void M1() { std::cout << "&N3::M1()" << std::endl; }
};
N3的虚函数表的情况如下:
ConsoleApplication1.cppclass N3 size(16):+---0 | +--- (base class N2)0 | | +--- (base class N1)0 | | | {vfptr}4 | | | m_A| | +---8 | | m_A| +---
12 | m_A+---N3::$vftable@:| &N3_meta| 00 | &N3::M11 | &N2::M2N3::M1 this adjustor: 0
五、虚析构函数
在将派生类指针赋值给基类指针对象时,在使用delete清理基类指针时,编译器会将整个派生类给清理掉。但是不会调用派生类的析构函数,所以,需要写虚析构函数。
六,设计哲学的体现
总而言之,继承关系、虚函数表、虚基类表等内容的实现,其实是为了适应OOP的开发理念而设计的。在物理现实上,基类与派生类并无关系,每个派生类其实是全新的类型,有独立的虚函数表、独立的虚基类表、有独立的内存布局。对于编译器而言,单独写一个弃用继承、多态等特性的类反而更简单。但为了使得代码在人类眼中看起来更加简洁和便于维护,为了适应长期迭代的任务开发和功能扩展,才建立了上述的底层实现的复杂性。这其实是把类型关系的复杂性从开发人员的认知层面转移到了编译器层面,用编译器的负担换取开发者的认知减负,使得代码逻辑更加贴近现实的逻辑认知。