拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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

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

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

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

// 反例,不要这么写:用 -1 当「没有值」的哨兵intfindIndex(conststd::vector<int>&data,inttarget);// 没找到返回 -1doublereadRatio(conststd::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对象里。

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看:

#include<cstdio>#include<optional>#include<string>intmain(){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>));}
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接需要在初始化处构造
#include<cstdio>#include<optional>#include<string>std::optional<std::string>findUserName(intid){if(id==1)returnstd::string("miao");if(id==2)returnstd::string("lq");returnstd::nullopt;// 「查无此人」是类型系统能表达的事}intmain(){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'),没有临时 stringstd::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());}
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()的异常行为实测一下:

#include<cstdio>#include<optional>intmain(){std::optional<int>empty;if(!empty)std::printf("empty 没有值(operator bool / has_value)\n");try{std::printf("不该打印: %d\n",empty.value());}catch(conststd::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));}
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有值,默认值那份构造也会照做一遍。

#include<cstdio>#include<optional>intmain(){intcalls=0;autoexpensiveDefault=[&calls]{++calls;// 统计默认值函数被调用的次数return42;};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);}
有值时 value_or = 7 默认值函数被调用 1 次 空值时 value_or = 42 默认值函数被调用 2 次

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

// 片段:想「没有值才算默认值」时,用短路写法替代 value_orstd::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>+ 引用————————见下一节的坑

用一张决策图概括:

「可能没有值」该选哪种表达? ────────────────────────────────────────────────── 需要表示的是「一个值可能缺席」吗? │ ├─ 不是(是「执行可能失败并带原因」)──► 用异常 / 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,报错可读性还算好,但性质是编译失败,不是运行期问题):

// 片段:反例 —— 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(...)一眼就告诉调用方「可能失败」,而失败原因(空串?含非法字符?越界?)不需要用哨兵值穷举。

#include<cstdio>#include<optional>#include<string>// 返回 optional:要么给出一个合法端口,要么明确「没有值」std::optional<int>parsePort(conststd::string&text){if(text.empty())returnstd::nullopt;intvalue=0;for(charch:text){if(ch<'0'||ch>'9')returnstd::nullopt;// 含非数字字符value=value*10+(ch-'0');}if(value<1||value>65535)returnstd::nullopt;// 端口范围校验returnvalue;}intmain(){conststd::string samples[]={"8080","0","80x",""};for(conststd::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);}
"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,要表示「可能不指向」就用指针,别混为一谈。

返回列表