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

C++ class与struct在内存布局上的差异

2024-07-115.4k 阅读

C++ class与struct在内存布局上的差异

基础概念回顾

在深入探讨内存布局差异之前,先简单回顾一下 classstruct 的基础概念。

在 C++ 中,classstruct 都用于定义用户自定义类型,它们可以包含数据成员和成员函数。从表面上看,二者有一些语法上的区别,例如默认访问控制权限不同。class 的默认访问控制权限是 private,而 struct 的默认访问控制权限是 public。但这只是最基本的区别,在内存布局方面,它们有着更微妙的差异。

简单数据成员的内存布局

  1. 无继承且无虚函数的情况 首先来看一个简单的 struct 定义:
struct SimpleStruct {
    int a;
    char b;
    short c;
};

在这种情况下,SimpleStruct 的内存布局是按照成员声明的顺序依次排列的。假设 int 占 4 个字节,char 占 1 个字节,short 占 2 个字节。那么 SimpleStruct 的大小理论上应该是 4 + 1 + 2 = 7 个字节。但实际上,由于内存对齐的原因,它的大小会是 8 个字节。内存对齐是为了提高 CPU 访问内存的效率,通常以结构体中最大基本数据类型的大小为对齐单位。这里最大的基本数据类型是 int,占 4 个字节,所以 SimpleStruct 的内存布局如下:

内存地址内容说明
0x00 - 0x03a 的值int 类型,4 字节对齐
0x04b 的值char 类型,1 字节,占用 1 个字节,剩余 3 个字节填充
0x05 - 0x06c 的值short 类型,2 字节,剩余 2 个字节填充
0x07填充字节对齐到 8 字节

再看对应的 class

class SimpleClass {
public:
    int a;
    char b;
    short c;
};

在这种简单情况下,SimpleClass 的内存布局和 SimpleStruct 是完全一样的。因为二者都没有继承和虚函数等复杂特性,只是定义了简单的数据成员,并且访问控制权限在这里并不影响内存布局。

  1. 包含成员函数 现在给 structclass 分别添加成员函数:
struct StructWithFunction {
    int a;
    void printA() {
        std::cout << "a = " << a << std::endl;
    }
};

class ClassWithFunction {
public:
    int a;
    void printA() {
        std::cout << "a = " << a << std::endl;
    }
};

成员函数并不占用类或结构体实例的内存空间。它们存储在代码段中,所有该类或结构体的实例共享这些函数代码。所以 StructWithFunctionClassWithFunction 的内存布局仍然只由数据成员决定,和之前没有成员函数时的内存布局相同。

继承情况下的内存布局

  1. struct 的继承 假设有如下的 struct 继承关系:
struct BaseStruct {
    int a;
};

struct DerivedStruct : BaseStruct {
    char b;
};

在这种情况下,DerivedStruct 的内存布局是先放置 BaseStruct 的数据成员,然后再放置自身的数据成员。假设 int 占 4 个字节,char 占 1 个字节,并且按照 4 字节对齐。DerivedStruct 的内存布局如下:

内存地址内容说明
0x00 - 0x03a(继承自 BaseStruct)的值int 类型,4 字节对齐
0x04b 的值char 类型,1 字节,剩余 3 个字节填充
0x05 - 0x07填充字节对齐到 8 字节
  1. class 的继承 同样的继承关系用 class 来实现:
class BaseClass {
public:
    int a;
};

class DerivedClass : public BaseClass {
public:
    char b;
};

这里 DerivedClass 的内存布局和 DerivedStruct 是类似的,先放置 BaseClass 的数据成员,再放置自身的数据成员。访问控制权限在内存布局上没有影响,只要继承方式相同(这里都是 public 继承),内存布局就一致。

  1. 不同继承方式下的内存布局 当使用 private 继承时,情况会有所不同。以 class 为例:
class Base {
public:
    int a;
};

class Derived : private Base {
public:
    char b;
};

private 继承下,Derived 类的对象内存布局依然是先放置 Base 类的数据成员,再放置自身的数据成员。但从外部来看,Base 类的成员在 Derived 类中变为 private 访问权限。这种继承方式主要用于实现上的复用,而不是类型继承。struct 也可以使用 private 继承,内存布局规则和 classprivate 继承类似。

虚函数与内存布局

  1. 虚函数表(VTable)的概念 当类或结构体中包含虚函数时,内存布局会发生显著变化。C++ 通过虚函数表(VTable)来实现多态。每个包含虚函数的类或结构体都有一个虚函数表,该表存储了虚函数的地址。对象的内存布局中会包含一个指向虚函数表的指针(vptr)。

  2. struct 包含虚函数 来看一个包含虚函数的 struct

