升级到 C++20 最省事的做法是先只改一个编译开关、代码一行不动,然后全量编译:编译器会把所有需要你操心的地方列成一张清单。这篇就是那张清单的解读版:每条改动配一个在 gcc 13.2.0 上真跑出来的报错原文和对应的修法,照着改就行。
1. 引子:改完开关的第一屏
# 只改这一处,然后全量编译-std=c++17 +std=c++20第一屏大概长这样:
error: invalid conversion from 'const char8_t*' to 'const char*' [-fpermissive] error: 'class std::allocator<int>' has no member named 'address' error: no matching function for call to 'Vec::Vec(int, int)'这三条分别对应char8_t的引入、std::allocator的成员移除、聚合的圆括号初始化:都不是你写错了,是标准变了。
2. 迁移路线图
┌────────────────────────────────────────────────────────────────┐ │ ① 只改编译开关:-std=c++20,代码一行不动,全量编译 │ └───────────────────────────┬────────────────────────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────┐ │ ② 收报错清单,按「改动类别」分类(不是按文件) │ │ · char8_t 相关 · allocator 相关 │ │ · 比较运算符相关 · 已移除设施相关 │ │ · 聚合初始化相关 │ └───────────────────────────┬────────────────────────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────┐ │ ③ 按类批量修:同一个类改完,所有用到它的地方一起验证 │ └───────────────────────────┬────────────────────────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────┐ │ ④ 开 -Wall -Wextra -Wpedantic 复验 │ │ 这一步才抓得到「能编译但行为变了」的问题(如 volatile 弃用)│ └───────────────────────────┬────────────────────────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────┐ │ ⑤ 全绿后再逐步引入新特性(<=> / format / ranges),一次一类 │ └────────────────────────────────────────────────────────────────┘关键点是① 和 ③ 之间不要混:先让编译器把清单交出来,再动手改。一边升开关一边顺手改用<=>,出问题时你分不清是「迁移破坏」还是「新特性用错」。
3. 第一步:改成什么开关
| 工具链 | 开关 | 版本门槛 |
|---|---|---|
| gcc | -std=c++20(想要 GNU 扩展用-std=gnu++20) | gcc 10 起有基本支持,gcc 13 起覆盖度已经很高 |
| clang | -std=c++20 | clang 10 起基本支持,clang 16+ 比较完整 |
| MSVC | /std:c++20 | VS 2019 16.11 起;建议再加/Zc:__cplusplus |
| CMake | set(CMAKE_CXX_STANDARD 20)+CMAKE_CXX_STANDARD_REQUIRED ON | CMake 3.12+ |
/Zc:__cplusplus这一项值得单独说:MSVC 默认的__cplusplus宏长期停在199711L,加了这个开关才会报真实值,靠__cplusplus >= 202002L做条件编译的代码才不会走错分支。
// verify: std=c++20// 需要 C++20:这段代码要验证的就是 C++20 模式下 __cplusplus 的真实取值// demo.cpp — 编译: g++ -std=c++20 -Wall -Wextra -Wpedantic demo.cpp -o demo#include<cstdio>intmain(){printf("__cplusplus = %ldL\n",static_cast<long>(__cplusplus));printf("是 C++20 或更新吗? %s\n",__cplusplus>=202002L?"是":"否");#ifdefined(__cpp_concepts)&&__cpp_concepts>=201907Lprintf("Concepts 可用\n");#elseprintf("Concepts 不可用\n");#endif}__cplusplus = 202002L 是 C++20 或更新吗? 是 Concepts 可用比__cplusplus更可靠的是功能测试宏(feature-test macro):__cpp_concepts、__cpp_lib_format、__cpp_lib_ranges……每个特性一个宏,值表示实现级别。判断「某个特性能不能用」时,用宏比用版本号靠谱得多。
官方文档:功能测试宏(cppreference)、编译器支持度总表
4. 会破坏现有代码的改动(重点)
| 改动 | 现象 | 严重度 | 修法难度 |
|---|---|---|---|
char8_t引入 | u8"..."的类型从const char[]变成const char8_t[] | 高(涉及面广) | 低 |
| 比较运算符重写 | 只定义operator==的类现在也能写a != b | 中(行为变化) | 低 |
std::allocator成员移除 | address/max_size/construct/destroy没了 | 中(自定义容器才用) | 低 |
volatile复合运算弃用 | v++、v += x变成警告 | 低(但-Werror下直接失败) | 低 |
| 聚合的圆括号初始化 | Vec v(1, 2)从编译失败变成合法 | 低 | 无(新能力) |
std::atomic默认构造 | 从不初始化变成值初始化(0 / false) | 低 | 低 |
typename的宽松处理 | 少数场景下T::X被当作类型解析 | 低 | 中 |
4.1char8_t:u8"..."换了类型
字面量 u8"abc" 的类型 C++17: u8"abc" → const char[4] (和普通 "abc" 一样是窄字符) const char* p = u8"abc"; 编译通过 C++20: u8"abc" → const char8_t[4] (专门的 UTF-8 码元类型,不是 char) const char* p = u8"abc"; 编译失败 const char8_t* p = u8"abc"; std::u8string s = u8"abc";这就是升级后最常撞的一条。实测 gcc 13.2.0 +-std=c++20的原文:
error: invalid conversion from 'const char8_t*' to 'const char*' [-fpermissive] 3 | const char* legacy = u8"hello"; | ^~~~~~~~~ | | | const char8_t*修法按场景分三种:
- 本来就是 UTF-8 文本(比如日志、JSON 片段)→ 改成
const char8_t*+std::u8string配套使用; - 需要窄字符串接口(
printf、老的 C API)→ 显式reinterpret_cast<const char*>,这是改类型不是改编码,字节内容一个没变; - 根本不需要
u8前缀(源码本身是 UTF-8 且用窄字符串)→ 直接删掉u8,退回普通字面量。
// verify: std=c++20// 需要 C++20:char8_t / std::u8string 是 C++20 新增类型// demo.cpp — 编译: g++ -std=c++20 -Wall -Wextra -Wpedantic demo.cpp -o demo#include<cstdio>#include<string>intmain(){// 升级前写法(C++17 能过,C++20 编译失败):// const char* legacy = u8"你好";constchar8_t*u8_text=u8"你好";// C++20:类型是 const char8_t[]conststd::u8string text=u8_text;// 配套类型 std::u8string// 需要窄字符串时显式「改类型」,字节内容不变conststd::stringnarrow(reinterpret_cast<constchar*>(text.data()),text.size());printf("u8string 字节数 = %zu\n",text.size());printf("窄字符串字节数 = %zu\n",narrow.size());printf("内容 = %s\n",narrow.c_str());}u8string 字节数 = 6 窄字符串字节数 = 6 内容 = 你好注意两个长度都是 6:u8string的size()是字节数不是字符数。char8_t的引入正是为了让「一个字节」和「一个窄字符」在类型上分开,顺手也修掉了u8字面量在模板推导里被当成char的老问题。
官方文档:char8_t 与 UTF-8 字面量、std::basic_string 的类型别名(u8string / u16string / u32string)、char8_t 类型
4.2 比较运算符的重写规则
C++20 给a != b加了「重写候选」:如果找不到operator!=,编译器会尝试!(a == b)(以及反过来的!(b == a))。这就让以前编译失败的代码突然能编译了。实测同一个片段在两个标准下的差别:
// 同一段代码:C++17 下编译失败,C++20 下编译通过structS{booloperator==(constS&)const{returntrue;}};boolcompare(){constS a;constS b;returna!=b;// C++20:重写成 !(a == b) 后编译通过}gcc 13.2.0 +-std=c++17的原文:
<source>: In function 'bool compare()': <source>:8:14: error: no match for 'operator!=' (operand types are 'const S' and 'const S') 8 | return a != b; // C++20:重写成 !(a == b) 后编译通过 | ~ ^~ ~ | | | | | const S | const S同一个文件用-std=c++20编译,直接过。所以升级后必须检查一件事:有没有代码「本来靠编译不过挡着」的误用。比如某个类只想支持==、故意不提供!=,现在这个限制消失了。
真正的坑在这里:无约束的模板operator==会把!=一起吃掉。上一段说的是「限制消失」,这一个说的是「限制消失之后会漏进来什么」。重写规则不是只放宽「!=能不能用」:凡是能被operator==匹配上的类型,都被拖进了比较体系。最典型的写法是这样:
// verify: std=c++20// 反例,不要这么写:无约束的模板 operator== 让任何类型都能和 A 比出「相等」#include<cstdio>structA{intv=0;};template<classT>booloperator==(constA&,constT&){returntrue;}// 任何 T 都返回 trueintmain(){constA a{1};std::printf("a==a:%d a==42:%d a==\"str\":%d\n",a==a,a==42,a=="str");std::printf("a!=42:%d a!=\"str\":%d\n",a!=42,a!="str");}a==a:1 a==42:1 a=="str":1 a!=42:0 a!="str":0a == "str"不但编过了,还返回1。无约束模板本身在 C++17 里也是这个毛病;但 C++20 用改写规则把它扩大到了!=:同一份文件用-std=c++17编译,最后两行直接报错:
<source>:12:48: error: no match for 'operator!=' (operand types are 'const A' and 'int') 12 | std::printf("a!=42:%d a!=\"str\":%d\n", a != 42, a != "str"); | ~ ^~ ~~ | | | | | int | const A <source>:12:57: error: no match for 'operator!=' (operand types are 'const A' and 'const char [4]')C++20 会用!(a == 42)把这两行合成出来,那个「永远返回 true」的模板于是返回!1 = 0:比较运算符从「只错==那一半」变成「错一整片」。迁移时的对策只有一个:给模板加约束,别让它接受任意T。
// 正确做法:用 concept 把允许比较的类型显式列出来,别接受任意 T#include<concepts>template<classT>requiresstd::same_as<T,A>// 只允许和 A 自己比booloperator==(constA&lhs,constT&rhs){returnlhs.v==rhs.v;// 现在 a == "str" 会直接编译失败}关于网上常说的「旧的operator!=会和重写候选撞出ambiguous」:这篇复现不出来,而且这次是跨 5 个 gcc 版本逐项试的。用 3 种写法——成员operator==+ 非成员operator!=、成员operator==+ 非成员operator!=模板、以及「两个类型各定义一半」(一个只给==、另一个只给!=)——在gcc 10.1 / 10.3 / 11.2 / 12.2 / 13.2上共 15 组组合,全部正常编译并跑出结果,编译器每次都选出唯一候选,没有一个报ambiguous。原因是重写候选的优先级规则早被P2468R2修正(非重写候选优先),而 gcc 10 起就已实现,所以这条「坑」在现代 gcc 上基本可以划掉。若换成更老的工具链、或实现还没跟上该修正的编译器,那类代码仍可能报ambiguous(这篇没有在这些工具链上实测)。
4.3std::allocator的成员移除
C++20 删掉了std::allocator的一批成员:address、max_size、construct、destroy,以及带位置提示的allocate(n, hint)。自定义容器/分配器代码最容易撞。实测原文:
error: 'class std::allocator<int>' has no member named 'address' 6 | (void)alloc.address(*p); | ^~~~~~~修法:address换成std::addressof,construct/destroy换成std::allocator_traits<A>::construct/destroy(这也是写容器的推荐写法,它同时兼容其他分配器类型),带 hint 的allocate换成普通的allocate(n)。
官方文档:std::allocator、std::allocator_traits
4.4volatile的复合运算弃用
C++20 弃用了volatile限定类型的复合赋值、自增自减。实测 gcc 13.2.0 +-std=c++20的原文(这一条是警告,但 Wandbox 把警告当失败,所以你在 CI 里用-Werror时它同样是硬错误):
warning: '++' expression of 'volatile'-qualified type is deprecated [-Wvolatile] 3 | int bump(volatile int& v) { return v++; } | ^修法分两种情况:如果这个变量本来就是给别的线程/硬件看的,volatile从根上就用错了:它不提供原子性和内存序,应该换成std::atomic<T>;如果只是「读一次写一次」,写成v = v + 1这类不带复合语义的形式即可(但通常这说明它也不需要volatile)。
官方文档:volatile 限定符、std::atomic
4.5 聚合的圆括号初始化与std::atomic的默认构造
两条都是「以前编译不通过的现在通了」和「以前不确定的现在确定了」,风险低但要知道。
// verify: std=c++20// 需要 C++20:聚合的圆括号初始化(P0960)// demo.cpp — 编译: g++ -std=c++20 -Wall -Wextra -Wpedantic demo.cpp -o demo#include<cstdio>structVec{intx;inty;};intmain(){Vecv(1,2);// C++17:no matching function for call to 'Vec::Vec(int, int)'printf("v = (%d, %d)\n",v.x,v.y);}v = (1, 2)C++17 下的原文(实测):
error: no matching function for call to 'Vec::Vec(int, int)' 3 | int main() { Vec v(1, 2); return v.x + v.y; } | ^ note: candidate: 'Vec::Vec()' note: candidate expects 0 arguments, 2 providedstd::atomic的默认构造则被 P0883 改成了值初始化:
// verify: std=c++20// 需要 C++20:std::atomic 的默认构造会值初始化(P0883)// demo.cpp — 编译: g++ -std=c++20 -Wall -Wextra -Wpedantic demo.cpp -o demo#include<atomic>#include<cstdio>intmain(){std::atomic<int>counter;// C++20:值初始化为 0;C++17 里它是不确定的std::atomic<bool>ready;// 同理,C++20 下是 falseprintf("counter = %d\n",counter.load());printf("ready = %s\n",ready.load()?"true":"false");std::atomic<int>explicit_zero{0};// 显式写上初始值,两个标准下行为一致printf("explicit_zero = %d\n",explicit_zero.load());}counter = 0 ready = false explicit_zero = 0这是变安全的改动:以前std::atomic<int> a;后直接a.load()是读未初始化值,现在确定是 0。代价是默认构造多了一次存储,可以忽略。迁移时反过来要检查的是「有没有代码依赖它不初始化」:理论上不会有。
官方文档:聚合初始化、std::atomic 的构造函数
4.6typename的宽松处理(低风险)
C++20 的 P0634R3 在「语法上必须是类型」的位置(比如基类子句、成员声明、返回类型)不再需要typename。这本身是好事,副作用是:如果某个类里恰好有一个名字叫type的静态成员(值,不是类型),以前靠「必须写typename」才能表达「我要的是类型」,现在编译器可能在那些位置按类型解析,行为和你预期的不一样。
「不再需要typename」这一半是可复现的,返回类型位置上最明显:
// verify: std=c++20// 需要 C++20:P0634R3 之后,返回类型的 dependent name 不必再写 typename#include<cstdio>structWrap{usingtype=int;};template<classT>T::typemake(){return42;}// C++17 要写成 typename T::typeintmain(){std::printf("make<Wrap>() = %d\n",make<Wrap>());}make<Wrap>() = 42同一份文件改用-std=c++17(gcc 13.2.0),报的是那句很眼熟的「需要 typename」:
<source>:4:1: error: need 'typename' before 'T::type' because 'T' is a dependent scope 4 | T::type make() { return 42; } // C++17 要写成 typename T::type | ^ | typename至于副作用那一半(某个X::type的解释被改掉),我没有构造出可复现的最小用例:在 gcc 13.2.0 上试了「名字叫type的静态数据成员被放在声明位置当类型用」等几种写法,C++17 和 C++20 都是编译失败,没有出现「以前能编、升级后解释变了」的情形。所以那一条只作为排查线索保留:升级后如果遇到「某个X::type的解释变了」这类怪现象,先往这条改动上想。
5. 必须手改的四件事
| 要改的地方 | 迁移前 | 迁移后 | 为什么 |
|---|---|---|---|
| 类的相等比较 | 手写operator==+operator!= | bool operator==(const T&) const = default; | 一行生成,语义必然一致 |
| 类的全序比较 | 手写<><=>= | auto operator<=>(const T&) const = default; | 按成员顺序比较,六个运算符一次到位 |
| 字符串字面量类型 | const char* p = u8"..."; | const char8_t* p = u8"...";或去掉u8 | char8_t是独立类型 |
| 枚举比较 | 手写operator!=只为可读性 | 删掉,内置比较就够 | 枚举的内置运算符不受重写规则影响 |
第一条值得展开:既写operator==又写operator<=>时,==不要写成= default的<=>之外的东西。==和<=>的语义必须自洽,否则a != b(走!==)和a < b(走<=>)可能给出矛盾的结论。
// verify: std=c++20// 需要 C++20:operator<=> 与比较运算符重写// demo.cpp — 编译: g++ -std=c++20 -Wall -Wextra -Wpedantic demo.cpp -o demo#include<cstdio>#include<set>#include<string>structUser{std::string name;intage=0;// 迁移前(要手写两个,还容易写歪):// bool operator==(const User& o) const { return name == o.name && age == o.age; }// bool operator!=(const User& o) const { return !(*this == o); }// 迁移后:一行搞定 == != < <= > >=autooperator<=>(constUser&)const=default;};intmain(){constUser a{"miaomiao",30};constUser b{"miaomiao",31};printf("a == b : %s\n",(a==b)?"true":"false");printf("a != b : %s\n",(a!=b)?"true":"false");// 自动用 !operator==printf("a < b : %s\n",(a<b)?"true":"false");// 先比 name,再比 ageconststd::set<User>users{a,b};// 有了 <=> 就能直接进有序容器printf("set 里有 %zu 个人\n",users.size());}a == b : false a != b : true a < b : true set 里有 2 个人注意a != b输出true、a < b也输出true:两个结论自洽(既然a < b,那当然a != b)。如果手写operator!=时写成了name != o.name(漏了 age),就会出现「a != b为 false,但a < b为 true」这种自相矛盾的状态,std::set的行为会直接变诡异。这就是「一行= default」最大的价值。
6. 常见报错对照表(原文均在 gcc 13.2.0 实测采集)
| 报错 / 警告原文 | 原因 | 修法 |
|---|---|---|
error: invalid conversion from 'const char8_t*' to 'const char*' [-fpermissive] | u8"..."的类型在 C++20 变成const char8_t[] | 改类型为const char8_t*/std::u8string;需要窄串就reinterpret_cast<const char*>;不需要u8就删前缀 |
error: 'class std::allocator<int>' has no member named 'address' | std::allocator移除了一批成员 | std::addressof;construct/destroy走std::allocator_traits |
error: no match for 'operator!=' (operand types are 'S' and 'S')(C++17 下的原文) | C++17 不会用operator==合成!= | C++20 下这条会自己消失;反过来要检查是否有代码靠「编译不过」挡误用 |
warning: '++' expression of 'volatile'-qualified type is deprecated [-Wvolatile] | C++20 弃用 volatile 的复合运算 | 需要同步语义就用std::atomic;其他情况去掉volatile |
error: no matching function for call to 'Vec::Vec(int, int)'(C++17 下的原文) | C++17 不支持聚合的圆括号初始化 | C++20 下自动可用;若只想用花括号初始化,保持原样即可 |
error: ambiguous overload for 'operator!='(这里未复现:gcc 10.1 / 10.3 / 11.2 / 12.2 / 13.2 上 3 种写法共 15 组组合全部正常编译,见 §4.2) | 重写候选与手写候选同时可行(P2468R2 之前的实现) | 升级到已实现 P2468R2 的编译器(gcc 10 起已实现);或给类补上显式operator!= |
官方文档:C++20 语言特性支持表、C++20 库特性支持表
7. 迁移完成后:用 C++20 改写哪些老代码收益最大
| 排名 | 改写目标 | 迁移前 | 迁移后 | 收益 |
|---|---|---|---|---|
| 1 | 所有「手写六个比较运算符」的类 | 6 行 × N 个类,容易写歪 | auto operator<=>(const T&) const = default; | bug 消失,代码量骤减 |
| 2 | v.erase(std::remove_if(...), v.end()) | 少写后半段就静默出错 | std::erase_if(v, pred) | 消灭一类经典缺陷 |
| 3 | sprintf/stringstream拼字符串 | 类型不安全、格式串容易错位 | std::format("{} {}", a, b) | 类型安全 + 可读 |
| 4 | (T* data, size_t len)双参数接口 | 长度和指针可以传错序 | std::span<const T> | 接口自解释 |
| 5 | (it, end)双参数的算法调用 | begin()/end()噪音 | std::ranges::sort(v) | 可读性 + 不易传错 |
| 6 | 返回-1/ 空串表示失败的函数 | 调用方不知道要不要判 | 返回std::optional<T> | 签名自解释 |
| 7 | for (i = 0; i < v.size(); ++i) | 手写下标、容易越界 | for (const auto& [k, val] : m) | 结构化绑定 + 范围 for |
| 8 | 模板约束用enable_if | 报错一屏天书 | requires/concepts | 报错可读 |
| 9 | 线程等待用while+ sleep 轮询 | 烧 CPU、延迟不可控 | std::atomic::wait | 省 CPU、响应快 |
| 10 | 自定义 Task / 生成器 | 回调地狱 | 协程 | 结构性改善,但要先搭基础设施 |
建议的引入顺序:先做 1–3(纯替换,风险极低),再做 4–5(接口改造,要过一遍调用点),最后才碰 8–10。std::format需要 gcc 13+,如果生产工具链还没到,先只做 1–2 这类纯语言特性。
8. 延伸阅读
- cppreference — 编译器支持度总表(C++20 语言特性):升级前先查「你要的这项特性,我的编译器支持到哪一步了」
- cppreference — 功能测试宏:写条件编译的正确姿势,比判断
__cplusplus可靠 - cppreference — 三路比较运算符:重写规则、
= default的生成规则都在这一页 - cppreference — 聚合初始化:C++17 / C++20 的聚合判定差异
- cppreference — std::allocator:看「哪些成员在 C++20 被移除」最准的一手来源
- C++ Core Guidelines:迁移时顺手把
new/delete、std::endl、C 风格转换一起清掉,正好是个时机
本知识库内的相关篇目:
- 《C++20 核心新特性总览:四大特性 + gcc 13 实测清单》 —— C++20 是继 C++11 之后最大的一次改动。
- 《三路比较运算符 <=>:C++20 两个运算符换六个》 —— C++20 的三路比较运算符 <=> 一次返回「小于/等价/大于」三种结论
- 《指定初始化器:C++20 给聚合初始化补上的最后一块》 —— C++20 的指定初始化器(designated initializers)让 Point p{.x =
9. 一句话总结
C++17 → C++20 的迁移本质上是一条命令 + 五类报错:改-std=c++20后全量编译,把char8_t、allocator成员移除、比较运算符重写、volatile弃用、聚合初始化这几类改动分类批量修完,再用-Wall -Wextra -Wpedantic复验一遍,最后才动手引入<=>、std::format、Ranges 这些真正改变写法的新特性。