C++20-23 新属性:性能导向的编译器提示
上一章我们看了 C++11-17 的标准属性,它们主要解决"代码正确性"的问题——强制检查返回值、消除警告、标记废弃 API。C++20 和 C++23 新增的属性则换了一个方向:它们更关注性能,给编译器提供优化提示。[[likely]] 和 [[unlikely]] 帮编译器做分支预测优化(啊哈,我记得我第一次接触是在看GNU C特性的代码),[[no_unique_address]] 节省内存布局中的冗余空间,[[assume]] 让编译器基于假设做更激进的优化。
这些属性用好了能带来实打实的性能提升,但用错了也可能适得其反。我们来一个一个拆解。
一句话总结:C++20-23 的新属性从"帮编译器找 bug"转向"帮编译器优化代码"。用对场景、验证效果,才是正道。
[[likely]] 和 [[unlikely]](C++20):分支预测提示
为什么需要手动提示
现代 CPU 都有动态分支预测器,会根据运行时的历史记录猜测分支走向。大部分情况下 CPU 的猜测已经足够聪明了。但在以下场景中,手动提示仍然有价值:第一,函数第一次被调用时分支预测器还没有历史数据;第二,在嵌入式系统中某些 CPU 的分支预测器比较简单;第三,编译器可以通过调整代码布局(把热路径放在一起)来提高指令缓存命中率。
[[likely]] 告诉编译器"这个分支更可能被执行",[[unlikely]] 则表示"这个分支很少执行"。
语法与放置位置
这对属性可以放在 if 语句的分支体中,也可以放在 switch 的 case 标签上:
// 放在 if 分支中
if (error == ErrorCode::Ok) [[likely]] {
// 正常路径——大概率执行
process_data();
} else {
// 错误路径——小概率执行
handle_error();
}
// 放在 switch case 上
switch (status) {
[[likely]] case Status::Running:
run_task();
break;
case Status::Error:
recover();
break;
default:
break;
}⚠️ 注意属性的放置位置:[[likely]] 放在分支体的 { 前面,不是放在条件表达式上。这是 C++20 标准的规定。
实际效果分析:先看汇编再说
很多文章会告诉你"加了 [[likely]] 编译器会优化代码布局",但到底优化了什么?口说无凭,我们直接看汇编。以下测试用 GCC 15 以 -O2 -std=c++20 编译:
// 不加提示
int process_no_hint(int value) {
if (value > 0) {
return value * 2;
} else {
return -value;
}
}
// 加 [[likely]]
int process_likely(int value) {
if (value > 0) [[likely]] {
return value * 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(error == ErrorCode::Ok, 1)) {
process_data();
}
// 新写法
if (error == ErrorCode::Ok) [[likely]] {
process_data();
}[[likely]] 的可读性好得多,而且标准化的属性意味着它在所有支持 C++20 的编译器上都能工作。
[[no_unique_address]](C++20):空基类优化
问题:空类也要占 1 字节
C++ 标准要求每个完整的对象都有唯一的地址,这意味着即使是没有任何数据成员的"空类",sizeof 也至少是 1。当你把一个空类作为另一个类的成员时,它就白白占了一个字节:
struct Empty {
void foo() {} // 只有成员函数,没有数据成员
};
struct Container {
Empty e; // sizeof(Empty) == 1,浪费
int x;
};
static_assert(sizeof(Empty) == 1);
static_assert(sizeof(Container) == sizeof(int) + 1); // 可能有 padding对于大多数应用来说浪费 1 字节不算什么,但在泛型编程中,策略类(allocator、mutex policy 等)经常是空类。如果多个策略类同时作为成员,每个都占 1 字节,累积起来就不容忽视了。更关键的是,这会让 sizeof 的结果不符合预期,影响缓存行对齐等优化。
传统的 EBO 方案
传统的解决方案是空基类优化(Empty Base Optimization, EBO)——通过继承而不是成员来持有空类,这样编译器就不需要给它分配独立的空间:
struct Empty {};
// 传统 EBO:通过继承
struct Container : private Empty {
int x;
};
static_assert(sizeof(Container) == sizeof(int)); // Empty 不占空间但 EBO 有几个缺点:你只能继承一个同类型的空基类(不能同时继承两个 Empty);继承是一种很强的耦合关系,为了节省内存而修改继承关系是不合理的;有些编码规范禁止私有继承。
[[no_unique_address]] 的方案
C++20 引入的 [[no_unique_address]] 让你可以通过成员变量(而不是继承)来实现同样的优化:
struct Empty {
void foo() {}
};
struct Container {
[[no_unique_address]] Empty e; // 如果 Empty 是空类,e 不占空间
int x;
};
static_assert(sizeof(Container) == sizeof(int)); // e 被优化掉了策略模式中的应用
[[no_unique_address]] 在策略模式中特别有用。假设你有一个容器类,它接受分配器策略和锁策略作为模板参数。在单线程场景下,锁策略是空类(所有方法都是空操作),你不想让它白白占空间:
struct NullMutex {
void lock() {}
void unlock() {}
};
struct StdMutex {
void lock() { mtx_.lock(); }
void unlock() { mtx_.unlock(); }
private:
std::mutex mtx_;
};
template<typename T, typename Mutex = NullMutex>
class ThreadSafeBuffer {
public:
void push(const T& item) {
mutex_.lock();
// ... 添加元素
mutex_.unlock();
}
private:
[[no_unique_address]] Mutex mutex_;
T* data_;
std::size_t size_;
std::size_t capacity_;
};
// 单线程版本:NullMutex 不占空间
ThreadSafeBuffer<int> single_thread_buf;
static_assert(sizeof(single_thread_buf) == sizeof(void*) + sizeof(std::size_t) * 2);
// 多线程版本:std::mutex 占实际空间
ThreadSafeBuffer<int, StdMutex> multi_thread_buf;
// sizeof 包含 std::mutex 的大小这个设计让你在不牺牲内存效率的前提下,通过模板参数灵活切换策略。单线程场景下不浪费一个字节,多线程场景下使用真正的互斥锁。
注意事项
[[no_unique_address]] 有一些需要注意的细节。同一类型的多个 [[no_unique_address]] 成员可能共享同一地址(因为它们都是空类,不需要区分),具体行为取决于编译器实现:
struct A {
[[no_unique_address]] Empty e1;
[[no_unique_address]] Empty e2;
int x;
};
A a;
// &a.e1 == &a.e2 可能为 true!(GCC 15.2.1 中不一定,但第一个空成员可能与后续非空成员共享地址)验证:在 GCC 15.2.1 上测试,多个
[[no_unique_address]]空成员不一定共享相同地址,但第一个空成员的地址可能与后续非空成员相同。sizeof的优化效果是确定且显著的。
如果你需要取这些成员的地址或用引用指向它们,请格外小心——它们的地址可能相同。此外,这个属性只对空类有效。如果类有数据成员,加了也没效果:
struct NotEmpty { int data; };
struct Test {
[[no_unique_address]] NotEmpty e; // e 仍然占 sizeof(int)
int x;
};
static_assert(sizeof(Test) == 2 * sizeof(int));另外,MSVC 在某些版本中对 [[no_unique_address]] 的支持存在 bug——即使空类也可能不被优化。这在跨平台项目中需要特别注意,建议在目标平台上验证 sizeof 的结果。
[[assume]](C++23):编译器假设
语义
C++23 引入的 [[assume(expression)]] 告诉编译器"请假设 expression 为真",编译器可以基于这个假设做更激进的优化。如果运行时 expression 实际为假,行为是未定义的。
这与 assert 不同。assert 在运行时检查条件,失败则终止程序;[[assume]] 完全不做运行时检查,只是让编译器放心大胆地优化。
示例
int divide(int a, int b) {
[[assume(b != 0)]];
return a / b;
}在这个例子中,编译器理论上可以省去除零检查的代码路径,生成更快的除法指令。但如果你传入 b == 0,后果是未定义的——可能崩溃,可能返回垃圾值,可能看起来正常但暗中搞破坏。
验证:在 GCC 15.2.1 的
-O2优化级别下,简单的除法函数无论是否使用[[assume]]都生成了相同的汇编代码。说明对于这种简单场景,编译器已经做了足够的优化。[[assume]]的价值主要体现在更复杂的场景中,此时编译器无法通过静态分析推断出不变量。
与 __builtin_assume 的对比
在 [[assume]] 之前,MSVC 用 __assume,GCC 用 __builtin_assume(虽然 GCC 更常用的方式是 if (cond) __builtin_unreachable()):
// MSVC
__assume(b != 0);
// GCC
if (b == 0) __builtin_unreachable();
// C++23 标准写法
[[assume(b != 0)]];使用场景
[[assume]] 的典型使用场景是:你对运行时的某些条件有确定性的知识,但编译器无法通过静态分析推断出来。比如你知道一个数组访问永远不会越界,或者你知道某个指针永远不为 null:
void process_array(int* data, std::size_t size) {
[[assume(data != nullptr)]];
[[assume(size > 0)]];
for (std::size_t i = 0; i < size; ++i) {
// 编译器可以省略 null 检查和越界检查
data[i] *= 2;
}
}⚠️ 警告:[[assume]] 是所有属性中最危险的。如果你的假设是错的,程序的行为完全不可预测。笔者建议只在经过充分 profiling、确认瓶颈、并且你能 100% 保证条件总是成立的情况下使用它。在 99% 的代码中,你不需要它。
C++20 [[nodiscard]] 增强
上一章已经提到,C++20 为 [[nodiscard]] 添加了自定义消息的能力。这里做一点补充说明。
标准库中的 nodiscard 扩展
C++20 还扩展了标准库中 [[nodiscard]] 的应用范围。以下标准库函数标记了 [[nodiscard]]:
std::vector::empty()(C++20 起)std::string::empty()(C++20 起)
验证:在 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()),C++20 编译器会发出警告。以前这是一个常见的 bug 来源——empty() 看起来像是"清空",实际上是"判空"。加了 [[nodiscard]] 之后,误用的代码至少会有警告提醒。
std::vector<int> vec = {1, 2, 3};
// C++20 之前:不检查返回值,静默通过
vec.empty(); // 看起来像是清空操作,实际上什么都没做
// C++20:编译器发出 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_lock<std::mutex> acquire_lock();与 C++11-17 属性的对比
把 C++11-17 的属性和 C++20-23 的新属性放在一起对比,能看到一条清晰的发展脉络:早期属性关注代码正确性和可维护性,后期属性更关注性能优化。
| 属性 | 版本 | 关注点 | 风险 |
|---|---|---|---|
[[noreturn]] | C++11 | 正确性 | 低 |
[[carries_dependency]] | C++11 | 性能 | 低 |
[[deprecated]] | C++14 | 可维护性 | 低 |
[[nodiscard]] | C++17 | 正确性 | 低 |
[[fallthrough]] | C++17 | 正确性 | 低 |
[[maybe_unused]] | C++17 | 可读性 | 低 |
[[likely]]/[[unlikely]] | C++20 | 性能 | 低 |
[[no_unique_address]] | C++20 | 性能 | 低 |
[[assume]] | C++23 | 性能 | 高 |
其中只有 [[assume]] 是真正"危险"的属性——如果假设错误,后果是未定义行为。其他属性即使"提示"错了,最坏情况也只是性能略差,不会导致程序崩溃。
性能影响实测建议
对于 [[likely]]/[[unlikely]] 和 [[assume]] 这类性能导向的属性,笔者的建议是:加了之后一定要实测。优化效果高度依赖具体的硬件、编译器和代码上下文。有些场景收益明显,有些场景完全看不出差异。
测试方法可以是简单的:用 perf stat 或 valgrind --tool=cachegrind 对比加属性前后的指令数、分支预测失败率和缓存命中率。如果数据没有显著改善,就不值得加——因为属性会增加代码的"信息密度",让读者多理解一个概念。
对于 [[no_unique_address]],验证更直接——直接看 sizeof 的结果就好。如果空策略类确实不占空间,说明属性生效了。
小结
C++20-23 新增的属性把编译器提示的能力从"找 bug"扩展到了"做优化"。[[likely]] 和 [[unlikely]] 帮编译器做分支预测,[[no_unique_address]] 消除空类成员的内存浪费,[[assume]] 让编译器基于确定性假设做更激进的优化。
三个属性的风险不同。[[no_unique_address]] 基本无害——最坏情况就是优化没生效,sizeof 不变。[[likely]]/[[unlikely]] 风险也不高——最坏情况是分支预测提示错误,性能略差。[[assume]] 是唯一真正危险的属性——假设错误会导致未定义行为,必须谨慎使用。
在实践中,[[no_unique_address]] 在泛型代码中几乎可以无脑用(策略类模式),[[likely]]/[[unlikely]] 建议在 profiling 确认热点后再加,[[assume]] 只在极端性能敏感的场景中使用,并且一定要有对应的断言或测试来保证假设总是成立。