MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++类与对象在内存中的表现

2021-02-101.3k 阅读

C++ 类的内存布局基础

在 C++ 中,类是一种自定义的数据类型,它将数据成员和成员函数封装在一起。当我们创建一个类的对象时,这些对象会在内存中占据一定的空间,其内存布局遵循特定的规则。

简单类的内存布局

考虑一个简单的类,它只包含数据成员,没有成员函数:

class SimpleClass {
public:
    int data1;
    double data2;
};

当我们创建 SimpleClass 的对象时,内存布局如下:对象在内存中是一块连续的区域,data1data2 按照声明的顺序依次存放。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 类的 virtualFunctionDerived 对象的内存布局同样包含 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 类从 AB 类多重继承。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()。它的代码存储在全局代码区,与对象的内存布局无关。

内存对齐

内存对齐的概念

内存对齐是指编译器为了提高内存访问效率,在分配内存空间时,按照一定的规则对数据成员进行排列。例如,对于一个包含 intchar 的类:

class AlignClass {
public:
    char c;
    int i;
};

如果不进行内存对齐,c 占用 1 个字节,i 占用 4 个字节,对象大小理论上为 5 个字节。但在实际中,编译器会对 i 进行对齐,使得 i 的地址是 4 的倍数(因为 int 的自然对齐边界通常是 4 字节)。所以,AlignClass 对象的大小会是 8 个字节,c 后面会填充 3 个字节。

对齐规则

不同编译器和系统可能有不同的对齐规则,但通常遵循以下原则:

  1. 数据成员的对齐边界是其自身大小和编译器默认对齐值中的较小值。例如,double 的大小是 8 字节,若编译器默认对齐值是 4 字节,则 double 的对齐边界是 4 字节。
  2. 类的大小是其所有数据成员对齐后大小之和,并且是最大对齐边界的倍数。

修改对齐方式

在 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();

在上述代码中,basePtrBase 类型的指针,但指向 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++ 类与对象内存表现要点

  1. 成员函数不占用对象空间,对象只包含数据成员。
  2. 虚函数通过虚函数表实现多态,每个包含虚函数的类都有自己的虚函数表,对象包含虚函数表指针。
  3. 静态成员不属于对象,存储在全局数据区或代码区。
  4. 内存对齐影响对象的大小,编译器会按照一定规则对数据成员进行排列。
  5. 构造函数初始化对象数据成员,析构函数释放对象资源,在继承体系下构造和析构有特定顺序。
  6. 多态性通过虚函数表和动态绑定实现,RTTI 基于虚函数表提供运行时类型识别功能。

理解 C++ 类与对象在内存中的表现,对于编写高效、正确的 C++ 代码至关重要。它有助于优化内存使用,避免内存泄漏,并更好地利用 C++ 的面向对象特性。在实际编程中,特别是在处理复杂的继承体系和多态性时,深入了解内存布局能帮助开发者更好地调试和优化程序。