你有没有过这样的经历:写了好几年 C++,提起sizeof一脸不屑,觉得不就是“算个字节数”嘛。直到某次面试被问到sizeof(空类)、sizeof("hello")、sizeof(数组形参),才发现自己全凭感觉。我第一次被问懵是在一个网络协议模块里:结构体明明定义好了,sizeof算出来总比预期大两字节,线上跨端解析总是错位。后来我把sizeof运算符认真捋了一遍,才发现它根本不是“求大小”这么简单,背后牵扯着编译期求值、指针退化、内存对齐、模板元编程这一整条知识链。这篇文章想从一个老 C++ 开发的角度,把它在基本类型、数组、结构体对齐、模板裁剪等场景下的用法连原理带坑一起讲透。适合想夯实基础的初学者,也适合准备面试、写网络协议和想做底层库的进阶开发者。
1. sizeof 运算符的本质与基本用法
1.1 它是运算符,不是函数
很多人写sizeof(int)写习惯了,就以为sizeof是个函数。它不是,它是个单目运算符,和++、&是同一类东西。语法上有两种写法:
std::size_t a = sizeof(int); // 作用于类型,必须加括号 int x = 0; std::size_t b = sizeof x; // 作用于表达式,括号可以省略注意,sizeof x能编译,sizeof int不能编译。因为运算符要求“类型”必须拿括号包起来,而“表达式”可以直接跟在后面。这个细节看起来无关紧要,但在模板里写sizeof(T)的时候,如果你漏了括号,编译器会直接拒收,报错还不容易看懂。
还有一个理解上的关键点:sizeof的操作数如果是表达式,它只看表达式的“静态类型”,不会真的去执行表达式。这一点是后面很多坑的根源,我在 1.3 节会展开。
另外,sizeof的返回类型是std::size_t,一种无符号整数类型,定义在<cstddef>里,通常就是unsigned long或unsigned long long的别名。这意味着它永远是非负数,和负整数做比较时会让人踩坑,后面我专门提。
1.2 基本类型上的参考尺寸
在常见 64 位平台上,基本类型的sizeof有下面这一组典型值:
| 类型 | sizeof(常见 64 位) | alignof(常见 64 位) |
|---|---|---|
char | 1 | 1 |
short | 2 | 2 |
int | 4 | 4 |
long | 4 或 8 | 4 或 8 |
long long | 8 | 8 |
float | 4 | 4 |
double | 8 | 8 |
long double | 8 或 16 | 8 或 16 |
| 指针 | 4 或 8 | 4 或 8 |
看着简单,但这里有个必须刻在脑子里的底层认知:C++ 标准只给出了一个“最小尺寸约束”,比如sizeof(char) == 1是硬性规定,而short至少 2 字节,int至少 2 字节,long不小于int,long long不小于long。至于到底是多少,由编译器和 ABI 决定。
我见过有人写跨平台序列化代码,直接reinterpret_cast<char*>(&val)然后按sizeof(int)往文件里写,结果在 32 位和 64 位平台上一会儿 4 一会儿 4,勉强没出事;但要是换到sizeof(long),Windows 上是 4,Linux 上是 8,文件格式直接崩。所以做数据格式时,一定要用固定宽度类型int32_t、int64_t,绝不能拿int、long这种平台相关的类型去当协议字段。
1.3 编译期求值:不执行的表达式
sizeof最反直觉的一点,是它“不真正计算表达式”。看代码:
int i = 0; std::size_t s = sizeof(i++); std::cout << i << std::endl; // 输出 0i++没有执行,i还是 0。标准里把这类上下文叫“未求值操作数”,sizeof、decltype、typeid都属于这种上下文,编译器只需要推断表达式的类型,不需要生成求值代码,也不会有副作用。
这个特性在工程里很有用。比如你不想构造一个对象,但想知道它占多大空间,直接sizeof(MyClass)就行,构造函数根本不会被调用。再比如写泛型代码时,你可以在 SFINAE 里写sizeof(T::method())这种表达式,探测某个类型是否有某个成员函数,因为不求值,所以不会产生额外成本,也不会引发运行期错误。
但正因为不求值,也产生了一个经典误区:有人想用sizeof获取动态分配的数组长度。
int* p = new int[20]; std::size_t n = sizeof(p) / sizeof(int); // 得到的是 1 或 2,取决于指针大小new int[20]返回的是一个int*指针,sizeof(p)只会给出指针本身的大小,和数组元素个数毫无关系。动态数组的长度信息在运行期堆管理里,C++ 没有暴露给程序员。这也是为什么现代 C++ 推荐std::vector,你要长度就调size(),而不是自己拿sizeof硬猜。
1.4 sizeof 与 strlen 的分工
这两个函数/运算符经常被人搞混,但它们完全是两个世界的产物。sizeof是编译期算字节数,strlen是运行期数到'\0'为止的字符数。
const char* p = "hello"; char arr[] = "hello"; std::size_t s1 = sizeof(p); // 8,指针大小(64位) std::size_t s2 = sizeof(arr); // 6,包含末尾的 '\0' std::size_t s3 = strlen(p); // 5,不含 '\0' std::size_t s4 = strlen(arr); // 5一个字符串字面量"hello",在 C++ 里是char[6]类型,末尾藏了一个'\0'。sizeof看到的是数组整体,所以是 6;strlen会从首地址开始逐个字节扫,直到遇见'\0',所以返回 5。如果你把arr当参数传给函数,函数内部sizeof(arr)又会变成指针大小,这就是下一章要讲的数组退化了。
2. 指针与数组:sizeof 最容易翻车的地方
2.1 数组名:sizeof 的正确用法
数组名只有在极少数情况下才和指针等价,在sizeof这里它代表的永远是“整个数组占用的字节数”。
int arr[10]; std::size_t bytes = sizeof(arr); // 40(假设 int 为 4) std::size_t count = sizeof(arr) / sizeof(arr[0]); // 10sizeof(arr) / sizeof(arr[0])是老派的求数组长度写法,至今仍能见到。它之所以能工作,是因为sizeof(arr)是整个数组的字节数,sizeof(arr[0])是单元素字节数,两边一除就是元素个数。这段代码有风险吗?有,如果你把arr传进一个函数,在函数里对形参用这个套路,结果就错了。所以现代 C++ 直接就别手算了,C++17 提供了std::size():
#include <iterator> int arr[10]; auto count = std::size(arr); // 10std::size还能直接用在std::vector、std::array上,统一接口。如果项目还停留在 C++11/14,可以自己写一个模板函数:
template<typename T, std::size_t N> constexpr std::size_t array_size(T (&)[N]) noexcept { return N; }注意形参写法是T (&)[N],引用数组。这样传进来的必须是数组本身,传指针编译不过,从根源上防止退化问题。
2.2 函数参数退化的陷阱
看这个例子:
void print_size(int arr[]) { std::cout << sizeof(arr) << std::endl; } int data[10]; print_size(data); // 输出 8,而不是 40为什么?因为 C++ 规定,数组作为函数参数时会发生“退化”,int arr[]实际就是int* arr。参数是一个指针变量,所以sizeof(arr)量的是指针大小,64 位平台上是 8。
这个坑尤其容易出现在刚写 C/C++ 的新手身上。他们以为int arr[]能把整个数组拷进来,其实函数间传递数组本来就是传地址,从来没有“拷贝整个数组”这回事。想强行固定数组大小,可以声明为引用:
void print_size(int (&arr)[10]) { std::cout << sizeof(arr) << std::endl; // 40 }但如果数组长度变化,这个函数签名就废了,所以更通用的做法是用模板推导长度,或者直接传std::span(C++20)。工程上我建议,能不用原生数组做函数参数就不用,容器加std::span是更现代、更安全的选择。
2.3 多维数组与动态分配
二维数组的sizeof也是逐层展开的:
int matrix[3][4]; sizeof(matrix) // 48 = 3 * 4 * 4 sizeof(matrix[0]) // 16,第一行(长度为4的int数组) sizeof(matrix[0][0]) // 4,单个int要分别求第一维和第二维长度,可以这样:
size_t rows = sizeof(matrix) / sizeof(matrix[0]); // 3 size_t cols = sizeof(matrix[0]) / sizeof(matrix[0][0]); // 4如果把二维数组传给函数,除了第一维会退化成指针外,第二维必须显式给出,因为编译器需要知道每个子数组的大小才能正确计算地址偏移。比如:
void f(int matrix[][4]);这里matrix退化成int (*)[4],指向“含 4 个 int 的数组”的指针。此时函数内sizeof(matrix)是指针大小,sizeof(matrix[0])依然是 16。这个细节在图形学和矩阵运算代码里经常成为隐性 Bug,值得多测一测。
3. 结构体、类与内存对齐:sizeof 背后的隐形空间
3.1 为什么 sizeof 不是成员相加
一个结构体,把所有成员大小加起来,往往不等于sizeof。这是为了让成员按“自然对齐”规则排列,编译器会在成员之间或末尾插入填充字节。
struct A { char a; // 1 字节 int b; // 4 字节 char c; // 1 字节 };直觉相加是 1 + 4 + 1 = 6,但sizeof(A)在常见平台上通常是 12。内存布局可以想象成:
偏移量 0: a 偏移量 1~3: 填充 偏移量 4~7: b 偏移量 8: c 偏移量 9~11: 填充int需要按 4 字节对齐,所以b不能放到偏移量 1,编译器只能插入 3 个空字节。结构体结尾还要填充 3 字节,因为整个结构体大小必须是其最大对齐值的整数倍,这样才能保证结构体数组里下一个元素的每个成员也正确对齐。
这带来一个直接指导:结构体成员声明顺序会影响 sizeof。同样的成员,换个顺序就能省内存:
struct B { char a; char c; int b; };B的大小通常就是 8,因为a占偏移 0,c占偏移 1,b放偏移 4,结尾无需再补。实际项目里如果结构体有成百上千个实例,重排成员省下的内存很可观。
3.2 对齐规则与内存布局计算
对齐规则的核心就三条:
- 每个成员的起始偏移需要能被它的“对齐要求”整除。
- 整个结构体的对齐要求等于所有成员对齐要求的最大值。
- 结构体总大小必须是对齐要求的整数倍。
拿一个稍复杂的例子演示一下,这个也是面试最爱考的组合:
struct X { char a; double b; short c; };假设double按 8 字节对齐:
a:偏移 0,占 1 字节b:要按 8 对齐,所以从偏移 8 开始,占 8 字节,到偏移 16c:按 2 对齐,偏移 16 可放,占 2 字节,到偏移 18- 结构体整体对齐值是 8,所以总大小要向上取整到 8 的倍数,18 -> 24
sizeof(X)是 24。接着看嵌套:
struct Y { char d; X x; char e; };d:偏移 0x:X 的对齐值是 8,所以x从偏移 8 开始,占 24 字节,到偏移 32e:偏移 32,占 1 字节,到偏移 33- Y 对齐值仍为 8,33 向上取整到 40
sizeof(Y)是 40。很多人在嵌套结构体上算错,是因为忘了嵌套成员的对齐值不一定等于它的sizeof,而要靠它所有成员的最大对齐值决定。X的sizeof是 24,但它的对齐值是 8,所以Y里X前面空了 7 个字节。
3.3 #pragma pack 与跨平台注意
有些场景需要紧凑布局,比如网络协议头、嵌入式寄存器映射。这时可以用编译器的打包指令:
#pragma pack(push, 1) struct Packed { char version; int length; }; #pragma pack(pop)#pragma pack(push, 1)表示后续结构体按 1 字节对齐,也就是不填充。上面这个Packed的sizeof在加了 pack 后是 5,不加时是 8。
但要清醒:#pragma pack不是 C++ 标准语法,属于编译器扩展,MSVC 和 GCC/Clang 都支持,可移植性却谈不上好。而且pack(1)会让结构体成员出现非对齐访问,在 x86 上只是慢一点,在某些 ARM 平台上可能直接触发异常。
我的实际建议是:协议封包尽量别依赖“结构体连续内存 + 直接发送”。更稳的做法是写显式序列化函数,把每个字段用固定宽度类型逐个按统一字节序写入 buffer,再用static_assert锁住预期大小。如果觉得手写序列化太重,至少也要在读写关键结构体的地方加一行:
static_assert(sizeof(Header) == 8, "Unexpected header layout");这行断言能把结构体布局被意外改变的问题挡在编译期,比线上排查成本低太多。
3.4 空类、虚函数、位域、联合体
几个高频 sizeof 考点密集出现,我用一个代码块加一张表说明:
struct Empty {}; struct WithStatic { static int value; }; struct WithVirtual { virtual void f(); };| 类型 | 典型sizeof(64 位) | 原因 |
|---|---|---|
Empty | 1 | 保证每个对象有唯一地址 |
WithStatic | 1 | 静态成员不属于任何对象 |
WithVirtual | 8 | 对象内隐含一个虚表指针 |
空类为什么是 1?因为 C++ 要求“同类型的不同对象必须有不同地址”,如果空类大小是 0,对象数组里相邻元素地址就会重叠,所以编译器强制给 1 字节。但空基类有个特殊优化叫 EBO,当空类作为基类子对象时可以不占空间:
struct Empty {}; struct Derived : Empty { int x; };sizeof(Derived)通常是 4,而不是 8。这个优化在标准库的某些类型萃取中很常见,它能让继承空类型不增加对象体积。
再到位域:
struct Bits { unsigned a : 3; unsigned b : 5; unsigned c : 24; };位域成员的位宽是显式指定的,总位宽 32,所以常见平台下sizeof(Bits)是 4。但位域的底层存储单元怎么分、跨单元怎么填,标准没说,由实现决定。所以位域适合嵌入式寄存器映射,不适合跨平台数据格式。还有一点:sizeof不能直接作用于位域成员,比如sizeof(Bits::a)是编译错误,因为位域不是完整对象。
联合体union的sizeof是“最大成员大小对齐后的结果”。比如:
union U { char c; int i; double d; };sizeof(U)通常就是 8,因为double占 8 字节且对齐值也是 8。另一个例子:
union U2 { char arr[13]; int i; };最大成员是 13 字节,但union的对齐值是 4,为了满足对齐,总大小要向上取整到 16。这个细节在实现变体数据结构时经常冒出来。
3.5 类内引用与静态成员
引用变量本身的sizeof比较容易理解——sizeof作用于引用时,得到的是被引用对象类型的大小。但引用作为类成员时,它是实实在在地占存储空间的,常见实现里按指针大小存放。
struct S { int& r; int x; };在 64 位平台上,第一个成员r通常占 8 字节,接着x占 4 字节,整体对齐取 8,所以sizeof(S)一般是 16。在 32 位平台上则通常是 8。标准没有强制规定引用成员怎么存,但这影响你不能假设 ABI 布局,跨编译器共享这类结构体时需要额外小心。
静态成员则完全不影响对象大小。类里声明static int value,只是声明了一个全局性的存储,不属于任何实例,所以sizeof(WithStatic)和没有静态成员的同类是一样的。
4. 模板与泛型编程中的 sizeof
4.1 在模板中读取“类型尺寸”
sizeof(T)在模板里是个很锋利的工具,因为它会在模板实例化时求出类型 T 的尺寸,而且这个值是编译期常量,可以被static_assert、非类型模板参数、std::integral_constant等使用。
一个很容易被忽略的前提:sizeof(T)要求 T 是完整类型。前置声明的类、void、未定义大小的数组,都不能求sizeof。利用这一点,可以写出经典的“类型是否完整”检测工具:
template<typename T, std::size_t = sizeof(T)> std::true_type is_complete_impl(void*); template<typename T> std::false_type is_complete_impl(...); template<typename T> using is_complete = decltype(is_complete_impl<T>(nullptr));原理是:第一个重载的模板参数std::size_t = sizeof(T)需要 T 完整;如果 T 不完整,替换失败,编译器就会选择第二个重载,返回false_type。这个手法在判断某个类型是否定义完成、是否可实例化时很实用,虽然标准库后来给了更多专业工具,但sizeof至今仍是很多元编程技巧的地基。
4.2 参数包计数:sizeof...
C++11 引入变长模板后,多了一个长得特别像sizeof的语法:sizeof...。它不能用来算字节,用来数“参数包里有几个类型或几个参数”。
template<typename... Args> constexpr std::size_t count_args(Args&&...) { return sizeof...(Args); } count_args(1, 2.5, "str"); // 3在实现std::tuple、std::function、递归遍历参数包时,这个计数几乎无处不在。比如要在编译期根据参数个数分支,或者做递归展开的条件判断,sizeof...(Args) == 0这样的表达式非常常见。
要特别注意:sizeof...(T)里面有个省略号,和sizeof是两种完全不同的语法。少写一个点,编译器就开始天书式报错,别问我怎么知道的。
4.3 alignas 与 alignof:size 和 align 的关系
C++11 之后,与sizeof经常一起出现的还有alignof和alignas。sizeof量的是对象占的字节数,alignof返回的是一种“对齐要求”,而alignas可以主动抬高对齐值。
思考题:
struct alignas(16) Data { char c; }; static_assert(sizeof(Data) == 16, "size"); static_assert(alignof(Data) == 16, "alignment");Data只有一个char成员,但alignas(16)把对齐要求抬到了 16,编译器会自动填充到 16 字节,让对象大小是 16 的整数倍。这种重对齐在 SIMD 向量化、缓存行友好数据布局里很关键。比如:
alignas(64) float temp[8];可以保证数组起始地址按 64 字节对齐,减少伪共享问题。
还有一个工程细节:C++17 之前,堆上用new创建 over-aligned 类型并不保证正确对齐;C++17 引入 aligned new,new Data会走专门的 alignedoperator new。如果你的编译器还停留在 C++14 且遇到对齐相关崩溃,这可能就是幕后黑手。
4.4 编译期筛选类型的小工具
sizeof是编译期常量,意味着可以参与编译期比较。比如想从一堆类型中选出“尺寸最小”的那个,直接可以写出名副其实的模板工具:
#include <type_traits> template<typename A, typename B> struct smaller { using type = std::conditional_t<(sizeof(A) <= sizeof(B)), A, B>; }; template<typename... Ts> struct smallest; template<typename T> struct smallest<T> { using type = T; }; template<typename A, typename B, typename... Rest> struct smallest<A, B, Rest...> { using type = typename smallest<typename smaller<A, B>::type, Rest...>::type; };这段代码把sizeof当成编译期的“尺子”,在类型之间做比较和筛选。这类技巧在内存池、变体类型、容器优化里都有应用场景。遇到“为什么这里要用 sizeof 而不是运行时 size”的问题,答案其实很简单:因为这是编译期,C++ 的模板元编程需要这种常量信息来生成最终代码。
5. 面试高频题与排坑实录
5.1 一网打尽:几个必须脱口而出的答案
下面这段代码,你心里应该有一个准确答案:
const char* p = "hello"; char a[] = "hello"; int nums[] = {1, 2, 3, 4}; struct Foo { char c; double d; }; sizeof(p) // 8,64位平台指针大小 sizeof(a) // 6,含 '\0' sizeof(nums) // 16 sizeof(nums) / sizeof(nums[0]) // 4 sizeof("hello") // 6,字符串字面量是 char[6] sizeof(Foo) // 16,char + 填充7 + double容易错的是sizeof("hello")。字符串字面量的类型是数组,不是const char*,所以拿到的是 6,不是 5,也不是指针大小。面试官很喜欢加一句“那strlen(\"hello\")呢”,你答 5 就行。
5.2 快速自测表
| 片段 | 结果/坑 | 说明 |
|---|---|---|
sizeof(void) | 编译错误 | void 是不完整类型 |
sizeof(EmptyClass) | 1 | 保证对象有唯一地址 |
sizeof(VirtualClass) | 8(64位) | 隐藏的虚表指针 |
sizeof(int&) | 等于sizeof(int) | 引用本身不求大小 |
sizeof(i++) | 不会执行 i++ | 未求值上下文 |
sizeof(new int[10]) | 8 | 堆数组退化成指针 |
sizeof(arr)/sizeof(arr[0])在形参里 | 等于指针大小/元素大小 | 数组形参退化 |
sizeof(Bits::field) | 编译错误 | 位域不是完整对象 |
还有一个很隐蔽的坑:sizeof是无符号数,别拿来和负数比较。
int x = 0; if (sizeof(x) > -1) { // 永远不会成立? 不,实际上因为隐式转换,-1 被转成很大的无符号数, // 所以这个条件几乎总是成立 }正确写法是把两边的类型搞一致,比如sizeof(x) > static_cast<std::size_t>(-1)或者干脆避免这种比较。这个错误在编译期范围内几乎不会触发警告,但运行时行为会很诡异。
5.3 真实调试经历:padding 引起的“神秘 Bug”
我之前做一个模拟协议模块时遇到过一个很经典的问题。定义了一个消息头结构体,大概长这样:
struct MsgHeader { uint16_t type; uint32_t length; uint16_t flags; };直觉大小是 2 + 4 + 2 = 8,我在本地写单测也没问题,可一到跨端联调,对端解析出来的字段总是不对。后来打印sizeof(MsgHeader)发现是 8,没毛病。但再看内存布局,type占偏移 0~1,length需要 4 对齐,所以从偏移 4 开始,中间空了 2 字节;flags在偏移 8,结构体大小又对齐到 12。我这才意识到:sizeof是 12,不是 8,而我当初的协议规格书按 8 字节画的内存布局。
解决方案是新结构体把uint16_t都排到前边,让uint32_t自然对齐,再用static_assert(sizeof(MsgHeader) == 8)锁住,同时序列化函数改成逐字段写入,不依赖内存布局。那次之后我养成了一个习惯:凡是结构体要发到网络、写进文件、跨模块传递,都必须显式考虑 padding,并且用断言固定预期大小。
说到底,sizeof不是单纯的“算字节”,它量的是类型在内存里真正占据的“地盘”,包括编译器为了对齐悄悄塞进来的那些看不见的砖头。把这个运算符吃透,很多内存布局、ABI、泛型代码的疑难杂症都能少走一大段弯路。