ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

Point p{1, 2}里的1是x还是y?这个问题只能靠回头看结构体定义来回答。C++20 的指定初始化器(designated initializers,P0329)把答案写进了初始化式本身:Point p{.x = 1, .y = 2}。这篇讲清它能做什么、必须遵守什么(规则比你想的严),以及聚合初始化这条线从 C++11 到 C++20 是怎么一步步放宽的。

需要 C++20:这篇所有带// verify: std=c++20的示例都要用-std=c++20编译。实测 gcc-13.2.0 在-std=c++17下会把它当扩展放行(另发警告),但那是实现的宽容,不是 C++17 的语法。

1. 引子:位置初始化的语义靠「数位置」

// 片段(无 main,不进校验)#include<string>structConfig{intretries;inttimeout_ms;boolverbose;};Config c{3,500,false};// 3 是 retries,500 是 timeout_ms —— 全靠数位置

现在有人往中间插了一个成员:

// 片段(无 main,不进校验)structConfig{intretries;intport;// 新加的inttimeout_ms;boolverbose;};Config c{3,500,false};// 依然编译通过!但语义已经全错:// retries=3, port=500, timeout_ms=0, verbose=false

这一段是新代码里最危险的 bug 类型:编译器一声不响,timeout_ms从 500 变成 0,等到线上超时才发现。指定初始化器解决的正是这个问题——名字写出来,位置就不重要了(但顺序依然重要,见第 3 节)。

2. 基本用法:写名字,跳过不想写的成员

// verify: std=c++20// designator_basic.cpp — 编译: g++ -std=c++20 -Wall -O2 designator_basic.cpp -o basic#include<cstdio>#include<string>structConfig{intretries{3};// 默认成员初始化器(NSDMI)inttimeout_ms{500};boolverbose{false};std::string tag{"default"};};intmain(){constConfig prod{.retries=5,.timeout_ms=1000,.verbose=true,.tag="prod"};constConfig quick{.retries=1};// 其余成员走 NSDMI 默认值std::printf("prod : retries=%d timeout=%d verbose=%d tag=%s\n",prod.retries,prod.timeout_ms,static_cast<int>(prod.verbose),prod.tag.c_str());std::printf("quick: retries=%d timeout=%d verbose=%d tag=%s\n",quick.retries,quick.timeout_ms,static_cast<int>(quick.verbose),quick.tag.c_str());}
prod : retries=5 timeout=1000 verbose=1 tag=prod quick: retries=1 timeout=500 verbose=0 tag=default

quick只写了retries,其余成员不是「未初始化」:没被显式初始化的成员会走它的默认成员初始化器(default member initializer,NSDMI);没有 NSDMI 的就按空初始化列表{}初始化(即置零)。所以指定初始化器不会引入未初始化内存,这一点比「先声明再逐个赋值」安全。

官方文档:Aggregate initialization — cppreference

3. 六条硬规则,每条都有真实报错

指定初始化器的规则比多数人以为的严格得多。下面是 gcc-13.2.0 用-std=c++20实测的报错,一条一条对。

规则一:必须按成员声明顺序写。这是最容易踩的,也是 C++ 与 C 最大的差异之一(C 允许乱序)。

// 片段(无 main,不进校验):反例,不要这么写 —— 违反「按声明顺序」规则structPoint{intx;inty;};Point p{.y=2,.x=1};// ✗ 编译失败// error: designator order for field 'Point::x' does not match declaration order in 'Point'

规则二:不能和位置初始化混用。一个初始化列表里,要么全是指定的,要么全是位置的。

// 片段(无 main,不进校验):反例,不要这么写 —— 违反「不许混用」规则structPoint{intx;inty;};Point p{1,.y=2};// ✗ 编译失败// 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.a=1};// ✗ 编译失败// error: expected primary-expression before '.' tokenOuter ok{.inner={.a=1}};// ✓ 另起一层花括号,两个编译器都接受

