用 -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本身就是合法下标怎么办?如果配置项真就是0.0呢?立刻要加第二套约定("用 -1 表示没配置,但 ratio 例外")。 - 调用方会忘 :
int idx = findIndex(...); data[idx]这种漏判空的代码,能一路编过、跑过,直到某个边界输入才越界。 - 语义丢失 :看函数签名
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本身只是多包了一层。
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。
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 可能表现为随机数值,也可能直接段错误,排查成本远高于一个异常。
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. 延伸阅读
- std::optional --- cppreference:成员函数全清单,末尾的「Notes」一节把
optional与指针的取舍讲得很清楚 - std::optional::value_or --- cppreference:解释默认值参数是「无条件使用」的那一段,是本文第 5 节的依据
- std::bad_optional_access --- cppreference:
value()抛出的异常类型,继承自std::exception - std::nullopt --- cppreference:空状态的标记常量,比
std::optional<T>{}更直白 - C++ Core Guidelines · ES.47 / E.14:用
nullptr而非0的精神,与本文「别用哨兵值」的取向一致
11. 一句话总结
std::optional<T> 把「可能没有值」从注释约定搬进了类型系统:值内嵌在对象里、无堆分配,has_value() / operator bool 判空、value() 抛 bad_optional_access、*opt 不检查(空则 UB)、value_or() 好用但会无条件求值默认值 ;它只回答「有没有」不回答「为什么没有」------ 要带原因就换 std::expected(C++23)或 std::variant,要表示「可能不指向」就用指针,别混为一谈。