资讯动态

sizeof的底层真相:运算符本质、数组退化与内存对齐全揭秘

发布时间:2026/10/13 12:37:56 来源:尧图企业网站定制
你有没有过这样的经历写了好几年 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 位char11short22int44long4 或 84 或 8long long88float44double88long double8 或 168 或 16指针4 或 84 或 8看着简单但这里有个必须刻在脑子里的底层认知C 标准只给出了一个“最小尺寸约束”比如sizeof(char) 1是硬性规定而short至少 2 字节int至少 2 字节long不小于intlong long不小于long。至于到底是多少由编译器和 ABI 决定。我见过有人写跨平台序列化代码直接reinterpret_castchar*(val)然后按sizeof(int)往文件里写结果在 32 位和 64 位平台上一会儿 4 一会儿 4勉强没出事但要是换到sizeof(long)Windows 上是 4Linux 上是 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看到的是数组整体所以是 6strlen会从首地址开始逐个字节扫直到遇见\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 直接就别手算了C17 提供了std::size()#include iterator int arr[10]; auto count std::size(arr); // 10std::size还能直接用在std::vector、std::array上统一接口。如果项目还停留在 C11/14可以自己写一个模板函数templatetypename 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::spanC20。工程上我建议能不用原生数组做函数参数就不用容器加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占偏移 0c占偏移 1b放偏移 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 - 24sizeof(X)是 24。接着看嵌套struct Y { char d; X x; char e; };d偏移 0xX 的对齐值是 8所以x从偏移 8 开始占 24 字节到偏移 32e偏移 32占 1 字节到偏移 33Y 对齐值仍为 833 向上取整到 40sizeof(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(); };类型典型sizeof64 位原因Empty1保证每个对象有唯一地址WithStatic1静态成员不属于任何对象WithVirtual8对象内隐含一个虚表指针空类为什么是 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. 模板与泛型编程中的 sizeof4.1 在模板中读取“类型尺寸”sizeof(T)在模板里是个很锋利的工具因为它会在模板实例化时求出类型 T 的尺寸而且这个值是编译期常量可以被static_assert、非类型模板参数、std::integral_constant等使用。一个很容易被忽略的前提sizeof(T)要求 T 是完整类型。前置声明的类、void、未定义大小的数组都不能求sizeof。利用这一点可以写出经典的“类型是否完整”检测工具templatetypename T, std::size_t sizeof(T) std::true_type is_complete_impl(void*); templatetypename T std::false_type is_complete_impl(...); templatetypename T using is_complete decltype(is_complete_implT(nullptr));原理是第一个重载的模板参数std::size_t sizeof(T)需要 T 完整如果 T 不完整替换失败编译器就会选择第二个重载返回false_type。这个手法在判断某个类型是否定义完成、是否可实例化时很实用虽然标准库后来给了更多专业工具但sizeof至今仍是很多元编程技巧的地基。4.2 参数包计数sizeof...C11 引入变长模板后多了一个长得特别像sizeof的语法sizeof...。它不能用来算字节用来数“参数包里有几个类型或几个参数”。templatetypename... 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 与 alignofsize 和 align 的关系C11 之后与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 字节对齐减少伪共享问题。还有一个工程细节C17 之前堆上用new创建 over-aligned 类型并不保证正确对齐C17 引入 aligned newnew Data会走专门的 alignedoperator new。如果你的编译器还停留在 C14 且遇到对齐相关崩溃这可能就是幕后黑手。4.4 编译期筛选类型的小工具sizeof是编译期常量意味着可以参与编译期比较。比如想从一堆类型中选出“尺寸最小”的那个直接可以写出名副其实的模板工具#include type_traits templatetypename A, typename B struct smaller { using type std::conditional_t(sizeof(A) sizeof(B)), A, B; }; templatetypename... Ts struct smallest; templatetypename T struct smallestT { using type T; }; templatetypename A, typename B, typename... Rest struct smallestA, B, Rest... { using type typename smallesttypename smallerA, 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) // 864位平台指针大小 sizeof(a) // 6含 \0 sizeof(nums) // 16 sizeof(nums) / sizeof(nums[0]) // 4 sizeof(hello) // 6字符串字面量是 char[6] sizeof(Foo) // 16char 填充7 double容易错的是sizeof(hello)。字符串字面量的类型是数组不是const char*所以拿到的是 6不是 5也不是指针大小。面试官很喜欢加一句“那strlen(\hello\)呢”你答 5 就行。5.2 快速自测表片段结果/坑说明sizeof(void)编译错误void 是不完整类型sizeof(EmptyClass)1保证对象有唯一地址sizeof(VirtualClass)864位隐藏的虚表指针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_caststd::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~1length需要 4 对齐所以从偏移 4 开始中间空了 2 字节flags在偏移 8结构体大小又对齐到 12。我这才意识到sizeof是 12不是 8而我当初的协议规格书按 8 字节画的内存布局。解决方案是新结构体把uint16_t都排到前边让uint32_t自然对齐再用static_assert(sizeof(MsgHeader) 8)锁住同时序列化函数改成逐字段写入不依赖内存布局。那次之后我养成了一个习惯凡是结构体要发到网络、写进文件、跨模块传递都必须显式考虑 padding并且用断言固定预期大小。说到底sizeof不是单纯的“算字节”它量的是类型在内存里真正占据的“地盘”包括编译器为了对齐悄悄塞进来的那些看不见的砖头。把这个运算符吃透很多内存布局、ABI、泛型代码的疑难杂症都能少走一大段弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