规则四:只对聚合类型有效。类一旦有了自己的构造函数,就不是聚合,指定初始化器直接报错——这条正好是第 6 节的落点。

// 片段(无 main,不进校验):反例,不要这么写 —— 违反了「只对聚合有效」structConn{inthost_id;intport;Conn(inth,intp):host_id(h),port(p){}// 有构造函数 → 非聚合};Conn c{.host_id=1,.port=80};// ✗ 编译失败// error: designated initializers cannot be used with a non-aggregate type 'Conn'

规则五:不允许窄化转换。这条和聚合初始化在 C++11 起就禁止窄化的规则一致。

// 片段(无 main,不进校验):反例,不要这么写 —— 窄化structRatio{intnum;};Ratio r{.num=3.9};// ✗ 编译失败// error: narrowing conversion of '3.8999999999999999e+0' from 'double' to 'int' [-Wnarrowing]

规则六:同一个成员只能指定一次。

// 片段(无 main,不进校验):反例,不要这么写 —— 重复指定structPoint{intx;inty;};Point p{.x=1,.x=2};// ✗ 编译失败// 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 迁过来的代码最容易在这些地方翻车:

写法CC++20说明
{.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)。判定条件不多,但C++20 相比 C++17 收紧了一条:

条件(C++20)反例
无用户声明或继承的构造函数写了Conn(int, int)→ 非聚合
无私有 / 受保护的非静态数据成员private: int v;→ 非聚合
无虚函数有virtual成员函数 → 非聚合
无虚基类struct D : virtual Base→ 非聚合
无私有 / 受保护的直接基类struct D : private Base→ 非聚合
可以是数组任意T arr[N]都是聚合
聚合判定的「与门」流程(C++20) 一个类型 T │ ┌─────────┴─────────┐ │ 是数组吗? │ └─────────┬─────────┘ 是 │ │ 否 ▼ ▼ ┌─────────┐ ┌──────────────────────────────────┐ │ 聚合 ✓ │ │ 逐条查 5 个条件(全部满足才是聚合) │ └─────────┘ │ ① 无用户声明/继承的构造函数 │ │ ② 无非 public 的非静态数据成员 │ │ ③ 无虚函数 │ │ ④ 无虚基类 │ │ ⑤ 无非 public 的直接基类 │ └──────────┬───────────────────────┘ 全满足 │ │ 任一不满足 ▼ ▼ 聚合 ✓ 非聚合 ✗ (可用指定初始化器)(只能用构造函数)

用std::is_aggregate_v把这几条真跑一遍最省事:

// verify: std=c++20// designator_aggregate.cpp — 编译: g++ -std=c++20 -Wall -O2 designator_aggregate.cpp -o aggr#include<cstdio>#include<type_traits>structPlainAgg{inta{};intb{};};// 光杆聚合structWithNsdmi{inta{1};intb{2};};// C++14 起带 NSDMI 也是聚合structBase{inta{};};structWithBase:Base{intb{};};// C++17 起带 public 基类也是聚合structVirtualBase:virtualBase{intb{};};// 虚基类structHasCtor{inta{};HasCtor()=default;};// 用户声明了构造函数structHasVirtual{inta{};virtualvoidf(){}};// 虚函数structHasPrivate{inta{};private:intb{};};// 私有非静态成员template<classT>voidshow(constchar*name){std::printf("%-14s is_aggregate = %d\n",name,static_cast<int>(std::is_aggregate_v<T>));}intmain(){show<PlainAgg>("PlainAgg");show<WithNsdmi>("WithNsdmi");show<WithBase>("WithBase");show<VirtualBase>("VirtualBase");show<HasCtor>("HasCtor");show<HasVirtual>("HasVirtual");show<HasPrivate>("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;只是「用户声明」而没「用户提供」函数体。在 C++17 里这仍然是聚合,C++20 起不再是:用static_assert(std::is_aggregate_v<HasCtor>)在-std=c++17下能过,在-std=c++20下直接static assertion failed(实测 gcc-13.2.0)。升级到 C++20 时,如果代码里靠「空花括号初始化 + 声明了一个 defaulted 构造函数」这套组合,就会当场编不过。

