C++11 是革命,C++17 是打磨,C++20 是让 C++ 终于像一门「现代语言」。
写在前面
如果说 C++17的升级是务实的,那 C++20 就是一次结构性的飞跃。协程、Concepts、Ranges、Modules------每一个都是重量级特性。但老实说,截至 2026 年,并非所有特性都已经在生产环境中稳定好用。
这篇文章从我在游戏服务器和 Hical 框架开发中的实际使用出发,聊聊哪些 C++20 特性已经值得用 、哪些还需要等等。
一、Concepts ------ 模板错误信息终于能看懂了
1.1 C++20 之前的模板报错
先感受一下 C++17 时代的"恐怖":
cpp
std::list<int> lst;
std::sort(lst.begin(), lst.end());
GCC 会喷出几十行模板展开错误,核心意思是 std::list::iterator 不是随机访问迭代器------但你得从一堆 __normal_iterator、__gnu_cxx 嵌套模板中自己悟出来。
1.2 Concepts:把约束说人话
cpp
template <std::random_access_iterator Iter>
void mySort(Iter first, Iter last) {
// ...
}
std::list<int> lst;
mySort(lst.begin(), lst.end());
// 错误信息:约束 'random_access_iterator' 不满足
// 一行,清清楚楚
Concepts 的本质:给模板参数加上编译期的「类型契约」。SFINAE 能做的它都能做,但写法是人能读懂的。
1.3 自定义 Concept
在 Hical 框架中,我用 Concepts 约束后端存储接口:
cpp
template <typename T>
concept BackendStore = requires(T store, const std::string& key, const std::string& value) {
{ store.get(key) } -> std::convertible_to<std::optional<std::string>>;
{ store.set(key, value) } -> std::same_as<bool>;
{ store.del(key) } -> std::same_as<bool>;
{ store.exists(key) } -> std::same_as<bool>;
};
template <BackendStore Store>
class CacheLayer {
Store store_;
public:
// 只要满足 BackendStore 约束,Redis/LevelDB/内存Map 都能接入
};
比起纯虚基类的运行时多态,Concepts 是零开销的编译期多态。不满足约束时,编译器直接告诉你缺了哪个方法,而不是在实例化深处爆炸。
1.4 简写函数模板(Abbreviated Function Templates)
cpp
// C++17:template <typename T> void process(T&& val)
// C++20:auto 参数即模板
void process(auto&& val) {
// val 是转发引用,T 由编译器推导
}
// 配合 Concepts
void sendPacket(std::integral auto msgId, const auto& body) {
// msgId 必须是整数类型,body 可以是任意类型
}
这让很多简单的泛型函数不再需要写 template<> 头了,代码量明显减少。
二、协程(Coroutines)------ 游戏服务器的杀手级特性
2.1 为什么协程对服务器开发如此重要
游戏服务器的核心矛盾:大量 I/O 操作(网络、数据库),但回调地狱让代码无法维护。
C++20 之前的异步写法:
cpp
// 回调地狱:读请求 → 查数据库 → 写响应,每一步都是回调嵌套
void handleLogin(Socket& sock) {
sock.asyncRead([&](Buffer buf) {
auto req = parseLogin(buf);
db.asyncQuery("SELECT ...", [&](Result result) {
auto resp = buildResponse(result);
sock.asyncWrite(resp, [&](Error ec) {
if (ec) LOG_ERROR << "写响应失败";
});
});
});
}
C++20 协程:
cpp
Awaitable<void> handleLogin(Socket& sock) {
auto buf = co_await sock.asyncRead();
auto req = parseLogin(buf);
auto result = co_await db.asyncQuery("SELECT ...");
auto resp = buildResponse(result);
co_await sock.asyncWrite(resp);
}
同步的写法,异步的性能。代码逻辑一目了然,异常处理也能用 try-catch,调试时调用栈是完整的。
2.2 Hical 框架中的协程实践
Hical 的 HTTP 处理全部基于协程:
cpp
Awaitable<void> HttpServer::handleSession(tcp::socket socket) {
auto requestPool = MemoryPool::instance().createRequestPool();
beast::basic_flat_buffer buffer(std::pmr::polymorphic_allocator<std::byte>(requestPool.get()));
for (;;) {
http::request<http::string_body> req;
co_await http::async_read(socket, buffer, req, use_awaitable);
auto response = co_await router_.dispatch(req);
co_await http::async_write(socket, response, use_awaitable);
if (req.need_eof()) break;
}
}
每个连接一个协程,挂起时不占线程,线程可以去服务其他连接。相比传统的一连接一线程模型,协程让 少量线程就能处理数万并发连接。
2.3 协程的坑:比你想象的多
坑 1:协程的生命周期管理
cpp
Awaitable<void> danger() {
std::string localData = "important";
auto& ref = localData;
co_await someAsyncOp();
// 如果协程被取消或移动,ref 可能已经悬垂
// 协程帧在堆上,局部变量的地址在 co_await 前后不变,但要注意外部引用
}
坑 2:co_await 是挂起点,之后的线程可能变了
cpp
Awaitable<void> mayChangeThread() {
// 此刻在线程 A
LOG_DEBUG << "线程: " << std::this_thread::get_id();
co_await someAsyncOp();
// 此刻可能在线程 B!
// 如果你持有 thread_local 的引用,这里就出问题了
LOG_DEBUG << "线程: " << std::this_thread::get_id();
}
坑 3:异常安全
协程中未捕获的异常会存储在 promise 对象中,如果没有人 co_await 或检查结果,异常就被静默吞掉了。Hical 框架中,我在最外层的 handleSession 统一 try-catch,确保不遗漏:
cpp
Awaitable<void> HttpServer::handleSession(tcp::socket socket) {
try {
// ... 所有请求处理逻辑 ...
} catch (const std::exception& e) {
LOG_ERROR << "会话异常: " << e.what();
}
// 协程正常结束,连接自动关闭
}
2.4 实话:C++20 协程是半成品
C++20 标准只定义了协程的底层机制 (co_await、co_yield、co_return),不提供现成的异步框架。你需要:
- 自己实现
promise_type(或依赖 Boost.Asio/cppcoro) - 自己处理调度、取消、超时
- 自己解决协程和线程池的交互
这就是为什么 Hical 框架基于 Boost.Asio 的 awaitable<>------自己从零写一个协程调度器性价比太低。
三、Ranges ------ 数据处理的管道化
3.1 告别裸迭代器
C++20 之前,STL 算法操作的是迭代器对。Ranges 让你直接操作容器,并支持管道式组合:
cpp
#include <ranges>
#include <algorithm>
std::vector<Player> players = getAllPlayers();
// C++17:分步操作,中间容器
std::vector<Player> alive;
std::copy_if(players.begin(), players.end(), std::back_inserter(alive),
[](const Player& p) { return p.hp > 0; });
std::sort(alive.begin(), alive.end(),
[](const Player& a, const Player& b) { return a.level > b.level; });
// C++20 Ranges:管道式,惰性求值,零中间容器
auto topAlive = players
| std::views::filter([](const Player& p) { return p.hp > 0; })
| std::views::transform([](const Player& p) -> std::string {
return std::format("{}(Lv.{})", p.name, p.level);
})
| std::views::take(10);
for (auto& name : topAlive) {
LOG_INFO << name;
}
惰性求值 意味着 filter 和 transform 不会创建临时容器,遍历时按需计算。
3.2 实战:日志过滤
cpp
// 从环形缓冲区中取最近的错误日志
auto recentErrors = logBuffer
| std::views::reverse
| std::views::filter([](const LogEntry& e) { return e.level >= LogLevel::ERROR; })
| std::views::take(20);
如果用传统写法,你需要倒着遍历、手动计数、提前 break------Ranges 一行搞定。
3.3 当前的遗憾
- 编译时间:Ranges 的模板深度惊人,复杂管道会显著增加编译时间
- 调试困难:中间类型是一长串嵌套模板,断点调试时几乎不可读
- 生态不完整:C++20 只有 views,缺少 actions(原地修改),C++23 补了一些但还不够
我的实践原则:简单的 filter/transform/take 用 Ranges,复杂逻辑还是写普通循环。
四、std::format ------ 终结 printf vs iostream 之争
4.1 类型安全 + 高性能 + 可读
cpp
// printf:快但不安全,类型错了就是 UB
printf("玩家 %s 等级 %d 金币 %lld\n", name, level, gold);
// iostream:安全但语法冗长
std::cout << "玩家 " << name << " 等级 " << level << " 金币 " << gold << "\n";
// std::format:两者的优点合一
auto msg = std::format("玩家 {} 等级 {} 金币 {}", name, level, gold);
4.2 格式化细节控制
cpp
// 十六进制显示协议ID
std::format("收到协议 0x{:04X}", msgId); // "收到协议 0x00A3"
// 浮点精度
std::format("帧率: {:.1f} FPS", fps); // "帧率: 59.8 FPS"
// 对齐填充:日志表格对齐
std::format("{:<20} {:>8} {:>8}", name, level, hp);
// "张三 85 12000"
// 自定义类型(C++20 需要特化 std::formatter)
template <>
struct std::formatter<Vector3> : std::formatter<string> {
auto format(const Vector3& v, format_context& ctx) const {
return std::format_to(ctx.out(), "({:.2f}, {:.2f}, {:.2f})", v.x, v.y, v.z);
}
};
std::format("玩家位置: {}", playerPos); // "玩家位置: (100.50, 0.00, 200.30)"
4.3 性能对比
在我的测试中(格式化 100 万条日志):
c
printf : 180ms (不安全)
std::format : 210ms (类型安全)
iostream : 450ms (安全但慢)
fmt::format : 195ms (第三方库 fmtlib)
std::format 基本就是标准化的 fmtlib,性能接近 printf,安全性和 iostream 持平。没有理由不用它。
五、三路比较运算符(<=>)------ 一行替六个
5.1 六个运算符的痛
C++20 之前,要让一个类型支持完整的比较,需要写六个运算符(==、!=、<、<=、>、>=)。对于排行榜、优先队列等场景,这简直是苦力活:
cpp
// C++17:手写六个
bool operator==(const Player& a, const Player& b) { return a.score == b.score; }
bool operator!=(const Player& a, const Player& b) { return !(a == b); }
bool operator< (const Player& a, const Player& b) { return a.score < b.score; }
bool operator<=(const Player& a, const Player& b) { return !(b < a); }
bool operator> (const Player& a, const Player& b) { return b < a; }
bool operator>=(const Player& a, const Player& b) { return !(a < b); }
5.2 C++20:一行搞定
cpp
struct RankEntry {
uint64_t playerId;
int64_t score;
int64_t timestamp;
// 先按分数降序,分数相同按时间升序(先达到的排前面)
auto operator<=>(const RankEntry& other) const {
if (auto cmp = other.score <=> score; cmp != 0) return cmp;
return timestamp <=> other.timestamp;
}
bool operator==(const RankEntry&) const = default;
};
// 直接用于排序、set、priority_queue
std::set<RankEntry> leaderboard;
编译器从 <=> 自动生成 <、<=、>、>=,从 == 自动生成 !=。
5.3 三种比较类别
| 类别 | 含义 | 典型场景 |
|---|---|---|
std::strong_ordering |
相等就是完全一样 | 整数、ID |
std::weak_ordering |
等价但不一定相同 | 字符串不区分大小写比较 |
std::partial_ordering |
可能无法比较 | 浮点数(NaN) |
大部分场景用 = default 让编译器按成员逐一比较就够了:
cpp
struct Point {
int x, y, z;
auto operator<=>(const Point&) const = default;
};
六、constexpr 的全面增强
6.1 constexpr 容器和算法
C++20 让 std::vector、std::string 和大部分 STL 算法都支持 constexpr:
cpp
consteval auto buildLookupTable() {
std::array<int, 256> table{};
for (int i = 0; i < 256; ++i) {
table[i] = (i < 'A' || i > 'Z') ? i : i + 32; // 大写转小写
}
return table;
}
// 编译期生成查找表,运行时零开销
constexpr auto kToLowerTable = buildLookupTable();
char toLower(char c) {
return static_cast<char>(kToLowerTable[static_cast<unsigned char>(c)]);
}
6.2 consteval:强制编译期
cpp
// constexpr:可以编译期,也可以运行时
// consteval:必须编译期,否则编译报错
consteval uint32_t compileTimeHash(std::string_view str) {
uint32_t hash = 0;
for (char c : str) {
hash = hash * 31 + static_cast<uint32_t>(c);
}
return hash;
}
// 消息ID在编译期计算,运行时直接用常量
switch (msgHash) {
case compileTimeHash("LoginReq"): handleLogin(buf); break;
case compileTimeHash("MoveReq"): handleMove(buf); break;
case compileTimeHash("ChatReq"): handleChat(buf); break;
}
比宏定义的消息 ID 更安全,比运行时哈希更快。
6.3 constinit:保证初始化顺序
cpp
// 跨翻译单元的全局变量初始化顺序是未定义的(Static Initialization Order Fiasco)
// constinit 保证变量在编译期初始化,避免这个问题
constinit int kServerPort = 8080;
constinit const char* kServerName = "GameServer";
constinit 不意味着 const------变量之后还是可以修改的,只是保证初始化时机。
七、其他实用特性
7.1 std::span:安全的数组视图
cpp
// 类似 string_view 之于 string,span 之于连续内存
void processPacket(std::span<const std::byte> data) {
auto header = data.subspan(0, 4);
auto body = data.subspan(4);
// 零拷贝,带边界信息
}
// 可以从 vector、array、C 数组无缝构造
std::vector<std::byte> buf(1024);
processPacket(buf); // 自动推导 span
processPacket(buf.data(), 512); // 显式指定长度
比传统的 (T* ptr, size_t len) 双参数安全得多,也更符合现代 C++ 风格。
7.2 std::jthread:自动 join 的线程
cpp
// std::thread:忘记 join 就 terminate
// std::jthread:析构时自动 join,支持协作式取消
void workerFunc(std::stop_token stoken) {
while (!stoken.stop_requested()) {
processTask();
}
LOG_INFO << "工作线程收到停止信号,优雅退出";
}
{
std::jthread worker(workerFunc);
// ... 做其他事情 ...
} // 析构时自动请求停止 + 等待线程结束
再也不用担心忘记 join() 导致程序 terminate 了。
7.3 指定初始化(Designated Initializers)
cpp
struct ServerConfig {
std::string host = "0.0.0.0";
int port = 8080;
int threadCount = 4;
int maxConnections = 10000;
bool enableSSL = false;
};
// C++20:按名初始化,可读性极强
auto config = ServerConfig{
.port = 9090,
.threadCount = 8,
.enableSSL = true,
// host 和 maxConnections 使用默认值
};
比起构造函数的位置参数,指定初始化让配置对象的意图一目了然。
7.4 contains() 终于加上了
cpp
// C++17
if (playerMap.find(id) != playerMap.end()) { /* ... */ }
if (str.find("error") != std::string::npos) { /* ... */ }
// C++20
if (playerMap.contains(id)) { /* ... */ }
if (str.contains("error")) { /* ... */ }
等了二十年,终于不用写 != end() 和 != npos 了。
7.5 std::source_location:替代 __FILE__ / __LINE__
cpp
void logError(std::string_view msg,
const std::source_location& loc = std::source_location::current()) {
std::format("[{}:{}] {}: {}",
loc.file_name(), loc.line(), loc.function_name(), msg);
}
logError("连接断开");
// 输出: [HttpServer.cpp:142] handleSession: 连接断开
比宏更安全,能正确处理内联和模板场景。
八、Modules ------ 理想很美好,现实要再等等
8.1 Modules 想解决的问题
C/C++ 的 #include 本质是文本替换------预处理器把头文件内容原样粘贴到每个翻译单元。一个大型项目中,同一个头文件可能被编译几百次。Modules 的目标是编译一次,到处引用:
cpp
// math_utils.cppm(模块接口单元)
export module math_utils;
export int add(int a, int b) { return a + b; }
// main.cpp
import math_utils;
int result = add(1, 2);
理论优势:编译速度大幅提升、无宏污染、更清晰的依赖关系。
8.2 为什么我还没在生产中用
截至 2026 年初,Modules 的现状:
- 构建系统支持不成熟:CMake 对 Modules 的支持还在演进中,复杂项目配置起来很头疼
- 三大编译器实现差异:GCC、Clang、MSVC 的 Modules 行为存在差异,跨平台项目要踩坑
- 第三方库几乎都不提供 Module 版本:Boost、Asio 等核心依赖还是头文件
- IDE 支持参差不齐:代码补全、跳转、重构等功能在 Modules 下体验不一
我的建议:关注进展,但暂时不投入生产项目。等 CMake、主流库和 IDE 都跟上再说。
九、迁移建议
9.1 可以立即用的(低风险高收益)
| 特性 | 改造成本 | 收益 |
|---|---|---|
| Concepts | 低 | 模板报错可读、代码自文档化 |
<=> 三路比较 |
极低 | 减少大量样板代码 |
std::format |
低 | 替代 printf/iostream |
contains() |
极低 | 可读性 |
| 指定初始化 | 极低 | 配置对象可读性 |
std::span |
低 | 替代裸指针+长度 |
constexpr 增强 |
中 | 编译期计算 |
9.2 需要框架支持的(中等投入)
| 特性 | 说明 |
|---|---|
| 协程 | 需要 Boost.Asio 或自建调度器,收益巨大但学习曲线陡 |
| Ranges | 简单管道立刻用,复杂场景等编译器优化跟上 |
std::jthread |
新线程代码直接用,旧代码逐步迁移 |
9.3 建议观望的
| 特性 | 原因 |
|---|---|
| Modules | 构建系统和生态还不成熟 |
std::coroutine_handle 裸用 |
太底层,用 Asio 或 cppcoro 的封装 |
9.4 编译器支持现状
| 编译器 | Concepts | 协程 | Ranges | format | Modules |
|---|---|---|---|---|---|
| GCC 12+ | 完整 | 完整 | 完整 | 13+ | 实验性 |
| Clang 16+ | 完整 | 完整 | 完整 | 17+ | 实验性 |
| MSVC 19.29+ | 完整 | 完整 | 完整 | 完整 | 较好 |
总结
C++20 是继 C++11 之后最重要的一次升级。如果让我选三个最值得投入的 C++20 特性:
- 协程------彻底改变了异步代码的写法,对服务器开发是质变
- Concepts------让模板代码从「只有作者能看懂」变成「新人也能维护」
- std::format------终结了 C++ 字符串格式化的二十年混乱
C++20 的核心哲学:让高级抽象的性能等于手写底层代码。协程的调度开销接近手写状态机,Concepts 的约束检查零运行时成本,Ranges 的惰性求值等价于手写循环。
这才是 C++ 应该有的样子。