C++20 日用特性:<=>、std::format 与 Attributes
C++20 日用特性:<=>、std::format 与 Attributes
C++20 的四大支柱(Concepts / Ranges / Coroutines / 线程库)获得了绝大多数关注,但还有一组随时随地在影响日常编码的项目:三向比较 <=>(spaceship operator)彻底简化了比较运算符、"类型安全 printf" 的 std::format / std::print 终结了 iostream 的冗长语法,以及一组准确标注开发者意图的属性(attributes)。
这组东西不会改变你写 C++ 的方式,但会改变你打字时的恼怒程度。
1. <=> (Spaceship Operator):告别手写比较
1.1 问题
在任何有多个成员的类上实现 ==、!=、<、<=、>、>= 六个运算符是件重复且易出错的事:
struct Person {
std::string name;
int age;
// 需要写 6 个运算符,且每个都序次比较两个成员
bool operator<(const Person& o) const {
return std::tie(name, age) < std::tie(o.name, o.age);
}
// ... 等等
};1.2 答案:default <=>
struct Person {
std::string name;
int age;
auto operator<=>(const Person&) const = default;
// 全部 6 个比较运算符自动生成,按成员声明顺序逐个比较
};= default 的三向比较生成的是字典序比较——先按 name 比,相等再按 age 比。
1.3 <=> 的返回类型
三向比较不返回 int,而是返回一个"序关系"类型:
// <=> 表达三种可能的关系,通过特殊的返回类型实现:
// std::strong_ordering —— 严格全序(< = >),可替代的关系
// std::weak_ordering —— 弱全序(< = >),但等效值不可区分
// std::partial_ordering —— 偏序(< = > 或不可比较)
auto r1 = (5 <=> 10); // r1 类型:std::strong_ordering::less
auto r2 = (5.0 <=> NAN); // r2 类型:std::partial_ordering::unordered你不需要知道具体类型——编译器会在 if (a < b) 时自动把比较结果重写为对 <=> 的调用。
1.4 比较重写规则
C++20 引入了对称重写:如果 a == b 找不到匹配的 operator==,编译器会尝试 operator==(b, a)(交换参数顺序)。
同样地,对于 a < b,编译器会尝试 (a <=> b) < 0。这就是 <=> 得名的由来——你定义一个 <=>,编译器帮你算出所有六个比较。
1.5 = default 的规则
struct Widget {
int id;
std::string label;
bool operator==(const Widget&) const = default; // C++20
auto operator<=>(const Widget&) const = default; // C++20
};= default的<=>按成员声明顺序做字典序比较,从第一个到最后一个= default的==对所有成员做相等比较- 两者是独立的——你可以只
default其中一项 - 如果自己定义了
<=>,==不会自动生成(反之亦然)
1.6 值与引用的陷阱
struct Value {
std::vector<int> data;
auto operator<=>(const Value&) const = default; // O(n) 比较整个 vector
};
struct Ref {
const std::vector<int>& data;
auto operator<=>(const Ref&) const = default; // 比较引用的地址,不是 vector 内容!
};default <=> 对引用成员比较的是引用本身(即地址),不是被引用对象的内容。这是一个微妙的 bug 来源。
2. std print:类型安全的格式化
2.1 iostream 的终老
// C++98 的方式——啰嗦、无类型安全、不能用 constexpr
std::cout << "Hello, " << name << "! You have " << count << " messages.\n";
// C 的方式——类型不安全、不能扩展自定义类型
printf("Hello, %s! You have %d messages.\n", name.c_str(), count);std::format (C++20) 和 std::print (C++23) 统一了二者:
// C++20——类型安全 + 简洁 + constexpr 可用
auto msg = std::format("Hello, {}! You have {} messages.", name, count);
// C++23——直接输出
std::println("Hello, {}! You have {} messages.", name, count);2.2 格式化说明符
// 对齐、填充
std::format("{:>10}", 42); // " 42" 右对齐
std::format("{:*<10}", 42); // "42********" 左对齐,* 填充
std::format("{:^10}", 42); // " 42 " 居中
// 浮点精度
std::format("{:.2f}", 3.14159); // "3.14"
std::format("{:.2e}", 3.14159); // "3.14e+00"
// 整数基数
std::format("{:#x}", 255); // "0xff"
std::format("{:b}", 255); // "11111111"
// 动态宽度和精度
std::format("{:{}}", 42, 10); // " 42"(右对齐,宽度 10)2.3 编译期格式检查 (C++23)
// C++23:格式字符串在编译期验证!
std::format("{:d}", 42); // 编译错误!整数没有 :d 格式说明
std::format("{:.2f}", "hello"); // 编译错误!字符串不能用浮点格式2.4 自定义类型的 formatter
struct Point { int x, y; };
template<>
struct std::formatter<Point> {
constexpr auto parse(std::format_parse_context& ctx) {
return ctx.begin(); // 无自定义格式
}
auto format(const Point& p, std::format_context& ctx) const {
return std::format_to(ctx.out(), "({}, {})", p.x, p.y);
}
};
auto s = std::format("point: {}", Point{3, 4}); // "point: (3, 4)"2.5 性能
std::format 通常比 iostream 快,因为它避免了 iostream 的 locale 开销和多次 operator<< 调用链。它也避免了 sprintf 的参数类型解析(va_arg)开销。在格式化密集的代码中(日志、序列化),迁移到 std::format 通常会看到可测量的性能改善。
3. Attributes:向编译器和读者说话
C++20 新增了几个影响代码正确性和优化的属性。
3.1 [[nodiscard]]:忽略返回值即错 (C++17,C++20 增强)
[[nodiscard]] int compute(); // 忽略返回值编译器发出警告
[[nodiscard("memory may leak")]] // C++20:可以带信息字符串
void* allocate(size_t);
compute(); // 警告:nodiscard 返回值被忽略
void* p = allocate(1024); // OKC++20 让 [[nodiscard]] 可以在构造函数的返回类型之前使用,且可以应用于 lambda:
auto important = [] [[nodiscard]] (int x) { return x * 2; };3.2 [[likely]] / [[unlikely]]:分支预测提示
if (ptr == nullptr) [[unlikely]] {
handle_error();
} else [[likely]] {
process(*ptr);
}这告诉编译器:else 分支更可能被执行。编译器会在代码布局(code layout)上做优化——把 likely 路径放在连续的指令缓存行中,降低分支预测失败的代价。它不会改变程序的语义,只影响生成的机器码布局。
只能用于 if/else 或 switch 的 case/default 标签,不能用于一般性表达式。
3.3 [[no_unique_address]]:空基类优化
struct Empty {};
struct WithAddress {
int value;
[[no_unique_address]] Empty tag;
};
// sizeof(WithAddress) == sizeof(int) —— 而不是 sizeof(int) + 1没有 [[no_unique_address]],标准要求每个非静态数据成员都有唯一的地址,即使它是空类。这意味着空类成员至少占 1 字节(加对齐填充)。[[no_unique_address]] 告诉编译器:这个成员不需要独享地址——可以做空基类优化。
对于模板库(尤其是 policy-based design),这个属性可以直接缩小运行时对象的大小。
3.4 [[assume(expr)]] (C++23)
int div(int a, int b) {
[[assume(b != 0)]];
return a / b;
}[[assume]] 向编译器承诺某个条件一定成立。如果条件不成立,行为是未定义的——这与 assert 根本不同。assert 在运行时检查,失败时终止程序;[[assume]] 是编译期的优化提示,允许编译器基于假设生成更激进的代码。用它就像在说:"我用人头担保这个条件永远不会为假,你可以大胆优化。"
4. 少量但高影响
这三个特性有一个共性:学习成本极低,但每次用到都为代码质量做正向贡献。
| 特性 | 每次用到你省了什么 |
|---|---|
auto operator<=>(const&) = default | 省 15-40 行重复的比较运算符 boilerplate |
std::format("{}", x) | 省类型不匹配的 bug + 更快的格式化 |
[[nodiscard]] | 省一个"忘记检查返回值"的生产事故 |
[[likely]] / [[unlikely]] | 省几个时钟周期的分支预测失误 |
总结
三向比较让你用一行 = default 替代六个函数的重复写法,编译器还会帮你做对称重写和表达式翻转。std::format 解决了 C++ 格式化领域三十年的分裂——类型安全、constexpr 可用、比 iostream 快、比 printf 安全。Attribute 们则为编译器优化提供了更多的"意图信号"。
它们都不是改变范式的大特性。但它们是你每一次打开编辑器都会用到的东西。