5. 聚合初始化的能力演进:C++11 → C++20

聚合初始化不是 C++20 才有的新东西,它从 C++98 就在,只是每一代标准都在拆限制:

标准新增 / 变更影响的写法特性测试宏
C++98聚合初始化的原始形态T t = {1, 2};—
C++11禁止窄化转换;聚合不允许有 NSDMIT t{1, 2.0}从合法变非法—
C++14允许 NSDMI 与聚合共存struct S { int a = 1; int b; }; S s{2, 3};__cpp_aggregate_nsdmi
C++17允许聚合带 public 非虚基类(聚合继承);元素列表变成「先基类后成员」struct D : Base { int b; }; D d{{1}, 2};__cpp_aggregate_bases
C++20允许指定初始化器;允许圆括号聚合初始化T(x, y);构造函数要求从「用户提供」收紧为「用户声明」T t{.a = 1}; T t(1, 2);__cpp_designated_initializers、__cpp_aggregate_paren_init

几个细节值得单独记一笔:

  • C++11 那条限制是反直觉的:struct S { int a = 1; int b; }; S s{2, 3};在 C++11 下会报no matching function for call to 'S::S(<brace-enclosed initializer list>)'(实测),因为成员上的= 1让S不再算聚合;C++14 才修好。
  • C++17 的聚合继承让带基类的结构体也能花括号初始化,写法是D d{{base_a}, derived_b}——基类子对象在前,成员在后。
  • C++20 的圆括号聚合初始化(P0960)是配套福利:Version v(1, 2);现在也能走聚合初始化,不再强制只能用花括号。反过来,带explicit的单参数构造函数就不会被这条路绕过。

官方文档:Aggregate 定义与历代变更 — cppreference

6. 选型:构造函数 / 聚合初始化 / 指定初始化器

三者不是替代关系,而是「这个类型需不需要维护不变量」这一个问题的三种答案:

维度构造函数聚合初始化(位置)指定初始化器(C++20)
成员名可见取决于参数名,调用处看不到看不到,只能数位置看得到,.x = 1
校验位置顺序不适用(参数顺序固定)编译器管,但语义靠位置编译器强制按声明顺序
能否有默认值靠默认参数 / 重载靠 NSDMI(C++14 起)靠 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: std=c++20// designator_layout.cpp — 编译: g++ -std=c++20 -Wall -O2 designator_layout.cpp -o layout#include<cstdio>// 按业务叙述顺序声明:可读,但有填充structLoose{charkind{};intvalue{};charflag{};};// 按大小降序重排:紧凑structTight{intvalue{};charkind{};charflag{};};intmain(){// 指定初始化器把名字写出来 —— 成员重排之后初始化处依然自解释constLoose l{.kind='A',.value=7,.flag=1};constTight t{.value=7,.kind='A',.flag=1};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);constexprintkCount=1000;std::printf("1000 个元素: Loose=%zu 字节, Tight=%zu 字节\n",sizeof(Loose)*kCount,sizeof(Tight)*kCount);}
Loose: sizeof=12 alignof=4 value=7 Tight: sizeof=8 alignof=4 value=7 1000 个元素: Loose=12000 字节, Tight=8000 字节

换成位置初始化,{.kind, .value, .flag}和{.value, .kind, .flag}在调用处长得一样,改完成员顺序要回去一个个数;指定初始化器把「名字」这一层信息保留了下来,重排成员的成本从「改一遍所有初始化点」降到零。当然,重排之后所有初始化式里的 designator 顺序也得跟着调整:这是规则一在盯着你,而这次编译器会报错,不再静默。

8. 完整示例:一个能自检的配置对象

把前面所有要点串起来:聚合 + NSDMI 默认值 + 指定初始化器 + 只写要改的成员 + 不变量检查(放在自由函数里,因为聚合本身没法拦)。

