Skip to content

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_optionalC++17 起齐备(比较运算符 C++20 起含 <=>)
and_then / transform / or_elseC++23 单子操作
optional<T&>C++26 新增(P2988,__cpp_lib_optional >= 202506L),见 §4.5
begin() / end()、range viewC++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 与对齐 ​

Tsizeof(T)alignof(T)sizeof(std::optional<T>)
char112
bool112
int448
double8816
void*8816
std::string32840
48 字节 struct48856
struct alignas(32)323264
struct alignas(64)6464128

两条规律:

  • 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 实现要点(面试官追问点) ​

  1. 两个方向都要写对:目标有值走 T::operator=,目标为空必须就地构造(§4.3),并处理自赋值。
  2. 就位构造模板必须加约束:无约束时 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 为 cv bool 时 U 不得是 optional;并用 explicit(!is_convertible_v<U, T>) 决定是否显式。
  3. emplace 先销毁后构造,空对象上不得先析构;顺序反了会留下泄漏或活跃成员冲突(§4.4)。
  4. 异常安全:先 reset() 再构造 ⇒ 构造失败保持空(基本保证);要强保证需先造临时对象再替换。
  5. reset() 两件事都要做:清标志(漏了 → 二次 reset()/析构 double free)与置空指针(漏了 → 悬垂指针,之后 value()/operator* 读到已释放对象)。
  6. value() / operator* / operator-> 提供 const 与非 const 版本,否则 const optional& 上无法读取。
  7. 允许的实参:void、数组、函数、不完整类型、nullopt_t / in_place_t 都不行;引用在 C++17~C++23 不允许,C++26 允许(§4.5)。
  8. 移动被删除时的连带影响:vector<可选类型> 中 push_back(右值) 编译失败(construct_at 无可用的构造),只能传左值或 emplace_back;扩容时因 is_move_constructible_v == false,move_if_noexcept 选择拷贝。
  9. 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 走。

糌粑的面经笔记 · VitePress 版