struct StructWithVirtualFunction {
    virtual void virtualFunction() {
        std::cout << "Struct virtual function" << std::endl;
    }
    int a;
};

StructWithVirtualFunction 的内存布局首先是一个指向虚函数表的指针(vptr),假设指针占 8 个字节(64 位系统),然后是数据成员 a。按照 8 字节对齐,其内存布局如下:

内存地址内容说明
0x00 - 0x07vptr,指向虚函数表8 字节指针,8 字节对齐
0x08 - 0x0Ba 的值int 类型,4 字节,剩余 4 个字节填充
  1. class 包含虚函数 类似的 class 定义如下:
class ClassWithVirtualFunction {
public:
    virtual void virtualFunction() {
        std::cout << "Class virtual function" << std::endl;
    }
    int a;
};

ClassWithVirtualFunction 的内存布局和 StructWithVirtualFunction 相同,都是先有一个 vptr,然后是数据成员。在内存布局层面,包含虚函数时 classstruct 的处理方式是一致的。

  1. 虚继承与内存布局 虚继承是一种特殊的继承方式,用于解决菱形继承带来的二义性问题。当使用虚继承时,内存布局会更加复杂。假设有如下的虚继承关系:
class VirtualBase {
public:
    int a;
};

class VirtualDerived1 : virtual public VirtualBase {
public:
    char b;
};

class VirtualDerived2 : virtual public VirtualBase {
public:
    short c;
};

class FinalDerived : public VirtualDerived1, public VirtualDerived2 {
public:
    double d;
};

在这种情况下,FinalDerived 的内存布局中,VirtualBase 的数据成员 a 只会出现一次,而不是在 VirtualDerived1VirtualDerived2 中各出现一次。同时,VirtualDerived1VirtualDerived2 会各自包含一个指向 VirtualBase 数据成员的偏移量指针(在某些实现中)。FinalDerived 的内存布局大致如下(简化描述,实际可能因编译器实现而异):

内存地址内容说明
0x00 - 0x07VirtualDerived1 的 vptr(如果有虚函数)8 字节指针,8 字节对齐
0x08 - 0x0Bb 的值char 类型,1 字节,剩余 3 个字节填充
0x0C - 0x13VirtualDerived2 的 vptr(如果有虚函数)8 字节指针,8 字节对齐
0x14 - 0x15c 的值short 类型,2 字节,剩余 6 个字节填充
0x16 - 0x23指向 VirtualBasea 的偏移量指针(假设)8 字节指针,8 字节对齐
0x24 - 0x31d 的值double 类型,8 字节

对于 struct 的虚继承,内存布局原理和 class 的虚继承是相同的,同样是为了保证虚基类数据成员在最终派生类中只出现一次,避免数据冗余和二义性。

多重继承下的内存布局

  1. class 的多重继承 假设有如下的多重继承关系:
class Base1 {
public:
    int a;
};

class Base2 {
public:
    char b;
};

class Derived : public Base1, public Base2 {
public:
    short c;
};

在这种情况下,Derived 的内存布局是先放置 Base1 的数据成员,再放置 Base2 的数据成员,最后放置自身的数据成员。假设 int 占 4 个字节,char 占 1 个字节,short 占 2 个字节,并且按照 4 字节对齐。Derived 的内存布局如下:

内存地址内容说明
0x00 - 0x03a(继承自 Base1)的值int 类型,4 字节对齐
0x04b(继承自 Base2)的值char 类型,1 字节,剩余 3 个字节填充
0x05 - 0x06c 的值short 类型,2 字节,剩余 2 个字节填充
0x07填充字节对齐到 8 字节
  1. struct 的多重继承 同样的多重继承关系用 struct 实现:
struct Base1Struct {
    int a;
};

struct Base2Struct {
    char b;
};

struct DerivedStruct : Base1Struct, Base2Struct {
    short c;
};

DerivedStruct 的内存布局和 Derived 类是一致的。在多重继承场景下,classstruct 的内存布局遵循相同的规则,即按照继承列表的顺序依次放置基类的数据成员,然后放置自身的数据成员。

内存布局与模板

  1. 模板类和模板结构体 模板在 C++ 中提供了代码复用的强大机制。当涉及到模板类和模板结构体时,内存布局的规则依然适用,但会根据模板实例化的具体类型有所不同。

    例如,有一个模板结构体:

template <typename T>
struct TemplateStruct {
    T data;
    void printData() {
        std::cout << "Data: " << data << std::endl;
    }
};

当实例化 TemplateStruct<int> 时,其内存布局就由 int 类型的数据成员 data 决定。如果 int 占 4 个字节,那么 TemplateStruct<int> 的实例大小就是 4 个字节(不考虑成员函数占用空间)。

