前言
先澄清一个容易混淆的点:在 C++ 里struct和class的唯一区别是默认访问级别(struct默认public,class默认private)和默认继承方式(struct默认public继承,class默认private继承)。struct可以有构造函数、成员函数、虚函数、继承——它不是一个"只能是数据"的东西。标题里的"struct 初始化与赋值"讲的正是这套机制在多数据类(data class)上怎么运转。
第二个普遍误解是把"初始化"和"赋值"当成一回事。它们在语言层面是两个完全不同的动作:初始化在执行时对象还不存在,它调用构造函数(或做聚合初始化)来创造对象;赋值发生在对象已经构造完成之后,它调用operator=改写已有对象。对const成员、引用成员、数组成员这些"初始化能行、赋值不行"的成员,两者的差别会直接变成编译错误。
本文先讲初始化与赋值的分界,再讲聚合初始化(aggregate initialization)的判定规则与几种写法的差别,然后讲默认成员初始化器与"默认初始化 / 值初始化"的陷阱,最后讲拷贝赋值的隐式生成规则和memcpy到底什么时候才安全。
示例基准为C++17,GCC 13 / Clang 17 / MSVC 19.3x 均可编译。涉及 C++20 的指定初始化器会单独标注并给出 C++17 替代写法。
一、初始化与赋值:两个不同的动作
| 动作 | 触发时机 | 调用的东西 | 对象是否已存在 |
|---|---|---|---|
| 初始化 | 变量定义、传参、返回、new | 构造函数 / 聚合初始化 | 否 |
| 赋值 | 对已有对象用= | operator=(通常是拷贝赋值) | 是 |
一句代码就能看出区别:
struct S { int v; }; S a = {1}; // 拷贝初始化:a 在此时被"创造"出来,值 1 S b; // 默认初始化:b 被创造出来,但 v 的值不确定 b = a; // 赋值:b 早已存在,这里调用隐式生成的 operator=对聚合类型,S a = {1};看起来像赋值,其实是拷贝初始化,走的是聚合初始化路径,不会调用operator=。
这里有一个 C 程序员会感到意外的地方:C++ 的隐式拷贝赋值运算符会逐成员赋值,数组成员也是逐元素赋值的,所以struct里带数组也能整体赋值:
struct Row { int cells[4]; }; Row x{{1, 2, 3, 4}}; Row y{}; y = x; // ✅ 合法:cells 的 4 个元素被逐个拷贝 // int p[4]; int q[4]; q = p; // ❌ 裸数组不能赋值C 语言里struct也能整体赋值,这一点 C 和 C++ 一致;但 C++ 还额外允许里面有数组、有非 POD 成员(如std::string),并会递归调用它们的拷贝赋值。
二、聚合初始化:判定规则与几种写法
聚合(aggregate)的定义在标准里有明确条件。以 C++17 为准,一个类型是聚合需要同时满足:
- 没有用户提供、显式默认或继承来的构造函数;
- 没有虚函数;
- 没有虚基类、私有基类、受保护基类;
- 没有私有或受保护的非静态数据成员。
注意第 3 条在 C++17 起放宽了:聚合可以有 public 非虚基类。C++17 之前,有基类就不是聚合。C++14 起还允许聚合有默认成员初始化器(C++11 不允许,这是 C++11 和 C++14 的一个实际差异)。
struct Plain { // 聚合 int id; double score; }; struct AlsoAggregate { // 也是聚合(C++14 起允许默认成员初始化器) int id = 0; double score = 0.0; static const int kMax = 8; // 静态成员不影响聚合性 }; struct NotAggregate { int id; explicit NotAggregate(int v) : id(v) {} // 有用户声明的构造函数 → 不是聚合 };对聚合,初始化列表可以这样写:
Plain a{1, 2.5}; // 直接列表初始化 Plain b = {2, 3.5}; // 拷贝列表初始化 Plain c{}; // 空列表 → 每个成员从 {} 初始化(标量归零) Plain d = {}; // 同上 Plain e{1}; // 后面的成员用 {} 初始化e{1}里score没有给出,会按"从空初始化列表初始化"处理,结果是0.0,而不是不确定值。这和Plain e;(默认初始化,成员值不确定)差别巨大,是本主题最容易踩的坑。
列表初始化还有一个关键性质:禁止窄化转换(narrowing conversion)。
Plain f{1, 2.5}; // Plain g{1.5, 2.5}; // ❌ 错误:1.5 是 double,窄化到 int Plain h{1, 2}; // ✅ 2 是 int,可以精确转成 double{1.5, 2.5}会编译失败,而写成Plain g(1.5, 2.5)只在有匹配构造函数时才谈得上、对聚合根本不能用圆括号。这是列表初始化的一个实打实的安全增益。
C++20 引入了指定初始化器(designated initializers),写法如下,需要 C++20 及以上:
struct P { int x = 0; int y = 0; int z = 0; }; // 仅 C++20 起合法 P p1{.x = 1, .z = 3}; // y 用默认成员初始化器C++17 的替代写法是老老实实按顺序全写:P p1{1, 0, 3};。另外 C++20 的指定初始化器有几条限制:必须按声明顺序书写,不能乱序,也不能和位置初始化混用(例如P{1, .z = 3}非法)。这些限制和 C 语言的指定初始化器不完全相同,从 C 迁过来时要注意。
三、默认成员初始化器与默认初始化 / 值初始化
对"有默认成员初始化器但仍是聚合"的类型,三种写法的效果不同:
| 写法 | 名称 | 效果 |
|---|---|---|
T t; | 默认初始化 | 有默认成员初始化器的成员用它的值;没有的不确定 |
T t{}; | 值初始化(对聚合即空列表聚合初始化) | 所有成员先从{}初始化:有默认初始化器用默认值,否则标量归零 |
T t = T(); | 值初始化 | 同上 |
struct Config { int id = 7; std::string name = "anonymous"; }; Config a; // id = 7, name = "anonymous" Config b{}; // 同上而对没有默认成员初始化器的类型:
struct Raw { int id; char tag; }; Raw a; // ⚠️ id、tag 的值不确定 Raw b{}; // ✅ id = 0, tag = '\0'读取a.id是读取未初始化的值。对int这类类型,读取不确定值是未定义行为(UB),标准不保证任何行为;而且编译器在开启优化后可能做出完全出乎意料的事。养成习惯:只要不是刻意做性能优化,聚合类型一律用{}初始化。
还有一点关于std::string之类的非平凡成员:Config a;里name会被默认构造(因为std::string有默认构造函数),不会是不确定值。真正"不确定"的只有标量、指针、以及没有默认构造函数的平凡成员。
四、拷贝赋值:隐式生成规则与 memcpy 的边界
没有显式声明拷贝赋值运算符时,编译器会隐式生成一个:逐成员调用各自类型的拷贝赋值。如果某个成员不可赋值,这个隐式版本会被定义为删除的(defined as deleted),于是任何赋值尝试都编译报错——报错信息通常指向赋值那一行,而不是成员声明处,这也是一类典型困惑来源。
struct WithRef { int& r; // 引用成员:不可被"重新绑定" }; // WithRef 的隐式拷贝赋值是 deleted // WithRef a{b1}, c{b2}; a = c; // ❌ 编译错误 struct WithConst { const int id; }; // WithConst 的隐式拷贝赋值同样是 deleted引用和const成员都会让隐式拷贝赋值被删除(拷贝构造仍然可用)。这是"初始化能行、赋值不行"的最典型体现。
memcpy 什么时候安全
std::memcpy做的是按字节复制。把对象当成字节序列搬运,只有在类型可平凡复制(trivially copyable)时才是定义良好的行为。判定方式是用<type_traits>里的std::is_trivially_copyable:
#include <cstdint> #include <cstring> #include <iostream> #include <string> #include <type_traits> struct Header { // 全部是标量成员,无自定义构造/析构 std::uint32_t magic; std::uint16_t version; std::uint16_t flags; }; struct Message { // 含 std::string:非平凡 std::uint32_t id; std::string body; }; static_assert(std::is_trivially_copyable<Header>::value, "Header 必须可平凡复制,才允许按字节搬运"); static_assert(!std::is_trivially_copyable<Message>::value, "Message 含有非平凡成员,不能 memcpy"); int main() { Header h1{0x01020304u, 2, 0}; Header h2{}; std::memcpy(&h2, &h1, sizeof h1); // ✅ 安全 std::cout << std::hex << h2.magic << '\n'; Message m1{1, "hello"}; Message m2{2, "world"}; // std::memcpy(&m2, &m1, sizeof m1); // ❌ UB:会复制 string 内部指针 m2 = m1; // ✅ 用赋值运算符,正确管理内部资源 std::cout << std::dec << m2.body << '\n'; return 0; }std::memcpy到Message上,会把m1.body内部的指针、大小、容量(或 SSO 缓冲区)原样拷给m2,两个std::string指向同一块内存,析构时二次释放。这是 UB,标准不保证任何行为,实践中常见的表现是double free崩溃。
顺带提一句实现细节的坑:std::string的内部布局(是否有小字符串优化、SSO 缓冲区多大)在 libstdc++、libc++、MSVC STL 上各不相同,MSVC 的std::string尺寸就和 libstdc++ 的不同。任何依赖sizeof(std::string)或内部字段偏移的代码都是不可移植的。
常见坑点
T t;之后直接读成员。
❌struct Raw { int id; }; Raw a; std::cout << a.id;—— 读取不确定值,对int是 UB,标准不保证任何行为。 ✅Raw a{};—— 空列表把标量成员归零。
- 列表初始化里发生窄化却不自知。
❌struct S { int a; }; S s{2.9};—— 编译错误,这是好事,别改成S s(2.9)绕过去。 ✅ 显式写出转换意图:S s{static_cast<int>(2.9)};
- 给聚合类型加了构造函数后,老的花括号初始化全炸。
❌ 原本Plain p{1, 2.0};用得好好的,后来加了Plain(int),类型不再是聚合,{1, 2.0}变成调用构造函数而找不到匹配。 ✅ 加构造函数前先评估调用方;或者保留聚合性,只用默认成员初始化器表达默认值。
struct里有const成员/引用成员,却想赋值。
❌struct S { const int id; }; S a{1}, b{2}; a = b;—— 隐式拷贝赋值被删除,编译错误信息常指向a = b;,看不出根因。 ✅ 去掉const,或者改用指针/std::reference_wrapper,或者干脆不赋值、只在构造时确定。
- 用
memcpy搬运含std::string/std::vector的结构体。
❌std::memcpy(&dst, &src, sizeof dst);且类型非平凡 —— UB,实践中表现为重复释放。 ✅ 直接赋值dst = src;;确实需要序列化时,自己定义逐字段的编码格式。
- 把 C++20 的指定初始化器当成 C 的写法用。
❌P p{.z = 3, .x = 1};乱序,或者P p{1, .z = 3};混用位置与指定 —— 在 C++20 里非法(C 的规则更宽松)。 ✅ 按声明顺序写,C++17 下退回P p{1, 0, 3};。
- 忘记 C++11 里"有默认成员初始化器就不是聚合"。
❌ 在 C++11 模式下写struct S { int id = 0; int n; }; S s{1, 2};—— C++11 下S不是聚合,编译失败。 ✅ 提升到 C++14 及以上,或把默认成员初始化器去掉、改用构造函数。
- 拿
sizeof或字段偏移做网络协议布局假设。
❌ 直接把Header结构体写到 socket 上,假设它没有填充字节、字节序与对方一致。 ✅ 结构体成员之间存在填充字节,其位置和数量是实现定义的;而且字节序取决于平台。跨机器传输必须显式按字节布局序列化,并统一字节序。
总结
| 主题 | 关键点 |
|---|---|
| 初始化 vs 赋值 | 前者创造对象走构造;后者改写已有对象走operator= |
| 聚合判定 | 无用户构造函数、无虚函数、无私有/受保护非静态成员(C++17 起可继承 public 基类) |
T t;vsT t{}; | 前者成员可能不确定,后者保证归零/用默认值 |
| 默认成员初始化器 | C++11 可用但会破坏聚合性;C++14 起聚合可用 |
| 列表初始化 | 禁止窄化转换,这是特性不是麻烦 |
| 指定初始化器 | C++20 起,须按声明顺序,C++17 用位置列表替代 |
| 隐式拷贝赋值 | 逐成员赋值;成员不可赋值时被删除 |
memcpy | 仅对可平凡复制类型安全;否则 UB |
理解"初始化创造对象、赋值改写对象"这条分界线之后,const成员、引用成员、数组、默认成员初始化器这些看似零散的规则就都串起来了:它们全都是同一条规则的推论。
最后提醒:结构体填充字节的位置与数量、std::string的内部布局、字节序,全都是实现定义或平台相关的,标准不作保证,跨平台代码不要依赖。