Skip to content

Commit 04289b2

Browse files
feat(vol10): CppCon 2025 std::optional 演讲二创六篇
Steve Downey《The Evolution of std::optional: From Boost to C++26》 拆 6 篇深度笔记,聚焦 optional<T&>(P2988) 从 2005 提出到 2025 年 Sofia 会议投票通过进 C++26 的历程。引用三重身份、assign-through 与 rebind 之争、赋值重绑定、浅层 const、移动语义陷阱、The Beman Project 标准化真相。全部 optional<T&> 代码在 GCC 16.1.1 -std=c++26 实跑,附赋值汇编实证。修正了素材的 CTAD 误断与 Beman 拼写。 接入 2025 目录页。四门禁(validate_frontmatter/check_links/ check_quality/build)全绿。
1 parent fa35089 commit 04289b2

9 files changed

Lines changed: 1004 additions & 1 deletion

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
---
2121

2222
<!-- COVERAGE_START -->
23-
![English Coverage](https://img.shields.io/badge/en_coverage-99%25-green.svg) 602/607 docs translated
23+
![English Coverage](https://img.shields.io/badge/en_coverage-98%25-green.svg) 602/614 docs translated
2424
<!-- COVERAGE_END -->
2525

2626
## 这是什么项目
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
---
2+
title: 为什么 optional 引用折腾了二十年
3+
description: CppCon 2025 笔记 —— Steve Downey 讲 std::optional<T&>(P2988) 为何从 2005 拖到 2025 年 Sofia 会议才进 C++26:引用的三重身份、assign-through 与 rebind 之争、最终落地为指针
4+
chapter: 6
5+
order: 1
6+
conference: cppcon
7+
conference_year: 2025
8+
talk_title: 'The Evolution of std::optional: From Boost to C++26'
9+
speaker: Steve Downey
10+
cpp_standard: [17, 23, 26]
11+
difficulty: intermediate
12+
platform: host
13+
reading_time_minutes: 10
14+
tags:
15+
- cpp-modern
16+
- host
17+
- intermediate
18+
- optional
19+
prerequisites:
20+
- "optional:把『可能没有』做成类型"
21+
related:
22+
- "optional:把『可能没有』做成类型"
23+
- "std::optional 的值语义底子"
24+
---
25+
26+
# 为什么 optional 引用折腾了二十年
27+
28+
:::tip
29+
这一系列笔记基于 CppCon 2025 Steve Downey 的演讲 *The Evolution of std::optional: From Boost to C++26* 做的二次发散。讲者是 Bloomberg 的 Steve Downey,也是把 `std::optional<T&>` 推进 C++26 的那篇提案(P2988)的主要作者。原讲视频可以在 CppCon 官方频道检索,国内观众找搬运的 Bilibili 版本看。
30+
:::
31+
32+
`std::optional<T&>`,一个看起来"不就是能装空值的引用嘛"的东西,从 2005 年第一次被提出,到 2025 年 6 月的 Sofia 会议才终于投票通过进入 C++26。二十年。C++11 到 C++23 都够走完一整轮了。
33+
34+
笔者 2022 年刚碰 C++ 的时候,天真地以为标准库里缺什么,就是委员会懒得加。后来真的挖进去才明白,有些东西没加,是因为它很难加对。这一篇咱们顺着 Steve Downey 的讲法,把"一个能装空值的引用为什么这么难"这件事拆开。
35+
36+
## 先把环境交代清楚
37+
38+
后面所有能跑的代码,笔者都用同一套环境验证过:Arch Linux WSL,GCC 16.1.1。
39+
40+
```bash
41+
$ g++ --version
42+
g++ (GCC) 16.1.1 20260625
43+
```
44+
45+
这里有个前提您得先记住:`optional<T&>` 是 C++26 才进标准的特性,提案号 P2988。GCC 16.1.1 在 `-std=c++26` 下已经实现了它,所以咱们能真的把代码跑起来,不是纸上谈兵。换成 `-std=c++23` 或更早,下面这行直接编不过:
46+
47+
```cpp
48+
#include <optional>
49+
int main() {
50+
int x = 42;
51+
std::optional<int&> opt = x; // P2988, C++26 才支持
52+
*opt = 100;
53+
}
54+
```
55+
56+
笔者实跑了一下,`-std=c++23` 报错,报的是 `optional` 内部 `union` 存不下引用类型的那串模板实例化错误;切到 `-std=c++26`,编过,输出 `x=100`。这条分界线本身就是"C++26 才支持引用 optional"的活证据,后面咱们会反复用到。
57+
58+
## 引用在 C++ 里其实干了三件事
59+
60+
很多人对引用的理解停在"别名,给变量起个外号"。Steve Downey 把引用的职责拆成了三件,笔者觉得这个拆法清楚得很。
61+
62+
第一件是调用约定。写 `void foo(const std::string& s)`,这里的引用在说"别拷贝了,直接操作原来的对象"。这对运算符重载尤其要紧,总不能让 `operator+` 每次都把操作数整个拷一遍。
63+
64+
第二件是给一个复杂的表达式起局部别名。`obj.get_container()[index].get_sub().value()` 这种长链,写 `auto& x = obj.get_container()...`,编译器就记下"你给这玩意儿起了个名字"。它不占空间,纯粹是个别名关系。这两件事里,引用"不能重新绑定"是个好特性,你既然给它起了名字,它就一直指向那个东西。
65+
66+
第三件就出问题了。你可以把引用塞进 `struct` 里。
67+
68+
这一塞,性质就变了。引用作为成员开始占空间,通常是一个指针的大小,但它又不能重新绑定,于是编译器根本不知道"拷贝这个结构体"该怎么定义。是拷贝引用本身?做不到,引用不能重新绑定。是拷贝它引用的那个对象?那又不是 `struct` 该干的事。
69+
70+
咱们跑个最小的例子,把这件事看清楚:
71+
72+
```cpp
73+
// ref_in_struct.cpp
74+
#include <cstdio>
75+
#include <type_traits>
76+
77+
struct HoldsRef { int& ref; }; // 引用成员
78+
struct HoldsValue { int val; }; // 普通值成员
79+
80+
int main() {
81+
std::printf("HoldsValue default_ctor=%d copy_assign=%d\n",
82+
std::is_default_constructible_v<HoldsValue>,
83+
std::is_copy_assignable_v<HoldsValue>);
84+
std::printf("HoldsRef default_ctor=%d copy_assign=%d\n",
85+
std::is_default_constructible_v<HoldsRef>,
86+
std::is_copy_assignable_v<HoldsRef>);
87+
}
88+
```
89+
90+
编译运行,`-std=c++20` 就够:
91+
92+
```bash
93+
$ g++ -std=c++20 ref_in_struct.cpp -o ref_in_struct && ./ref_in_struct
94+
HoldsValue default_ctor=1 copy_assign=1
95+
HoldsRef default_ctor=0 copy_assign=0
96+
```
97+
98+
全零。就因为多了一个引用成员,这个结构体的默认构造和拷贝赋值全没了。您想想,如果 `optional<T&>` 内部真的放一个引用成员,那它连默认构造都做不到,更别提赋值语义的混乱。所以"内部用指针"这个决定,不是偷懒,是不得不这么做。
99+
100+
## assign-through 还是 rebind
101+
102+
好,假设咱们真要造一个 `std::optional<T&>`,它内部有个引用成员。现在你给它赋值,到底该发生什么?
103+
104+
这里有两个选项,Steve Downey 把它们叫 **assign-through(穿透赋值)****rebind(重新绑定)**
105+
106+
assign-through 的意思是赋值"穿透"optional,直接改被引用的对象。你的 `optional<int&>` 当前引用着变量 `x`,你给它赋一个 `y`,结果 `x` 的值变成 `y`,optional 本身还引用着 `x`
107+
108+
rebind 则相反,赋值之后 optional 不再引用 `x`,转而引用 `y`
109+
110+
如果您把 optional 当成"一个内部持有引用的 `struct`",那按 `struct` 的规矩,assign-through 才说得通,毕竟你不能重新绑定一个引用成员。确实有一派人这么主张,动机完全站得住脚。
111+
112+
:::warning
113+
坑在这里:一旦 optional 当前是空的(disengaged),assign-through 就讲不通了。它没有底层对象可以"穿透",那空状态下的赋值难道偷偷切换成 rebind?这样一来,同一个赋值运算符的行为就依赖于 optional 当前的运行时状态。
114+
:::
115+
116+
这就是整个争论的死结。Steve Downey 转述了另一位委员 JeanHeyd 的关键观察:**如果赋值行为依赖于 optional 当前的状态,这个类型就没办法被静态推导**
117+
118+
什么叫没办法推导?你看到一行 `opt = value`,光看这行代码本身,你根本不知道它干了什么。你必须知道 `opt` 在运行时有没有值,才能确定这行是穿透还是重绑定。而 C++ 的整个类型系统、概念约束、模板元编程,都建立在"知道类型就知道行为"这个前提上。一旦行为依赖运行时状态,所有静态推理一起失效。
119+
120+
之前所有尝试走 assign-through 这条路的实现,最后都踩进了这个坑里。腾讯云上有一篇 Sofia 会议的快报写得很直白:C++17 周期标准化 `std::optional` 的时候,就为"要不要支持引用 optional"爆发过激烈争吵,争吵点正是"能不能用 `T*` 代替"和"`operator=` 该 assign-through 还是 rebind",吵到有人负气去了 C 标准委员会(WG14)。这个特性就这么被搁置了下来。
121+
122+
## 最终:别绕了,它就是个指针
123+
124+
吵了二十年的结论其实很简单。`optional<T&>` 内部,就存一个指针。
125+
126+
不是引用,是指针。然后在这个指针上施加一堆约束,让它表现得像一个"可选的引用"。这样一来赋值就清晰了:不管 optional 当前有没有值,赋值都是重新绑定这个指针。行为不依赖状态,推导问题彻底消失。
127+
128+
笔者第一次听到这个结论的反应是"就这?折腾二十年得出一个'用指针'?"。但冷静下来想,这个"用指针"不是随便用的。它背后有一整套语义要定义清楚,还要跟值版本的 `optional<T>` 保持某种一致性,这些细节才是真正花时间的地方,也是后面几篇要讲的东西。
129+
130+
## P2988 这二十年
131+
132+
把时间线捋一下,您就明白这二十年花在哪了。
133+
134+
2005 年,optional 第一次被提出,最初的草案其实是带引用语义的。但到了 2017 年 `std::optional` 正式进 C++17 的时候,只进了值版本,引用版本因为上面那场 assign-through/rebind 的争吵被拿掉了。中间有个提案在 C++20 周期走得相当远,最后还是没被采纳。
135+
136+
转机出在 JeanHeyd 身上。他因为一直没能把 optional 引用推进标准,干脆做了次彻底的考古式梳理,把历史上到底讨论过什么、各方理由是什么都翻了出来。这份梳理直接催生了 Steve Downey 的 P2988,主张"别再纠结了,直接做"。当然,真做起来细节比想象的多得多。最终在 2025 年 6 月的 Sofia 会议上,P2988 投票通过,进入 C++26。
137+
138+
所以当您在 GCC 16.1.1 上用 `-std=c++26` 写出 `std::optional<int&>` 并且它真的编过的时候,背后是二十年的来回拉锯。
139+
140+
## 接下来
141+
142+
这一篇咱们搞清楚了 optional 引用为什么难,以及它最终落地为"一个带约束的指针"。但"内部用指针"只是起点,围绕这个指针还有一堆问题:它跟裸指针有什么区别?它跟 `optional<T*>` 有什么区别?赋值、`operator*``value()` 这些操作到底返回什么?
143+
144+
在那之前,笔者觉得有必要先把普通 `optional<T>` 的底子摸透说透。因为一旦 `T` 变成引用,"拥有所有权""值语义"这些前提全都不成立了,那会是一个完全不同的故事。[下一篇](./02-value-semantics-of-optional.md)咱们就从值版本的 `optional<T>` 讲起。
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
---
2+
title: std::optional 的值语义底子
3+
description: CppCon 2025 笔记 —— 在啃 optional<T&> 之前先把值版本说透:拥有所有权、值语义、T 加一个状态、C++26 当 range 用、以及默认参数背后的重载集噩梦
4+
chapter: 6
5+
order: 2
6+
conference: cppcon
7+
conference_year: 2025
8+
talk_title: 'The Evolution of std::optional: From Boost to C++26'
9+
speaker: Steve Downey
10+
cpp_standard: [17, 23, 26]
11+
difficulty: intermediate
12+
platform: host
13+
reading_time_minutes: 11
14+
tags:
15+
- cpp-modern
16+
- host
17+
- intermediate
18+
- optional
19+
prerequisites:
20+
- "为什么 optional 引用折腾了二十年"
21+
related:
22+
- "optional:把『可能没有』做成类型"
23+
- "为什么 optional 引用折腾了二十年"
24+
---
25+
26+
# std::optional 的值语义底子
27+
28+
[上一篇](./01-why-optional-reference-took-20-years.md)咱们讲完 `optional<T&>` 为什么难、为什么最终落地为指针。但在啃这个硬骨头之前,笔者觉得有必要先把普通的 `optional<T>` 说透。因为一旦 `T` 换成引用,您会发现 `optional<T>` 的几乎每一条前提都被推翻了。得先知道"正常情况"长什么样,才能体会"引用情况"怪在哪。
29+
30+
## 它到底是个什么类型
31+
32+
`optional<T>` 首先是个拥有所有权的类型,内部真的存放了一个 `T` 对象,而且是值语义的。这意味着您可以拷贝它、移动它,底层 `T` 允许的操作它都允许。它没有什么代理行为,就是个老老实实装着东西(或者没装东西)的值类型。
33+
34+
它和指针的 nullable 行为有几分像,都表达"可能没有值"。指针靠空指针来做到这一点,地址零在正常情况下不是有效地址,等于指针类型凭空多出一个带外值来表示"这里什么都没有"。`optional<T>` 干的是同一件事,在代数上就是 `T` 加上一个额外状态。您可以把它理解成 `variant<T, monostate>``monostate` 扮演那个"什么都没有"的状态。实际实现不会真用 `variant`(那样太重),但记住这个等价关系有用,因为后来关于 optional 参数推导、expected 设计的大量讨论,本质上都是在替咱们真正想用代数数据类型做的事情铺路。
35+
36+
## 实测:值语义到底"值"在哪
37+
38+
口说无凭,跑一下。写一个会打印自己每次构造、拷贝、析构的 `Tracer`,塞进 optional,看它什么时候真构造、什么时候析构、拷贝时是不是各自独立。
39+
40+
```cpp
41+
// tracer.cpp
42+
#include <iostream>
43+
#include <optional>
44+
45+
struct Tracer {
46+
Tracer() { std::cout << "Tracer()\n"; }
47+
~Tracer() { std::cout << "~Tracer()\n"; }
48+
Tracer(const Tracer&) { std::cout << "copy ctor\n"; }
49+
Tracer(Tracer&&) noexcept { std::cout << "move ctor\n"; }
50+
};
51+
52+
int main() {
53+
std::optional<Tracer> a;
54+
std::cout << "a has_value=" << a.has_value() << "\n";
55+
56+
a.emplace(); // 这里才真正构造
57+
std::optional<Tracer> b = a; // 拷贝构造
58+
std::cout << "b has_value=" << b.has_value() << "\n";
59+
60+
a.reset(); // 析构 a 里的对象
61+
std::cout << "after reset a has_value=" << a.has_value()
62+
<< " b has_value=" << b.has_value() << "\n";
63+
}
64+
```
65+
66+
`-std=c++17` 就够,跑出来:
67+
68+
```bash
69+
$ g++ -std=c++17 tracer.cpp -o tracer && ./tracer
70+
a has_value=0
71+
Tracer()
72+
copy ctor
73+
b has_value=1
74+
~Tracer()
75+
after reset a has_value=0 b has_value=1
76+
~Tracer()
77+
```
78+
79+
读一下这个输出。声明 `optional<Tracer> a` 的时候,`Tracer()` 没被调用,`has_value` 是 0。直到 `emplace()` 才真正构造出对象。拷贝 `b = a` 走的是 `Tracer` 的拷贝构造。`a.reset()` 析构了 `a` 里的对象,但 `b` 完全不受影响,它有自己那一份。这就是值语义的所有权行为,非常干净。
80+
81+
## 一个让笔者深有体会的用例:读配置
82+
83+
Steve Downey 提到读配置文件的场景,笔者深有体会。以前写配置读取,某个配置项可能不存在,要么用 `map::find` 查迭代器再判 end,要么返回一个指针(nullptr 表示不存在),要么搞个 `bool` 加输出参数的组合。这些写法的问题在于,"这个值可能不存在"这个信息太容易丢。你可能五层函数调用之后,忘了检查那个指针是不是 null。
84+
85+
换成 optional 之后,类型系统本身就在盯着您:这个值可能没有,您必须处理。笔者之前写的一段业务代码,配置项嵌套了三四层,用指针的方式每次都要回头翻"我到底检查了没有",改成 optional 之后,编译器直接逼着在用之前做判断。那种安全感是完全不一样的。这部分 vol3 有一篇专门的 [optional 深入](../../../../vol3-standard-library/error-utils/61-optional.md),讲得更细,您可以对照着看。
86+
87+
## C++26:optional 当 range 用
88+
89+
C++26 给 optional 加了 `begin``end`,让它能当 range 用。提案是 P3168,特性测试宏 `__cpp_lib_optional_range_support` 在 GCC 16.1.1 上是 `202406L`。笔者刚看到这个提案的时候心里想的是"就为遍历一个值?有必要吗"。后来仔细想想自己的代码,改观了。
90+
91+
您想想这种模式:
92+
93+
```cpp
94+
std::optional<User> maybe_user = find_user(id);
95+
if (maybe_user) {
96+
// 接下来几十行都在操作 maybe_user.value()
97+
// 中间可能又嵌套了判断
98+
// 你得一直记住"我在 if 里面,它是安全的"
99+
}
100+
```
101+
102+
当 if 的 body 很长,您在中间某一行读到 `maybe_user.value()`,得往上翻才能确认外面有 if 保护着。C++26 改成这样写:
103+
104+
```cpp
105+
for (auto& user : maybe_user) {
106+
// 进到这里,user 就是 User&,不是 optional
107+
// 几十行里随便用 user,它就是一个确定的值
108+
}
109+
```
110+
111+
原理很简单。optional 是 engaged 的,`begin()` 返回指向内部对象的指针,`end()` 返回 `begin() + 1`,循环执行一次;disengaged 的,`begin()` 等于 `end()`,循环零次。它不是 container(container 有一大堆要求),只是恰好提供了 `begin``end` 的 range。跑一下验证:
112+
113+
```cpp
114+
// opt_as_range.cpp
115+
#include <iostream>
116+
#include <optional>
117+
118+
int main() {
119+
std::optional<int> engaged = 42;
120+
std::optional<int> empty;
121+
122+
int count = 0, sum = 0;
123+
for (auto&& x : engaged) { count++; sum += x; }
124+
for (auto&& x : empty) { count++; sum += x; }
125+
126+
std::cout << "count=" << count << " sum=" << sum << "\n";
127+
}
128+
```
129+
130+
```bash
131+
$ g++ -std=c++26 opt_as_range.cpp -o opt_as_range && ./opt_as_range
132+
count=1 sum=42
133+
```
134+
135+
engaged 的 optional 迭代一次拿到 42,empty 的迭代零次。不是什么惊天动地的特性,但在那种大段业务逻辑里,能让你在循环体内把 optional 完全忘掉、只跟裸类型打交道,这个心智负担的降低是实打实的。
136+
137+
:::warning
138+
遍历 optional 时循环变量写 `auto&&`,别写 `auto``auto` 会丢掉引用性,下一篇您会看到 `optional<T&>` 这种特化,那时候 `auto x` 拿到的是拷贝而不是引用,语义就错了。`auto&&` 是万能引用,能正确保持原始值类别。这个点其实是 Steve Downey 自己幻灯片上的 bug,Q&A 环节被人指出来他才承认。
139+
:::
140+
141+
## 默认 optional 参数:好用,实现却是噩梦
142+
143+
optional 还有个很常见的用法,函数的默认参数:
144+
145+
```cpp
146+
void process(std::optional<int> timeout = std::nullopt);
147+
148+
process(); // timeout 是 nullopt
149+
process(42); // timeout 是 optional<int>(42)
150+
process(std::optional<int>{}); // 也可以显式传
151+
```
152+
153+
用起来非常自然,`int` 会自动提升成 `optional<int>`。但您可能没想过,为了支持这种隐式转换,optional 的构造函数设计变得极其复杂。它得同时处理从 `T` 构造、从 `nullopt` 构造、从另一个 `optional<U>` 构造、拷贝、移动,这些构造函数和转换操作符组合在一起,形成一个庞大的重载集。
154+
155+
笔者以前一直觉得"重载决议不就是找最匹配的嘛"。但实际上当重载集大到一定程度,结果经常出人意料。人脑思考重载决议倾向于走决策树,"这个类型走这条分支,那个类型走那条分支"。编译器不这么干,它把所有候选函数摊在一个平面的集合里,按隐式转换等级、模板特化排序这些规则打分,选最优。候选一多,打分结果就可能跟直觉对不上。optional 实现里那一大坨 SFINAE 和 concepts 约束,本质上就是在驯服这个庞大的重载集,确保"传 int 走 int 的路径,传 nullopt 走 nullopt 的路径,不会出现意外的歧义"。每一行 SFINAE 背后都是有血有泪的重载决议踩坑史。
156+
157+
## 接下来
158+
159+
值版本的底子摸清了:拥有所有权、值语义、`T` 加一个额外状态、C++26 能当 range、构造函数为了隐式转换背着沉重的重载集。但一旦 `T` 换成引用,这些前提全部推翻。`optional<T&>` 不拥有任何东西,赋值不再是值拷贝,构造函数链要重新设计。[下一篇](./03-optional-reference-and-assignment.md)咱们正式进 `optional<T&>` 的核心:它到底是什么,它的赋值为什么一定是重新绑定,以及 `make_optional` 和 CTAD 在引用上挖的坑。

0 commit comments

Comments
 (0)