C++类与对象在内存中的表现
C++ 类的内存布局基础
在 C++ 中,类是一种自定义的数据类型,它将数据成员和成员函数封装在一起。当我们创建一个类的对象时,这些对象会在内存中占据一定的空间,其内存布局遵循特定的规则。
简单类的内存布局
考虑一个简单的类,它只包含数据成员,没有成员函数:
class SimpleClass {
public:
int data1;
double data2;
};
当我们创建 SimpleClass
的对象时,内存布局如下:对象在内存中是一块连续的区域,data1
和 data2
按照声明的顺序依次存放。data1
作为 int
类型通常占用 4 个字节(在 32 位系统下),data2
作为 double
类型占用 8 个字节。所以,SimpleClass
对象的大小为 4 + 8 = 12 个字节。
成员函数不占用对象空间
现在,为 SimpleClass
添加成员函数:
class SimpleClass {
public:
int data1;
double data2;
void printData() {
std::cout << "data1: " << data1 << ", data2: " << data2 << std::endl;
}
};
尽管 SimpleClass
现在有了一个成员函数 printData
,但对象的大小仍然是 12 个字节。这是因为成员函数并不存储在每个对象的内存空间中。所有对象共享类的成员函数代码。成员函数通过对象的地址(this
指针)来访问对象的数据成员。
虚函数与内存布局
虚函数表(VTable)
当类中包含虚函数时,情况变得更为复杂。虚函数允许在派生类中被重写,以实现多态性。为了支持多态,C++ 引入了虚函数表(VTable)的概念。
class Base {
public:
virtual void virtualFunction() {
std::cout << "Base::virtualFunction" << std::endl;
}
};
对于包含虚函数的 Base
类,每个 Base
对象的内存布局除了数据成员外,还会增加一个指针,称为虚函数表指针(vptr)。这个 vptr 指向一个虚函数表(VTable)。VTable 是一个数组,其中每个元素是一个指向虚函数的指针。在上述 Base
类中,VTable 只包含一个元素,即指向 Base::virtualFunction
的指针。
派生类的虚函数表
当有派生类继承自包含虚函数的基类时,派生类会有自己的虚函数表。
class Derived : public Base {
public:
void virtualFunction() override {
std::cout << "Derived::virtualFunction" << std::endl;
}
};
Derived
类重写了 Base
类的 virtualFunction
。Derived
对象的内存布局同样包含 vptr,这个 vptr 指向 Derived
类的虚函数表。Derived
的虚函数表中,对应 virtualFunction
的位置,存放的是指向 Derived::virtualFunction
的指针。
多重继承与虚函数表
在多重继承的情况下,内存布局会更加复杂。
class A {
public:
virtual void funcA() {
std::cout << "A::funcA" << std::endl;
}
};
class B {
public:
virtual void funcB() {
std::cout << "B::funcB" << std::endl;
}
};
class C : public A, public B {
public:
void funcA() override {
std::cout << "C::funcA" << std::endl;
}
void funcB() override {
std::cout << "C::funcB" << std::endl;
}
};
C
类从 A
和 B
类多重继承。C
对象的内存布局中,会有两个 vptr,分别指向 A
类和 B
类的虚函数表(在 C
类中被重写后的版本)。这两个 vptr 的顺序与继承的顺序一致,即先有指向 A
类虚函数表的 vptr,然后是指向 B
类虚函数表的 vptr,接着是 C
类的数据成员。
静态成员与内存布局
静态数据成员
静态数据成员是类的所有对象共享的成员。它不存储在每个对象的内存空间中。
class StaticClass {
public:
static int sharedData;
int nonStaticData;
};
int StaticClass::sharedData = 0;
StaticClass
的对象只包含 nonStaticData
的空间,而 sharedData
存储在全局数据区,与对象的实例无关。所有 StaticClass
对象都共享这一份 sharedData
。
静态成员函数
静态成员函数同样不依赖于对象的实例。它没有 this
指针,因为它不操作对象的非静态数据成员。
class StaticClass {
public:
static int sharedData;
int nonStaticData;
static void staticFunction() {
std::cout << "StaticClass::staticFunction, sharedData: " << sharedData << std::endl;
}
};
int StaticClass::sharedData = 0;
staticFunction
可以直接通过类名调用,如 StaticClass::staticFunction()
。它的代码存储在全局代码区,与对象的内存布局无关。
内存对齐
内存对齐的概念
内存对齐是指编译器为了提高内存访问效率,在分配内存空间时,按照一定的规则对数据成员进行排列。例如,对于一个包含 int
和 char
的类:
class AlignClass {
public:
char c;
int i;
};
如果不进行内存对齐,c
占用 1 个字节,i
占用 4 个字节,对象大小理论上为 5 个字节。但在实际中,编译器会对 i
进行对齐,使得 i
的地址是 4 的倍数(因为 int
的自然对齐边界通常是 4 字节)。所以,AlignClass
对象的大小会是 8 个字节,c
后面会填充 3 个字节。
对齐规则
不同编译器和系统可能有不同的对齐规则,但通常遵循以下原则:
- 数据成员的对齐边界是其自身大小和编译器默认对齐值中的较小值。例如,
double
的大小是 8 字节,若编译器默认对齐值是 4 字节,则double
的对齐边界是 4 字节。 - 类的大小是其所有数据成员对齐后大小之和,并且是最大对齐边界的倍数。
修改对齐方式
在 C++ 中,可以使用 #pragma pack
指令来修改编译器的默认对齐方式。
#pragma pack(push, 1)
class CustomAlignClass {
public:
char c;
int i;
};
#pragma pack(pop)
通过 #pragma pack(push, 1)
,将对齐值设置为 1 字节,这样 CustomAlignClass
对象的大小就是 1 + 4 = 5 字节,因为不再进行填充。#pragma pack(pop)
则恢复原来的对齐设置。
类对象的构造与析构在内存中的表现
构造函数的内存操作
构造函数用于初始化对象的数据成员。当创建一个对象时,首先会为对象分配内存空间,然后调用构造函数进行初始化。
class InitClass {
public:
int value;
InitClass(int v) : value(v) {
std::cout << "InitClass constructor, value: " << value << std::endl;
}
};
当执行 InitClass obj(10);
时,首先在内存中为 obj
分配 4 个字节(int
类型 value
的大小)的空间,然后调用构造函数,将 10
赋值给 value
。
析构函数的内存操作
析构函数用于释放对象占用的资源,当对象生命周期结束时,会调用析构函数。
class ResourceClass {
public:
int* data;
ResourceClass() {
data = new int[10];
std::cout << "ResourceClass constructor" << std::endl;
}
~ResourceClass() {
delete[] data;
std::cout << "ResourceClass destructor" << std::endl;
}
};
在 ResourceClass
的构造函数中,为 data
分配了动态内存。当 ResourceClass
对象生命周期结束时,析构函数会被调用,释放 data
指向的动态内存。
继承体系下对象的内存布局与构造析构
继承体系下的内存布局
当存在继承关系时,派生类对象的内存布局包含基类部分和派生类自身新增的部分。
class BaseClass {
public:
int baseData;
BaseClass(int b) : baseData(b) {
std::cout << "BaseClass constructor, baseData: " << baseData << std::endl;
}
};
class DerivedClass : public BaseClass {
public:
int derivedData;
DerivedClass(int b, int d) : BaseClass(b), derivedData(d) {
std::cout << "DerivedClass constructor, derivedData: " << derivedData << std::endl;
}
};
DerivedClass
对象的内存布局中,首先是 BaseClass
部分,包含 baseData
,然后是 DerivedClass
自身的 derivedData
。
继承体系下的构造顺序
在创建 DerivedClass
对象时,构造顺序是先调用基类的构造函数,然后调用派生类的构造函数。例如,当执行 DerivedClass obj(10, 20);
时,首先调用 BaseClass
的构造函数,初始化 baseData
为 10,然后调用 DerivedClass
的构造函数,初始化 derivedData
为 20。
继承体系下的析构顺序
析构顺序与构造顺序相反。当 DerivedClass
对象生命周期结束时,首先调用派生类的析构函数,然后调用基类的析构函数。
多态性在内存层面的实现
动态绑定与虚函数表
多态性通过动态绑定实现,而虚函数表是动态绑定的关键。当通过基类指针或引用调用虚函数时,实际调用的函数取决于对象的实际类型。
Base* basePtr = new Derived();
basePtr->virtualFunction();
在上述代码中,basePtr
是 Base
类型的指针,但指向 Derived
对象。当调用 virtualFunction
时,通过 basePtr
指向的对象的 vptr,找到 Derived
类的虚函数表,从而调用 Derived::virtualFunction
。
运行时类型识别(RTTI)
运行时类型识别(RTTI)是 C++ 支持多态性的另一个重要机制。它基于虚函数表实现。typeid
运算符和 dynamic_cast
运算符都依赖于 RTTI。
Base* basePtr = new Derived();
if (Derived* derivedPtr = dynamic_cast<Derived*>(basePtr)) {
std::cout << "It is a Derived object" << std::endl;
}
dynamic_cast
运算符在运行时检查 basePtr
实际指向的对象是否为 Derived
类型。这一检查依赖于对象的虚函数表和 RTTI 信息。
总结 C++ 类与对象内存表现要点
- 成员函数不占用对象空间,对象只包含数据成员。
- 虚函数通过虚函数表实现多态,每个包含虚函数的类都有自己的虚函数表,对象包含虚函数表指针。
- 静态成员不属于对象,存储在全局数据区或代码区。
- 内存对齐影响对象的大小,编译器会按照一定规则对数据成员进行排列。
- 构造函数初始化对象数据成员,析构函数释放对象资源,在继承体系下构造和析构有特定顺序。
- 多态性通过虚函数表和动态绑定实现,RTTI 基于虚函数表提供运行时类型识别功能。
理解 C++ 类与对象在内存中的表现,对于编写高效、正确的 C++ 代码至关重要。它有助于优化内存使用,避免内存泄漏,并更好地利用 C++ 的面向对象特性。在实际编程中,特别是在处理复杂的继承体系和多态性时,深入了解内存布局能帮助开发者更好地调试和优化程序。