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

C++空类的大小及其内存布局再探

2021-06-194.1k 阅读

C++ 空类大小与内存布局概述

在 C++ 编程中,空类是一种特殊的类,它不包含任何数据成员和成员函数(除了编译器自动生成的特殊成员函数,如默认构造函数、析构函数、拷贝构造函数、赋值运算符重载等)。尽管空类看似没有实际内容,但了解其大小和内存布局对于深入理解 C++ 的内存管理机制和对象模型具有重要意义。

空类大小的理论分析

从直观上看,一个不包含任何数据成员的类似乎应该占用 0 字节的内存空间,因为它没有存储任何实际的数据。然而,在 C++ 标准中,空类的大小并不为 0。这是出于对象唯一性的考虑,如果空类大小为 0,那么两个不同的空类对象在内存中就可能具有相同的地址,这会导致一些逻辑上的混乱。例如,在数组中存储多个空类对象时,如果它们都占用 0 字节,就无法区分各个对象。因此,C++ 规定空类至少占用 1 字节的内存空间,这 1 字节通常被称为“占位字节”,仅仅是为了保证对象在内存中有唯一的地址。

空类内存布局的基本认识

虽然空类只占用 1 字节,但它的内存布局相对简单。由于没有数据成员,也就不存在数据成员在内存中的排列问题。这个 1 字节的占位空间在对象创建时被分配,其作用主要是标识对象的存在和唯一性。

代码示例展示空类大小

#include <iostream>

class EmptyClass {
};

int main() {
    std::cout << "Size of EmptyClass: " << sizeof(EmptyClass) << " bytes" << std::endl;
    return 0;
}

在上述代码中,定义了一个空类 EmptyClass。通过 sizeof 运算符获取 EmptyClass 的大小,并输出到控制台。运行这段代码,会发现输出结果为 1,这证明了空类在 C++ 中占用 1 字节的内存空间。

继承对空类大小和内存布局的影响

单继承下空类大小与内存布局变化

当一个空类继承自另一个空类时,情况会有所不同。

class BaseEmpty {
};

class DerivedEmpty : public BaseEmpty {
};

int main() {
    std::cout << "Size of BaseEmpty: " << sizeof(BaseEmpty) << " bytes" << std::endl;
    std::cout << "Size of DerivedEmpty: " << sizeof(DerivedEmpty) << " bytes" << std::endl;
    return 0;
}

在这段代码中,BaseEmpty 是一个空类,DerivedEmpty 继承自 BaseEmpty。运行结果会发现,BaseEmptyDerivedEmpty 的大小都为 1 字节。这是因为虽然 DerivedEmpty 继承自 BaseEmpty,但由于两者都是空类,没有新增数据成员,所以仍然只需要 1 字节的占位空间来保证对象的唯一性。从内存布局角度看,DerivedEmpty 对象在内存中的布局实际上包含了 BaseEmpty 部分(虽然为空),但整体仍然只占用 1 字节。

多重继承下空类大小与内存布局变化

考虑多重继承的情况:

class EmptyA {
};

class EmptyB {
};

class MultipleDerived : public EmptyA, public EmptyB {
};

int main() {
    std::cout << "Size of EmptyA: " << sizeof(EmptyA) << " bytes" << std::endl;
    std::cout << "Size of EmptyB: " << sizeof(EmptyB) << " bytes" << std::endl;
    std::cout << "Size of MultipleDerived: " << sizeof(MultipleDerived) << " bytes" << std::endl;
    return 0;
}

在上述代码中,MultipleDerived 类多重继承自 EmptyAEmptyB 两个空类。运行结果通常会发现 MultipleDerived 的大小为 1 字节。这是因为尽管 MultipleDerived 继承了两个空类,但从内存布局角度看,编译器仍然可以将其作为一个整体来处理,通过 1 字节的占位空间来保证对象的唯一性。不过,在更复杂的多重继承场景下,尤其是涉及到虚继承等情况时,内存布局会变得复杂得多。

虚函数对空类大小和内存布局的影响

空类中添加虚函数后的大小变化

当为空类添加虚函数时,情况会发生显著变化。

class VirtualEmpty {
public:
    virtual void virtualFunction() {}
};

int main() {
    std::cout << "Size of VirtualEmpty: " << sizeof(VirtualEmpty) << " bytes" << std::endl;
    return 0;
}

