使用std::optional告别魔数值语义,优雅的表达可能有效 使用std::optional告别魔数值语义优雅的表达可能有效这个仓库已经开源现代化 CC11/14/17/20从基础到进阶的系统教程都在这里力争做一条完备的现代 C 学习路径欢迎各位大佬前来参观喜欢的话点个⭐Github 一键直达: git clone https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP看看超酷的新网站https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/引言孩子们我是写C过来的——相信大伙都这样干过类似的事情。函数返回-1表示没找到返回nullptr表示出错了返回空字符串表示配置项不存在。这些约定在写的时候觉得理所当然三个月后回头看就开始冒冷汗——-1到底是没找到还是真的返回了 -1nullptr是可选的空值还是出错了每一个返回特殊值的函数都在给未来的自己埋雷。std::optionalC17 引入就是来解决如何安全表达可能没有值这个问题的。它把有值还是没值这个信息编码进了类型系统——编译器和调用方都能从函数签名直接看到这个返回值可能为空不需要靠注释或文档来传达。第一步——可能没有值的传统方案在optional出现之前如果不谈论用库就自己最简单的手搓的话主要有以下几种方式来表达可能没有值特殊值哨兵值用某个特定的值来表示无效。-1表示查找失败UINT_MAX表示无效索引空字符串表示未配置。问题在于每个函数的特殊值都不一样调用方必须记住这些约定。而且有些类型根本找不到合适的特殊值——比如double的-1.0完全可能是一个合法的返回值。裸指针返回nullptr表示没值。这在查找函数中很常见。问题在于指针的语义太宽泛了。T*可以表示可能为空的可选值也可以表示不拥有所有权的观察指针还可以表示指向动态分配的对象。调用方无法从类型上区分这些语义。更危险的是解引用空指针是 UB不会给你任何友好的错误提示。std::pairT, bool第二个元素表示值是否有效。这比前两种方案好一点但使用起来很啰嗦——每次都要检查.second而且first在second false时的值是未定义的默认构造可能不合法。// 三种传统方案对比intfind_index_old(conststd::vectorintv,inttarget){for(inti0;istatic_castint(v.size());i){if(v[i]target)returni;}return-1;// 特殊值约定调用方必须记住 -1 表示没找到}int*find_ptr_old(std::vectorintv,inttarget){for(autox:v){if(xtarget)returnx;}returnnullptr;// 裸指针语义不明确}std::pairint,boolfind_pair_old(conststd::vectorintv,inttarget){for(inti0;istatic_castint(v.size());i){if(v[i]target)return{i,true};}return{0,false};// first 的值在此处无意义}这三种方案有一个共同的缺陷类型签名没有表达出可能没有值的语义。int的返回类型不会告诉你-1是特殊值int*不会告诉你nullptr代表没找到而不是出错了。std::optional直接在类型层面解决了这个问题。第二步——optional 的核心语义与 APIstd::optionalT表示要么持有一个T类型的值要么什么都没有。它是一个值类型不是指针持有的对象直接嵌套在optional内部的存储中——没有动态内存分配。构造#includeoptional#includestring#includeiostreamstd::optionalinta;// 空不持有值std::optionalintb42;// 持有 42std::optionalintcstd::nullopt;// 显式空std::optionalstd::stringdhello;// 持有 hello// 就地构造避免临时对象std::optionalstd::stringe(std::in_place,10,x);// xxxxxxxxxx检查与访问std::optionalintopt42;// 检查是否有值if(opt.has_value()){/* ... */}if(opt){/* ... */}// 等价的隐式 bool 转换// 访问值intx*opt;// 解引用未检查——空时是 UBintyopt.value();// 空时抛 std::bad_optional_accessintzopt.value_or(0);// 空时返回默认值 0// 访问成员对于类类型std::optionalstd::stringnameAlice;if(name){std::coutlength: name-size()\n;// operator-}⚠️ 关于operator*和value()的选择笔者的建议是在你已经检查过has_value()的代码路径中使用*opt就够了性能更好而且语义清晰。在没有检查的情况下value()更安全——它会抛异常而不是 UB。但这两种方式都不如value_or()来得优雅因为后者直接处理了空值怎么办的问题。value_or 的妙用value_or()接受一个默认值参数如果optional有值就返回持有的值否则返回默认值可以帮您省略写一些代码std::optionalstd::stringget_config(conststd::stringkey);// 读取配置未配置则使用默认值std::string hostget_config(server_host).value_or(localhost);intportget_config(server_port).transform([](conststd::strings){returnstd::stoi(s);}).value_or(8080);上面这个transform是 C23 的新特性我们稍后会详细介绍。第三步——optional 的内存布局optionalT的内部存储通常由两部分组成一个用于存放T的对齐缓冲区加上一个bool标志位表示是否有值。这意味着sizeof(std::optionalT)通常大于sizeof(T)。#includeoptionalstd::coutsizeof(int): sizeof(int)\n;// 4std::coutsizeof(optionalint): sizeof(std::optionalint)\n;// 典型8std::coutsizeof(double): sizeof(double)\n;// 8std::coutsizeof(optionaldouble): sizeof(std::optionaldouble)\n;// 典型16std::coutsizeof(string): sizeof(std::string)\n;// 典型32std::coutsizeof(optionalstring): sizeof(std::optionalstd::string)\n;// 典型40实际的sizeof结果取决于标准库的实现和平台的对齐要求。但核心事实是optionalT大约比T大一个对齐后的bool的大小。由于对齐的要求有时候会增加得比预期多一些。这不是optional的设计缺陷——它是在栈上直接存储T的值不涉及堆分配所以这个额外开销是合理的。optional持有的对象和是否有值的标志在同一个对象内部不涉及任何动态内存分配。析构时如果optional持有值就会自动调用T的析构函数。这一切都是自动的不需要手工管理。第四步——optional 与指针的区别optionalT和T*都能表达可能没有值但它们的语义截然不同。optionalT是值语义——它持有或打算持有一个完整的T对象。拷贝optional会拷贝T的值如果有值的话析构optional会析构T。它表达的是这里有一个T或者暂时没有。T*是引用语义——它指向某个外部的T对象或者为空。拷贝指针只是拷贝地址不会拷贝对象本身。它表达的是某个地方有一个T我可能指向它。std::optionalintopt42;int*ptropt.value();// 指向 optional 内部的 intopt123;// optional 重新赋值旧的 42 被销毁// ptr 现在可能指向 123取决于实现也可能悬空——不要这么用std::optionalintopt2opt;// 拷贝opt2 是独立的副本持有 123int*ptr2raw;// 假设 raw 是某个 int 变量std::optionalintopt3*ptr2;// 拷贝 ptr2 指向的值——与 ptr2 无关笔者的一般原则是如果你需要表达值可能存在也可能不存在用optional如果你需要表达指向某个外部对象的可空引用用指针。不要用optional来模拟指针也不要用指针来模拟optional——它们的职责不同。第五步——optional 作为返回值optional最常见的用途是作为函数返回值。它的语义非常明确函数可能返回一个有效值也可能返回无值。调用方必须在类型系统层面处理无值的情况。查找操作#includeoptional#includevector#includestringstd::optionalstd::size_tfind_index(conststd::vectorintv,inttarget){for(std::size_t i0;iv.size();i){if(v[i]target)returni;}returnstd::nullopt;}// 调用方autoidxfind_index(data,42);if(idx){std::coutfound at index *idx\n;}else{std::coutnot found\n;}对比之前用-1做哨兵值的版本optional的优势在于调用方不可能忘记检查返回值。如果你直接写data[*find_index(data, 42)]而不检查has_value()在空值情况下解引用是 UB但至少从 API 的设计意图上是明确的——类型签名已经告诉了你这个值可能为空。工厂函数classConnection{public:staticstd::optionalConnectioncreate(conststd::stringaddr){// 尝试建立连接if(addr.empty())returnstd::nullopt;// 无效参数// ... 实际连接逻辑returnConnection(addr);}private:explicitConnection(std::string addr):addr_(std::move(addr)){}std::string addr_;};// 使用autoconnConnection::create(192.168.1.1);if(conn){// 连接成功}else{// 连接失败}第六步——optional 作为参数optional也可以用作函数参数表示这个参数是可选的。这比函数重载或者默认参数更灵活因为调用方可以在运行时决定是否提供值voidprint_greeting(conststd::stringname,std::optionalstd::stringtitlestd::nullopt){if(title){std::coutHello, *title name!\n;}else{std::coutHello, name!\n;}}print_greeting(Alice);// Hello, Alice!print_greeting(Bob,std::string(Dr.));// Hello, Dr. Bob!不过笔者要提醒一点不要过度使用optional参数。如果一个参数在大多数情况下都需要提供那用默认值可能比optional更合适。optional参数适合那种有时有、有时没有而且两种情况的含义完全不同的场景。第七步——C23 monadic 操作预告C23 为std::optional引入了三个 monadic 操作and_then、transform和or_else。这些操作借鉴了函数式编程的概念让optional的链式处理变得更加优雅。transform对值做变换transform接受一个函数如果optional有值就用这个函数对值做变换返回一个包含变换结果的optional如果optional为空返回一个空的optional。std::optionalintparse_int(conststd::strings){try{returnstd::stoi(s);}catch(...){returnstd::nullopt;}}// C20 风格手动检查std::optionalstd::stringinputget_input();std::optionalintresult;if(input){resultparse_int(*input);}// C23 风格链式 transformautoresult2get_input().transform([](conststd::strings)-int{returnstd::stoi(s);// 简化示例实际应处理异常});and_then链式组合可能失败的操作and_then接受一个返回optional的函数。如果当前optional有值就调用这个函数并返回其结果否则直接返回空optional。这比transform更适合上一步的结果是下一步的输入且每步都可能失败的场景。std::optionalUserfind_user(intid);std::optionalstd::stringget_email(constUseru);// C20 风格嵌套 ifautouserfind_user(42);if(user){autoemailget_email(*user);if(email){std::coutEmail: *email\n;}}// C23 风格链式 and_thenfind_user(42).and_then(get_email).transform([](conststd::stringemail){std::coutEmail: email\n;returnemail;});or_else处理空值情况or_else接受一个函数当optional为空时调用它。通常用于日志记录或提供替代方案autoemailfind_user(42).and_then(get_email).or_else([]{std::cerrFailed to get email\n;returnstd::optionalstd::string(fallbackexample.com);});这三个操作组合起来可以让你写出非常流畅的链式代码避免多层嵌套的if语句。如果你的编译器还不支持 C23可以参考之前的optional_map辅助函数来实现类似的效果。延迟初始化optional可以用来实现延迟初始化lazy initialization的对象的构造推迟到真正需要的时候。这在对象构造代价较高、但是否需要在编译期无法确定的场景中非常有用classExpensiveResource{public:ExpensiveResource(){/* 耗时的初始化 */}voiddo_work(){/* ... */}};classService{public:voidprocess(){if(!resource_){resource_.emplace();// 首次使用时才构造}resource_-do_work();}private:std::optionalExpensiveResourceresource_;// 初始为空};这比用std::unique_ptr实现延迟初始化更优因为optional不涉及堆分配——对象直接存储在optional内部的缓冲区中。参考资源cppreference: std::optionalcppreference: std::bad_optional_accessC23 Monadic operations for std::optionalC Core Guidelines: Optional