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

发布时间:2026/10/1 21:41:50
std::optional 完全指南:别再用 -1 和 nullptr 表达「没有值」 用-1表示「没找到」、用nullptr表示「没配置」这类约定在 C 里活了几十年代价是每个调用方都得记住「-1 是特殊值」并且每次都记得判断。C17 的std::optionalT把这件事变成类型系统的一部分函数的返回值类型直接写着「可能没有值」编译器会逼着调用方处理空的情况。它不涉及堆分配、不引入间接跳转值就躺在对象内部。1. 引子哨兵值sentinel value的三种翻车方式先看一个在工业代码里随处可见的接口// 反例不要这么写用 -1 当「没有值」的哨兵intfindIndex(conststd::vectorintdata,inttarget);// 没找到返回 -1doublereadRatio(conststd::stringkey);// 没配置返回 0.00.0 也可能是合法值它有三个绕不过去的问题哨兵值和合法值撞车如果-1本身就是合法下标怎么办如果配置项真就是0.0呢立刻要加第二套约定“用 -1 表示没配置但 ratio 例外”。调用方会忘int idx findIndex(...); data[idx]这种漏判空的代码能一路编过、跑过直到某个边界输入才越界。语义丢失看函数签名double readRatio(...)你根本不知道它可能失败 —— 除非你去翻注释。std::optionalT的定位就是消掉这三种病把「可能没有」写进类型里。2. optional 是什么值就存在对象内部一句话std::optionalT是一个可能为空的容器它要么持有一个T有值要么什么都不持有空。它不是指针没有间接层 ——T的存储就内嵌在optional对象里。std::optionalT 的两种状态内存布局概念图顺序由实现决定 ────────────────────────────────────────────────────────── ① 有值engaged ┌──────────────────────┬──────────────┐ │ T 的存储区42 │ 标志位 true │ └──────────────────────┴──────────────┘ has_value() true; *opt 42 ② 空disengaged / nullopt ┌──────────────────────┬────────────────┐ │ T 的存储区未构造 │ 标志位 false │ │ 只是原始字节 | | | 对象生命期没开始 │ | └──────────────────────┴────────────────┘ has_value() false; 访问 *opt 是 UB ★ 关键空的 optional 里那块 T 的存储「没有构造过对象」 所以不能读 —— 这正是 *opt 不检查就 UB 的根源。 ──────────────────────────────────────────────────────────布局上有两件事必须实测才放心标志位会不会把对象撑大、T 会不会因此多一次堆分配。用sizeof看#includecstdio#includeoptional#includestringintmain(){std::printf(sizeof(char) %zu\n,sizeof(char));std::printf(sizeof(std::optionalchar) %zu\n,sizeof(std::optionalchar));std::printf(sizeof(int) %zu\n,sizeof(int));std::printf(sizeof(std::optionalint) %zu\n,sizeof(std::optionalint));std::printf(sizeof(void*) %zu\n,sizeof(void*));std::printf(sizeof(std::optionalvoid*) %zu\n,sizeof(std::optionalvoid*));std::printf(sizeof(std::string) %zu\n,sizeof(std::string));std::printf(sizeof(std::optionalstd::string) %zu\n,sizeof(std::optionalstd::string));}sizeof(char) 1 sizeof(std::optionalchar) 2 sizeof(int) 4 sizeof(std::optionalint) 8 sizeof(void*) 8 sizeof(std::optionalvoid*) 16 sizeof(std::string) 32 sizeof(std::optionalstd::string) 40怎么读这份输出optionalchar 2 字节1 字节值 1 字节标志没有对齐填充。optionalint 8 字节4 字节int 1 字节标志 3 字节填充对齐到 4。相比裸int翻了一倍但绝对值依然很小。optionalstd::string 40 字节std::string自己就 32 字节含 SSO 缓冲标志位再加填充到 40。没有额外堆分配——optionalstd::string里的string是原地存放的optional本身只是多包了一层。官方文档std::optional — cppreference3. 构造与赋值nullopt、make_optional、emplace三种构造方式覆盖了绝大多数场景写法含义何时用std::optionalT opt;默认构造 空先声明稍后按条件赋值opt std::nullopt;显式置空也可opt.reset()把已有值清掉std::optionalT opt value;std::optional opt value;从T或其可转换类型隐式构造有值短平快opt.emplace(args...)用参数就地构造T不产生临时对象构造代价高、或要避开拷贝/移动std::make_optional(args...)同上但返回一个新optional可用auto接需要在初始化处构造#includecstdio#includeoptional#includestringstd::optionalstd::stringfindUserName(intid){if(id1)returnstd::string(miao);if(id2)returnstd::string(lq);returnstd::nullopt;// 「查无此人」是类型系统能表达的事}intmain(){std::optionalinta;// 空std::optionalintb42;// 有值std::optionalstd::stringcstd::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::optionalstd::stringd;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再移动进optionalopt.emplace(3, x)直接在optional的存储区里构造省掉一次临时对象的构造与析构。对string这种移动便宜的类型差别不大但如果T移动要深拷贝就值得改成emplace。官方文档std::optional::emplace、std::make_optional4. 四种取值方式只有两种是安全的取值是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()的异常行为实测一下#includecstdio#includeoptionalintmain(){std::optionalintempty;if(!empty)std::printf(empty 没有值operator bool / has_value\n);try{std::printf(不该打印: %d\n,empty.value());}catch(conststd::bad_optional_accesse){std::printf(value() 抛异常: %s\n,e.what());}std::printf(value_or(-1) %d\n,empty.value_or(-1));empty100;std::printf(赋值后 value() %dvalue_or(-1) %d\n,empty.value(),empty.value_or(-1));}empty 没有值operator bool / has_value value() 抛异常: bad optional access value_or(-1) -1 赋值后 value() 100value_or(-1) 100注意*empty与empty.value()的区别value()好歹会抛异常按值抛、按引用捕符合 Core Guidelines 的 E.14/E.15而*empty是直接读一块没构造过的内存没有任何检查和报错 —— 这类 bug 可能表现为随机数值也可能直接段错误排查成本远高于一个异常。官方文档std::optional::value、std::bad_optional_access5. 陷阱value_or() 会「无条件」求值默认值这是最容易写出性能 bug 的一处。value_or的签名是value_or(U default_value)—— 参数是按值/引用传进来的实参函数还没执行实参就已经求值完了。也就是说即使optional有值默认值那份构造也会照做一遍。#includecstdio#includeoptionalintmain(){intcalls0;autoexpensiveDefault[calls]{calls;// 统计默认值函数被调用的次数return42;};std::optionalinthas7;std::printf(有值时 value_or %d\n,has.value_or(expensiveDefault()));std::printf( 默认值函数被调用 %d 次\n,calls);std::optionalintnone;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::optionalConfigcachedloadFromCache();// ✗ 慢无论 cached 有没有值loadFromDisk() 都会执行一次Config cfgcached.value_or(loadFromDisk());// ✓ 快只有 cached 为空时才去加载Config cfgcached?*cached:loadFromDisk();性能上差的是一次求值语义上也可能有副作用差异比如loadFromDisk()会把结果写进缓存那就白白多跑一次 IO。官方文档std::optional::value_or — cppreference页面里明确写了参数「unconditionally」用u的值6. 与指针、哨兵值的对比optional不是唯一表达「可能没有值」的方式选型要看语义方案语义清晰度类型安全能表达「有值且值为 0 / 空串」开销生命周期int 哨兵-1、0差得靠文档约定无不能和合法值混淆0最小值语义T*不拥有中nullptr表示没有弱可能悬空、容易漏判能一个指针调用方必须保证有效期std::unique_ptrT中中能至少一次堆分配拥有堆上std::optionalT强类型名就写着「可能没有」强取值有明确接口能sizeof(T) 标志 对齐填充栈上值语义随对象一起析构std::optionalT 引用————————见下一节的坑用一张决策图概括「可能没有值」该选哪种表达 ────────────────────────────────────────────────── 需要表示的是「一个值可能缺席」吗 │ ├─ 不是是「执行可能失败并带原因」──► 用异常 / std::expectedC23 │ └─ 是 ├─ 目标对象已经存在我只是「可能不指向它」 │ └─► 用 T*不拥有或 reference_wrapper │ ├─ 目标是「需要堆分配、可能延迟创建的大对象」 │ └─► 用 std::unique_ptrT │ └─ 目标是「一个普通值要么有要么没有」 └─► std::optionalT ← 默认答案 ──────────────────────────────────────────────────一句话记忆optional表达「值缺席」指针表达「指向/不指向」unique_ptr表达「拥有/不拥有」—— 三者不该互相替代。7. 两个必须知道的坑坑一std::optionalT在 C17 不合法。想表达「可能没有的引用」编译期就直接被拦住libstdc 里是一条static_assert报错可读性还算好但性质是编译失败不是运行期问题// 片段反例 —— C17 没有「可选引用」这个东西#includeoptionalstd::optionalintbad;// ✗ 编译错误optional 不支持引用类型// 想要「可能没有的引用」退而求其次有两种写法// ① 用指针表达「可能不指向」推荐语义最直白不拥有// std::optionalint* 或直接 int*nullptr 表示没有// ② 用 reference_wrapper 包一层值语义但不能为「空」// std::optionalstd::reference_wrapperint ref;官方文档std::reference_wrapper — cppreferenceoptionalreference_wrapperT是「可能没有的引用」的常见替代坑二*opt和opt-不做检查。这一点第 4 节已经说过但要强调它的后果范围空的optional里那块存储是未构造的原始内存读它属于 UB —— 编译器可以做任意假设-O2下的表现可能比你预想的更离奇。工程上的做法是约定*opt只允许出现在刚刚判过空的代码块里跨行、跨函数一律用value()或value_or()。8. 完整示例一个「解析端口」的强类型接口把构造、判空、*、value_or、比较、reset全用上。这个例子的重点在接口签名std::optionalint parsePort(...)一眼就告诉调用方「可能失败」而失败原因空串含非法字符越界不需要用哨兵值穷举。#includecstdio#includeoptional#includestring// 返回 optional要么给出一个合法端口要么明确「没有值」std::optionalintparsePort(conststd::stringtext){if(text.empty())returnstd::nullopt;intvalue0;for(charch:text){if(ch0||ch9)returnstd::nullopt;// 含非数字字符valuevalue*10(ch-0);}if(value1||value65535)returnstd::nullopt;// 端口范围校验returnvalue;}intmain(){conststd::string samples[]{8080,0,80x,};for(conststd::strings:samples){std::optionalintportparsePort(s);if(port){std::printf(\%s\ - 端口 %d\n,s.c_str(),*port);// 判过空才解引用}else{std::printf(\%s\ - 没有有效端口\n,s.c_str());}}std::optionalinta5;std::optionalintb5;std::optionalintc;std::printf(a b : %d\n,(ab)?1:0);std::printf(a c : %d\n,(ac)?1:0);std::printf(c std::nullopt : %d\n,(cstd::nullopt)?1:0);std::printf(a c : %d\n,(ac)?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比较。没有optionalT与裸T的比较运算opt 5编不过要先显式包成optional或先取出值 —— 这是标准刻意为之避免隐式构造掩盖语义。官方文档std::optional 的比较运算符9. optional 的边界它只回答「有没有」不回答「为什么没有」optional有一个先天局限它丢掉了失败的原因。parsePort()和parsePort(80x)都返回nullopt调用方无法区分。想同时携带原因就要换工具 —— 这张表是 C17 到 C23 的选型速查工具表达标准版本典型场景std::optionalT有值 / 没有值C17「查不到」「未配置」std::variantA, B, C三选一类型集合编译期已知C17解析结果Port / Error / NeedMore这类状态机std::any运行时装任意类型C17类型要到运行时才知道插件、异构容器std::expectedT, E有值 / 有错误带原因C23替代「返回错误码」的现代写法后两个在知识库里各有专文可以对照着看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 — cppreferencevalue()抛出的异常类型继承自std::exceptionstd::nullopt — cppreference空状态的标记常量比std::optionalT{}更直白C Core Guidelines · ES.47 / E.14用nullptr而非0的精神与本文「别用哨兵值」的取向一致11. 一句话总结std::optionalT把「可能没有值」从注释约定搬进了类型系统值内嵌在对象里、无堆分配has_value()/operator bool判空、value()抛bad_optional_access、*opt不检查空则 UB、value_or()好用但会无条件求值默认值它只回答「有没有」不回答「为什么没有」—— 要带原因就换std::expectedC23或std::variant要表示「可能不指向」就用指针别混为一谈。