std::optional 完全指南:别再用 -1 和 nullptr 表达「没有值」

用 -1 表示「没找到」、用 nullptr 表示「没配置」,这类约定在 C++ 里活了几十年,代价是每个调用方都得记住「-1 是特殊值」并且每次都记得判断。C++17 的 std::optional<T> 把这件事变成类型系统的一部分:函数的返回值类型直接写着「可能没有值」,编译器会逼着调用方处理空的情况。它不涉及堆分配、不引入间接跳转,值就躺在对象内部。

1. 引子:哨兵值(sentinel value)的三种翻车方式

先看一个在工业代码里随处可见的接口:

cpp 复制代码
// 反例,不要这么写:用 -1 当「没有值」的哨兵
int findIndex(const std::vector<int>& data, int target);   // 没找到返回 -1
double readRatio(const std::string& key);                  // 没配置返回 0.0(?0.0 也可能是合法值)

它有三个绕不过去的问题:

  1. 哨兵值和合法值撞车 :如果 -1 本身就是合法下标怎么办?如果配置项真就是 0.0 呢?立刻要加第二套约定("用 -1 表示没配置,但 ratio 例外")。
  2. 调用方会忘 :int idx = findIndex(...); data[idx] 这种漏判空的代码,能一路编过、跑过,直到某个边界输入才越界。
  3. 语义丢失 :看函数签名 double readRatio(...),你根本不知道它可能失败 ------ 除非你去翻注释。

std::optional<T> 的定位就是消掉这三种病:把「可能没有」写进类型里。

2. optional 是什么:值就存在对象内部

一句话:std::optional<T> 是一个可能为空的容器 ,它要么持有一个 T(有值 ),要么什么都不持有(空 )。它不是指针 ,没有间接层 ------ T 的存储就内嵌在 optional 对象里。

text 复制代码
std::optional<T> 的两种状态(内存布局概念图,顺序由实现决定)
──────────────────────────────────────────────────────────
  ① 有值(engaged):
     ┌──────────────────────┬──────────────┐
     │  T 的存储区:42       │ 标志位 true   │
     └──────────────────────┴──────────────┘
         has_value() == true;   *opt == 42

  ② 空(disengaged / nullopt):
     ┌──────────────────────┬────────────────┐
     │  T 的存储区:未构造!  │ 标志位 false    │
     │  (只是原始字节,      |                |
     |    对象生命期没开始)  │                |
     └──────────────────────┴────────────────┘
         has_value() == false;   访问 *opt 是 UB

  ★ 关键:空的 optional 里那块 T 的存储「没有构造过对象」,
    所以不能读 ------ 这正是 *opt 不检查就 UB 的根源。
──────────────────────────────────────────────────────────

布局上有两件事必须实测才放心:标志位会不会把对象撑大 、T 会不会因此多一次堆分配 。用 sizeof 看:

cpp 复制代码
#include <cstdio>
#include <optional>
#include <string>

int main() {
    std::printf("sizeof(char)                = %zu\n", sizeof(char));
    std::printf("sizeof(std::optional<char>) = %zu\n", sizeof(std::optional<char>));
    std::printf("sizeof(int)                 = %zu\n", sizeof(int));
    std::printf("sizeof(std::optional<int>)  = %zu\n", sizeof(std::optional<int>));
    std::printf("sizeof(void*)               = %zu\n", sizeof(void*));
    std::printf("sizeof(std::optional<void*>) = %zu\n", sizeof(std::optional<void*>));
    std::printf("sizeof(std::string)                = %zu\n", sizeof(std::string));
    std::printf("sizeof(std::optional<std::string>) = %zu\n", sizeof(std::optional<std::string>));
}
text 复制代码
sizeof(char)                = 1
sizeof(std::optional<char>) = 2
sizeof(int)                 = 4
sizeof(std::optional<int>)  = 8
sizeof(void*)               = 8
sizeof(std::optional<void*>) = 16
sizeof(std::string)                = 32
sizeof(std::optional<std::string>) = 40

怎么读这份输出:

  • optional<char> = 2 字节:1 字节值 + 1 字节标志,没有对齐填充。
  • optional<int> = 8 字节:4 字节 int + 1 字节标志 + 3 字节填充 (对齐到 4)。相比裸 int 翻了一倍,但绝对值依然很小。
  • optional<std::string> = 40 字节:std::string 自己就 32 字节(含 SSO 缓冲),标志位再加填充到 40。没有额外堆分配 ------ optional<std::string> 里的 string 是原地存放的,optional 本身只是多包了一层。

官方文档:std::optional --- cppreference

3. 构造与赋值:nullopt、make_optional、emplace

三种构造方式覆盖了绝大多数场景:

