随意聊聊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 错误处理的演进路线最后提到std::optional可以用于表达可能失败的操作。这一篇我们就来深入看看optional在错误处理场景下到底好不好用、该怎么用、以及什么时候不该用它。先说结论std::optional是一把精确的手术刀不是瑞士军刀。它在特定场景下非常好用但如果拿来当通用的错误处理工具你会发现自己到处都在猜为什么返回了 nullopt。optional 的语义成功或无值std::optionalT的语义非常直白——它要么持有一个T类型的值要么是空的std::nullopt。把它用在错误处理上就是成功返回值失败返回空#includeoptional#includestring/// 尝试将字符串解析为整数失败则返回空std::optionalintparse_int(conststd::strings){try{std::size_t pos0;intvaluestd::stoi(s,pos);if(pos!s.size()){returnstd::nullopt;// 有多余字符解析不完整}returnvalue;}catch(...){returnstd::nullopt;}}这种写法最大的好处是语义在类型里。函数签名std::optionalint就已经告诉调用方这个函数可能不返回值你不需要查文档、不需要记约定——类型本身就是文档。调用方拿到返回值后第一件事自然是检查有没有值autoresultparse_int(42);if(result){std::coutGot: *result\n;}else{std::coutParse failed\n;}适合 optional 的场景optional最适合的场景有一个共同特征失败是正常情况的一部分而且调用方不需要知道失败的具体原因。场景一查找操作查找是最经典的 optional 场景。从容器中查找一个元素找不到不是错误而是没找到——这个区别很重要。你不需要告诉调用方为什么没找到因为原因只有一个不存在。#includeunordered_map#includeoptional#includestringstructUser{std::string name;intage;};classUserRegistry{public:std::optionalUserfind(intid)const{autoitusers_.find(id);if(it!users_.end()){returnit-second;}returnstd::nullopt;}voidadd(intid,User user){users_[id]std::move(user);}private:std::unordered_mapint,Userusers_;};// 使用UserRegistry registry;registry.add(1,User{Alice,30});autouserregistry.find(1);if(user){std::coutuser-name\n;// Alice}automissingregistry.find(99);// missing 是 nullopt但这是正常情况不是错误场景二解析操作从外部输入配置文件、用户输入、网络数据中解析信息失败是家常便饭。如果调用方只需要知道解析成功了吗optional就够了#includeoptional#includestring#includecharconv#includesystem_error/// 从字符串视图解析浮点数std::optionaldoubleparse_double(std::string_view sv){doublevalue0.0;auto[ptr,ec]std::from_chars(sv.data(),sv.data()sv.size(),value);if(ecstd::errc{}ptrsv.data()sv.size()){returnvalue;}returnstd::nullopt;}// 使用autov1parse_double(3.14);// optional(3.14)autov2parse_double(hello);// nulloptautov3parse_double(3.14abc);// nullopt有多余字符场景三带默认值的场景当操作失败时你有合理的默认值optional的value_or可以让代码非常简洁#includeoptional#includestring#includecstdlibstd::optionalstd::stringget_env(conststd::stringkey){constchar*valstd::getenv(key.c_str());if(val)returnstd::string(val);returnstd::nullopt;}// 使用 value_or 提供默认值std::string log_levelget_env(LOG_LEVEL).value_or(INFO);intmax_threadsparse_int(get_env(MAX_THREADS).value_or(4)).value_or(4);场景四缓存查找缓存命中就返回值未命中就返回空——这不需要任何错误信息templatetypenameKey,typenameValueclassSimpleCache{public:std::optionalValueget(constKeykey)const{autoitcache_.find(key);if(it!cache_.end()!it-second.expired){returnit-second.data;}returnstd::nullopt;}voidput(constKeykey,Value value){cache_[key]{std::move(value),false};}private:structEntry{Value data;boolexpiredfalse;};std::unordered_mapKey,Entrycache_;};不适合 optional 的场景optional的致命局限是不携带错误信息。当调用方需要知道为什么失败时optional就不够用了。需要区分多种错误类型// 不好三种不同的失败原因被揉成了一个 nulloptstd::optionalConfigload_config(conststd::stringpath){autofopen_file(path);if(!f)returnstd::nullopt;// 文件不存在权限不够autocontentread_content(f);if(content.empty())returnstd::nullopt;// 空文件读取出错returnparse_config(content);// 解析失败也是 nullopt}autocfgload_config(app.cfg);if(!cfg){// 我现在该怎么办文件不存在要创建格式错误要报告权限不够要提权// 但我只知道失败了什么都区分不了}这种情况应该用std::expectedConfig, ConfigError或者一个携带错误信息的返回结构体。需要错误传播链当你需要把多个可能失败的操作串联起来并且在链的末端知道是哪一步失败了optional会让调试变得非常痛苦。每一步失败都变成nullopt到了最后你只知道某个地方失败了但不知道是哪里。C23 的 monadic 操作C23 为std::optional新增了三个 monadic 成员函数and_then、transform、or_else。这三个操作让optional的链式处理变得优雅得多。and_then链接可能失败的操作and_then接受一个函数该函数接受optional内部的值并返回一个新的optional。如果原始optional为空直接返回空不调用函数#includeoptional#includestring#includeiostreamstructUserProfile{std::string name;intage;};std::optionalUserProfilefetch_from_cache(intuser_id){// 模拟ID 1 在缓存中if(user_id1)returnUserProfile{Alice,30};returnstd::nullopt;}std::optionalUserProfilefetch_from_server(intuser_id){// 模拟ID 1 和 2 在服务器上if(user_id1||user_id2)returnUserProfile{Bob,25};returnstd::nullopt;}std::optionalintextract_age(constUserProfileprofile){if(profile.age0)returnprofile.age;returnstd::nullopt;}intmain(){intuser_id1;// C23 monadic 链autoage_nextfetch_from_cache(user_id).or_else([user_id](){returnfetch_from_server(user_id);}).and_then(extract_age).transform([](intage){returnage1;});if(age_next){std::coutNext year age: *age_next\n;}}对比一下没有 monadic 操作时的写法// C20 风格嵌套的 if/elseautoprofilefetch_from_cache(user_id);if(!profile){profilefetch_from_server(user_id);}std::optionalintage_next;if(profile){autoageextract_age(*profile);if(age){age_next*age1;}}monadic 版本把正常路径放在一条链上每个步骤都清楚地表达了拿到数据后做什么。错误传播是自动的——任何一步返回空后续步骤全部跳过。transform对值做变换transform和and_then的区别在于传入transform的函数返回一个普通值不是optionaltransform会自动把结果包回optional// transform返回值会被自动包装成 optionalautoupper_namefetch_from_cache(1).transform([](constUserProfilep)-std::string{std::string sp.name;for(autoc:s)cstd::toupper(c);returns;});// upper_name 的类型是 std::optionalstd::string一句话区分and_then用于下一步可能失败的操作函数返回optionaltransform用于下一步一定成功的变换函数返回普通值。or_else提供备选方案or_else在optional为空时调用传入的函数通常用于提供回退方案或记录日志autoresultfetch_from_cache(user_id).or_else([user_id](){std::cerrCache miss for user user_id\n;returnfetch_from_server(user_id);}).or_else([](){std::cerrServer also failed, using default\n;returnstd::optionalUserProfile(UserProfile{Default,0});});与 Rust Option 的对比用过 Rust 的朋友可能觉得 C 的optional有点不够力。确实如此主要体现在两个方面Rust 的OptionT有编译器的#[must_use]检查——如果你忽略了一个Option返回值编译器会发出警告。C 的std::optional没有这个保证虽然你可以用[[nodiscard]]标注返回类型但标准库并没有这么做。Rust 的OptionT有一个强大的?操作符用于错误传播。在函数里写let val might_fail()?;如果might_fail返回None函数立即返回None。C 没有这么优雅的语法你需要手动检查或者用宏来模拟比如前面提到的TRY宏。不过 C23 的 monadic 操作已经在很大程度上弥补了这个差距——链式调用虽然不如?操作符简洁但已经足够好用了。通用示例最后来看一个比较完整的例子——配置文件解析展示optional在真实场景下的使用方式#includeoptional#includestring#includestring_view#includefstream#includesstream#includeiostream#includecharconvstructServerConfig{std::string host;intport;inttimeout_ms;};classConfigParser{public:std::optionalServerConfigparse(std::string_view content){ServerConfig cfg;cfg.hostextract_field(content,host).value_or(localhost);autoport_strextract_field(content,port);if(port_str){autopparse_int(*port_str);if(!p||*p1||*p65535){returnstd::nullopt;// 端口无效}cfg.port*p;}else{cfg.port8080;}autotimeout_strextract_field(content,timeout_ms);if(timeout_str){autotparse_int(*timeout_str);if(!t||*t0){returnstd::nullopt;}cfg.timeout_ms*t;}else{cfg.timeout_ms5000;}returncfg;}private:staticstd::optionalstd::stringextract_field(std::string_view content,std::string_view key){std::string searchstd::string(key);autoposcontent.find(search);if(posstd::string_view::npos)returnstd::nullopt;autostartpossearch.size();autoendcontent.find(\n,start);if(endstd::string_view::npos)endcontent.size();returnstd::string(content.substr(start,end-start));}staticstd::optionalintparse_int(std::string_view sv){intvalue0;auto[ptr,ec]std::from_chars(sv.data(),sv.data()sv.size(),value);if(ecstd::errc{}ptrsv.data()sv.size()){returnvalue;}returnstd::nullopt;}};intmain(){std::string config_texthost192.168.1.1\nport3000\ntimeout_ms10000\n;ConfigParser parser;autocfgparser.parse(config_text);if(cfg){std::coutHost: cfg-host, Port: cfg-port, Timeout: cfg-timeout_msms\n;}else{std::coutFailed to parse config\n;}}这个例子展示了optional的典型用法查找字段时用optional表示可能不存在解析数值时用optional表示可能失败用value_or提供默认值。代码清晰正常路径和失败路径一目了然。小结std::optional在错误处理领域的定位很明确它适合那些失败不需要原因的简单场景——查找、解析、缓存、默认值。如果场景需要区分错误类型、需要错误传播链、或者需要在链末端诊断问题就该换expected或者其他更重的方案了。C23 的 monadic 操作and_then、transform、or_else让optional的链式处理变得优雅大大减少了嵌套的if/else代码。如果你的项目还在 C17手写几个辅助函数也能达到类似效果。下一篇我们就来看看std::expectedT, E—— 当你需要值 错误信息时它是怎么做的。参考资源cppreference: std::optionalMonadic operations for std::optional (C23)P0798R8 - Monadic operations for std::expected