先讲个我自己的事。某次给一个协议解析模块排查线上问题现象特别诡异服务偶发卡死日志里没有任何异常最后重启就好了。查了两天才定位到是某个底层调用的返回值被丢弃了那个返回值里带着关键的错误状态丢掉的瞬间整个状态机就歪了。当时我的第一反应是“这谁能防得住”后来认真翻了编译日志才发现编译器其实早就给出过 warning只是那个告警混在几百条其它告警里没人注意到。这件事让我后怕了很久也让我彻底改变了看待返回值的方式。今天想聊聊[[nodiscard]]这个 C17 属性。它解决的不是“能不能编译”的问题而是把“返回值必须被使用”这件事从人的自觉变成了编译器的底线。1. 为什么我们真正需要[[nodiscard]]从一次线上事故说起1.1 一次让人头大的死锁排查那段问题的代码简化之后大概是这样的ErrorCode init_connection(Connection conn); bool send_frame(Connection conn, const Frame frame);调用方写的是send_frame(conn, frame);看起来很正常对吧但send_frame内部会维护状态机它会返回当前状态是否成功一旦失败上层需要立刻清理资源。结果这句话把返回值丢得一干二净于是失败分支没有走资源没有释放下一次调用直接撞上死锁。问题发生之后我去读了代码评审记录发现当初大家都觉得“返回值在这里不重要反正框架会自愈”。这个判断错得彻底。所有想着“返回值不重要”的地方最后都会变成事故现场只是不知道什么时候爆。1.2 编译器默认能帮你抓到多少很多人有个错觉编译器会把忽略返回值当成错误。实际上 C 编译器默认抱着“不关我事”的态度下面这种写法不会产生任何告警int compute_hash(std::string_view data); std::string_view payload ...; compute_hash(payload); // 编译器默许你丢掉它除非你开启了-Wunused-result并且函数本身带有特殊标记否则编译器根本不会管你。默认情况下能拦截的只有极少数局部情况比如[[nodiscard]]属性本身生效的那些。更麻烦的是C 风格函数很多也是“返回错误码但没人看”的重灾区。比如fscanf、fread这类函数返回值代表实际读写字节数丢失以后几乎等于在裸奔。C 语言里可以通过编译器扩展来处理比如 GCC 的warn_unused_result但 C 直到 C17 才把“返回值必须使用”作为标准属性写进语言。1.3 从注释约定到编译器契约在没有属性之前团队靠的是注释和代码评审。注释写“调用者必须检查返回值”评审时靠人眼盯。只要项目规模一大这种软约定就全是破绽因为任何一个着急上线的人都有可能略过注释。[[nodiscard]]真正的意义是把“必须检查返回值”从文字约定升级为编译器契约。一旦标记编译器就能在开发阶段用 warning 提示你如果配合-Werror甚至可以把它直接变成编译错误。契约化的好处是错误发生的时间点从“运行时线上事故”提前到了“开发者写代码的那一秒”。这里有一个需要提前说明的点属性本身只是告诉编译器“这里值得提醒”不会像 Java 的 checked exception 那样强制你 catch。它靠的是编译器的告警机制所以严格的 CI 策略才是[[nodiscard]]真正发挥威力的前提。2. 三种标记位置函数、类型与构造函数的语义差别2.1 函数声明最直接的使用方式最简单的用法就是加在函数声明前面或者放在返回类型之后。两种写法都合法[[nodiscard]] int compute_hash(std::string_view data); // 这种写法也合法但可读性不如上面 int compute_hash(std::string_view data) [[nodiscard]];我实际项目里基本只用第一种把属性放在函数名前面最直观多人协作时也不会有人看漏。标记之后任何丢弃返回值的调用都会被警告compute_hash(payload); // warning: ignoring result of compute_hash declared with nodiscard attribute但是有一点要注意[[nodiscard]]只影响“未使用返回值”的场景如果你把它的返回值存进一个变量然后再也不读那个变量有些编译器也会给出“变量未使用”的提示这是另一条告警路径跟[[nodiscard]]没有直接关系。真正容易漏掉的是你把返回值直接丢掉的裸调用。2.2 类型标记从返回值源头控制比函数级标记更厉害的是把[[nodiscard]]用在类或枚举上。一旦类型被标记那么所有返回该类型的函数其返回值都不能被忽略class [[nodiscard]] Error { public: explicit Error(int code) : code_(code) {} bool ok() const { return code_ 0; } private: int code_; }; Error check_status(); check_status(); // warning: ignoring returned value of type Error这种设计特别适合错误处理场景。你不用在每个返回Error的函数上重复写属性只需要在类型上声明一次整个类型系统就帮你管住了。如果项目里有用std::expectedT, Error的习惯效果类似但expected本身现在还没被标记成 nodiscard这是标准库还没跟上的地方。在 C17 下有一个明显的边界类型上的[[nodiscard]]只对“函数返回值”生效直接丢弃一个类型为Error的临时对象并不会告警Error{100}; // C17 下编译器一般不会管这个边界在 C20 里被补上了后面会说。2.3 构造函数C20带来的新变化C20 把[[nodiscard]]的语义扩展到了“丢弃临时对象”这个场景。一个被标记的类如果构造出来的临时对象被直接丢弃编译器也会提示。其实就是开头的例子从“返回值”扩展到了“构造函数本身产生的纯右值”class [[nodiscard]] MyGuard { public: explicit MyGuard() default; void reset() {} }; MyGuard{}; // C20 下warningC17 下通常不提示这个变化看着小实际影响非常大。它让“构造一个 RAII 对象但不保存”这个经典错误终于能被抓到了。我见过不少把人丢的奇葩代码——构造了一个锁对象没有任何变量接住语句执行完就析构锁等于白加。C20 之后这种情况会被明确警告。如果你的代码还跑在 C17 标准下又想对构造函数的临时对象生效暂时没有特别好的标准办法只能靠命名规范和人工评审。这也是为什么所有现代 C 新项目我都建议直接切到 C20光这一条就值回票价。3. 标准库里的[[nodiscard]]实战样本3.1 容器与字符串empty()这类容易误用的接口标准库从 C20 开始大规模地给“纯查询类函数”加上了[[nodiscard]]。最典型的是容器和std::string的empty()、find()这类成员函数。它们的返回值如果被丢弃几乎一定意味着逻辑错误。比如下面这段代码std::vectorint data; // 错误原意可能是 data.clear() data.empty();在empty()被标记了[[nodiscard]]的编译环境里会得到类似这样的提醒Clang:warning: ignoring result of empty declared with nodiscard attribute [-Wunused-result]GCC:warning: ignoring return value of bool std::vectorint::empty() const, declared with attribute nodiscard [-Wunused-result]MSVC:warning C4834: discarding return value of function with nodiscard attribute标准库不会把clear()标记成 nodiscard因为clear()返回 void所以看到data.empty()被警告几乎可以确定你写错了函数。拿纯查询函数被警告当成“Bug 雷达”是我最喜欢的使用方式之一。3.2 并发原语与RAIIlock、join、atomic并发领域是返回值丢失的重灾区。std::unique_lock的try_lock()、std::atomic的compare_exchange_weak()返回的 bool 都代表这次操作是否成功。不看返回值就继续往下走等于在赌。更隐蔽的是std::future相关操作。std::async如果忘了保存future析构时会阻塞等待任务完成行为和“不等待”完全相反。标准库虽然不一定在async上直接标[[nodiscard]]但这类“操作有状态”的返回对象正是我们应该自己补标的地方。我一般会给团队定一条规则凡是返回值是“是否成功 / 是否有效 / 是否拥有资源”这类语义的接口一律标[[nodiscard]]。宁可误报也不要放过。真正产生噪音的反而很少因为这类接口丢返回值基本就是状态机被打破。3.3 我的实测编译器表现为了验证标准库的效果我做过一个小实验在同一台机器上分别用三个编译器编译下面这段代码#include vector int main() { std::vectorint v; return v.empty(); }在 Linux 上用 GCC 11 和 Clang 14开启-Wall都会因为empty()被标记而给出告警Clang 的提示还会带上[-Wunused-result]标签。MSVC 需要开启/W4才会触发 C4834。整体体验下来GCC 和 Clang 对[[nodiscard]]的支持最到位MSVC 也还算靠谱但告警默认等级不够高这在第五章会细说。4. 版本兼容与属性组合宏、编译器扩展和带说明的nodiscard4.1 老项目如何在C14/11下享受同样的保护很多存量项目的编译标准还停留在 C14 甚至 C11[[nodiscard]]是标准语法直接写会编译失败。但 GCC 和 Clang 很早就支持功能等价的 GNU 扩展__attribute__((warn_unused_result))可以封装一个通用宏#if defined(__cplusplus) __cplusplus 201703L # define MY_NODISCARD [[nodiscard]] #elif defined(__GNUC__) || defined(__clang__) # define MY_NODISCARD __attribute__((warn_unused_result)) #else # define MY_NODISCARD #endif这个宏用在函数声明前面MY_NODISCARD int compute_checksum(const std::string data);好处是将来切到 C20 时只需要改宏定义业务代码完全不用动。我改造老项目时基本都是这个套路先让关键函数用宏标记起来再逐步把标准切上去。4.2 与[[noreturn]]、[[deprecated]]组合时的边界属性可以叠加需要注意语义冲突。[[noreturn]]表示函数不会返回比如[[noreturn]] void terminate_with_error();这时候加不加[[nodiscard]]其实没有意义因为函数根本没有“返回值”会被丢弃。编译器会在两边提示你。我见过有人头铁把两个属性叠一起结果发现告警反而变奇怪了——这不是属性本身冲突而是这种组合本来就不符合常理。[[deprecated]]和[[nodiscard]]可以共存且非常实用[[deprecated(use compute_hash_v2 instead)]] [[nodiscard]] int compute_hash_v1(const std::string data);调用方如果不使用返回值编译器会先给nodiscard告警如果函数本身被标记了 deprecated还会有另一条“已弃用”警告。两条一起出来信息量足够不会让人摸不着头脑。注意顺序无所谓但代码风格统一成“属性先于返回类型”更容易读。4.3 带说明文字的nodiscard[[nodiscard]]在 C23 标准里支持带一段说明文字格式是这样[[nodiscard(result represents whether connection is established)]] bool establish_connection(Connection conn);GCC 和 Clang 其实早就支持了这个扩展只是标准直到 C23 才正式纳入。这么写之后编译器的告警里会直接带上这段说明对阅读告警的人来说非常友好。需要注意 MSVC 对带消息的[[nodiscard(...)]]支持有限如果你要在 Windows 上保持跨平台告警一致暂时用纯[[nodiscard]]更稳妥说明文字放到注释里也一样能达到让人看懂的目的。5. 实践中的三个坑与我的取舍原则5.1 坑一副作用函数被标记后防御式代码变得很吵闹最容易出的问题是把“返回值重要”和“返回值必须被用到”混为一谈。例如有些查询接口调用方明确不关心结果只是想让这个函数跑一遍比如预热缓存、写入日志缓冲[[nodiscard]] bool warm_up_cache();这种函数如果标上 nodiscard调用方必须写(void)warm_up_cache();才能消掉告警时间久了大家就会养成“看到告警就加 void cast”的肌肉记忆告警系统慢慢就失效了。我的原则是只给“应该总是检查”的接口加给“通常不用但有时需要”的接口加只会带来噪音。5.2 坑二模板和decltype(auto)会悄悄绕过检查模板推导有时会让人误以为[[nodiscard]]一定会传导。实际上如果你用一个泛型函数包了一层返回类型变成了模板参数那么 nodiscard 属性不一定能传过去template typename F auto call_and_get(F f) { return f(); // 返回类型是 autonodiscard 不会自动传导 }decltype(auto)同样有这个问题。如果你在封装层丢弃了返回值的语义内层函数即使标了 nodiscard告警也可能在模板展开时消失。这个坑很难排查因为它不是语法层面的错误而是告警系统在模板实例化环节的盲区。遇到这种情况要么在封装层也手动补标 nodiscard要么把返回类型写死成原始类型。我更倾向于后者能让属性语义完整传递。5.3 我的取舍原则少而准宁可漏告不可误告实践下来我给自己定了一套标准用到现在效果很好场景是否标记原因返回错误码/状态/是否成功的接口总是标记不检查必然导致逻辑错误返回新创建对象/句柄/资源通常标记丢弃多半是忘了保存返回容器大小/是否为空等纯查询总是标记丢弃基本等于代码写错有副作用但结果不常用的接口不标记避免强制 cast 泛滥返回值是模板参数的通用接口不依赖传导手动补标或调整设计关于编译器告警等级我强烈建议把-Wunused-result直接加进你的全局构建配置。GCC 和 Clang 默认会带它但 MSVC 需要/W4才能显示 C4834而很多 Windows 项目还停在/W3等于完全没开。给一个最小可用的 CMake 配置if(MSVC) target_compile_options(project PRIVATE /W4 /w44244) else() target_compile_options(project PRIVATE -Wall -Wextra -Wunused-result) endif()拿这个跑一遍现有代码库你会看到不少之前被埋没的告警。有条件的项目再开-Werror从根上杜绝沉默故障的生存空间。回头再看那个协议解析模块的事故如果当初底层函数标了[[nodiscard]]那条告警会单独跳出来而不是混在几百条冗余信息里。哪怕只是提醒一下我也不会让它带着状态机错误上线。所以如果你问我这个属性值不值得认真用我的答案是它是那种“加一个大括号就能改变代码底线”的东西真正该做的不是问值不值得而是赶紧去翻开自己的编译告警看看有多少返回值正在被你悄悄扔掉。