Appearance
std::optional(C++17)
面试高频新特性组件。表示「可能存在的值」:要么含有值,要么为空(无值)。学会讲清「为什么需要、怎么用、底层怎么存(就地存储、零堆分配)、和指针/variant 的区别」,以及手写一个 optional 的关键实现点。
文中
sizeof、分配次数等数据均取自本机实测(GCC 16.2.1 / x86-64 / libstdc++)。
一、为什么需要 optional?
此前表达「可能没有结果 / 无效值」的办法都有缺点:
- 用指针判空:不安全、易悬垂、所有权语义混乱;
- 返回
-1/哨兵值:依赖约定、容易漏判、对非整数类型不优雅; - 抛异常:对「正常无值」情况过重。
optional<T> 语义清晰:「这个 int 可能没有」,读取前 has_value() / 安全访问即可。
cpp
#include <optional>
std::optional<int> find(const std::string& s, char c); // 找不到返回 nullopt
auto r = find("abc", 'x');
if (r) { // 或 r.has_value()
int v = r.value();
} else {
// 无值分支
}optional<T> 持有的是值本身(标准措辞:contained value 是 nested within optional 对象),不是「指向对象的指针」——这是它与裸指针、unique_ptr 的本质区别。
二、构造与访问
cpp
std::optional<int> a; // 默认无值
std::optional<int> b = std::nullopt; // 显式无值
std::optional<int> c = 42; // 有值| 成员 | 语义 |
|---|---|
has_value() / operator bool | 判断是否含值 |
operator* / operator-> | 不做检查:空时调用是 UB(operator-> 有 const/非 const 两个重载,operator* 另有 &&、const&& 重载) |
value() | 空时抛 std::bad_optional_access;有 & / const& / && / const&& 四个重载 |
value_or(d) | 空时返回 d;返回类型是按值的 T:has_value() ? **this : static_cast<T>(std::forward<U>(d))。C++17 起另有 && 重载(有值时返回 std::move(**this))。要求 T 可拷贝构造(const& 版)/ 可移动构造(&& 版),且 U&& 可转换为 T,否则程序 ill-formed |
emplace(args...) | 先销毁旧值、再就地构造新值,返回 T& |
reset() | 有值则析构并转为空,返回 void |
swap / 比较 / hash / make_optional | C++17 起齐备(比较运算符 C++20 起含 <=>) |
and_then / transform / or_else | C++23 单子操作 |
optional<T&> | C++26 新增(P2988,__cpp_lib_optional >= 202506L),见 §4.5 |
begin() / end()、range view | C++26(P3168),optional 可视为「0 或 1 个元素」的 view |
constexpr:标准实现从 C++17 起全程 constexpr,value_or 也是(-std=c++17 下 static_assert(std::optional<int>{}.value_or(7) == 7); 通过)。
允许的模板实参([optional.optional.general]/2):lvalue 引用(C++26 起)或完整、非数组的对象类型,且 remove_cvref_t<T> 不是 in_place_t / nullopt_t;对象类型须满足 Destructible。据此:optional<void>、optional<int[]>、optional<int()>、不完整类型、析构被删除的类型均编译失败(前几种命中 static_assert,不完整类型硬报错);optional<const int> 合法(sizeof == 8)。
三、底层实现(面试常问「optional 是怎么实现的」)
不堆分配 —— T 直接内联存储于 optional 内部。核心思想同手写容器:原始存储 + 按需构造 + 标记是否有值。
cpp
template <typename T>
class optional {
union {
// 用 union/字节数组预留存放 T 的空间,仅在需要时 placement new
};
bool has_value_;
// 内存布局 ≈ alignas(合适的存储) + bool
};- 构造用 placement new 就地构造(C++20 起可用
std::construct_at),析构时若has_value才调~T()。 - 手写时不能裸写
union { T value; }:T非平凡时,该 union 的默认构造与析构被定义为删除(NaiveUnion<std::string>二者均不可用,NaiveUnion<int>可用)。必须加入平凡占位成员,并自行定义 union 的构造与析构。 - 也可以用
alignas(T) unsigned char buf[sizeof(T)]+ placement new,但要处理reinterpret_cast/std::launder,且 C++20 之前reinterpret_cast不允许出现在常量求值中;用 union 则对齐自动正确。
3.1 sizeof 与对齐
T | sizeof(T) | alignof(T) | sizeof(std::optional<T>) |
|---|---|---|---|
char | 1 | 1 | 2 |
bool | 1 | 1 | 2 |
int | 4 | 4 | 8 |
double | 8 | 8 | 16 |
void* | 8 | 8 | 16 |
std::string | 32 | 8 | 40 |
48 字节 struct | 48 | 8 | 56 |
struct alignas(32) | 32 | 32 | 64 |
struct alignas(64) | 64 | 64 | 128 |
两条规律:
sizeof(optional<T>) == align_up(sizeof(T) + 1, alignof(T)),「+1」即标志位;T较小时标志位被尾部 padding 吸收(char:1+1=2)。alignof(optional<T>) == alignof(T),过对齐类型同样继承(表中alignas(32)得 64 字节、alignas(64)得 128 字节)。
因此 sizeof(optional<T>) 不是 sizeof(T) 加常数,而是按上式计算;标准只规定 padding 的数量与位置由实现决定([expr.sizeof]/2)。
3.2 libstdc++ 的实现(/usr/include/c++/16/optional,行号对应 GCC 16.2.1)
cpp
struct _Empty_byte { }; // L221 空状态占位
template<typename _Up, bool = is_trivially_destructible_v<_Up>> // L223
union _Storage
{
constexpr _Storage() noexcept : _M_empty() { }
// ... 把参数转发给 _M_value 的若干构造函数 ...
_Empty_byte _M_empty; // L263
_Up _M_value; // L264
};
_Storage<_Stored_type> _M_payload; // L307
bool _M_engaged = false; // L309
template<typename... _Args>
constexpr void _M_construct(_Args&&... __args) { // L313
std::_Construct(std::__addressof(this->_M_payload._M_value), // == placement new
std::forward<_Args>(__args)...);
this->_M_engaged = true;
}
constexpr void _M_destroy() noexcept { // L322
_M_engaged = false; // 先落标志
_M_payload._M_value.~_Stored_type(); // 再析构
}要点:
_M_empty不是装饰:它让 union 非空且默认构造平凡,同时给「只有空状态」提供可构造的成员。_M_destroy()先置标志再析构,因此即使~T()抛异常(noexcept(false)的类型),也不会留下「标志为真、对象已亡」的状态。- 平凡析构分派:C++20 起改为条件平凡析构——同一模板内两条声明
~_Storage() = default;与~_Storage() requires (!is_trivially_destructible_v<_Up>);教科书常说的「两个_Storage特化」只存在于#if __cpp_concepts < 202002L的旧路径。目的相同:平凡T的析构不需要运行时判断。 - 平凡性:
std::optional<int>的trivially_copyable/trivially_destructible均为 1;std::optional<std::string>均为 0;移动构造的异常规格等价is_nothrow_move_constructible_v<T>([optional.ctor]/12)。
3.3 为什么标准库不用「指针 + bool」
| 维度 | 指针 + bool(堆版) | std::optional(就地版) |
|---|---|---|
T 存哪 | 堆上一个独立对象 | 对象内部(nested within) |
| engage 一次 | 1 次堆分配(allocs=1) | 0 次(allocs=0) |
| 失败可能 | 分配失败 → bad_alloc | 不分配;只有构造 T 本身可能抛 |
| 访问 | 多一次间接寻址 | 直接寻址 |
| 对象大小 | 恒为 16(与 T 无关) | 随 T 与 alignof(T) 走(见 §3.1) |
| 对齐 | 只按指针对齐 | 继承 T 的 alignof |
| 平凡性 | 手写拷贝 ⇒ trivially_copyable 为 0 | 平凡 T ⇒ 1 |
constexpr | 不可(new/delete) | C++17 起可 |
小类型(char/int)时原地版明显更省;T 很大时指针版看似更小,但堆块之外还有分配器开销与碎片,且每次 engage 都要一次分配。
四、行为要点
4.1 移动之后,源仍然「有值」
[optional.ctor]/9:optional(optional&& rhs) 的效果是把 rhs.val 移动构造进 val,并且 rhs.has_value() 保持不变。
30 字符的 optional<std::string> 移动后源 has_value() == 1(其 string 处于 moved-from,size() == 0);optional<int> 同样。因此要清空必须显式 reset()——这与「手写容器移动后把源置空」的习惯不同。
4.2 空值访问
*o / o-> 不做检查,空时是 UB;value() 是受检版本,抛 bad_optional_access(what() 返回 bad optional access)。是否使用受检版本取决于「空」属于 bug 还是正常分支。
4.3 赋值:四个分支,以及为什么赋值里会出现构造
[optional.assign] 对 operator=(const optional&) 的规定:
*this 有值 | *this 无值 | |
|---|---|---|
rhs 有值 | assigns rhs.val to val(拷贝赋值) | direct-non-list-initializes val with rhs.val(拷贝构造) |
rhs 无值 | destroys(~T()) | 无操作 |
- 「目标无值」一格里只能构造:此时
*this内没有活着的T对象,没有左操作数可以调用T::operator=。同理,任何「延迟构造」的类型(variant换 alternative、any换类型、容器的未初始化尾部)都必须在此处构造而非赋值。 - 因此标准规定该运算符在
is_copy_constructible_v<T> && is_copy_assignable_v<T>不同时成立时定义为删除([optional.assign]/7)——实现中两种动作都会用到。T可拷贝构造但operator=被删除时,std::is_copy_assignable_v<std::optional<T>>仍为 false,实际书写a = b报 use of deleted function。 - libstdc++ 的实现与上表逐行对应(
optional:1093-1105):if (__u) { engaged ? _M_get() = __u._M_fwd() : _M_construct(__u._M_fwd()); } else _M_reset();。 - 移动赋值同形状(
[optional.assign]Table 68),把上表的拷贝换成std::move:目标无值时是移动构造。
4.4 emplace:先销毁、再构造
- 语义:先
reset()销毁旧值,再对val做直接初始化,返回T&。libstdc++(optional:1154-1160)即this->_M_reset(); this->_M_construct(std::forward<_Args>(__args)...);。 - 顺序不可反:新对象要落在同一块存储上,不先结束旧对象生命周期就会造成泄漏(堆版)或 union 活跃成员冲突(就地版,
[basic.life]/2规定 union 成员的声明周期只在该成员是被初始化的那个时开始)。 - 异常:构造抛异常时对象为空(旧值已销毁、新值未建成、标志位为 false)。需要「失败保留旧值」(强异常保证)时应自行先构造临时对象,再替换或交换。
- 为什么不能用赋值替代:
emplace要支持任意构造参数,以及不可赋值的类型(const成员、引用成员、operator=被删除的类型);赋值路径覆盖不到;C++ 也没有「就地重造对象」的语法,换值只能显式析构 + 就地构造。代价是构造失败只能回到空。
4.5 optional<T&>(C++26)
[optional.optional.general]/2把 lvalue 引用列为合法 contained value(C++26 起);C++17~C++23 下std::optional<int&>编译失败。- libstdc++ 的
class optional<_Tp&>(optional:1534)内部就是一个指针_Tp* _M_val,nullptr表示空,has_value()即return _M_val;;sizeof(std::optional<int&>) == 8。 - 语义:
o = b是重绑定(*o改指b,不修改先前的被引用对象);o.value() = x才修改被引用对象;emplace(a)也是重绑定。不能绑定临时对象(std::optional<int&> o = 5;编译失败),optional<int&&>非法。
五、手写 optional(面试高频)
手写实现(反链):本节保留原理与最小骨架;完整题面(先读代码写 ctor/dtor 时序 + 再实现)、 可编译答案模板(堆版与
union原地版)、sizeof/bool布局追问链见 → 手写实现专题 · 手写 optional。
5.1 指针 + bool 版
cpp
#include <type_traits>
#include <utility>
template <typename T>
class optional {
T* obj;
bool has_;
public:
optional() : obj(nullptr), has_(false) {} // 空:不构造 T
// 就位构造。约束不可缺少:无约束时本模板对 optional& 是恒等匹配,
// 会参与拷贝初始化并试图构造 T(optional<T>)。
template <typename U,
typename = std::enable_if_t<!std::is_same_v<std::decay_t<U>, optional> &&
std::is_constructible_v<T, U&&>>>
optional(U&& value) : obj(new T(std::forward<U>(value))), has_(true) {}
optional(const optional& o)
: obj(o.has_ ? new T(*o.obj) : nullptr), has_(o.has_) {}
// 三分支与 §4.3 的表一致
optional& operator=(const optional& o) {
if (this != &o) {
if (!o.has_) reset();
else if (has_) *obj = *o.obj; // 双方有值:穿透赋值
else { obj = new T(*o.obj); has_ = true; } // 目标为空:就地拷贝构造
}
return *this;
}
~optional() { reset(); }
void reset() {
if (has_) { has_ = false; delete obj; obj = nullptr; }
}
template <typename... Args>
T& emplace(Args&&... args) {
reset(); // 先销毁
obj = new T(std::forward<Args>(args)...); // 再构造
has_ = true;
return *obj;
}
bool has_value() const { return has_; }
explicit operator bool() const { return has_; }
T& value() { return *obj; }
const T& value() const { return *obj; }
T& operator*() { return *obj; }
const T& operator*() const { return *obj; }
T* operator->() { return obj; }
const T* operator->() const { return obj; }
template <typename U>
T value_or(U&& d) const {
return has_ ? *obj : static_cast<T>(std::forward<U>(d));
}
};5.2 union 就地版
cpp
#include <cstddef> // std::in_place_t
#include <new> // placement new / std::construct_at
#include <utility>
template <typename T>
class inplace_optional {
union Storage {
char empty; // 占位成员:让 union 非空且默认构造平凡
T value;
Storage() : empty() {}
~Storage() {} // 空析构:T 的析构由外层负责
};
Storage s_;
bool has_ = false;
public:
inplace_optional() = default;
template <typename... Args>
explicit inplace_optional(std::in_place_t, Args&&... args) : has_(true) {
::new (static_cast<void*>(&s_.value)) T(std::forward<Args>(args)...);
// C++20 起可改用:std::construct_at(&s_.value, std::forward<Args>(args)...);
}
inplace_optional(const inplace_optional& o) : has_(o.has_) {
if (has_) ::new (static_cast<void*>(&s_.value)) T(o.s_.value);
}
inplace_optional& operator=(const inplace_optional& o) {
if (this != &o) {
if (!o.has_) reset();
else if (has_) s_.value = o.s_.value;
else { ::new (static_cast<void*>(&s_.value)) T(o.s_.value); has_ = true; }
}
return *this;
}
~inplace_optional() { if (has_) s_.value.~T(); } // union 不会替成员调析构
void reset() { if (has_) { has_ = false; s_.value.~T(); } }
template <typename... Args>
T& emplace(Args&&... args) {
reset();
::new (static_cast<void*>(&s_.value)) T(std::forward<Args>(args)...);
has_ = true;
return s_.value;
}
bool has_value() const { return has_; }
T& value() { return s_.value; }
const T& value() const { return s_.value; }
};该实现与 std::optional 等价:sizeof(inplace_optional<std::string>) == sizeof(std::optional<std::string>) == 40;emplace 的堆分配次数为 0;emplace 抛异常时保持空。
5.3 实现要点(面试官追问点)
- 两个方向都要写对:目标有值走
T::operator=,目标为空必须就地构造(§4.3),并处理自赋值。 - 就位构造模板必须加约束:无约束时
optional<T> z = y;(拷贝初始化)会让模板以U = optional<T>&胜出,进而在函数体内尝试T(optional<T>)而报错;x = y;走非模板的operator=(const optional&),不受影响。标准要求的约束共四条([optional.ctor]/23):is_constructible_v<T, U>、remove_cvref_t<U>不是in_place_t、不是本optional、T为 cvbool时U不得是optional;并用explicit(!is_convertible_v<U, T>)决定是否显式。 emplace先销毁后构造,空对象上不得先析构;顺序反了会留下泄漏或活跃成员冲突(§4.4)。- 异常安全:先
reset()再构造 ⇒ 构造失败保持空(基本保证);要强保证需先造临时对象再替换。 reset()两件事都要做:清标志(漏了 → 二次reset()/析构 double free)与置空指针(漏了 → 悬垂指针,之后value()/operator*读到已释放对象)。value()/operator*/operator->提供 const 与非 const 版本,否则const optional&上无法读取。- 允许的实参:
void、数组、函数、不完整类型、nullopt_t/in_place_t都不行;引用在 C++17~C++23 不允许,C++26 允许(§4.5)。 - 移动被删除时的连带影响:
vector<可选类型>中push_back(右值)编译失败(construct_at无可用的构造),只能传左值或emplace_back;扩容时因is_move_constructible_v == false,move_if_noexcept选择拷贝。 constexpr:标准实现从 C++17 起可用;指针 + bool 版因new/delete不能在常量求值中使用而不可用,需换成 union /construct_at的就地版。
六、高频问答
Q1. optional<T&> 支持吗?
C++17~C++23 不支持引用类型(引用无法存进 union,且需要 rebind 语义);C++26 起支持(P2988),语义见 §4.5。若要在 C++17 下表达「可空的引用」,用 optional<std::reference_wrapper<T>> 或指针。
Q2. optional vs unique_ptr/shared_ptr?
- shared/unique 表达「所有权 + 可能为空 + 动态对象」,需堆分配。
- optional 表达「值本身可有可无」且对象就地存放,通常更快(无堆分配)。
- 语义不同:optional 不是「指向对象的指针」,是「一个可能没值的值」。
Q3. optional 什么时候不该用?
- 若「空」代表严重错误并需要调用方能识别原因 → 用异常/错误码,不吞成普通空值。
- 大量频繁判断空值导致代码啰嗦时,也许更适合「值对象 + 哨兵」或直接返回可空指针(视场景)。
Q4. 与 std::variant/std::any/std::expected 区别
- optional:
T有 / 无; - variant:同时可能为
T1/T2/…(sum type); - any:运行期任意类型(类型擦除);
- expected(C++23):失败时还能携带原因/错误码。
七、手写思路 15 秒版
字节 buffer + has_value 标志;构造:std::construct_at(&buf_, args...)(即 placement new)并置位;析构有值才 ~T();拷贝赋值双方有值则穿透赋值、否则就地拷贝构造;emplace 先 reset() 再构造;value() 判空否则抛,operator* 不加检查;就位构造模板必须加约束。标准库不用堆上的「指针 + bool」,是因为要求 contained value 内联在对象中(省一次分配与一次间接寻址),代价是 sizeof 与 alignof 随 T 走。