写法 含义 何时用
std::optional<T> opt; 默认构造 = 空 先声明,稍后按条件赋值
opt = std::nullopt; 显式置空(也可 opt.reset()) 把已有值清掉
std::optional<T> opt = value; std::optional opt = value; 从 T(或其可转换类型)隐式构造,有值 短平快
opt.emplace(args...) 用参数就地构造 T,不产生临时对象 构造代价高、或要避开拷贝/移动
std::make_optional(args...) 同上,但返回一个新 optional,可用 auto 接 需要在初始化处构造
cpp 复制代码
#include <cstdio>
#include <optional>
#include <string>

std::optional<std::string> findUserName(int id) {
    if (id == 1) return std::string("miao");
    if (id == 2) return std::string("lq");
    return std::nullopt;                 // 「查无此人」是类型系统能表达的事
}

int main() {
    std::optional<int> a;                                        // 空
    std::optional<int> b = 42;                                   // 有值
    std::optional<std::string> c = std::make_optional(std::string("hi"));

    std::printf("a.has_value() = %d\n", a.has_value() ? 1 : 0);
    std::printf("b.value() = %d, *b = %d\n", b.value(), *b);
    std::printf("c->c_str() = %s, c->size() = %zu\n", c->c_str(), c->size());

    std::optional<std::string> d;
    d.emplace(3, 'x');          // 就地调用 string(3, 'x'),没有临时 string
    std::printf("d.emplace(3, 'x') -> %s\n", d->c_str());

    std::printf("findUserName(1) = %s\n", findUserName(1).value_or("(查无此人)").c_str());
    std::printf("findUserName(9) = %s\n", findUserName(9).value_or("(查无此人)").c_str());
}
text 复制代码
a.has_value() = 0
b.value() = 42, *b = 42
c->c_str() = hi, c->size() = 2
d.emplace(3, 'x') -> xxx
findUserName(1) = miao
findUserName(9) = (查无此人)

emplace 与 = value 的差别值得单独说一句:opt = std::string(3, 'x') 会先构造一个临时 string,再移动进 optional;opt.emplace(3, 'x') 直接在 optional 的存储区里构造,省掉一次临时对象的构造与析构 。对 string 这种移动便宜的类型差别不大,但如果 T 移动要深拷贝,就值得改成 emplace。

官方文档:std::optional::emplace、std::make_optional

4. 四种取值方式,只有两种是安全的

取值是 optional 最容易踩坑的地方。四个入口,语义完全不同:

写法 空的时候会怎样 有值的时候 建议
opt.has_value() / if (opt) 返回 false,安全 true 判断后再取值
opt.value() 抛 std::bad_optional_access 返回 T& 确定有值,或想用异常表达失败
opt.value_or(def) 返回 def 返回 T 的副本/移动 有默认值的场景
*opt / opt->member 未定义行为(UB) 直接访问 只在已判空后使用

value() 的异常行为实测一下:

cpp 复制代码
#include <cstdio>
#include <optional>

int main() {
    std::optional<int> empty;
    if (!empty) std::printf("empty 没有值(operator bool / has_value)\n");

    try {
        std::printf("不该打印: %d\n", empty.value());
    } catch (const std::bad_optional_access& e) {
        std::printf("value() 抛异常: %s\n", e.what());
    }

    std::printf("value_or(-1) = %d\n", empty.value_or(-1));

    empty = 100;
    std::printf("赋值后 value() = %d,value_or(-1) = %d\n", empty.value(), empty.value_or(-1));
}
text 复制代码
empty 没有值(operator bool / has_value)
value() 抛异常: bad optional access
value_or(-1) = -1
赋值后 value() = 100,value_or(-1) = 100

注意 *empty 与 empty.value() 的区别:value() 好歹会抛异常(按值抛、按引用捕,符合 Core Guidelines 的 E.14/E.15),而 *empty 是直接读一块没构造过的内存,没有任何检查和报错 ------ 这类 bug 可能表现为随机数值,也可能直接段错误,排查成本远高于一个异常。

官方文档:std::optional::value、std::bad_optional_access

5. 陷阱:value_or() 会「无条件」求值默认值

这是最容易写出性能 bug 的一处。value_or 的签名是 value_or(U&& default_value) ------ 参数是按值/引用传进来的实参 ,函数还没执行,实参就已经求值完了。也就是说:即使 optional 有值,默认值那份构造也会照做一遍。

cpp 复制代码
#include <cstdio>
#include <optional>

int main() {
    int calls = 0;
    auto expensiveDefault = [&calls] {
        ++calls;                 // 统计默认值函数被调用的次数
        return 42;
    };

    std::optional<int> has = 7;
    std::printf("有值时 value_or = %d\n", has.value_or(expensiveDefault()));
    std::printf("  默认值函数被调用 %d 次\n", calls);

    std::optional<int> none;
    std::printf("空值时 value_or = %d\n", none.value_or(expensiveDefault()));
    std::printf("  默认值函数被调用 %d 次\n", calls);
}
text 复制代码
有值时 value_or = 7
  默认值函数被调用 1 次
