资讯动态

指定初始化器:C++20 给聚合初始化补上的最后一块

发布时间:2026/10/19 1:57:45 来源:尧图企业网站定制
Point p{1, 2}里的1是x还是y这个问题只能靠回头看结构体定义来回答。C20 的指定初始化器designated initializersP0329把答案写进了初始化式本身Point p{.x 1, .y 2}。这篇讲清它能做什么、必须遵守什么规则比你想的严以及聚合初始化这条线从 C11 到 C20 是怎么一步步放宽的。需要 C20这篇所有带// verify: stdc20的示例都要用-stdc20编译。实测 gcc-13.2.0 在-stdc17下会把它当扩展放行另发警告但那是实现的宽容不是 C17 的语法。1. 引子位置初始化的语义靠「数位置」// 片段无 main不进校验#includestringstructConfig{intretries;inttimeout_ms;boolverbose;};Config c{3,500,false};// 3 是 retries500 是 timeout_ms —— 全靠数位置现在有人往中间插了一个成员// 片段无 main不进校验structConfig{intretries;intport;// 新加的inttimeout_ms;boolverbose;};Config c{3,500,false};// 依然编译通过但语义已经全错// retries3, port500, timeout_ms0, verbosefalse这一段是新代码里最危险的 bug 类型编译器一声不响timeout_ms从 500 变成 0等到线上超时才发现。指定初始化器解决的正是这个问题——名字写出来位置就不重要了但顺序依然重要见第 3 节。2. 基本用法写名字跳过不想写的成员// verify: stdc20// designator_basic.cpp — 编译: g -stdc20 -Wall -O2 designator_basic.cpp -o basic#includecstdio#includestringstructConfig{intretries{3};// 默认成员初始化器NSDMIinttimeout_ms{500};boolverbose{false};std::string tag{default};};intmain(){constConfig prod{.retries5,.timeout_ms1000,.verbosetrue,.tagprod};constConfig quick{.retries1};// 其余成员走 NSDMI 默认值std::printf(prod : retries%d timeout%d verbose%d tag%s\n,prod.retries,prod.timeout_ms,static_castint(prod.verbose),prod.tag.c_str());std::printf(quick: retries%d timeout%d verbose%d tag%s\n,quick.retries,quick.timeout_ms,static_castint(quick.verbose),quick.tag.c_str());}prod : retries5 timeout1000 verbose1 tagprod quick: retries1 timeout500 verbose0 tagdefaultquick只写了retries其余成员不是「未初始化」没被显式初始化的成员会走它的默认成员初始化器default member initializerNSDMI没有 NSDMI 的就按空初始化列表{}初始化即置零。所以指定初始化器不会引入未初始化内存这一点比「先声明再逐个赋值」安全。官方文档Aggregate initialization — cppreference3. 六条硬规则每条都有真实报错指定初始化器的规则比多数人以为的严格得多。下面是 gcc-13.2.0 用-stdc20实测的报错一条一条对。规则一必须按成员声明顺序写。这是最容易踩的也是 C 与 C 最大的差异之一C 允许乱序。// 片段无 main不进校验反例不要这么写 —— 违反「按声明顺序」规则structPoint{intx;inty;};Point p{.y2,.x1};// ✗ 编译失败// error: designator order for field Point::x does not match declaration order in Point规则二不能和位置初始化混用。一个初始化列表里要么全是指定的要么全是位置的。// 片段无 main不进校验反例不要这么写 —— 违反「不许混用」规则structPoint{intx;inty;};Point p{1,.y2};// ✗ 编译失败// error: either all initializer clauses should be designated or none of them should be规则三designator 只有.成员名一种形式不能再嵌套一个成员名。C 里的{.a.x 0}在 C 不合法要在子聚合里也用指定初始化就再起一层花括号。// 片段无 main不进校验反例与正例对照structInner{inta;intb;};structOuter{Inner inner;};Outer bad{.inner.a1};// ✗ 编译失败// error: expected primary-expression before . tokenOuter ok{.inner{.a1}};// ✓ 另起一层花括号两个编译器都接受规则四只对聚合类型有效。类一旦有了自己的构造函数就不是聚合指定初始化器直接报错——这条正好是第 6 节的落点。// 片段无 main不进校验反例不要这么写 —— 违反了「只对聚合有效」structConn{inthost_id;intport;Conn(inth,intp):host_id(h),port(p){}// 有构造函数 → 非聚合};Conn c{.host_id1,.port80};// ✗ 编译失败// error: designated initializers cannot be used with a non-aggregate type Conn规则五不允许窄化转换。这条和聚合初始化在 C11 起就禁止窄化的规则一致。// 片段无 main不进校验反例不要这么写 —— 窄化structRatio{intnum;};Ratio r{.num3.9};// ✗ 编译失败// error: narrowing conversion of 3.8999999999999999e0 from double to int [-Wnarrowing]规则六同一个成员只能指定一次。// 片段无 main不进校验反例不要这么写 —— 重复指定structPoint{intx;inty;};Point p{.x1,.x2};// ✗ 编译失败// error: .x designator used multiple times in the same initializer list六条汇总成一张速查表规则合法写法非法写法真实报错见上报错关键词按声明顺序.x 1, .y 2.y 2, .x 1designator order ... does not match不许混用全指定 / 全位置{1, .y 2}either all initializer clauses should be designated不许嵌套指定.inner {.a 1}.inner.a 1expected primary-expression before . token只对聚合有效普通struct带构造函数的类non-aggregate type不许窄化.num 3.num 3.9narrowing conversion不许重复指定.x 1, .y 2.x 1, .x 2designator used multiple times官方文档C 与 C 在聚合初始化上的差异Notes 一节— cppreference顺带把 C 和 C 的差异摆在一起从 C 迁过来的代码最容易在这些地方翻车写法CC20说明{.y 1, .x 2}✓✗C 允许乱序C 必须按声明顺序{.x 1, 2}✓✗C 允许混用C 全指定或全位置{.a.x 0}✓✗C 允许嵌套指定C 要写.a {.x 0}int arr[3] {[1] 5}✓✗数组的下标指定是 C 专有{.x 1}✓✓共同子集只有这部分能跨语言复用4. 聚合判定清单什么样的类型算「聚合」指定初始化器的适用范围就是聚合类型aggregate。判定条件不多但C20 相比 C17 收紧了一条条件C20反例无用户声明或继承的构造函数写了Conn(int, int)→ 非聚合无私有 / 受保护的非静态数据成员private: int v;→ 非聚合无虚函数有virtual成员函数 → 非聚合无虚基类struct D : virtual Base→ 非聚合无私有 / 受保护的直接基类struct D : private Base→ 非聚合可以是数组任意T arr[N]都是聚合聚合判定的「与门」流程C20 一个类型 T │ ┌─────────┴─────────┐ │ 是数组吗 │ └─────────┬─────────┘ 是 │ │ 否 ▼ ▼ ┌─────────┐ ┌──────────────────────────────────┐ │ 聚合 ✓ │ │ 逐条查 5 个条件全部满足才是聚合 │ └─────────┘ │ ① 无用户声明/继承的构造函数 │ │ ② 无非 public 的非静态数据成员 │ │ ③ 无虚函数 │ │ ④ 无虚基类 │ │ ⑤ 无非 public 的直接基类 │ └──────────┬───────────────────────┘ 全满足 │ │ 任一不满足 ▼ ▼ 聚合 ✓ 非聚合 ✗ 可用指定初始化器只能用构造函数用std::is_aggregate_v把这几条真跑一遍最省事// verify: stdc20// designator_aggregate.cpp — 编译: g -stdc20 -Wall -O2 designator_aggregate.cpp -o aggr#includecstdio#includetype_traitsstructPlainAgg{inta{};intb{};};// 光杆聚合structWithNsdmi{inta{1};intb{2};};// C14 起带 NSDMI 也是聚合structBase{inta{};};structWithBase:Base{intb{};};// C17 起带 public 基类也是聚合structVirtualBase:virtualBase{intb{};};// 虚基类structHasCtor{inta{};HasCtor()default;};// 用户声明了构造函数structHasVirtual{inta{};virtualvoidf(){}};// 虚函数structHasPrivate{inta{};private:intb{};};// 私有非静态成员templateclassTvoidshow(constchar*name){std::printf(%-14s is_aggregate %d\n,name,static_castint(std::is_aggregate_vT));}intmain(){showPlainAgg(PlainAgg);showWithNsdmi(WithNsdmi);showWithBase(WithBase);showVirtualBase(VirtualBase);showHasCtor(HasCtor);showHasVirtual(HasVirtual);showHasPrivate(HasPrivate);}PlainAgg is_aggregate 1 WithNsdmi is_aggregate 1 WithBase is_aggregate 1 VirtualBase is_aggregate 0 HasCtor is_aggregate 0 HasVirtual is_aggregate 0 HasPrivate is_aggregate 0注意HasCtor这一行HasCtor() default;只是「用户声明」而没「用户提供」函数体。在 C17 里这仍然是聚合C20 起不再是用static_assert(std::is_aggregate_vHasCtor)在-stdc17下能过在-stdc20下直接static assertion failed实测 gcc-13.2.0。升级到 C20 时如果代码里靠「空花括号初始化 声明了一个 defaulted 构造函数」这套组合就会当场编不过。5. 聚合初始化的能力演进C11 → C20聚合初始化不是 C20 才有的新东西它从 C98 就在只是每一代标准都在拆限制标准新增 / 变更影响的写法特性测试宏C98聚合初始化的原始形态T t {1, 2};—C11禁止窄化转换聚合不允许有 NSDMIT t{1, 2.0}从合法变非法—C14允许 NSDMI 与聚合共存struct S { int a 1; int b; }; S s{2, 3};__cpp_aggregate_nsdmiC17允许聚合带 public 非虚基类聚合继承元素列表变成「先基类后成员」struct D : Base { int b; }; D d{{1}, 2};__cpp_aggregate_basesC20允许指定初始化器允许圆括号聚合初始化T(x, y)构造函数要求从「用户提供」收紧为「用户声明」T t{.a 1}; T t(1, 2);__cpp_designated_initializers、__cpp_aggregate_paren_init几个细节值得单独记一笔C11 那条限制是反直觉的struct S { int a 1; int b; }; S s{2, 3};在 C11 下会报no matching function for call to S::S(brace-enclosed initializer list)实测因为成员上的 1让S不再算聚合C14 才修好。C17 的聚合继承让带基类的结构体也能花括号初始化写法是D d{{base_a}, derived_b}——基类子对象在前成员在后。C20 的圆括号聚合初始化P0960是配套福利Version v(1, 2);现在也能走聚合初始化不再强制只能用花括号。反过来带explicit的单参数构造函数就不会被这条路绕过。官方文档Aggregate 定义与历代变更 — cppreference6. 选型构造函数 / 聚合初始化 / 指定初始化器三者不是替代关系而是「这个类型需不需要维护不变量」这一个问题的三种答案维度构造函数聚合初始化位置指定初始化器C20成员名可见取决于参数名调用处看不到看不到只能数位置看得到.x 1校验位置顺序不适用参数顺序固定编译器管但语义靠位置编译器强制按声明顺序能否有默认值靠默认参数 / 重载靠 NSDMIC14 起靠 NSDMI可只写要改的成员跳过中间成员不行除非默认参数不行可以不变量检查可以构造时抛异常 / 断言不行不行加成员时的破坏力参数列表要改编译期报错静默改语义最危险编译期报错顺序/类型不符零开销有构造体可被内联掉零额外开销零额外开销适合需要维护不变量的类型POD 数据包、简单 struct有多个可选字段的配置 / 参数包什么时候应该用构造函数而不是聚合初始化只要这个类型有「不变量」invariant比如「port必须在 1–65535」「非空字符串」「数组长度必须是 2 的幂」。聚合初始化把字段一个个摊开给你写正确性没人兜底构造函数可以在里面throw或者改成工厂函数返回std::optional。这也是 Core Guidelines 的 C.2能用类就用类别用散装数据与 C.40有不变量的类型就给它构造函数的意思没有不变量的纯数据包用聚合有不变量的用构造函数。7. 顺带一个真实收益成员可以为了 padding 重排指定初始化器写的是成员名所以重排成员声明顺序不会降低可读性。这给了你一个以前不太敢用的优化手段按大小降序排列成员来减少填充padding。成员顺序 → 内存布局64 位平台alignof(int) 4 struct Loose { char kind; int value; char flag; } sizeof 12 偏移 0 1 2 3 4 5 6 7 8 9 10 11 ┌────────┬──────────┬──────────┬────────┬───────────┐ │ kind │ padding │ value │ flag │ padding │ └────────┴──────────┴──────────┴────────┴───────────┘ ↑ 3 字节填充 ↑ 3 字节尾部填充 合计白扔 6 字节 → 12 字节里有 50% 是填充 struct Tight { int value; char kind; char flag; } sizeof 8 偏移 0 1 2 3 4 5 6 7 ┌───────────┬────────┬────────┬──────────┐ │ value │ kind │ flag │ padding │ └───────────┴────────┴────────┴──────────┘ ↑ 0 字节填充 ↑ 2 字节尾部填充 只扔 2 字节 → 省下 33% 内存 为什么每个成员必须落在自身对齐要求alignment的整数倍偏移上。 char 只有 1 字节对齐后面接 int 时编译器必须插 3 字节空洞。// verify: stdc20// designator_layout.cpp — 编译: g -stdc20 -Wall -O2 designator_layout.cpp -o layout#includecstdio// 按业务叙述顺序声明可读但有填充structLoose{charkind{};intvalue{};charflag{};};// 按大小降序重排紧凑structTight{intvalue{};charkind{};charflag{};};intmain(){// 指定初始化器把名字写出来 —— 成员重排之后初始化处依然自解释constLoose l{.kindA,.value7,.flag1};constTight t{.value7,.kindA,.flag1};std::printf(Loose: sizeof%zu alignof%zu value%d\n,sizeof(Loose),alignof(Loose),l.value);std::printf(Tight: sizeof%zu alignof%zu value%d\n,sizeof(Tight),alignof(Tight),t.value);constexprintkCount1000;std::printf(1000 个元素: Loose%zu 字节, Tight%zu 字节\n,sizeof(Loose)*kCount,sizeof(Tight)*kCount);}Loose: sizeof12 alignof4 value7 Tight: sizeof8 alignof4 value7 1000 个元素: Loose12000 字节, Tight8000 字节换成位置初始化{.kind, .value, .flag}和{.value, .kind, .flag}在调用处长得一样改完成员顺序要回去一个个数指定初始化器把「名字」这一层信息保留了下来重排成员的成本从「改一遍所有初始化点」降到零。当然重排之后所有初始化式里的 designator 顺序也得跟着调整这是规则一在盯着你而这次编译器会报错不再静默。8. 完整示例一个能自检的配置对象把前面所有要点串起来聚合 NSDMI 默认值 指定初始化器 只写要改的成员 不变量检查放在自由函数里因为聚合本身没法拦。// verify: stdc20// designator_full.cpp — 编译: g -stdc20 -Wall -O2 designator_full.cpp -o full#includecstdio#includestringnamespace{structEndpoint{std::string host{127.0.0.1};intport{8080};};structServerConfig{Endpoint bind{};// 嵌套聚合intworkers{4};intbacklog{128};boolkeep_alive{true};std::string name{unnamed};// 聚合不能拦不变量但可以用一个常量表达式给出硬边界staticconstexprintkMinPort1;staticconstexprintkMaxPort65535;};// 不变量检查放在外面聚合类型没法在构造时拦就显式查一次boolvalid(constServerConfigc){returnc.bind.portServerConfig::kMinPortc.bind.portServerConfig::kMaxPortc.workers1c.backlog1;}voiddump(constServerConfigc){std::printf(%-5s bind%s:%d workers%d backlog%d keep_alive%d valid%d\n,c.name.c_str(),c.bind.host.c_str(),c.bind.port,c.workers,c.backlog,static_castint(c.keep_alive),static_castint(valid(c)));}}// namespaceintmain(){// 只写真正要改的成员其余走 NSDMIconstServerConfig dev{.namedev};// 嵌套聚合用「另起一层花括号」的方式指定不允许写成 .bind.hostconstServerConfig prod{.bind{.host0.0.0.0,.port443},.workers32,.nameprod,};// 故意写坏端口越界 workers 0靠 valid() 拦住constServerConfig bad{.bind{.port70000},.workers0,.namebad,};dump(dev);dump(prod);dump(bad);}dev bind127.0.0.1:8080 workers4 backlog128 keep_alive1 valid1 prod bind0.0.0.0:443 workers32 backlog128 keep_alive1 valid1 bad bind127.0.0.1:70000 workers0 backlog128 keep_alive1 valid0这个例子正好落在第 6 节的结论上ServerConfig是个没有强不变量的数据包所以用聚合 指定初始化器最舒服调用处能一眼看出改了哪几个字段dev只改了name其余全走默认值。但它也暴露了代价bad那份配置能构造出来valid(c)为 0 要有人去查。如果这个类型的不变量是硬要求比如「构造出来就必须可用」就该换成构造函数 工厂函数返回std::optionalServerConfig把错误拦在类型边界上。程序本身遵循 Core Guidelines能const的局部对象都加了conststatic constexpr替代魔法数字ES.45static_castint而不是 C 风格转换ES.48输出用\n不用std::endl。9. 延伸阅读Aggregate initialization — cppreference —— 这篇的主参考页designator 的完整语法、C 与 C 的差异、历代标准变更都在这一页P0329R4: Designated Initialization — open-std.org —— 指定初始化器的提案原文解释了「为什么 C 只收 C 的一个子集」P0960R3: Allow initializing aggregates from a parenthesized list of values — open-std.org —— C20 圆括号聚合初始化的来源std::is_aggregate — cppreference —— 判定聚合的编译期工具写模板时常用C Core Guidelines: C.2 / C.40 —— 「有不变量就给构造函数」的原始依据选型部分以它为准本知识库内的相关篇目《三路比较运算符 C20 两个运算符换六个》 —— C20 的三路比较运算符 一次返回「小于/等价/大于」三种结论《if / switch 初始化语句C17 把变量作用域关进笼子》 —— C17 给 if 和 switch 加了 init-statement《C17 升 C20 迁移清单编译开关、破坏性改动与报错对照表》 —— 一份可逐条执行的 C17 → C20 迁移手册。10. 一句话总结C20 的指定初始化器把「成员名」写进了初始化式Point p{.x 1, .y 2}从此合法它的价值在于抗「加成员 / 调顺序」——位置初始化的语义靠数位置中间插一个成员就会静默改掉后面所有字段的含义而指定初始化器要么名字对上、要么编译报错。代价是规则比 C 严得多必须按声明顺序、不许与位置初始化混用、不许嵌套指定要写.inner {.a 1}、只对聚合类型有效、不许窄化、同一成员只能指定一次——六条各有明确的编译报错。选型上记住一条分界线没有不变量的纯数据包用聚合 指定初始化器有不变量的类型老老实实写构造函数聚合初始化天生没法在构造时拦截非法值别指望它。

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

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

免费获取报价 →
↑