在 C++20 搭好范围库与字符串格式化的大框架之后,C++23 标准重点围绕日常开发体验做了一次实用补齐。
对于初学的小伙伴来说,现阶段先把精力放在前面的 C++17 和 C++20 主线语法上即可。C++23 新增的工具都很直观,我们先了解它们的常见用法,以后阅读新项目代码时能够顺利看懂就可以了。
C++23 新特性概览
在 C++23 中,标准库补上了直接格式化打印、带错误原因的返回值包装以及视图转容器等省事工具,同时也简化了编译期分支与成员函数写法。常用的代表性特性如下表所示。
| 代表特性 | 所属类别 / 头文件 | 主要变化与作用 |
|---|---|---|
| std::print() / std::println() | <print> | 将 {} 占位符格式化与控制台输出合二为一,省去手写 std::cout << |
| std::expected | <expected> | 在返回值中同时表达 “正常结果 T” 或 “错误原因 E”,替代错误码约定 |
| std::ranges::to() | <ranges> | 将管道过滤或转换后的范围视图直接收集为 vector 等真实容器 |
| std::mdspan | <mdspan> | 提供多维连续数组的轻量观察视图,适合矩阵与科学计算场景 |
| if consteval | 语言特性 | 专门判断当前代码是否处于编译期常量求值环境中 |
| 显式对象参数(Deducing this) | 语言特性 | 支持在成员函数形参列表中通过 this 显式声明调用对象本身 |
直接格式化输出:std::print()
在 C++20 中,我们可以使用 std::format() 函数配合花括号({})占位符拼接字符串,但拼接完之后往往还得再写一遍 std::cout << 才能显示到屏幕上。
到了 C++23,标准库在 <print> 头文件中新增了 std::print() 和 std::println() 两个函数,把 “文本格式化” 与 “控制台输出” 合并成了一步。
语法:
#include <print>
std::print("格式字符串", 参数1, 参数2, ...);
std::println("格式字符串", 参数1, 参数2, ...);说明:
- std::print():按照
{}占位符将格式化后的文本直接输出到控制台,末尾不会自动换行。 - std::println():在完成格式化输出后,自动在末尾追加一个换行符,相当于自带了
'\n'。
示例 1:直接格式化输出文本
#include <print>
#include <string>
int main() {
std::string name = "Jack";
int score = 95;
// 格式化输出并自动换行
std::println("姓名:{},成绩:{}", name, score);
// 末尾不自动换行
std::print("正在处理...");
std::println("完成");
return 0;
}运行结果如下。
姓名:Jack,成绩:95
正在处理...完成分析:
在这个例子中,我们调用 std::println() 直接把 name 和 score 按格式打印到了屏幕上,省去了先生成临时字符串再交给 std::cout 的步骤。下方的 std::print("正在处理..."); 输出后不换行,紧跟着打印的 "完成" 就会接在同一行末尾。
带原因的错误处理:std::expected
当我们编写整数除法或数据解析这类可能失败的函数时,如果使用 std::optional,返回空状态 std::nullopt 只能表示 “操作失败”,调用方无法得知具体的失败原因。
为了补上这块短板,C++23 在 <expected> 头文件中引入了 std::expected<T, E> 模板类型,用来同时承载正常结果或错误信息。
语法:
#include <expected>
std::expected<正常结果类型, 错误信息类型>说明:
std::expected<T, E> 就像一个二选一的结果包装盒,每次返回时只会处于以下 2 种状态之一:
- 操作成功:内部保存着类型为
T的正常结果,我们可以通过.value()或解引用运算符(*)取出结果。 - 操作失败:通过
std::unexpected(错误信息)返回类型为E的错误对象,调用方可以通过.error()读取具体的失败原因。
示例 2:返回计算结果或错误原因
#include <expected>
#include <iostream>
#include <string>
// 成功返回商,失败返回错误原因
std::expected<int, std::string> safeDivide(int a, int b) {
if (b == 0) {
return std::unexpected("除数不能为 0");
}
return a / b;
}
int main() {
auto res1 = safeDivide(20, 4);
if (res1.has_value()) {
std::cout << "计算成功:" << res1.value() << '\n';
}
auto res2 = safeDivide(20, 0);
if (!res2.has_value()) {
std::cout << "计算失败:" << res2.error() << '\n';
}
return 0;
}运行结果如下。
计算成功:5
计算失败:除数不能为 0分析:
在上面例子中,safeDivide() 的返回类型为 std::expected<int, std::string>。当我们传入 20 和 4 时,函数正常返回整数 5。当我们传入 20 和 0 时,函数通过 std::unexpected("除数不能为 0") 包装错误文本,外层调用 res2.error() 就能直接拿到失败原因。
在实际开发中,这两者的选型标准很清晰:如果失败原因只有一种,比如 “没查到记录”,使用 std::optional<T> 就够用了。如果需要把具体的错误原因传回给调用方,同时又不想抛出异常,那么使用 std::expected<T, E> 更合适。
视图转容器:std::ranges::to()
在 C++20 中,当我们通过范围库的 std::views::filter 或 std::views::transform 处理数据时,得到的是按需计算的 “视图”,而不是真正的容器。如果想把处理后的结果保存下来反复使用,以前还得手动创建空容器再逐个拷贝。
到了 C++23,<ranges> 头文件新增了 std::ranges::to<容器>() 工具,支持在管道末尾直接把筛选、转换后的结果收集为 std::vector 等真实容器。请看下面例子。
示例 3:将视图直接收集为 vector
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> nums = {1, 2, 3, 4, 5, 6};
// 筛选偶数翻倍后直接转为 vector
auto evenDoubled = nums
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * 2; })
| std::ranges::to<std::vector>();
std::cout << "偶数的 2 倍:";
for (int n : evenDoubled) {
std::cout << n << ' ';
}
std::cout << '\n';
return 0;
}运行结果如下。
偶数的 2 倍:4 8 12分析:
在这个例子中,整条竖线管道按照从左往右的顺序依次处理数据:先从 nums 中通过 views::filter 挑出偶数 2, 4, 6,再通过 views::transform 将每个偶数乘以 2 得到 4, 8, 12。
最后一步的 std::ranges::to<std::vector>() 会自动遍历前面的结果,并根据元素类型推导出 std::vector<int>,注意末尾的一对空圆括号 () 不能省略。这样我们连 <int> 都不用手动书写,就能一步拿到完整的容器对象 evenDoubled。
if consteval 与显式对象参数
除了标准库工具,C++23 还在编译期求值与类成员函数写法上补充了两项实用的语法改进。
1. 编译期求值分支(if consteval)
在 C++ 中,使用 constexpr 修饰的函数既可以在编译阶段求值,也可以在程序运行时像普通函数一样被调用。如果我们希望函数在这两种情况下执行不同的代码逻辑,就可以使用 C++23 新增的 if consteval 分支。
示例 4:区分编译期与运行期调用
#include <iostream>
constexpr int compute(int n) {
if consteval {
return n * 10; // 编译期求值分支
} else {
return n * 20; // 运行期调用分支
}
}
int main() {
constexpr int a = compute(5); // 触发编译期求值
int x = 5;
int b = compute(x); // 触发运行期调用
std::cout << "编译期结果:" << a << '\n';
std::cout << "运行期结果:" << b << '\n';
return 0;
}运行结果如下。
编译期结果:50
运行期结果:100分析:
constexpr int a = compute(5); 要求在编译阶段算出常量结果,因此命中 if consteval 分支,返回 50。而 int b = compute(x); 传入的是普通运行期变量 x,因此走 else 分支,返回 100。
小伙伴们要注意它与 C++17 if constexpr 的写法区别:if consteval 后面不需要写圆括号条件,直接紧跟花括号 {} 即可。
2. 显式对象参数(Deducing this)
在传统的类成员函数中,this 指针是隐式存在的。但在编写返回成员引用的函数时,为了让普通对象拿到可修改引用、让 const 只读对象拿到只读引用,以前我们往往得把逻辑相同的函数重复写两遍。
在 C++23 中,我们可以在成员函数的第 1 个形参位置加上 this 关键字,把调用该函数的对象本身显式声明为引用参数。配合 auto& 自动推导后,写一份函数就能同时适配普通对象与 const 对象。下面我们来看一个例子。
示例 5:显式声明调用对象参数
#include <iostream>
#include <string>
struct User {
std::string name;
// 显式声明调用对象 self
auto& getName(this auto& self) {
return self.name;
}
};
int main() {
User u1 = {"Jack"};
std::string& ref1 = u1.getName(); // 返回可修改引用
ref1 = "Lucy";
const User u2 = {"Tom"};
const std::string& ref2 = u2.getName(); // 返回只读引用
std::cout << "u1 姓名:" << u1.name << '\n';
std::cout << "u2 姓名:" << ref2 << '\n';
return 0;
}运行结果如下。
u1 姓名:Lucy
u2 姓名:Tom分析:
在这个例子中,auto& getName(this auto& self) 把调用对象显式声明为第 1 个引用形参 self。注意这里的 self 是对象引用而不是指针,因此函数体内直接通过点号写 self.name(不用写 this->name),外部调用时依然正常写 u1.getName()。
当普通对象 u1 调用时,self 自动推导为 User&,返回的 ref1 可以直接修改成员。当只读对象 u2 调用时,self 则推导为 const User&,返回安全的只读引用 ref2。对于这个例子来说,下面 2 种方式是等价的。
// 方式 1(推荐)
struct User {
std::string name;
auto& getName(this auto& self) {
return self.name;
}
};
// 方式 2
struct User {
std::string name;
std::string& getName() {
return name;
}
const std::string& getName() const {
return name;
}
};对比可以看出,以前的方式 2 必须分别编写普通成员函数和 const 成员函数两个重载版本,而使用 C++23 的方式 1 一份代码就兼顾了两种场景。
提示 如果小伙伴们想在本地编译运行 C++23 的新特性,建议使用 GCC 14、Clang 18 或 MSVC 2022(17.6 及以上)编译器,并在编译命令中加上 -std=c++23 参数。