空值时 value_or = 42
  默认值函数被调用 2 次

有值时默认值函数照样被调用了一次 (第 1 次调用就是白白浪费的)。对 int 无所谓,但如果默认值是一次数据库查询、一次大对象构造,这就实打实地亏了。要避开它,就用短路写法:

cpp 复制代码
// 片段:想「没有值才算默认值」时,用短路写法替代 value_or
std::optional<Config> cached = loadFromCache();

// ✗ 慢:无论 cached 有没有值,loadFromDisk() 都会执行一次
Config cfg = cached.value_or(loadFromDisk());

// ✓ 快:只有 cached 为空时才去加载
Config cfg = cached ? *cached : loadFromDisk();

性能上差的是一次求值 ,语义上也可能有副作用差异(比如 loadFromDisk() 会把结果写进缓存,那就白白多跑一次 IO)。

官方文档:std::optional::value_or --- cppreference(页面里明确写了参数「unconditionally」用 u 的值)

6. 与指针、哨兵值的对比

optional 不是唯一表达「可能没有值」的方式,选型要看语义:

方案 语义清晰度 类型安全 能表达「有值且值为 0 / 空串」 开销 生命周期
int + 哨兵(-1、0) 差:得靠文档约定 无 不能(和合法值混淆) 0(最小) 值语义
T*(不拥有) 中:nullptr 表示没有 弱:可能悬空、容易漏判 能 一个指针 调用方必须保证有效期
std::unique_ptr<T> 中 中 能 至少一次堆分配 拥有,堆上
std::optional<T> 强:类型名就写着「可能没有」 强:取值有明确接口 能 sizeof(T) + 标志 + 对齐填充(栈上) 值语义,随对象一起析构
std::optional<T> + 引用 ------ ------ ------ ------ 见下一节的坑

用一张决策图概括:

text 复制代码
「可能没有值」该选哪种表达?
──────────────────────────────────────────────────
 需要表示的是「一个值可能缺席」吗?
    │
    ├─ 不是(是「执行可能失败并带原因」)──► 用异常 / std::expected(C++23)
    │
    └─ 是
        ├─ 目标对象已经存在,我只是「可能不指向它」?
        │     └─► 用 T*(不拥有)或 reference_wrapper
        │
        ├─ 目标是「需要堆分配、可能延迟创建的大对象」?
        │     └─► 用 std::unique_ptr<T>
        │
        └─ 目标是「一个普通值,要么有要么没有」
              └─► std::optional<T>   ← 默认答案
──────────────────────────────────────────────────

一句话记忆:optional 表达「值缺席」,指针表达「指向/不指向」,unique_ptr 表达「拥有/不拥有」 ------ 三者不该互相替代。

7. 两个必须知道的坑

坑一:std::optional<T&> 在 C++17 不合法。 想表达「可能没有的引用」,编译期就直接被拦住(libstdc++ 里是一条 static_assert,报错可读性还算好,但性质是编译失败,不是运行期问题):

cpp 复制代码
// 片段:反例 ------ C++17 没有「可选引用」这个东西
#include <optional>

std::optional<int&> bad;        // ✗ 编译错误:optional 不支持引用类型

// 想要「可能没有的引用」,退而求其次有两种写法:
//   ① 用指针表达「可能不指向」(推荐:语义最直白,不拥有)
//      std::optional<int*> 或直接 int*(nullptr 表示没有)
//   ② 用 reference_wrapper 包一层(值语义,但不能为「空」)
//      std::optional<std::reference_wrapper<int>> ref;

官方文档:std::reference_wrapper --- cppreference(optional<reference_wrapper<T>> 是「可能没有的引用」的常见替代)

坑二:*opt 和 opt-> 不做检查。 这一点第 4 节已经说过,但要强调它的后果范围 :空的 optional 里那块存储是未构造的原始内存,读它属于 UB ------ 编译器可以做任意假设,-O2 下的表现可能比你预想的更离奇。工程上的做法是约定 :*opt 只允许出现在刚刚判过空的代码块里,跨行、跨函数一律用 value() 或 value_or()。

8. 完整示例:一个「解析端口」的强类型接口

把构造、判空、*、value_or、比较、reset 全用上。这个例子的重点在接口签名 :std::optional<int> parsePort(...) 一眼就告诉调用方「可能失败」,而失败原因(空串?含非法字符?越界?)不需要用哨兵值穷举。

cpp 复制代码
#include <cstdio>
#include <optional>
#include <string>