在这段代码中,VirtualEmpty 类添加了一个虚函数 virtualFunction。运行代码后会发现,VirtualEmpty 的大小不再是 1 字节,而是在 32 位系统下通常为 4 字节,在 64 位系统下通常为 8 字节。这是因为当类中包含虚函数时,编译器会为该类生成一个虚函数表(vtable),每个对象中会包含一个指向这个虚函数表的指针(vptr)。在 32 位系统中,指针大小为 4 字节,64 位系统中指针大小为 8 字节,所以类的大小相应增加。

虚函数对空类内存布局的影响

从内存布局角度看,VirtualEmpty 对象的内存布局首先是一个指向虚函数表的指针(vptr)。虚函数表是一个存储虚函数地址的数组,当调用虚函数时,通过对象中的 vptr 找到对应的虚函数表,再从虚函数表中获取虚函数的实际地址进行调用。例如,假设在 64 位系统下,VirtualEmpty 对象的内存布局如下:

内存地址内容
[对象起始地址]vptr(8 字节)

静态成员对空类大小和内存布局的影响

空类中添加静态成员变量后的大小变化

在空类中添加静态成员变量时,类的大小并不会改变。

class StaticEmpty {
public:
    static int staticVariable;
};

int StaticEmpty::staticVariable = 0;

int main() {
    std::cout << "Size of StaticEmpty: " << sizeof(StaticEmpty) << " bytes" << std::endl;
    return 0;
}

在这段代码中,StaticEmpty 类添加了一个静态成员变量 staticVariable。运行结果会发现,StaticEmpty 的大小仍然为 1 字节。这是因为静态成员变量不属于类的对象,而是属于类本身,它存储在全局数据区,不占用对象的内存空间。

静态成员变量在内存中的布局

静态成员变量的内存布局独立于类对象。它在程序的全局数据区分配内存,在程序加载时就已经存在,并且所有类对象共享这个静态成员变量。例如,对于 StaticEmpty 类的多个对象,它们都共享 staticVariable 这个静态成员变量,其内存地址是固定的,与具体的类对象无关。

空类中添加静态成员函数后的大小变化

同样,在空类中添加静态成员函数也不会改变类的大小。

class StaticFunctionEmpty {
public:
    static void staticFunction() {}
};

int main() {
    std::cout << "Size of StaticFunctionEmpty: " << sizeof(StaticFunctionEmpty) << " bytes" << std::endl;
    return 0;
}

StaticFunctionEmpty 类添加了静态成员函数 staticFunction,其大小依然为 1 字节。这是因为静态成员函数同样不属于类的对象,它的调用不依赖于具体的对象实例,而是通过类名直接调用,所以也不占用对象的内存空间。

数据对齐对空类大小和内存布局的影响

基本的数据对齐原则

在 C++ 中,数据对齐是一种内存优化策略,它规定了数据成员在内存中存储的位置对齐方式。通常,编译器会按照一定的对齐规则来分配内存,以提高内存访问效率。例如,在 32 位系统中,通常 4 字节对齐,即数据成员的地址必须是 4 的倍数;在 64 位系统中,通常 8 字节对齐。

空类在数据对齐影响下的大小和内存布局

虽然空类本身只占用 1 字节,但当它作为其他类的数据成员时,可能会受到数据对齐的影响。

class ContainingClass {
    EmptyClass empty;
    int num;
};

int main() {
    std::cout << "Size of ContainingClass: " << sizeof(ContainingClass) << " bytes" << std::endl;
    return 0;
}

在这段代码中,ContainingClass 包含一个 EmptyClass 对象和一个 int 类型的数据成员。在 32 位系统下,int 类型通常占用 4 字节,并且按照 4 字节对齐原则。由于 EmptyClass 本身占用 1 字节,为了满足 int 的 4 字节对齐要求,ContainingClass 的大小会变为 8 字节。从内存布局角度看,EmptyClass 对象占用 1 字节后,会填充 3 字节,然后 int 类型的 num 从 4 字节对齐的地址开始存储。即:

内存地址内容
[对象起始地址]EmptyClass 对象(1 字节)
[对象起始地址 + 1]填充(3 字节)
[对象起始地址 + 4]num(4 字节)

位域对空类大小和内存布局的影响

空类中位域的概念

