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++ 设计问题:

posted @ 2026-10-05 13:28  光風霽月  阅读(2)  评论(0)    收藏  举报