// 返回 optional:要么给出一个合法端口,要么明确「没有值」
std::optional<int> parsePort(const std::string& text) {
    if (text.empty()) return std::nullopt;
    int value = 0;
    for (char ch : text) {
        if (ch < '0' || ch > '9') return std::nullopt;   // 含非数字字符
        value = value * 10 + (ch - '0');
    }
    if (value < 1 || value > 65535) return std::nullopt; // 端口范围校验
    return value;
}

int main() {
    const std::string samples[] = {"8080", "0", "80x", ""};
    for (const std::string& s : samples) {
        std::optional<int> port = parsePort(s);
        if (port) {
            std::printf("\"%s\" -> 端口 %d\n", s.c_str(), *port);   // 判过空才解引用
        } else {
            std::printf("\"%s\" -> 没有有效端口\n", s.c_str());
        }
    }

    std::optional<int> a = 5;
    std::optional<int> b = 5;
    std::optional<int> c;
    std::printf("a == b            : %d\n", (a == b) ? 1 : 0);
    std::printf("a == c            : %d\n", (a == c) ? 1 : 0);
    std::printf("c == std::nullopt : %d\n", (c == std::nullopt) ? 1 : 0);
    std::printf("a > c             : %d\n", (a > c) ? 1 : 0);

    a.reset();
    std::printf("reset 后 a.has_value() = %d\n", a.has_value() ? 1 : 0);
}
text 复制代码
"8080" -> 端口 8080
"0" -> 没有有效端口
"80x" -> 没有有效端口
"" -> 没有有效端口
a == b            : 1
a == c            : 0
c == std::nullopt : 1
a > c             : 1
reset 后 a.has_value() = 0

比较规则里有两条容易猜错:

  • 空 == 空 :两个 nullopt 互相相等;有值的 optional 永远大于 nullopt(等价于「nullopt 是最小值」)。
  • 有值 vs 有值 :按 T 的 operator< / operator== 比较。没有 optional<T> 与裸 T 的比较运算 (opt == 5 编不过),要先显式包成 optional 或先取出值 ------ 这是标准刻意为之,避免隐式构造掩盖语义。

官方文档:std::optional 的比较运算符

9. optional 的边界:它只回答「有没有」,不回答「为什么没有」

optional 有一个先天局限:它丢掉了失败的原因 。parsePort("") 和 parsePort("80x") 都返回 nullopt,调用方无法区分。

想同时携带原因,就要换工具 ------ 这张表是 C++17 到 C++23 的选型速查:

工具 表达 标准版本 典型场景
std::optional<T> 有值 / 没有值 C++17 「查不到」「未配置」
std::variant<A, B, C> 三选一,类型集合编译期已知 C++17 解析结果:Port / Error / NeedMore 这类状态机
std::any 运行时装任意类型 C++17 类型要到运行时才知道(插件、异构容器)
std::expected<T, E> 有值 / 有错误(带原因) C++23 替代「返回错误码」的现代写法

后两个在知识库里各有专文,可以对照着看:std::variant 见《std::variant 完全指南》,std::any 见《C++ std::any 完全指南》------ 那张选型表和本节这张是对得上的。

10. 延伸阅读

11. 一句话总结

std::optional<T> 把「可能没有值」从注释约定搬进了类型系统:值内嵌在对象里、无堆分配,has_value() / operator bool 判空、value() 抛 bad_optional_access、*opt 不检查(空则 UB)、value_or() 好用但会无条件求值默认值 ;它只回答「有没有」不回答「为什么没有」------ 要带原因就换 std::expected(C++23)或 std::variant,要表示「可能不指向」就用指针,别混为一谈。

相关推荐
无名猿1 小时前
nullptr 取代 NULL 和 0:空指针的三种写法和唯一正确解
c++·现代c++·语法基础·踩坑记录
大侠归来1 小时前
C++ 与 Lua 的协程交互:从原理到实战
c++·lua·交互
无名猿2 小时前
size_t 与 int 混用陷阱:有符号无符号比较与整数提升
c++·标准库·语法基础·踩坑记录
.道阻且长.2 小时前
C++ 11:可变参数模板
前端·c++·算法
m0_380743872 小时前
Qt实现调用相机进行拍照并进行图像处理
开发语言·c++
励志不掉头发的内向程序员4 小时前
【从零写一个CAD 03】三个 double 值得单独一个类吗:把视图变换抽成 View
开发语言·c++·qt·学习·系统架构
蒸蒸yyyyzwd10 小时前
秋招学习笔记 day46
c++·八股
码匠许师傅14 小时前
【C++三方组件】cpp-httplib:一个头文件起 HTTP 服务
开发语言·c++·http
无名猿15 小时前
new/delete 与 malloc/free:为什么绝对不能混用
c++·内存管理·现代c++·踩坑记录