1. 先把最常见的误解挑明编译器眼里 struct 和 class 几乎是同一个东西我带过几个刚入行的同事几乎每个人在第一次写 C 项目时都会问同一个问题这个类型到底应该写成 struct 还是 class问法五花八门但背后的潜台词是一样的——他们默认这两个关键字在 C 里代表了两种不同的东西性能不同、能力不同、适用场景泾渭分明。这个印象多半是从别的语言带过来的比如 C# 里 struct 是值类型、class 是引用类型Java 里干脆没有 struct。但在 C 里情况完全不是这样struct 和 class 在语言层面几乎是同义词它们共用同一套对象模型、同一套内存布局规则、同一套继承和虚函数机制。真正把它们区分开的只有两处默认权限。剩下的差异全是人写代码时约定出来的习惯不是编译器强加的规则。把这个前提搞清楚后面关于用哪个的讨论才有意义否则很容易陷入struct 是不是比 class 快这种伪命题里绕圈子。这篇东西我想按我自己的理解顺序来讲先拆语法差异再拆对象模型最后落到工程里怎么选、怎么改、哪些地方容易翻车。看完之后你应该能形成一套自己的判断标准而不是背结论。1.1 唯一的硬性差异只有两处默认权限第一处是成员默认访问级别。用 struct 定义时没写访问说明符的成员默认是 public用 class 定义时默认是 private。就这么一句话struct PointA { int x; // 隐式 public int y; // 隐式 public }; class PointB { int x; // 隐式 private int y; // 隐式 private };PointA可以直接PointA p; p.x 1;PointB这样写就编译不过必须先补一个public:。第二处是默认继承方式。用 struct 做派生时不写继承说明符默认是 public 继承用 class 做派生时默认是 private 继承struct Base { int v; }; struct DeriveA : Base {}; // public 继承 class DeriveB : Base {}; // private 继承这两处差异都不是能力差异而是默认值差异。你完全可以在 struct 里写private:也完全可以在 class 里写public:编译器一视同仁。我见过有人为了表达这是个纯数据而专门用 struct也见过有人为了表达这是个有封装的对象而专门用 class但两边都有人写private:段落这在语法上没有任何问题。1.2 除了权限语法能力完全对等构造函数、析构函数、拷贝构造、移动构造、运算符重载、虚函数、纯虚函数、静态成员、嵌套类型、模板成员、友元、继承体系、多继承、虚继承——这些 struct 全都能写写出来的行为和 class 完全一致struct Shape { virtual double area() const 0; virtual ~Shape() default; }; struct Circle : Shape { double r 0.0; double area() const override { return 3.14159265 * r * r; } };Circle是个 struct但它有虚函数、有基类、有默认成员初始化器该有的都有。反过来一个 class 也可以纯粹只放 public 数据成员一个成员函数都不写。编译器不会因为关键字不同就生成不同的代码也不会因为关键字不同就改变重载决议或名字查找规则。有一个细节值得单独提一下class这个关键字还能用来声明模板的类型参数templateclass T和templatetypename T完全等价。而templatestruct T是非法的struct 不能出现在这个位置。这是两个关键字在语法覆盖范围上少有的不对称之处虽然日常写代码时几乎不会遇到但面试里偶尔会被问到。1.3 为什么还会有struct 更快这种说法这个说法流传很广但它是错的或者说它把因果关系搞反了。一个类型访问内存快不快取决于它的布局、对齐、是否可平凡复制、是否内联跟它叫 struct 还是叫 class 没有半点关系。之所以有人觉得 struct 更快是因为人们倾向于用 struct 写轻量的、纯数据的类型用 class 写有封装和虚函数的类型而前者确实在拷贝、序列化、缓存友好性上更有优势。优势来自使用方式不来自关键字。我实测过一个很朴素的对比例子同样的{double x; double y;}一个写成 struct一个写成 class 且把所有成员设成 public在 -O2 下生成的汇编几乎逐行相同sizeof都是 16alignof都是 8std::is_trivially_copyable都是 true。真正会让性能出现分化的是在这个类型里加不加虚函数、加不加用户定义的拷贝构造、有没有调用非内联的外部函数。所以下次再听到struct 比 class 快可以直接把话题扭到这个类型是不是可平凡复制、有没有虚表指针上那才是性能讨论的着力点。提示判断两个类型是否等价不要靠肉眼看关键字用static_assert(std::is_trivially_copyable_vT)这类编译期断言来验证比记忆口诀可靠得多。2. 对象模型层面内存布局、对齐与 ABI 到底有没有区别把语法层面的事情说清楚之后下一个必须回答的问题是既然语法上几乎一样那内存里的样子是不是也一样答案是一样。C 标准里没有struct 布局和class 布局两套规则只有一套对象布局规则对所有类类型包括 union统一适用。也就是说一个 struct 占多少字节、成员之间怎么填充、虚表指针放在哪个偏移判断依据是成员类型和虚函数声明跟关键字无关。这一节我想把布局规则拆开讲一遍因为很多选型上的犹豫其实源于对布局的误解。比如有人担心用 class 包一层会有额外开销或者struct 传给 C 函数会被编译器加东西。理解了布局的推导过程这些顾虑会自然消失。2.1 成员函数不占空间虚函数才改写布局非虚成员函数、静态成员函数、构造函数、析构函数统统不占用对象的存储空间。它们在编译后就是普通的函数通过隐藏的this指针接收对象地址。所以下面这两个类型的大小完全相同struct A { int a; int b; int sum() const { return a b; } // 不占空间 static int mul(int x, int y); // 不占空间 }; class B { public: int a; int b; int sum() const { return a b; } };sizeof(A) sizeof(B) 8假设 int 是 4 字节无额外对齐要求。真正改变布局的是虚函数。只要一个类或它的任一基类声明了虚函数编译器就会给它放一个虚表指针在 64 位平台上通常占 8 字节并且这个指针会被放在对象的最前面具体位置由 ABI 决定Itanium ABI 里放在偏移 0。这一点 struct 和 class 一样谁声明虚函数谁就有虚表指针跟关键字无关struct WithVirt { virtual ~WithVirt() default; int a; }; // 64 位下 sizeof 一般是 16而不是 4我见过有人为了避免虚表开销把带虚函数的类型改成 struct改完发现sizeof一点没变白忙一场。选型时如果目标类型需要多态那就接受虚表指针这笔开销如果不需要多态那无论是 struct 还是 class 都不该有虚函数。2.2 对齐与填充的推导过程对象大小不是成员大小简单相加还要加上填充字节保证每个成员的偏移是其对齐值的整数倍。举一个手算的例子struct Mixed { char c; // 偏移 0大小 1 int i; // 对齐 4偏移必须 4 的倍数所以填充 3 字节偏移 4大小 4 double d; // 对齐 8偏移必须是 8 的倍数当前是 8正好偏移 8大小 8 char e; // 偏移 16大小 1 }; // 结构体总大小必须是最大对齐值 8 的倍数17 向上取整到 24所以sizeof(Mixed)是 24而不是 148114。把成员按对齐值从大到小重排通常能省下不少填充struct Packed { double d; // 0 int i; // 8 char c; // 12 char e; // 13 }; // 对齐 8总大小 16从 24 降到 16省了三分之一。这个技巧在把类型数组化时会带来明显的缓存收益比如同样是 100 万个元素Mixed数组要 24MBPacked数组只要 16MB遍历时的缓存命中率差别不小。这是个和关键字完全无关的布局知识但是实际项目里非常实用。注意重排成员会改变字段的偏移顺序如果有外部代码或序列化协议依赖字段顺序就不能随便动否则线上线下对不上。2.3 跨模块与跨语言边界为什么偏爱 struct在 ABI 边界上比如动态库导出接口、C 语言接口、操作系统 APIstruct 出现得远比 class 多。原因主要有两个。第一很多这类接口本身就来自 C 语言的约定C 里只有 struct 没有 class为了在 C 里对接只能沿用 struct 的叫法。第二跨编译器、跨版本的 ABI 兼容性要求让设计者倾向于把接口类型限制成纯数据——没有虚函数、没有用户定义的构造析构、成员顺序和类型完全固定这样的类型在不同编译器之间布局一致传递起来最稳妥。这就引出一个很实际的原则凡是需要跨编译单元、跨编译器、跨语言传递的类型优先做成纯数据结构用 struct 表达。一旦这个类型里加进了虚函数或者用户定义的构造函数它的布局和调用约定就不再是显然的了跨边界使用就容易出现诡异的崩溃。3. 聚合初始化与 PODstruct 最不可替代的那部分能力上一节讲的是布局这一节讲能力。struct 在工程里被大量使用最核心的原因是它能保住聚合初始化这项能力而这项能力在带封装的类型上很容易被破坏。很多人把 struct 当成轻量 class来理解我觉得更准确的表述是struct 常常承载的是聚合类型这个语义而聚合类型有一整套专属的语法糖和优化空间。这一节把聚合的判定条件、初始化写法、以及和 POD 的关系理一理。3.1 什么条件下一个类型才是聚合聚合aggregate是一个有明确定义的术语满足条件的类型才能用T obj{...}这种花括号按成员顺序初始化的写法。以 C17 为基准聚合需要满足没有用户提供的、继承来的或 explicit 的构造函数没有 private 或 protected 的直接非静态数据成员没有虚函数没有 private 或 protected 的直接的基类没有虚基类。注意第三条和第四条——带虚函数的类型不能是聚合有私有数据成员的类型不能是聚合。这就是为什么一个典型的 class 往往不是聚合而一个纯数据的 struct 通常是。C20 对构造函数那一条做了放宽从用户提供改成用户声明同时允许带 public 基类的一些情况细节上不同标准版本有差异写代码时最好以项目实际使用的标准为准。判定是不是聚合不用背规则写一句编译期检查最直接#include type_traits struct Point { int x; int y; }; static_assert(std::is_aggregate_vPoint, Point 应该是聚合类型);C17 起std::is_aggregate是标准设施拿来当文档用非常合适。3.2 加了构造函数就等于关掉了聚合初始化的门这是我踩过最多次的坑之一。假设一开始有个简单结构struct Config { int timeout_ms; bool retry; int max_retries; }; Config c{3000, true, 3}; // 聚合初始化很舒服后来需求变了需要保证 timeout 不为负于是有人加了个构造函数struct Config { int timeout_ms; bool retry; int max_retries; Config(int t, bool r, int m) : timeout_ms(t), retry(r), max_retries(m) {} };加上这个构造函数之后Config就不再是聚合了。原来代码里所有Config c{3000, true, 3};的写法看起来还能编译但实际调用的是新写的构造函数而任何依赖聚合初始化的地方——比如Config arr[4] {{1,false,0},{2,true,1}};这种嵌套初始化或者把Config作为另一个聚合的成员——行为可能变了。更麻烦的是如果构造函数的参数顺序和成员声明顺序不一致或者参数类型能被隐式转换就会悄悄产生错误的初始化结果非常难查。所以我的经验是如果这个类型当前依赖聚合初始化加构造函数之前先全项目搜一遍它的花括号初始化写法。如果确实需要校验逻辑优先考虑用默认成员初始化器加一个显式的工厂函数尽量别动聚合性质struct Config { int timeout_ms 3000; bool retry false; int max_retries 3; }; Config make_config(int t, bool r, int m) { Config c; c.timeout_ms t 0 ? t : 3000; c.retry r; c.max_retries m; return c; }3.3 POD、trivially copyable 与 memcpy 的边界PODPlain Old Data这个说法在 C11 之后被拆成了两个更精确的概念trivial可平凡构造、析构、复制和standard layout布局符合 C 兼容规则。POD 大致等于两者兼具。实际写代码时比 POD 更值得关注的是std::is_trivially_copyable因为它决定了能不能用memcpy搬数据。一个类型可平凡复制意味着它的复制语义等价于逐字节拷贝没有隐藏的资源管理动作。满足条件时memcpy、memset、按字节读写都是安全的struct Header { uint32_t magic; uint16_t version; uint16_t flags; }; static_assert(std::is_trivially_copyable_vHeader);反过来一旦类型里有虚函数虚表指针需要正确初始化、有std::string这类管理资源的成员、或者有用户定义的拷贝构造memcpy就是未定义行为。把带虚函数的对象用memset清零虚表指针会被写成空之后任何虚调用都会直接崩而且崩溃点往往离出错点很远排查起来很痛苦。提示在序列化、共享内存、网络收发包这些场景里动手写memcpy之前先加一条static_assert(std::is_trivially_copyable_vT std::is_standard_layout_vT)。这条断言我加进项目模板之后帮我挡了至少三次事故。4. 选型的判断标准从数据结构到带不变量的对象讲完语法和机制终于可以谈选型了。我不太喜欢struct 就是纯数据、class 就是有行为这种一刀切说法因为实际项目里两类场景都有反例。我更愿意用是否需要在任意时刻维持一个约束作为主线判断这条线比有没有成员函数更稳定。这一节给出三条可以照着做的判断线再对照一下标准库和常见项目的实际做法。4.1 三条可以照着做的判断线第一条线所有成员是否互相独立、任意组合都合法。如果任意取值组合都是有效的这个类型本质上是个记录用 struct。比如颜色{r,g,b,a}、矩形的两个对角点{x1,y1,x2,y2}、日志条目{level, timestamp, message}随便你怎么赋值都不会出现这个对象不合法的状态。第二条线是否存在必须时刻成立的约束。如果存在用 class并把约束写进构造函数和修改函数里。比如一个表示时间区间的类型要求 start 必须小于等于 end一个表示网络地址的类型要求字符串形式必须可解析。这类约束一旦被外部代码绕过就会在别处产生错误必须靠封装守住。第三条线是否需要多态。如果需要被继承、被当作基类指针使用、需要虚函数分派用 class。这不是因为 class 有特权而是因为带多态的类型通常也带着一套生命周期管理约定用 class 表达更容易让读者意识到这里要小心对象切片、要小心析构函数是否虚。三条线都不满足说明它就是个数据袋子用 struct 最省事满足任意一条就该考虑用 class即使它只有两个成员变量。4.2 标准库和主流项目里的实际惯例翻一翻标准库的源码能看到很清晰的惯例。std::pair、std::tuple、std::array、std::ratio、各种 trait 类型都是 struct成员直接暴露。std::vector、std::string、std::map、std::fstream都是 class内部数据私有接口方法公开。标准库甚至在类型特征里直接依赖 struct 的默认 public 特性比如很多 trait 实现写成struct就为了少写一行public:。再看一些大型开源项目日志库里的配置结构、渲染引擎里的顶点数据、物理引擎里的碰撞信息普遍是 struct而资源管理器、连接池、状态机这类东西普遍是 class。这个分布不是巧合它反映的正是数据聚合和状态封装这两类需求的分野。4.3 什么时候必须用 class——哪怕看起来像纯数据有一种情况我特别想强调当这个类型的成员之间存在派生关系时即使当前看起来像纯数据也应该用 class。最简单的例子是缓存值struct BadCache { std::vectorint data; int sum; // sum 必须等于 data 中所有元素之和 };data和sum之间有一个恒定关系但BadCache完全暴露了这两个成员任何人改完data忘了更新sum后续读到的就是脏数据。这种错误在业务代码里极其常见而且往往潜伏很久才暴露。正确的做法是私有化成员把改数据必须同步改派生值这条规则写进修改函数里。另一个必须用 class 的场景是成员的生命周期需要与外部资源绑定比如文件句柄、锁、连接。这类类型必须控制构造和析构绝不能让使用者随便拷贝RAII 语义必须靠私有成员加自定义特殊成员函数来保证。5. 实操中最容易翻车的几个点前面几节讲的是判断标准这一节讲实际操作里会碰到的坑。这些坑的共同特点是写的时候看不出来编译也能过但运行时行为不对或者换个编译器就崩。我把它们归在一起讲每个都给出排查思路。5.1 默认初始化差异与未初始化内存最经典的坑是这两行的区别struct P { int x; int y; }; P a; // 默认初始化x、y 的值不确定 P b{}; // 值初始化x、y 都是 0 P c{1, 2}; // 聚合初始化显式指定P a;里a.x是未初始化的读它就是未定义行为。这个规则在局部变量上最容易出问题因为成员变量如果是全局或静态存储期会被零初始化行为看起来正常一旦搬到函数内部就变成随机值。我在排查一个偶发崩溃时最后定位到就是一个局部 struct 的成员没初始化值恰好是个巨大的数用来做数组下标直接越界。对抗手段很直接给所有数据成员加默认成员初始化器。struct P { int x 0; int y 0; };这么写之后P a;也会把成员初始化成 0代价基本为零编译器会生成对应的初始化代码同时不影响聚合性质因为默认成员初始化器不破坏聚合。这条习惯我强烈建议写进编码规范。5.2 位域、packed 结构与可移植性位域bit-field在协议解析和硬件寄存器映射里很常见struct Flags { uint32_t enable : 1; uint32_t mode : 3; uint32_t reserve : 28; };位域的分配顺序、跨存储单元的切分方式、位域能不能跨字节边界这些都是实现定义的行为。同一份代码用不同编译器、不同平台编译成员的位偏移可能完全不同。如果这份数据要写进文件或发到网络上就会出现A 机器写的、B 机器读不懂的问题。#pragma pack也是同理。它能让结构体按指定字节对齐省掉填充但会带来未对齐访问在某些处理器上直接触发异常或者性能大幅下降。我的建议是位域和 packed 结构只用于进程内、单编译器的场景比如驱动里映射寄存器一旦涉及跨机器传输改成手动按字节拆装uint32_t encode_flags(uint32_t enable, uint32_t mode) { return (enable 0x1u) | ((mode 0x7u) 1); }这样写虽然啰嗦但是字节序和位顺序完全可控可移植性有保障。5.3 struct 继承、空基类与 private 继承的连锁反应struct 默认 public 继承这个特性会导致一些意外。假设你这样写struct Base { int v; }; struct Derive : Base { int w; };这等价于public继承Derive保留了Base的公开接口。而如果同样两个类型写成 classclass Base { int v; }; class Derive : Base { int w; };这就是 private 继承外部完全看不到Base的那部分。这个差异在重构时很容易被忽略有人把一批 class 统一改成 struct 想简化写法结果继承关系从 private 变成了 public接口意外暴露出去。另一个相关的坑是空基类优化EBO。空类的大小是 1但作为基类时可以被优化到 0 字节这是标准允许的优化。如果你把空类用作成员而不是基类那就占 1 字节而且会破坏后续成员的对齐可能让整个结构体多占 8 字节。用继承代替成员来省空间是个常见手法但会带来继承语义上的副作用用之前想清楚值不值得。5.4 前向声明的关键字不一致与工具链怪现象前向声明时用的是 struct 还是 class跟定义时用的关键字可以不一致struct Foo; // 前向声明 class Foo { // 定义 public: int a; };这段代码是合法的因为两个关键字指向同一个类型。但某些编译器尤其是 Windows 平台上的会给出警告提示关键字不一致。更烦人的是 IDE 的代码补全和跳转有时会因为这种不一致而失效尤其是当声明散落在多个头文件里时。排查这类工具链怪现象的经验是先把关键字统一再看问题是否消失。我遇到过一次成员补全失效的情况最后发现是头文件里前向声明写成 struct、定义写成 class索引器把它当成了两个不同的符号。统一之后补全立刻恢复。所以我的做法是一个类型只要确定了关键字前向声明、定义、模板特化所有出现的地方都用同一个。这不会改变编译结果但能让工具链少出幺蛾子。6. 具体场景的落地写法最后一节把我自己在几类常见场景里的做法整理出来可以直接参考。这些写法不是唯一正确答案但都是经过项目验证、能跑得住的方案。6.1 几类典型场景与推荐写法对照场景推荐关键字关键理由坐标、尺寸、颜色等几何数据struct无约束可任意组合纯记录配置项集合struct成员独立便于聚合初始化和拷贝日志条目、事件描述struct传递方便可平凡复制需要维持不变量的值对象class必须封装校验逻辑资源句柄、连接、锁class需要 RAII禁止随意拷贝多态基类class虚函数和析构语义模板类型参数两者皆可无差异按语义选择跨语言/跨模块接口数据struct布局可控ABI 稳定这张表不是教条。判断的时候还是回到第 4 节那三条线有没有不变量、需不需要多态、成员之间是否独立。6.2 与 C 交互、硬件寄存器映射的写法和 C 代码对接时用 struct 并在头文件里加静态断言是这个场景的标准做法extern C { struct DeviceReg { volatile uint32_t ctrl; volatile uint32_t status; volatile uint32_t data; }; static_assert(sizeof(DeviceReg) 12, 寄存器块大小必须是 12 字节); }extern C保证不会做名字修饰volatile防止编译器优化掉读写static_assert在编译期检查大小是否符合作手册。映射到固定地址时用指针转换auto* reg reinterpret_castDeviceReg*(0x40000000u); reg-ctrl 1u;要在项目里注意两点一是这类转换的对齐要求必须满足地址没对齐在某些平台上会报错二是编译器的-fstrict-aliasing优化有可能把你意料之外的访问顺序改掉涉及外设读写的代码建议对相关文件单独降低优化等级或者全程用volatile。6.3 序列化与网络协议结构的注意事项网络协议结构建议遵循三条规则用固定宽度的整数类型uint32_t而不是int、显式指定对齐、统一字节序处理。struct MsgHeader { uint32_t magic; uint16_t version; uint16_t body_len; }; static_assert(sizeof(MsgHeader) 8, 头部不能有填充);发送前把多字节字段转成网络字节序接收后转回这一步千万别省。协议结构里不要放std::string、std::vector这类类型它们的内部布局和实现相关直接发出去对方读不了正确做法是先序列化成长度加内容的字节序列。还有一点值得提醒不要用memcpy把结构体整体写进文件或 socket 就当序列化完成了。填充字节的内容不确定可能泄漏栈上的残留数据也可能让文件哈希不稳定。要么逐字段写要么显式清零填充区。6.4 从 struct 平滑演进到 class 的迁移手法项目演进过程中一个 struct 变成 class 是常有的事。为了把改动控制住我一般按这个顺序走先把需要保护的成员改成 private其余保持 public编译一遍找到所有需要访问的地方。给访问点补上读写接口接口名尽量和原来的成员名接近减少心智负担。在写接口里加入校验和不变量维护逻辑。全局搜索这个类型的聚合初始化写法改成构造调用或工厂函数。补一个static_assert或者单元测试把不变量固定下来。第 4 步是最容易漏的。聚合初始化写法往往散落在测试代码、初始化列表、嵌套结构体里光靠肉眼翻容易漏。我的做法是在改名之前先加一版构造函数并用-Wmissing-field-initializers之类的警告选项编译一遍让编译器帮忙把所有位置列出来。最后分享一个我自己的习惯如果一个类型我拿不准该用哪个我就先写成 struct然后在文件头写一行注释说明这个类型当前没有不变量如果以后要加校验改成 class。这行注释看起来是废话但在半年后回头看代码时能立刻回忆出当初的判断依据避免重新推一遍。我试过在一个中型项目里坚持这个习惯后来做重构时省下的时间相当可观。