位域是一种特殊的数据成员声明方式,它允许在一个字节内更细粒度地分配内存空间。在空类中使用位域,虽然本身不会改变空类作为独立对象的大小,但会影响包含该空类的更大对象的内存布局。

示例代码展示位域对包含空类对象的影响

class BitFieldEmpty {
    int value : 3;
};

class OuterClass {
    BitFieldEmpty bitField;
    char c;
};

int main() {
    std::cout << "Size of BitFieldEmpty: " << sizeof(BitFieldEmpty) << " bytes" << std::endl;
    std::cout << "Size of OuterClass: " << sizeof(OuterClass) << " bytes" << std::endl;
    return 0;
}

在这段代码中,BitFieldEmpty 类使用位域声明了一个只占用 3 位的 int 类型数据成员 value。由于 int 类型在 32 位系统下通常占用 4 字节,但 value 只使用 3 位,所以 BitFieldEmpty 类的大小可能仍然是 4 字节(具体取决于编译器的实现,有些编译器可能会优化为 1 字节)。OuterClass 包含 BitFieldEmpty 对象和一个 char 类型数据成员。如果 BitFieldEmpty 占用 4 字节,并且 char 类型占用 1 字节,在没有特殊优化的情况下,OuterClass 的大小可能为 8 字节,因为要满足 4 字节对齐。内存布局大致如下:

内存地址内容
[对象起始地址]BitFieldEmpty 对象(4 字节)
[对象起始地址 + 4]c(1 字节)
[对象起始地址 + 5]填充(3 字节)

编译器优化对空类大小和内存布局的影响

不同编译器的优化策略

不同的编译器在处理空类大小时可能会采用不同的优化策略。例如,GCC 编译器在某些情况下会对空类进行更激进的优化。当一个空类作为另一个类的第一个非静态数据成员时,GCC 可能会将其优化为不占用额外空间,前提是这种优化不会违反对象唯一性原则。而 Visual C++ 编译器在处理空类时,通常遵循标准的 1 字节占位规则,但在一些特定的编译选项下也可能会有不同的行为。

优化示例及分析

考虑以下代码:

class InnerEmpty {
};

class OuterOptimized {
    InnerEmpty empty;
    int num;
};

class OuterUnoptimized {
    int num;
    InnerEmpty empty;
};

int main() {
    std::cout << "Size of OuterOptimized (GCC optimized): " << sizeof(OuterOptimized) << " bytes" << std::endl;
    std::cout << "Size of OuterUnoptimized (GCC): " << sizeof(OuterUnoptimized) << " bytes" << std::endl;
    return 0;
}

在 GCC 编译器下,如果开启优化选项,OuterOptimized 的大小可能会比 OuterUnoptimized 小。对于 OuterOptimized,由于 InnerEmpty 是第一个非静态数据成员,GCC 可能会将其优化为不占用额外空间,OuterOptimized 的大小可能为 4 字节(假设 int 占用 4 字节)。而对于 OuterUnoptimized,由于 InnerEmpty 不是第一个非静态数据成员,它仍然需要 1 字节的占位空间,OuterUnoptimized 的大小可能为 8 字节(考虑 4 字节对齐)。

总结空类大小与内存布局相关要点

  1. 空类大小基础:空类通常占用 1 字节,这是为了保证对象的唯一性。
  2. 继承影响:单继承和多重继承下,如果基类和派生类都是空类,整体大小可能仍为 1 字节,但内存布局会涉及到基类部分的概念。
  3. 虚函数影响:添加虚函数会使空类大小增加,因为会引入虚函数表指针,在不同系统下指针大小不同导致类大小变化,同时改变内存布局。
  4. 静态成员影响:静态成员变量和函数不影响空类对象大小,因为它们不属于对象,内存布局独立于对象。
  5. 数据对齐影响:空类作为其他类成员时,会受数据对齐规则影响,改变包含它的类的大小和内存布局。
  6. 位域影响:位域在空类中的使用,会影响包含该空类的更大对象的内存布局。
  7. 编译器优化:不同编译器对空类大小和内存布局有不同优化策略,了解这些有助于编写高效且可移植的代码。

通过深入研究 C++ 中空类的大小及其内存布局,我们能更好地理解 C++ 的内存管理机制和对象模型,在实际编程中进行更有效的内存优化和代码设计。