C++20-23 新属性:性能导向的编译器提示
C20-23 新属性性能导向的编译器提示这个仓库已经开源现代化 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/之前那篇我们看了 C11-17 的标准属性它们主要解决代码正确性的问题——强制检查返回值、消除警告、标记废弃 API。C20 和 C23 新增的属性则换了一个方向它们更关注性能给编译器提供优化提示。[[likely]]和[[unlikely]]帮编译器做分支预测优化(啊哈,我记得我第一次接触是在看GNU C特性的代码)[[no_unique_address]]节省内存布局中的冗余空间[[assume]]让编译器基于假设做更激进的优化。这些属性用好了能带来实打实的性能提升但用错了也可能适得其反。我们来一个一个拆解。一句话总结C20-23 的新属性从帮编译器找 bug转向帮编译器优化代码。用对场景、验证效果才是正道。[[likely]] 和 [[unlikely]]C20分支预测提示为什么需要手动提示现代 CPU 都有动态分支预测器会根据运行时的历史记录猜测分支走向。大部分情况下 CPU 的猜测已经足够聪明了。但在以下场景中手动提示仍然有价值第一函数第一次被调用时分支预测器还没有历史数据第二在嵌入式系统中某些 CPU 的分支预测器比较简单第三编译器可以通过调整代码布局把热路径放在一起来提高指令缓存命中率。[[likely]]告诉编译器这个分支更可能被执行[[unlikely]]则表示这个分支很少执行。语法与放置位置这对属性可以放在if语句的分支体中也可以放在switch的case标签上// 放在 if 分支中if(errorErrorCode::Ok)[[likely]]{// 正常路径——大概率执行process_data();}else{// 错误路径——小概率执行handle_error();}// 放在 switch case 上switch(status){[[likely]]caseStatus::Running:run_task();break;caseStatus::Error:recover();break;default:break;}⚠️ 注意属性的放置位置[[likely]]放在分支体的{前面不是放在条件表达式上。这是 C20 标准的规定。实际效果分析先看汇编再说很多文章会告诉你加了[[likely]]编译器会优化代码布局但到底优化了什么口说无凭我们直接看汇编。以下测试用 GCC 15 以-O2 -stdc20编译// 不加提示intprocess_no_hint(intvalue){if(value0){returnvalue*2;}else{return-value;}}// 加 [[likely]]intprocess_likely(intvalue){if(value0)[[likely]]{returnvalue*2;}else{return-value;}}两个函数生成的汇编完全一样process_no_hint: process_likely: movl %edi, %eax leal (%rdi,%rdi), %edx negl %eax testl %edi, %edi cmovg %edx, %eax ret编译器根本没有生成条件分支——它用cmovg条件移动把两条路径都算好然后根据testl的结果选一个。分支预测不存在的。[[likely]]在这里没有任何效果因为编译器已经找到了比分支更好的方案。这不是孤例。现代编译器在-O2甚至-O1下经常会把简单的条件分支优化成cmov、位运算或数学公式让[[likely]]变成纯粹的代码注释。真正能看到[[likely]]影响代码布局的场景通常是分支体比较长超过几条指令、分支内有函数调用或内存操作、或者编译器无法用cmov替代的复杂逻辑。什么时候值得用所以[[likely]]并不是加了就更快的魔法开关。正确的使用方式是先通过 profiling比如perf stat -e branch-misses确认某个分支预测失败率确实很高再考虑加提示。加之前对比汇编确认编译器确实改变了代码布局。如果汇编没变说明编译器已经用更好的方式优化了[[likely]]就是多余的信息噪声。典型有效场景包括错误检查分支正常路径[[likely]]错误路径[[unlikely]]、边界条件处理、以及分支体较复杂、编译器无法用cmov替代的逻辑。与编译器内置函数的对比在[[likely]]出现之前GCC/Clang 用__builtin_expect来做分支预测提示// 旧写法if(__builtin_expect(errorErrorCode::Ok,1)){process_data();}// 新写法if(errorErrorCode::Ok)[[likely]]{process_data();}[[likely]]的可读性好得多而且标准化的属性意味着它在所有支持 C20 的编译器上都能工作。[[no_unique_address]]C20空基类优化问题空类也要占 1 字节C 标准要求每个完整的对象都有唯一的地址这意味着即使是没有任何数据成员的空类sizeof也至少是 1。当你把一个空类作为另一个类的成员时它就白白占了一个字节structEmpty{voidfoo(){}// 只有成员函数没有数据成员};structContainer{// 在x86-64架构上常见内存布局为// offset 0: e (1 字节)// offset 1~3: padding (3 字节)// offset 4~7: x (4 字节)// 因为 int类型大小为4字节所以编译器通常// 会将它放在4的倍数的内存地址Empty e;intx;};struct[[gnu::packed]]PackedContainer{// offset 0: e (1 字节)// offset 1~4: x (4 字节)Empty e;intx;};static_assert(sizeof(Empty)1);static_assert(sizeof(Container)8);// 大概率有paddingstatic_assert(sizeof(PackedContainer)sizeof(int)1);// 提示编译器不要添加padding对于大多数应用来说浪费 1 字节不算什么但在泛型编程中策略类allocator、mutex policy 等经常是空类。如果多个策略类同时作为成员每个都占 1 字节累积起来就不容忽视了。更关键的是这会让sizeof的结果不符合预期影响缓存行对齐等优化。传统的 EBO 方案传统的解决方案是空基类优化Empty Base Optimization, EBO——通过继承而不是成员来持有空类这样编译器就不需要给它分配独立的空间structEmpty{};// 传统 EBO通过继承structContainer:privateEmpty{intx;};static_assert(sizeof(Container)sizeof(int));// Empty 不占空间但 EBO 有几个缺点你只能继承一个同类型的空基类不能同时继承两个Empty继承是一种很强的耦合关系为了节省内存而修改继承关系是不合理的有些编码规范禁止私有继承。[[no_unique_address]] 的方案C20 引入的[[no_unique_address]]让你可以通过成员变量而不是继承来实现同样的优化structEmpty{voidfoo(){}};structContainer{[[no_unique_address]]Empty e;// 如果 Empty 是空类e 不占空间intx;};static_assert(sizeof(Container)sizeof(int));// e 被优化掉了策略模式中的应用[[no_unique_address]]在策略模式中特别有用。假设你有一个容器类它接受分配器策略和锁策略作为模板参数。在单线程场景下锁策略是空类所有方法都是空操作你不想让它白白占空间structNullMutex{voidlock(){}voidunlock(){}};structStdMutex{voidlock(){mtx_.lock();}voidunlock(){mtx_.unlock();}private:std::mutex mtx_;};templatetypenameT,typenameMutexNullMutexclassThreadSafeBuffer{public:voidpush(constTitem){mutex_.lock();// ... 添加元素mutex_.unlock();}private:[[no_unique_address]]Mutex mutex_;T*data_;std::size_t size_;std::size_t capacity_;};// 单线程版本NullMutex 不占空间ThreadSafeBufferintsingle_thread_buf;static_assert(sizeof(single_thread_buf)sizeof(void*)sizeof(std::size_t)*2);// 多线程版本std::mutex 占实际空间ThreadSafeBufferint,StdMutexmulti_thread_buf;static_assert(sizeof(multi_thread_buf)sizeof(std::mutex)sizeof(void*)sizeof(std::size_t)*2);这个设计让你在不牺牲内存效率的前提下通过模板参数灵活切换策略。单线程场景下不浪费一个字节多线程场景下使用真正的互斥锁。注意事项[[no_unique_address]]有一些需要注意的细节。同一类型的多个[[no_unique_address]]成员可能共享同一地址因为它们都是空类不需要区分具体行为取决于编译器实现structA{[[no_unique_address]]Empty e1;[[no_unique_address]]Empty e2;intx;};A a;// a.e1 a.e2 可能为 trueGCC 15.2.1 中不一定但第一个空成员可能与后续非空成员共享地址验证在 GCC 15.2.1 上测试多个[[no_unique_address]]空成员不一定共享相同地址但第一个空成员的地址可能与后续非空成员相同。sizeof的优化效果是确定且显著的。如果你需要取这些成员的地址或用引用指向它们请格外小心——它们的地址可能相同。此外这个属性只对空类有效。如果类有数据成员加了也没效果structNotEmpty{intdata;};structTest{[[no_unique_address]]NotEmpty e;// e 仍然占 sizeof(int)intx;};static_assert(sizeof(Test)2*sizeof(int));另外MSVC 在某些版本中对[[no_unique_address]]的支持存在 bug——即使空类也可能不被优化。这在跨平台项目中需要特别注意建议在目标平台上验证sizeof的结果。[[assume]]C23编译器假设语义C23 引入的[[assume(expression)]]告诉编译器请假设expression为真编译器可以基于这个假设做更激进的优化。如果运行时expression实际为假行为是未定义的。这与assert不同。assert在运行时检查条件失败则终止程序[[assume]]完全不做运行时检查只是让编译器放心大胆地优化。示例intdivide(inta,intb){[[assume(b!0)]];returna/b;}在这个例子中编译器理论上可以省去除零检查的代码路径生成更快的除法指令。但如果你传入b 0后果是未定义的——可能崩溃可能返回垃圾值可能看起来正常但暗中搞破坏。验证在 GCC 15.2.1 的-O2优化级别下简单的除法函数无论是否使用[[assume]]都生成了相同的汇编代码。说明对于这种简单场景编译器已经做了足够的优化。[[assume]]的价值主要体现在更复杂的场景中此时编译器无法通过静态分析推断出不变量。与 __builtin_assume 的对比在[[assume]]之前MSVC 用__assumeGCC 用__builtin_assume虽然 GCC 更常用的方式是if (cond) __builtin_unreachable()// MSVC__assume(b!0);// GCCif(b0)__builtin_unreachable();// C23 标准写法[[assume(b!0)]];使用场景[[assume]]的典型使用场景是你对运行时的某些条件有确定性的知识但编译器无法通过静态分析推断出来。比如你知道一个数组访问永远不会越界或者你知道某个指针永远不为 nullvoidprocess_array(int*data,std::size_t size){[[assume(data!nullptr)]];[[assume(size0)]];for(std::size_t i0;isize;i){// 编译器可以省略 null 检查和越界检查data[i]*2;}}⚠️ 警告[[assume]]是所有属性中最危险的。如果你的假设是错的程序的行为完全不可预测。笔者建议只在经过充分 profiling、确认瓶颈、并且你能 100% 保证条件总是成立的情况下使用它。在 99% 的代码中你不需要它。C20 [[nodiscard]] 增强之前那篇已经提到C20 为[[nodiscard]]添加了自定义消息的能力。这里做一点补充说明。标准库中的 nodiscard 扩展C20 还扩展了标准库中[[nodiscard]]的应用范围。以下标准库函数标记了[[nodiscard]]std::vector::empty()C20 起std::string::empty()C20 起验证在 libstdc 15.2.1 中测试empty()方法确实会产生 nodiscard 警告。但文章中声称的std::unique_ptr和std::shared_ptr类型本身标记[[nodiscard]]在当前实现中并不准确——至少std::make_unique()和构造函数不会产生警告。不同标准库实现libstdc、libc、MSVC STL对此的支持可能不同。这意味着如果你写了vec.empty();而不是if (vec.empty())C20 编译器会发出警告。以前这是一个常见的 bug 来源——empty()看起来像是清空实际上是判空。加了[[nodiscard]]之后误用的代码至少会有警告提醒。std::vectorintvec{1,2,3};// C20 之前不检查返回值静默通过vec.empty();// 看起来像是清空操作实际上什么都没做// C20编译器发出 nodiscard 警告vec.empty();// warning: ignoring return value of empty()在自己的代码中使用 nodiscard 消息对于库作者来说[[nodiscard(reason)]]非常实用。你可以在消息中解释为什么不应该忽略返回值以及正确的使用方式// 告诉调用方为什么需要检查返回值[[nodiscard(Memory leak: returned pointer must be freed)]]void*allocate_buffer(std::size_t size);// 告诉调用方应该怎么用[[nodiscard(Store the lock_guard to keep the mutex locked)]]std::unique_lockstd::mutexacquire_lock();与 C11-17 属性的对比把 C11-17 的属性和 C20-23 的新属性放在一起对比能看到一条清晰的发展脉络早期属性关注代码正确性和可维护性后期属性更关注性能优化。属性版本关注点风险[[noreturn]]C11正确性低[[carries_dependency]]C11性能低[[deprecated]]C14可维护性低[[nodiscard]]C17正确性低[[fallthrough]]C17正确性低[[maybe_unused]]C17可读性低[[likely]]/[[unlikely]]C20性能低[[no_unique_address]]C20性能低[[assume]]C23性能高其中只有[[assume]]是真正危险的属性——如果假设错误后果是未定义行为。其他属性即使提示错了最坏情况也只是性能略差不会导致程序崩溃。性能影响实测建议对于[[likely]]/[[unlikely]]和[[assume]]这类性能导向的属性笔者的建议是加了之后一定要实测。优化效果高度依赖具体的硬件、编译器和代码上下文。有些场景收益明显有些场景完全看不出差异。测试方法可以是简单的用perf stat或valgrind --toolcachegrind对比加属性前后的指令数、分支预测失败率和缓存命中率。如果数据没有显著改善就不值得加——因为属性会增加代码的信息密度让读者多理解一个概念。对于[[no_unique_address]]验证更直接——直接看sizeof的结果就好。如果空策略类确实不占空间说明属性生效了。参考资源cppreference: assume (C23)cppreference: likely/unlikely (C20)cppreference: no_unique_address (C20)Don’t use [[likely]] or [[unlikely]] - Aaron Ballman