
从 Optional 理解值类别重载与单子操作
阅读 stl1weekend 的 Optional.hpp 时,value()、operator*()、and_then() 等函数各有四个重载,而 value_or()、or_else() 等只有两个。这里的区别不在于成员函数能写出多少种引用限定符,而在于接口需要向调用者保留哪些信息,以及这些信息会如何影响操作结果。
另一个容易让人疑惑的地方是,value() const && 返回 const T&& 时也写了 std::move(m_value)。既然值是 const,这还能移动吗?要回答这个问题,需要把“把表达式转换成右值”和“实际执行移动操作”分开看。
Optional 的存储与对象生命周期
这份 Optional<T> 可以理解为:在 Optional 自身内部始终留出一块适合存放一个 T 的存储,再单独记录那里是否有一个活着的 T。 Optional 的生命期内,这块内部存储随它存在;有值和无值的切换,通常是在这块存储中开始或结束 T 的生命期。
源码用匿名 union 和状态标记表达这一点:
bool m_has_value;
union {
T m_value;
};union 为 T 提供空间,但 Optional 为空时并没有一个可供访问的 T。空构造函数只设置 m_has_value = false;emplace 则在内部位置构造对象,reset 在有值时销毁对象。下面省略了模板参数和异常细节,突出源码中的存储与生命周期操作:
Optional<std::string> opt; // 内部有存放 string 的空间,但还没有 string
opt.emplace("hello"); // 在该位置构造 string
opt.reset(); // 销毁 string;Optional 自身及其内部空间仍在
opt.emplace("world"); // 在同一存储位置构造另一个 string这里的 reset() 对应源码中的 m_value.~T();emplace() 使用 placement new 在 &m_value 处构造。如果原来已经有值,源码的 emplace() 先销毁旧对象,再构造新对象;如果新构造抛出异常,它保持空状态。Optional 自身销毁时也会先销毁仍然存活的内部 T。
“内部空间一直存在”不意味着 T 自己管理的资源也一直存在。例如 Optional<std::string> 的内部空间放的是 std::string 对象,字符串内容仍可能另外动态分配;销毁字符串时,其管理的资源按字符串自身的规则释放。另外,Optional 本身也未必位于栈上:它可以是局部变量、另一个对象的成员,或通过动态分配创建。这里说的是 T 的存储嵌在 Optional 内,而不是规定外层对象在哪里分配。
与 vector、list 的存储方式比较
三者都要区分存储空间与活着的元素对象,但空间的组织方式不同。下表中的 vector 和 list 指标准容器的通常实现;节点分配和内存池的具体行为取决于分配器与实现。
| 类型 | 元素存储 | 元素构造与销毁 | 空间何时变化 |
|---|---|---|---|
optional<T> | 容器对象内部容纳零个或一个 T 的空间 | 在内部存储中构造或销毁至多一个 T | 内部空间随 optional 对象存在,不因 reset 或 emplace 单独分配、释放 |
vector<T> | 一段连续的元素存储,通常由分配器取得 | 已构造元素的数量是 size;剩余容量不是现成的元素 | 扩容可能另取更大空间并迁移元素;clear 销毁元素但保留容量 |
list<T> | 元素通常位于分别管理的链表节点中 | 插入时构造节点中的元素,删除时销毁元素 | 插入、删除通常伴随节点存储的取得、归还;分配器可以使用内存池 |
vector 与 optional 的共同点,是都可以让“有合适的存储”和“该位置有活着的元素”发生在不同时间:例如 vector 的容量大于大小时,剩余位置尚无已构造元素。区别是 vector 管理可变数量的连续位置,扩容时存储位置可能改变;optional 固定只有一个内部位置,不需要为了让 T 从空变为有值而扩容。详见 Vector 的内存分配与对象生命周期。
list 的元素则通常各自在一个节点中;节点除了元素,还需要链表链接信息。删除元素时通常也会将节点存储交还分配器,而分配器是否立即将底层内存还给系统是另一回事。节点类型和分配器的关系见 List 的节点分配器。
因此,把这个 Optional 理解为“长期保留一个位置,并反复开始和结束该位置上 T 的生命期”是合适的,但要把“长期”限定为对应的 Optional 对象自身的生命期。它描述的是 reset、emplace 等操作,不意味着每一种赋值都必须销毁后重建:例如这份教学实现的赋值采用销毁并重新构造,而标准 std::optional 在两边均有值的相应赋值情形下会对已有的 T 进行赋值。
判断重载数量的依据
分析一个成员为什么有几个重载,可以先问:这个函数把什么交给下一步操作?下一步需要区分外层对象的哪些属性? 这里的“下一步”可能是调用者接收返回值,也可能是函数内部调用用户传入的 f。
| 成员 | 交给下一步的是什么 | 需要区分的信息 | 本实现的重载 |
|---|---|---|---|
value、operator* | 内部 T 的引用 | 调用者能否修改,以及内部值是左值还是右值 | 四个 |
transform、and_then | 将内部 T 作为参数传给 f | f 能否修改,以及按哪种值类别接收或选择重载 | 四个 |
value_or | 新的 T | 有值时,通常是从内部值拷贝还是移动构造结果 | 两个 |
or_else | 新的 Optional;空时调用无参数 f() | 有值时,通常是拷贝还是移动构造结果 | 两个 |
operator-> | 指向内部 T 的指针 | 能否通过指针修改 T | 两个 |
has_value | 一个 bool | 此操作只读取状态,无须区分值类别 | 一个 const 成员 |
这是理解接口设计的分析方法,并不是根据函数返回类型机械推导重载数量的语言规则:实际接口还可以选择其他语义或施加其他约束。下面分别看这些成员为什么需要、或不需要传递那些信息。
四个重载保留内部值的访问方式
Optional<T> 是一个持有或不持有 T 的包装器。value() 返回内部对象的引用,因此外层对象的两种属性都会影响返回值:const 决定是否允许通过引用修改,值类别决定内部值以左值还是右值交给调用者。
T& value() &;
T const& value() const &;
T&& value() &&;
T const&& value() const &&;Optional<T> 的调用方式 | 返回类型 | 调用者获得的访问方式 |
|---|---|---|
非 const 左值 | T& | 可读写的左值 |
const 左值 | const T& | 只读的左值 |
非 const 右值 | T&& | 可供后续操作移动的右值 |
const 右值 | const T&& | 保留 const 的右值 |
例如,假设 opt 非空且类型是 Optional<std::string>:
opt.value() += " world"; // string&,修改内部对象
std::string a = opt.value(); // 从左值拷贝构造
std::string b = std::move(opt).value(); // 从非 const 右值移动构造value() && 返回引用本身并不移动;最后一行中,移动发生在用返回的 string&& 构造 b 时。移动内部字符串也不会让 opt 自动变空:它仍含有一个有效但状态未指定的 std::string。
这四个重载使包装器尽可能透明:调用者对包装器拥有什么访问方式,取得内部对象时就保留相应的访问方式。operator*() 也遵循这套设计;区别是这份实现的 value() 会在空时抛异常,而 operator*() 要求调用者先确保对象有值。
为什么其他接口只有两个重载
重载数量由函数要提供的行为决定,而不是所有成员函数都必须把四种组合逐一列出来。
value_or:按值返回,区分拷贝和移动
源码中的两个重载简化后如下:
T value_or(T default_value) const & {
if (!m_has_value)
return default_value;
return m_value;
}
T value_or(T default_value) && noexcept {
if (!m_has_value)
return default_value;
return std::move(m_value);
}value_or() 返回新的 T,而不是把内部引用交给调用者。原对象有值时,主要区别是:从需要保留的值构造结果,通常拷贝;从非 const 右值构造结果,通常移动。非 const 左值可调用 const & 版本,const 右值也可调用该版本。因此不必为了返回四种引用而写四个重载。
默认值参数在调用前就会求值,所以 opt.value_or(make_default()) 即使在 opt 有值时也会调用 make_default()。如果希望只在空时才执行备用操作,可以考虑下面的 or_else()。
or_else:按值返回 Optional
or_else() 返回新的 Optional<T>。原对象有值时,const & 版本返回 *this 的副本,&& 版本从 *this 移动构造结果;原对象为空时,才调用备用函数。关键是备用函数的调用形式是 f(),没有内部值参数:有值时它不被调用,空时也没有 T 可传。因此,不需要为传给 f 的内部值区分四种访问方式,只需要为有值时按值返回区分通常的拷贝和移动路径。非 const 左值也可调用 const & 版本,const 右值则可调用 const & 版本并通常拷贝。
operator->:指针只需区分可修改性
T const* operator->() const noexcept;
T* operator->() noexcept;这里返回的是指针。T* 和 const T* 表达通过该指针能否修改内部对象;指针本身并不对应 T& 与 T&& 那样的两种引用类别,所以这份实现只区分 const 与非 const。
const 右值为什么使用 std::move
源码中的重载是:
T const &&value() const && {
if (!m_has_value)
throw BadOptionalAccess();
return std::move(m_value);
}在这个 const 成员函数中,m_value 作为表达式是 const T 左值。std::move(m_value) 将它转换为 const T 的右值表达式,允许返回 const T&&。std::move 不会去掉 const,也不会自行调用移动构造函数。
const Optional<std::string> opt("hello");
std::string s = std::move(opt).value();value() 的结果是 const std::string&&。std::string 常见的移动构造函数接受 std::string&&,不能绑定到 const 对象;其拷贝构造函数接受 const std::string&,因此这个例子通过拷贝构造 s。对于只能移动、不能拷贝的类型,相应构造可能无法通过编译。
保留 const && 仍然有意义:const 和右值类别是互相独立的信息,调用者可以据此选择接受 const T&& 的重载。这里的 std::move 是为了让返回表达式具有所需的右值类别,并不承诺之后一定能移动。包装器整体的拷贝和移动可与 Function 包装器的拷贝与移动 对照;这里讨论的是访问包装器内部值时保留什么信息。
transform 与 and_then
Optional 的单子操作用于串联可能缺值的步骤:有值时继续调用函数,空时跳过后续处理。以下示例中的 parse_int 是示意函数,失败时返回空的 Optional<int>。
Optional<int> parse_int(const std::string& text);
Optional<std::string> text = /* 可能为空的输入 */;
auto result = text
.and_then(parse_int)
.transform([](int n) { return n * n; });and_then 只在 text 有值时调用 parse_int;解析得到的 Optional<int> 有值时,transform 才计算平方。任何一步为空,后续步骤都被跳过。
两者的区别在于传入函数的返回类型:
| 操作 | 传入函数 | 最终结果 |
|---|---|---|
transform(f) | T → U | Optional<U> |
and_then(f) | T → Optional<U> | Optional<U> |
transform 把普通的函数结果再包装为 Optional;and_then 则直接返回函数产生的 Optional。如果对返回 Optional<int> 的 parse_int 使用 transform,结果会多包一层,成为 Optional<Optional<int>>。使用 and_then 可以直接串联可能失败的步骤。
两者各有四个重载,因为它们都把内部值传给函数:左值版本分别传 T& 或 const T&,右值版本分别传 T&& 或 const T&&。这让 f 获得与调用者对应的访问方式。例如:
Optional<std::string> opt("hello");
auto length = opt.transform([](std::string& s) {
s += "!"; // 非 const 左值:可以修改内部字符串
return s.size();
});
auto length2 = std::move(opt).transform([](std::string s) {
return s.size(); // 非 const 右值:s 可从内部字符串移动构造
});如果第二次调用把内部值当作左值传入,按值参数 s 通常需要拷贝。对于 const 右值,std::move(m_value) 产生 const T 右值,它通常不能调用接受 T&& 的移动构造函数,但仍能让 f 根据值类别选择不同重载:
struct Inspect {
int operator()(const std::string&) const { return 1; }
int operator()(const std::string&&) const { return 2; }
};
const Optional<std::string> opt("hello");
auto a = opt.transform(Inspect{}); // 选择 const string&
auto b = std::move(opt).transform(Inspect{}); // 选择 const string&&右值版本调用 f 的源码是:
std::forward<F>(f)(std::move(m_value));std::move(m_value) 将内部值作为右值传递;std::forward<F>(f) 则保留可调用对象 f 自身的值类别。它们处理的是两个不同对象。传递右值并不意味着一定发生移动,实际操作取决于 f 的参数及函数体。
or_else 与备用操作
Optional<std::string> load_config();
Optional<std::string> load_default_config();
auto config = load_config().or_else([] {
return load_default_config();
});load_config() 有值时,直接得到这个 Optional,备用函数不会执行;为空时,才调用 lambda。与返回 T 的 value_or() 相比,or_else() 返回 Optional<T>,备用函数自身也可以返回空。
教学实现与标准库
这份代码展示了这些接口的主要思想,但并非 std::optional 的完整实现。尤其是它的 and_then 没有约束返回类型必须是 Optional:空分支只是默认构造推导出的返回类型,若传入函数返回 int,空分支可能得到 0,从而失去空值语义。C++23 起标准库提供的 std::optional::and_then 则要求函数的结果是 std::optional 特化。阅读实现细节时,应分别判断教学代码和标准库的约束。