文章目录
- 一、如何计算一个类的大小?
- 二、sizeof 计算的几个关键因素:
- 2.1 特殊情况(空类、静态成员变量):
- 2.2 示例
- 三、结构体对齐
- 3.1 alignas 关键字:
- 3.2 C++ 结构体对齐举例
- 3.3 offsetof 宏
- 3.3.1 如何理解偏移量?
- 四、一些问题
- 4.1 什么是内存对齐,结构体是如何进行内存对齐的?
- 4.2 如何让结构体按照指定的默认对齐数进行对齐?
- 4.3 如何知道结构体某个成员相对于起始位置的偏移量
- 4.4 什么是大小端?如何测试一个机器是大端还是小端?有没有什么需要考虑大小端的场景
- 4.4.1 什么是大小端(Endianness)?
- 4.4.2 如何测试一个机器是大端还是小端?
- 4.4.3 需要考虑大小端的场景
- 4.4.4 如何进行字节序转换?
无意间刷到一些关于类大小的内容,正好写一篇文章作为另一篇的跳转,下面我们直接进入正题:
一、如何计算一个类的大小?
在 C++ 中,计算一个类的大小通常是通过 sizeof
运算符来完成的。sizeof
运算符可以用来计算一个类、结构体、数组或基本数据类型的内存占用大小。
如果要计算一个类的大小,需要考虑下面的因素:
-
类的成员变量
- 类的大小由它的成员变量、继承的基类、以及内存对齐方式共同决定。成员变量包括基本类型、指针、对象、数组等。
-
内存对齐(Padding)
- 由于大多数系统的内存访问效率较高时需要遵守对齐规则,因此类的大小可能会比成员变量的总和大一些。
- 编译器通常会对类的成员进行内存对齐,使得每个成员变量的地址都满足特定的对齐要求。为了实现对齐,编译器可能会在成员变量之间插入空白(填充字节),即 “padding”。
-
虚函数
- 如果类有虚函数,那么类通常会有一个虚函数表(vtable),这会占用额外的空间。
-
继承
- 如果类继承了其他类(基类),则继承的成员也会计入该类的大小。
一般我们可以通过sizeof 直接计算类的大小,比如:
cout << sizeof(MyClass) << endl;
这会返回类 MyClass
在内存中的大小,单位是字节。
二、sizeof 计算的几个关键因素:
-
内存对齐:编译器通常会在成员之间插入填充字节,以使每个成员按照其类型的对齐要求进行对齐。一般来说,
int
的对齐要求是 4 字节,double
的对齐要求是 8 字节,char
对齐要求是 1 字节。 -
虚函数:如果类中有虚函数,类会有一个虚函数表指针(vptr),这会占用额外的空间。在 64 位系统中,这通常是 8 字节。
-
继承:如果类继承了其他类,继承的成员会增加类的大小。例如,子类继承了父类的成员变量和虚函数表指针。
2.1 特殊情况(空类、静态成员变量):
-
空类:对于没有成员的空类,
sizeof
计算结果通常为 1。原因是即使没有成员,C++ 也要求每个对象在内存中有一个唯一的地址。因此,空类的大小是 1 字节。 -
类的静态成员变量:静态成员变量属于类本身,而不是类的每个实例。因此,静态成员变量的大小不计算在每个实例的大小中。只有实例化对象时,类的非静态成员变量的大小才会计算。
2.2 示例
#include <iostream>
using namespace std;// 类 A:包含基本成员变量
class A {
public:int x; // 4 字节char y; // 1 字节
};class B {
public:int x; // 4 字节char y; // 1 字节double z; // 8 字节
};class C {
public:virtual void foo() {} // 有虚函数int x; // 4 字节double y; // 8 字节
};class D {
public:static int staticVar; // 静态成员变量int x; // 4 字节char y; // 1 字节
};// 基类 E,含有一个虚函数
class E {
public:virtual void bar() {}int x;
};// 子类 F,继承自基类 E
class F : public E {
public:double y;char z;
};int main() {cout << "Size of A: " << sizeof(A) << " bytes" << endl; // 计算类 A 的大小cout << "Size of B: " << sizeof(B) << " bytes" << endl; // 计算类 B 的大小cout << "Size of C: " << sizeof(C) << " bytes" << endl; // 计算类 C 的大小cout << "Size of D: " << sizeof(D) << " bytes" << endl; // 计算类 D 的大小cout << "Size of E: " << sizeof(E) << " bytes" << endl; // 计算基类 E 的大小cout << "Size of F: " << sizeof(F) << " bytes" << endl; // 计算子类 F 的大小return 0;
}int D::staticVar = 10; // 静态成员变量的定义
代码解析和类大小的计算:
-
类 A:
class A { public:int x; // 4 字节char y; // 1 字节 };
x
是int
类型,通常占 4 字节。y
是char
类型,占 1 字节。- 由于内存对齐规则,
x
需要在 4 字节边界上对齐,所以y
后面会有 3 字节的填充字节。最终,类A
的大小为 8 字节。
-
类 B:
class B { public:int x; // 4 字节char y; // 1 字节double z; // 8 字节 };
x
是int
类型,占 4 字节。y
是char
类型,占 1 字节。z
是double
类型,占 8 字节。y
后面会有 3 字节的填充,以满足double
类型的 8 字节对齐要求。所以,类B
的大小为 16 字节(4 + 1 + 3 填充 + 8 字节)。
-
类 C(有虚函数):
class C { public:virtual void foo() {} // 有虚函数int x; // 4 字节double y; // 8 字节 };
- 类
C
中有一个虚函数,因此会为类对象添加一个虚函数表指针(vptr),通常占 8 字节(在 64 位系统上)。 x
占 4 字节,y
占 8 字节。- 类
C
的总大小将是 24 字节(8 字节虚函数表指针 + 4 字节x
+ 8 字节y
)。
- 类
-
类 D(含有静态成员变量):
class D { public:static int staticVar; // 静态成员变量int x; // 4 字节char y; // 1 字节 };
- 静态成员变量
staticVar
并不属于类的每个实例,它是在类加载时分配内存,而不是为每个对象单独分配内存,因此它不会影响类对象的大小。 - 只有
x
(4 字节)和y
(1 字节),以及填充字节(3 字节),所以类D
的大小为 8 字节。
- 静态成员变量
-
类 E(基类):
class E { public:virtual void bar() {}int x; // 4 字节 };
- 类
E
含有一个虚函数,所以它也有一个虚函数表指针(vptr),通常占 8 字节。 x
占 4 字节。- 类
E
的总大小为 12 字节(8 字节虚函数表指针 + 4 字节x
)。
- 类
-
类 F(继承自基类 E):
class F : public E { public:double y; // 8 字节char z; // 1 字节 };
- 类
F
继承了类E
的虚函数表指针(8 字节)和成员变量x
(4 字节)。 y
是double
类型,占 8 字节。z
是char
类型,占 1 字节。- 为了满足对齐要求,
z
后面会有 7 字节的填充,以保证类的大小是 8 字节对齐的。 - 类
F
的总大小为 24 字节(8 字节虚函数表指针 + 4 字节x
+ 8 字节y
+ 1 字节z
+ 7 字节填充)。
- 类
输出示例:
Size of A: 8 bytes
Size of B: 16 bytes
Size of C: 24 bytes
Size of D: 8 bytes
Size of E: 12 bytes
Size of F: 24 bytes
总结
在计算类的大小时,主要受到以下因素的影响:
- 成员变量的类型和对齐要求:编译器会根据成员的类型插入必要的填充字节,以确保成员按其对齐要求正确对齐。
- 虚函数:如果类中有虚函数,那么类的大小会包括虚函数表指针(vptr),通常占用 8 字节(在 64 位系统中)。
- 静态成员变量:静态成员变量不占用对象内存,因此它们不影响对象的大小。
- 继承:子类继承父类时,除了父类的成员和虚函数表指针外,还会增加自己独立的成员。
三、结构体对齐
关于类与结构体的对齐规则可以看下面的文章:
结构体的内存对齐(规则、存在原因、默认对齐数的修改等+实例分析)
上面的文章是关于C语言的struct的结构体对齐,而C++类与struct的结构体对齐规则与C语言是一致的,需要注意的就是C++的一些新内容、新特性:
3.1 alignas 关键字:
C++11
引入了 alignas
关键字,可以明确指定类或结构体的对齐要求。这样可以影响类或结构体的对齐方式。
示例:
struct A {char a; // 1 字节int b; // 4 字节
};alignas(16) struct B {char a; // 1 字节int b; // 4 字节
};
通过 alignas(16)
,结构体 B 的起始地址会对齐到 16 字节边界,从而可能导致更多的填充字节。
3.2 C++ 结构体对齐举例
对于下面代码的四个类,我们进行画图分析:
#include <iostream>
using namespace std;class Base {
public:virtual void func() {} // 虚函数int baseData; // 基类数据成员
};class Derived : public Base {
public:double derivedData; // 派生类数据成员
};class VirtualBase {
public:virtual void vfunc() {} // 虚函数char vBaseData; // 虚基类数据成员
};class MultipleInheritance : public Derived, public VirtualBase {
public:float multipleData; // 多重继承类的数据成员
};int main() {cout << "Size of Base: " << sizeof(Base) << endl;cout << "Size of Derived: " << sizeof(Derived) << endl;cout << "Size of VirtualBase: " << sizeof(VirtualBase) << endl;cout << "Size of MultipleInheritance: " << sizeof(MultipleInheritance) << endl;return 0;
}
我们通过一个示例图分析class Base 结构体对齐后的大小,
- Base 类
有一个虚函数 func()
和一个 int baseData
。由于虚函数,编译器会为它添加一个虚函数表指针 vptr。假设:
vptr
需要 8 字节。int baseData
占用 4 字节。
由于 int 通常要求 4 字节对齐,Base 类的内存布局可能是:
- 8 字节的 vptr,紧接着 4 字节的 int baseData,因此 Base 的总大小为 16 字节。
其他的以此,我们分别进行分析:
Derived
类:
Derived
类继承自 Base
,它有一个额外的 double derivedData
。由于 double
通常要求 8 字节对齐,因此 Derived
类的布局会受到影响:
Base
部分(16 字节)已经包含虚函数表指针和baseData
。double derivedData
需要 8 字节,并且它的对齐要求是 8 字节。
因此,Derived
类的总大小是:
- 16 字节(来自
Base
类的部分) + 8 字节(double derivedData
)= 24 字节。
VirtualBase
类:
VirtualBase
类有一个虚函数 vfunc()
和一个 char vBaseData
。虚继承会引入虚基类指针。假设:
vptr
需要 8 字节(虚函数表指针)。char vBaseData
占用 1 字节。
由于虚继承,VirtualBase
可能还需要填充字节来满足对齐要求,通常会填充 7 字节。
因此,VirtualBase
类的总大小为:
- 8 字节(
vptr
) + 1 字节(vBaseData
) + 7 字节填充 = 16 字节。
MultipleInheritance
类:
MultipleInheritance
类同时继承自Derived
和VirtualBase
,因此它会包含两个虚函数表指针(一个来自Base
,一个来自VirtualBase
),以及来自这两个父类的数据成员。
Derived
类的部分:包含一个 8 字节的虚函数表指针和double derivedData
(8 字节),总计 24 字节。VirtualBase
类的部分:包含一个 8 字节的虚函数表指针和char vBaseData
(1 字节),总计 16 字节。MultipleInheritance
类自身的数据成员multipleData
(float
类型,4 字节)。
因为虚继承,MultipleInheritance
类还需要额外的填充字节来确保对齐,因此其总大小可能为:
- 24 字节(来自
Derived
)+ 16 字节(来自VirtualBase
)+ 4 字节(multipleData
)+ 填充字节 = 48 字节。
输出示例
Size of Base: 16
Size of Derived: 24
Size of VirtualBase: 16
Size of MultipleInheritance: 48
3.3 offsetof 宏
如果要知道一个结构体成员相对于结构体起始位置的偏移量,可以使用 offsetof
宏;offsetof
宏返回结构体成员相对于结构体起始地址的字节偏移量。
std::size_t offsetof(type, member); // #include <cstddef>
type
是结构体的类型。member
是结构体的成员名称。
例子
假设有一个结构体:
#include <iostream>
#include <cstddef> // 引入 offsetof 宏所在的头文件struct MyStruct {char a; // 1 字节int b; // 4 字节short c; // 2 字节double d; // 8 字节
};int main() {std::cout << "Offset of 'a': " << offsetof(MyStruct, a) << std::endl; // 输出 0std::cout << "Offset of 'b': " << offsetof(MyStruct, b) << std::endl; // 输出 4std::cout << "Offset of 'c': " << offsetof(MyStruct, c) << std::endl; // 输出 8std::cout << "Offset of 'd': " << offsetof(MyStruct, d) << std::endl; // 输出 16return 0;
}
3.3.1 如何理解偏移量?
偏移量是一个成员相对于结构体起始地址的字节数。在内存中,结构体成员是按特定顺序排列的,但为了满足对齐要求,编译器可能会插入填充字节。因此,实际偏移量不一定是成员的声明顺序所决定的,而是取决于对齐要求和编译器的布局规则。
应用场景
offsetof
宏在调试、内存管理、序列化和二进制协议处理中非常有用。例如,如果你需要通过指针操作结构体中的成员,或者在内存中对齐结构体成员时,知道偏移量可以帮助你正确计算成员的位置。
注意事项
offsetof
宏通常只用于已知结构体类型和成员名称的情况。- 使用
offsetof
获取偏移量时,不会引起任何实际的内存访问操作,它只是计算符号信息。
四、一些问题
4.1 什么是内存对齐,结构体是如何进行内存对齐的?
内存对齐(Memory Alignment)是指将数据按照其数据类型的要求存储在内存中的方式。由于不同的数据类型对内存存储有不同的要求,内存对齐旨在提高访问效率,确保数据按正确的边界存储,从而避免性能损失或者硬件异常。
对于第二问,上面的内容以及进行了解释。
4.2 如何让结构体按照指定的默认对齐数进行对齐?
在上文进行了 介绍,可以使用alignas关键字:alignas(16) struct B {}
4.3 如何知道结构体某个成员相对于起始位置的偏移量
在上文进行了介绍,我们可以使用 offsetof 宏;offsetof(MyStruct, a)
4.4 什么是大小端?如何测试一个机器是大端还是小端?有没有什么需要考虑大小端的场景
4.4.1 什么是大小端(Endianness)?
大小端是指在多字节数据类型(如 int
、float
、double
等)在内存中的存储顺序。它决定了数据的各个字节在内存中是如何排列的。
- 大端(Big-endian):高位字节存储在低地址,低位字节存储在高地址。
- 小端(Little-endian):低位字节存储在低地址,高位字节存储在高地址。
例如,假设有一个 32 位的整数 0x12345678
,它占用 4 个字节。
- 大端存储:内存中按顺序存储为
12 34 56 78
,其中12
存储在最小地址(低地址),78
存储在最大地址(高地址)。 - 小端存储:内存中按顺序存储为
78 56 34 12
,其中78
存储在最小地址(低地址),12
存储在最大地址(高地址)。
示意图表
地址 | 低字节端顺序(小端) | 高字节端顺序(大端) |
---|---|---|
0x1000 | 0x78 | 0x12 |
0x1001 | 0x56 | 0x34 |
0x1002 | 0x34 | 0x56 |
0x1003 | 0x12 | 0x78 |
4.4.2 如何测试一个机器是大端还是小端?
要测试一个机器的字节序,我们可以通过构造一个多字节数据类型,查看其数据在内存中的存储顺序。常用的方法是使用一个 2 字节(或 4 字节)数据,并检查它们在内存中的存储顺序。
示例:通过整数判断大小端
我们通过下面的代码可以测试机器的字节序:
#include <iostream>int main() {// 采用一个 2 字节的整数unsigned int num = 0x12345678; // 0x12 34 56 78(假设大端)unsigned char* bytePtr = reinterpret_cast<unsigned char*>(&num);// 检查存储顺序if (bytePtr[0] == 0x78) {std::cout << "This machine is Little Endian." << std::endl;} else if (bytePtr[0] == 0x12) {std::cout << "This machine is Big Endian." << std::endl;}return 0;
}
4.4.3 需要考虑大小端的场景
-
网络协议(如 TCP/IP):
网络协议通常使用 大端 字节序(也称为网络字节序)。例如,IP 地址、端口号等数据在传输时是以大端方式发送的。因此,在网络通信时,可能需要进行字节序转换(即大端和小端之间的转换)。- 例子:在将数据发送到网络时,主机可能是小端系统,但数据需要以大端顺序发送。你可以使用像
htonl()
(主机字节序到网络字节序转换)和ntohl()
(网络字节序到主机字节序转换)这样的函数来处理。
- 例子:在将数据发送到网络时,主机可能是小端系统,但数据需要以大端顺序发送。你可以使用像
-
文件存储和二进制协议:
在读写二进制文件时,文件的字节顺序可能与系统的字节序不同。若文件是在不同的机器上生成的(可能有不同的字节序),在读取时需要考虑字节序转换。- 例子:某些二进制文件格式(如图像文件、音频文件等)可能规定了字节顺序,跨平台操作时需要进行字节序转换。
-
多平台开发:
在进行跨平台开发时,必须特别注意字节序的问题。例如,编写需要在不同架构之间共享数据的程序时(如跨操作系统通信、数据交换等),需要确保字节序一致,否则会导致数据解释错误。 -
硬件与嵌入式开发:
某些硬件设备和嵌入式系统可能使用特定的字节序。例如,某些 ARM 处理器是支持大端和小端模式的,可以通过配置寄存器来选择字节序。在与硬件进行通信时,必须确保正确处理字节序问题。 -
数据库和其他持久化存储:
数据库或持久化存储系统可能会以特定字节序存储数据(如在多平台数据库同步时)。在实现数据存储和读取时,正确处理字节序非常重要。
4.4.4 如何进行字节序转换?
在实际开发中,通常使用以下函数进行字节序转换:
htonl()
(host to network long):将 32 位整数从主机字节序转换为网络字节序。htons()
(host to network short):将 16 位整数从主机字节序转换为网络字节序。ntohl()
(network to host long):将 32 位整数从网络字节序转换为主机字节序。ntohs()
(network to host short):将 16 位整数从网络字节序转换为主机字节序。