std::optional 与 引用
https://zhuanlan.zhihu.com/p/2078137549621671350
这篇文章主要讲的是:std::optional<T&> 这个看起来很小的功能,为什么从 2005 年第一次被提到,到 2025 年进入 C++26,前后拖了大约二十年。
std::optional 本质上用来表达“一个值可能存在,也可能不存在”。比如函数查找某个对象,找到就返回对象,找不到就返回“空”。普通的 std::optional<int> 很容易理解:里面要么有一个 int,要么什么都没有。
问题出在“引用”上。
C++ 的引用和普通值不一样。一个普通引用:
int& r = a;
一旦绑定到 a,以后就不能重新绑定到 b。如果写:
r = b;
它的意思不是“让 r 改为引用 b”,而是“把 b 的值赋给 a”。
但如果是:
std::optional<int&> opt;
情况就复杂了,因为 optional 本身允许“空”。
假设:
std::optional<int&> opt = a;
opt = b;
这里就出现了整个争论最核心的问题:这句话到底应该是什么意思?
第一种方案叫 assign-through,也就是“穿透赋值”。因为 opt 现在引用的是 a,所以 opt = b 等价于:
a = b;
opt 仍然引用 a。
第二种方案叫 rebind,也就是“重新绑定”。opt = b 不修改 a,而是让 opt 从引用 a 改为引用 b。
真正麻烦的地方是:如果采用第一种方案,那么当 opt 已经有引用时,赋值表示“修改对象”;但如果 opt 是空的,就根本没有对象可以修改,只能把它绑定到 b。
于是同样一句:
opt = b;
可能有两种完全不同的含义:
opt非空:修改原来的对象;opt为空:建立新的绑定。
这种“行为取决于当前状态”的设计,被很多人认为很危险,也很难理解。
而 rebind 方案就简单得多:
opt = b;
无论 opt 以前为空、引用 a,还是引用别的对象,结果始终都是:
opt现在引用b。
如果你真想修改被引用的对象,可以明确写:
*opt = b;
于是两件事被清楚地区分:
opt = b; // 改绑定
*opt = b; // 改对象
这就是最后 C++26 选择的方向。
文章接下来回顾了这场争论的历史。
早在 2003 年,Boost.Optional 就已经支持引用,而且采用的就是 rebind 语义。
到了 2005 年,Fernando Cacciola 提交了早期 optional 标准提案 N1878。当时 optional 支持引用其实已经是原始设计的一部分,并不是后来突然想到的新功能。
但是 optional 本身的标准化已经非常困难,再加上引用语义争议巨大,所以到了 2013 年,为了让普通的 optional 能尽快进入标准,委员会决定暂时把引用支持拆出去。
结果就是:C++17 正式加入了 std::optional,却明确不允许 optional<T&>。
程序员如果想表达“一个可能不存在的引用”,通常只能写:
std::optional<std::reference_wrapper<T>>
使用起来会出现:
opt.value().get()
之类比较笨重的代码。
这就产生了一个很有意思的局面:Boost、folly、LLVM 等很多库都有“optional reference”,但标准库反而没有。
到了 2018 年,JeanHeyd Meneide 提出了 P1175。
他的想法比较保守:既然大家对赋值语义争论不休,那干脆先只让:
std::optional<T&>
能够存在,但暂时删除容易引起争议的赋值、比较等操作。
也就是说,先解决“能不能存引用”,以后再解决“怎么赋值”。
但委员会里很多人反而认为这种设计更糟,因为这样 optional<T> 和 optional<T&> 的行为差异会非常大。
例如普通 optional<T> 可以赋值,但 optional<T&> 却突然不能赋值,这会破坏泛型代码对 optional 的基本假设。
讨论中还经常有人提到著名的:
std::vector<bool>
它也是一个模板特化,但行为和普通 vector<T> 差别很大,长期以来一直被很多 C++ 程序员视为标准库设计中的反面教材。
所以有人担心:
optional<T&>会不会变成下一个vector<bool>?
P1175 最终没有通过。
之后 Meneide 做了一件很关键的事情:调查现实世界里的 optional-reference 实现。
他发现,已经投入实际使用的很多库,几乎都选择了 rebind。
而那些尝试 assign-through 的实现,往往出现了非常难发现的问题。
原因还是那个核心问题:程序员看到:
opt = x;
通常会认为是在改变 opt 自己的状态,而不容易想到这条语句居然可能绕过 optional,偷偷修改另一个对象。
这在大型程序中尤其危险。
于是围绕这个问题的讨论逐渐从:
“真正的 C++ 引用本来就是 assign-through,所以 optional reference 也应该这样。”
转变成:
“optional<T&> 本身是一个独立对象,它的赋值应该修改 optional 的状态,而不是偷偷修改被引用对象。”
也就是说,人们开始区分:
引用本身的赋值语义
和
装着引用的 wrapper 的赋值语义
这是整个争论最后能解决的关键。
到了 2023 年,Steve Downey 和 Peter Sommerlad 提出 P2988。
这个提案不再回避争议,而是明确规定:
std::optional<T&>的赋值采用 rebind。
经过 2023—2025 年多轮修改和委员会审议,这个设计最终进入 C++26。
最终的 optional<T&> 在概念上非常接近:
T*
也就是内部保存一个指向对象的东西,没有对象时相当于空指针。
但它提供的是 optional 风格的接口。
比如可以写:
int a = 1;
int b = 2;
std::optional<int&> opt = a;
opt = b;
之后:
a仍然等于1;opt改为引用b。
如果写:
*opt = 10;
才是把 b 改成 10。
这样 API 的语义就非常清楚:
给 optional 赋值,是修改 optional;
给
*optional赋值,是修改它引用的对象。
文章还介绍了最终设计中的几个细节。
例如:
const std::optional<T&>
这里的 const 只意味着 optional 自己不能随便改变状态,并不意味着被引用对象自动变成 const。
所以:
const std::optional<int&> opt = x;
*opt = 5;
仍然可以修改 x。
如果真正想表达只读引用,要写:
std::optional<const int&>
另外,value_or() 会返回一个值,而不是随便返回引用,以降低产生悬垂引用的风险。
make_optional 也没有简单地开放制造引用 optional,因为某些写法非常容易把 temporary 的引用保存下来,造成悬垂引用。
文章最后想表达的其实不只是一个标准库功能的历史,而是一个更大的 C++ 设计问题:

浙公网安备 33010602011771号