同样,对于模板类:

template <typename T>
class TemplateClass {
public:
    T data;
    void printData() {
        std::cout << "Data: " << data << std::endl;
    }
};

TemplateClass<int> 的内存布局和 TemplateStruct<int> 是一样的,只由数据成员 data 的类型决定。模板类和模板结构体在内存布局上的行为与普通类和结构体类似,只是具体的内存布局会随着模板参数类型的不同而变化。

  1. 模板继承与内存布局 当存在模板继承关系时,内存布局也遵循继承的一般规则。例如:
template <typename T>
struct BaseTemplateStruct {
    T a;
};

template <typename T>
struct DerivedTemplateStruct : BaseTemplateStruct<T> {
    T b;
};

当实例化 DerivedTemplateStruct<int> 时,其内存布局先放置 BaseTemplateStruct<int> 的数据成员 a,然后放置自身的数据成员 b。假设 int 占 4 个字节,按照 4 字节对齐,DerivedTemplateStruct<int> 的内存布局如下:

内存地址内容说明
0x00 - 0x03a 的值int 类型,4 字节对齐
0x04 - 0x07b 的值int 类型,4 字节对齐

对于模板类的继承,如:

template <typename T>
class BaseTemplateClass {
public:
    T a;
};

template <typename T>
class DerivedTemplateClass : public BaseTemplateClass<T> {
public:
    T b;
};

DerivedTemplateClass<int> 的内存布局和 DerivedTemplateStruct<int> 相同,都是先放置基类的数据成员,再放置自身的数据成员。

内存对齐与优化

  1. 内存对齐的原因 内存对齐是为了提高 CPU 访问内存的效率。现代 CPU 通常以特定的字节数(如 4 字节、8 字节等)为单位来访问内存。如果数据存储的地址不是对齐的,CPU 可能需要多次访问内存才能获取完整的数据,这会降低性能。例如,假设 CPU 以 4 字节为单位访问内存,一个 int 类型的数据如果存储在非 4 字节对齐的地址上,CPU 可能需要先访问一次获取前一部分数据,再访问一次获取后一部分数据。

  2. 控制内存对齐 在 C++ 中,可以通过 #pragma pack 指令来控制内存对齐。例如:

#pragma pack(push, 1)
struct UnalignedStruct {
    int a;
    char b;
    short c;
};
#pragma pack(pop)

这里 #pragma pack(push, 1) 表示将对齐方式设置为 1 字节对齐,这样 UnalignedStruct 的大小就是 4 + 1 + 2 = 7 个字节,不再进行填充。#pragma pack(pop) 则恢复到之前的对齐方式。但需要注意的是,过度使用非标准的对齐方式可能会导致性能问题,因为 CPU 访问非对齐数据的效率较低。

  1. 优化内存布局 为了优化内存布局,可以合理安排数据成员的顺序。例如,将较大的数据成员放在前面,较小的数据成员放在后面,以减少填充字节的数量。对于结构体或类:
struct OptimizedStruct {
    double d; // 8 字节
    int a;    // 4 字节
    char b;   // 1 字节,剩余 3 字节填充
};

相比:

struct UnoptimizedStruct {
    char b;   // 1 字节,剩余 7 字节填充
    double d; // 8 字节
    int a;    // 4 字节
};

OptimizedStruct 的内存布局更紧凑,浪费的填充字节更少。在设计复杂的类或结构体时,这种优化可以显著减少内存占用,特别是在大量对象实例化的情况下。

总结内存布局差异

综上所述,C++ 中 classstruct 在内存布局上大部分情况下是相同的。它们的内存布局主要由数据成员的类型、顺序、继承关系、虚函数以及内存对齐等因素决定。

  1. 默认访问控制权限不影响内存布局class 默认的 private 访问权限和 struct 默认的 public 访问权限,在内存布局方面没有直接影响。
  2. 继承关系下内存布局规则相同:无论是 class 还是 struct,继承时内存布局都是先放置基类的数据成员,再放置自身的数据成员,并且不同的继承方式(publicprivateprotected)在内存布局上的差异主要体现在访问权限的变化,而不是内存布局结构本身。
  3. 虚函数处理一致:当包含虚函数时,classstruct 都会在对象内存布局的开头放置一个指向虚函数表的指针(vptr),后续再放置数据成员。
  4. 模板相关内存布局类似:模板类和模板结构体在内存布局上遵循与普通类和结构体相同的规则,只是具体布局会根据模板参数类型的不同而变化。

理解 classstruct 在内存布局上的差异,有助于编写高效、紧凑的代码,特别是在处理大型数据结构和高性能应用程序时。通过合理利用内存布局规则,可以优化内存使用和提高程序性能。