// verify: std=c++20// designator_full.cpp — 编译: g++ -std=c++20 -Wall -O2 designator_full.cpp -o full#include<cstdio>#include<string>namespace{structEndpoint{std::string host{"127.0.0.1"};intport{8080};};structServerConfig{Endpoint bind{};// 嵌套聚合intworkers{4};intbacklog{128};boolkeep_alive{true};std::string name{"unnamed"};// 聚合不能拦不变量,但可以用一个常量表达式给出硬边界staticconstexprintkMinPort=1;staticconstexprintkMaxPort=65535;};// 不变量检查放在外面:聚合类型没法在构造时拦,就显式查一次boolvalid(constServerConfig&c){returnc.bind.port>=ServerConfig::kMinPort&&c.bind.port<=ServerConfig::kMaxPort&&c.workers>=1&&c.backlog>=1;}voiddump(constServerConfig&c){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_cast<int>(c.keep_alive),static_cast<int>(valid(c)));}}// namespaceintmain(){// 只写真正要改的成员,其余走 NSDMIconstServerConfig dev{.name="dev"};// 嵌套聚合用「另起一层花括号」的方式指定,不允许写成 .bind.hostconstServerConfig prod{.bind={.host="0.0.0.0",.port=443},.workers=32,.name="prod",};// 故意写坏:端口越界 + workers = 0,靠 valid() 拦住constServerConfig bad{.bind={.port=70000},.workers=0,.name="bad",};dump(dev);dump(prod);dump(bad);}
dev bind=127.0.0.1:8080 workers=4 backlog=128 keep_alive=1 valid=1 prod bind=0.0.0.0:443 workers=32 backlog=128 keep_alive=1 valid=1 bad bind=127.0.0.1:70000 workers=0 backlog=128 keep_alive=1 valid=0

这个例子正好落在第 6 节的结论上:ServerConfig是个没有强不变量的数据包,所以用聚合 + 指定初始化器最舒服:调用处能一眼看出改了哪几个字段,dev只改了name,其余全走默认值。但它也暴露了代价:bad那份配置能构造出来,valid(c)为 0 要有人去查。如果这个类型的不变量是硬要求(比如「构造出来就必须可用」),就该换成构造函数 + 工厂函数返回std::optional<ServerConfig>,把错误拦在类型边界上。

程序本身遵循 Core Guidelines:能const的局部对象都加了const,static constexpr替代魔法数字(ES.45),static_cast<int>而不是 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 —— C++20 圆括号聚合初始化的来源
  • std::is_aggregate — cppreference —— 判定聚合的编译期工具,写模板时常用
  • C++ Core Guidelines: C.2 / C.40 —— 「有不变量就给构造函数」的原始依据,选型部分以它为准

本知识库内的相关篇目:

  • 《三路比较运算符 <=>:C++20 两个运算符换六个》 —— C++20 的三路比较运算符 <=> 一次返回「小于/等价/大于」三种结论
  • 《if / switch 初始化语句:C++17 把变量作用域关进笼子》 —— C++17 给 if 和 switch 加了 init-statement
  • 《C++17 升 C++20 迁移清单:编译开关、破坏性改动与报错对照表》 —— 一份可逐条执行的 C++17 → C++20 迁移手册。

10. 一句话总结

C++20 的指定初始化器把「成员名」写进了初始化式,Point p{.x = 1, .y = 2}从此合法;它的价值在于抗「加成员 / 调顺序」——位置初始化的语义靠数位置,中间插一个成员就会静默改掉后面所有字段的含义,而指定初始化器要么名字对上、要么编译报错。代价是规则比 C 严得多:必须按声明顺序、不许与位置初始化混用、不许嵌套指定(要写.inner = {.a = 1})、只对聚合类型有效、不许窄化、同一成员只能指定一次——六条各有明确的编译报错。选型上记住一条分界线:没有不变量的纯数据包用聚合 + 指定初始化器,有不变量的类型老老实实写构造函数;聚合初始化天生没法在构造时拦截非法值,别指望它。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进