升级到 C20 最省事的做法是先只改一个编译开关、代码一行不动然后全量编译编译器会把所有需要你操心的地方列成一张清单。这篇就是那张清单的解读版每条改动配一个在 gcc 13.2.0 上真跑出来的报错原文和对应的修法照着改就行。1. 引子改完开关的第一屏# 只改这一处然后全量编译-stdc17 stdc20第一屏大概长这样error: invalid conversion from const char8_t* to const char* [-fpermissive] error: class std::allocatorint has no member named address error: no matching function for call to Vec::Vec(int, int)这三条分别对应char8_t的引入、std::allocator的成员移除、聚合的圆括号初始化都不是你写错了是标准变了。2. 迁移路线图┌────────────────────────────────────────────────────────────────┐ │ ① 只改编译开关-stdc20代码一行不动全量编译 │ └───────────────────────────┬────────────────────────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────┐ │ ② 收报错清单按「改动类别」分类不是按文件 │ │ · char8_t 相关 · allocator 相关 │ │ · 比较运算符相关 · 已移除设施相关 │ │ · 聚合初始化相关 │ └───────────────────────────┬────────────────────────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────┐ │ ③ 按类批量修同一个类改完所有用到它的地方一起验证 │ └───────────────────────────┬────────────────────────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────┐ │ ④ 开 -Wall -Wextra -Wpedantic 复验 │ │ 这一步才抓得到「能编译但行为变了」的问题如 volatile 弃用│ └───────────────────────────┬────────────────────────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────┐ │ ⑤ 全绿后再逐步引入新特性 / format / ranges一次一类 │ └────────────────────────────────────────────────────────────────┘关键点是① 和 ③ 之间不要混先让编译器把清单交出来再动手改。一边升开关一边顺手改用出问题时你分不清是「迁移破坏」还是「新特性用错」。3. 第一步改成什么开关工具链开关版本门槛gcc-stdc20想要 GNU 扩展用-stdgnu20gcc 10 起有基本支持gcc 13 起覆盖度已经很高clang-stdc20clang 10 起基本支持clang 16 比较完整MSVC/std:c20VS 2019 16.11 起建议再加/Zc:__cplusplusCMakeset(CMAKE_CXX_STANDARD 20)CMAKE_CXX_STANDARD_REQUIRED ONCMake 3.12/Zc:__cplusplus这一项值得单独说MSVC 默认的__cplusplus宏长期停在199711L加了这个开关才会报真实值靠__cplusplus 202002L做条件编译的代码才不会走错分支。// verify: stdc20// 需要 C20这段代码要验证的就是 C20 模式下 __cplusplus 的真实取值// demo.cpp — 编译: g -stdc20 -Wall -Wextra -Wpedantic demo.cpp -o demo#includecstdiointmain(){printf(__cplusplus %ldL\n,static_castlong(__cplusplus));printf(是 C20 或更新吗 %s\n,__cplusplus202002L?是:否);#ifdefined(__cpp_concepts)__cpp_concepts201907Lprintf(Concepts 可用\n);#elseprintf(Concepts 不可用\n);#endif}__cplusplus 202002L 是 C20 或更新吗 是 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_tu8...换了类型字面量 u8abc 的类型 C17 u8abc → const char[4] 和普通 abc 一样是窄字符 const char* p u8abc; 编译通过 C20 u8abc → const char8_t[4] 专门的 UTF-8 码元类型不是 char const char* p u8abc; 编译失败 const char8_t* p u8abc; std::u8string s u8abc;这就是升级后最常撞的一条。实测 gcc 13.2.0 -stdc20的原文error: invalid conversion from const char8_t* to const char* [-fpermissive] 3 | const char* legacy u8hello; | ^~~~~~~~~ | | | const char8_t*修法按场景分三种本来就是 UTF-8 文本比如日志、JSON 片段→ 改成const char8_t*std::u8string配套使用需要窄字符串接口printf、老的 C API→ 显式reinterpret_castconst char*这是改类型不是改编码字节内容一个没变根本不需要u8前缀源码本身是 UTF-8 且用窄字符串→ 直接删掉u8退回普通字面量。// verify: stdc20// 需要 C20char8_t / std::u8string 是 C20 新增类型// demo.cpp — 编译: g -stdc20 -Wall -Wextra -Wpedantic demo.cpp -o demo#includecstdio#includestringintmain(){// 升级前写法C17 能过C20 编译失败// const char* legacy u8你好;constchar8_t*u8_textu8你好;// C20类型是 const char8_t[]conststd::u8string textu8_text;// 配套类型 std::u8string// 需要窄字符串时显式「改类型」字节内容不变conststd::stringnarrow(reinterpret_castconstchar*(text.data()),text.size());printf(u8string 字节数 %zu\n,text.size());printf(窄字符串字节数 %zu\n,narrow.size());printf(内容 %s\n,narrow.c_str());}u8string 字节数 6 窄字符串字节数 6 内容 你好注意两个长度都是 6u8string的size()是字节数不是字符数。char8_t的引入正是为了让「一个字节」和「一个窄字符」在类型上分开顺手也修掉了u8字面量在模板推导里被当成char的老问题。官方文档char8_t 与 UTF-8 字面量、std::basic_string 的类型别名u8string / u16string / u32string、char8_t 类型4.2 比较运算符的重写规则C20 给a ! b加了「重写候选」如果找不到operator!编译器会尝试!(a b)以及反过来的!(b a)。这就让以前编译失败的代码突然能编译了。实测同一个片段在两个标准下的差别// 同一段代码C17 下编译失败C20 下编译通过structS{booloperator(constS)const{returntrue;}};boolcompare(){constS a;constS b;returna!b;// C20重写成 !(a b) 后编译通过}gcc 13.2.0 -stdc17的原文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; // C20重写成 !(a b) 后编译通过 | ~ ^~ ~ | | | | | const S | const S同一个文件用-stdc20编译直接过。所以升级后必须检查一件事有没有代码「本来靠编译不过挡着」的误用。比如某个类只想支持、故意不提供!现在这个限制消失了。真正的坑在这里无约束的模板operator会把!一起吃掉。上一段说的是「限制消失」这一个说的是「限制消失之后会漏进来什么」。重写规则不是只放宽「!能不能用」凡是能被operator匹配上的类型都被拖进了比较体系。最典型的写法是这样// verify: stdc20// 反例不要这么写无约束的模板 operator 让任何类型都能和 A 比出「相等」#includecstdiostructA{intv0;};templateclassTbooloperator(constA,constT){returntrue;}// 任何 T 都返回 trueintmain(){constA a{1};std::printf(aa:%d a42:%d a\str\:%d\n,aa,a42,astr);std::printf(a!42:%d a!\str\:%d\n,a!42,a!str);}aa:1 a42:1 astr:1 a!42:0 a!str:0a str不但编过了还返回1。无约束模板本身在 C17 里也是这个毛病但 C20 用改写规则把它扩大到了!同一份文件用-stdc17编译最后两行直接报错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])C20 会用!(a 42)把这两行合成出来那个「永远返回 true」的模板于是返回!1 0比较运算符从「只错那一半」变成「错一整片」。迁移时的对策只有一个给模板加约束别让它接受任意T。// 正确做法用 concept 把允许比较的类型显式列出来别接受任意 T#includeconceptstemplateclassTrequiresstd::same_asT,A// 只允许和 A 自己比booloperator(constAlhs,constTrhs){returnlhs.vrhs.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的成员移除C20 删掉了std::allocator的一批成员address、max_size、construct、destroy以及带位置提示的allocate(n, hint)。自定义容器/分配器代码最容易撞。实测原文error: class std::allocatorint has no member named address 6 | (void)alloc.address(*p); | ^~~~~~~修法address换成std::addressofconstruct/destroy换成std::allocator_traitsA::construct/destroy这也是写容器的推荐写法它同时兼容其他分配器类型带 hint 的allocate换成普通的allocate(n)。官方文档std::allocator、std::allocator_traits4.4volatile的复合运算弃用C20 弃用了volatile限定类型的复合赋值、自增自减。实测 gcc 13.2.0 -stdc20的原文这一条是警告但 Wandbox 把警告当失败所以你在 CI 里用-Werror时它同样是硬错误warning: expression of volatile-qualified type is deprecated [-Wvolatile] 3 | int bump(volatile int v) { return v; } | ^修法分两种情况如果这个变量本来就是给别的线程/硬件看的volatile从根上就用错了它不提供原子性和内存序应该换成std::atomicT如果只是「读一次写一次」写成v v 1这类不带复合语义的形式即可但通常这说明它也不需要volatile。官方文档volatile 限定符、std::atomic4.5 聚合的圆括号初始化与std::atomic的默认构造两条都是「以前编译不通过的现在通了」和「以前不确定的现在确定了」风险低但要知道。// verify: stdc20// 需要 C20聚合的圆括号初始化P0960// demo.cpp — 编译: g -stdc20 -Wall -Wextra -Wpedantic demo.cpp -o demo#includecstdiostructVec{intx;inty;};intmain(){Vecv(1,2);// C17no matching function for call to Vec::Vec(int, int)printf(v (%d, %d)\n,v.x,v.y);}v (1, 2)C17 下的原文实测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: stdc20// 需要 C20std::atomic 的默认构造会值初始化P0883// demo.cpp — 编译: g -stdc20 -Wall -Wextra -Wpedantic demo.cpp -o demo#includeatomic#includecstdiointmain(){std::atomicintcounter;// C20值初始化为 0C17 里它是不确定的std::atomicboolready;// 同理C20 下是 falseprintf(counter %d\n,counter.load());printf(ready %s\n,ready.load()?true:false);std::atomicintexplicit_zero{0};// 显式写上初始值两个标准下行为一致printf(explicit_zero %d\n,explicit_zero.load());}counter 0 ready false explicit_zero 0这是变安全的改动以前std::atomicint a;后直接a.load()是读未初始化值现在确定是 0。代价是默认构造多了一次存储可以忽略。迁移时反过来要检查的是「有没有代码依赖它不初始化」理论上不会有。官方文档聚合初始化、std::atomic 的构造函数4.6typename的宽松处理低风险C20 的 P0634R3 在「语法上必须是类型」的位置比如基类子句、成员声明、返回类型不再需要typename。这本身是好事副作用是如果某个类里恰好有一个名字叫type的静态成员值不是类型以前靠「必须写typename」才能表达「我要的是类型」现在编译器可能在那些位置按类型解析行为和你预期的不一样。「不再需要typename」这一半是可复现的返回类型位置上最明显// verify: stdc20// 需要 C20P0634R3 之后返回类型的 dependent name 不必再写 typename#includecstdiostructWrap{usingtypeint;};templateclassTT::typemake(){return42;}// C17 要写成 typename T::typeintmain(){std::printf(makeWrap() %d\n,makeWrap());}makeWrap() 42同一份文件改用-stdc17gcc 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; } // C17 要写成 typename T::type | ^ | typename至于副作用那一半某个X::type的解释被改掉我没有构造出可复现的最小用例在 gcc 13.2.0 上试了「名字叫type的静态数据成员被放在声明位置当类型用」等几种写法C17 和 C20 都是编译失败没有出现「以前能编、升级后解释变了」的情形。所以那一条只作为排查线索保留升级后如果遇到「某个X::type的解释变了」这类怪现象先往这条改动上想。5. 必须手改的四件事要改的地方迁移前迁移后为什么类的相等比较手写operatoroperator!bool operator(const T) const default;一行生成语义必然一致类的全序比较手写auto operator(const T) const default;按成员顺序比较六个运算符一次到位字符串字面量类型const char* p u8...;const char8_t* p u8...;或去掉u8char8_t是独立类型枚举比较手写operator!只为可读性删掉内置比较就够枚举的内置运算符不受重写规则影响第一条值得展开既写operator又写operator时不要写成 default的之外的东西。和的语义必须自洽否则a ! b走!和a b走可能给出矛盾的结论。// verify: stdc20// 需要 C20operator 与比较运算符重写// demo.cpp — 编译: g -stdc20 -Wall -Wextra -Wpedantic demo.cpp -o demo#includecstdio#includeset#includestringstructUser{std::string name;intage0;// 迁移前要手写两个还容易写歪// bool operator(const User o) const { return name o.name age o.age; }// bool operator!(const User o) const { return !(*this o); }// 迁移后一行搞定 ! autooperator(constUser)constdefault;};intmain(){constUser a{miaomiao,30};constUser b{miaomiao,31};printf(a b : %s\n,(ab)?true:false);printf(a ! b : %s\n,(a!b)?true:false);// 自动用 !operatorprintf(a b : %s\n,(ab)?true:false);// 先比 name再比 ageconststd::setUserusers{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...的类型在 C20 变成const char8_t[]改类型为const char8_t*/std::u8string需要窄串就reinterpret_castconst char*不需要u8就删前缀error: class std::allocatorint has no member named addressstd::allocator移除了一批成员std::addressofconstruct/destroy走std::allocator_traitserror: no match for operator! (operand types are S and S)C17 下的原文C17 不会用operator合成!C20 下这条会自己消失反过来要检查是否有代码靠「编译不过」挡误用warning: expression of volatile-qualified type is deprecated [-Wvolatile]C20 弃用 volatile 的复合运算需要同步语义就用std::atomic其他情况去掉volatileerror: no matching function for call to Vec::Vec(int, int)C17 下的原文C17 不支持聚合的圆括号初始化C20 下自动可用若只想用花括号初始化保持原样即可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!官方文档C20 语言特性支持表、C20 库特性支持表7. 迁移完成后用 C20 改写哪些老代码收益最大排名改写目标迁移前迁移后收益1所有「手写六个比较运算符」的类6 行 × N 个类容易写歪auto operator(const T) const default;bug 消失代码量骤减2v.erase(std::remove_if(...), v.end())少写后半段就静默出错std::erase_if(v, pred)消灭一类经典缺陷3sprintf/stringstream拼字符串类型不安全、格式串容易错位std::format({} {}, a, b)类型安全 可读4(T* data, size_t len)双参数接口长度和指针可以传错序std::spanconst T接口自解释5(it, end)双参数的算法调用begin()/end()噪音std::ranges::sort(v)可读性 不易传错6返回-1/ 空串表示失败的函数调用方不知道要不要判返回std::optionalT签名自解释7for (i 0; i v.size(); i)手写下标、容易越界for (const auto [k, val] : m)结构化绑定 范围 for8模板约束用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 — 编译器支持度总表C20 语言特性升级前先查「你要的这项特性我的编译器支持到哪一步了」cppreference — 功能测试宏写条件编译的正确姿势比判断__cplusplus可靠cppreference — 三路比较运算符重写规则、 default的生成规则都在这一页cppreference — 聚合初始化C17 / C20 的聚合判定差异cppreference — std::allocator看「哪些成员在 C20 被移除」最准的一手来源C Core Guidelines迁移时顺手把new/delete、std::endl、C 风格转换一起清掉正好是个时机本知识库内的相关篇目《C20 核心新特性总览四大特性 gcc 13 实测清单》 —— C20 是继 C11 之后最大的一次改动。《三路比较运算符 C20 两个运算符换六个》 —— C20 的三路比较运算符 一次返回「小于/等价/大于」三种结论《指定初始化器C20 给聚合初始化补上的最后一块》 —— C20 的指定初始化器designated initializers让 Point p{.x 9. 一句话总结C17 → C20 的迁移本质上是一条命令 五类报错改-stdc20后全量编译把char8_t、allocator成员移除、比较运算符重写、volatile弃用、聚合初始化这几类改动分类批量修完再用-Wall -Wextra -Wpedantic复验一遍最后才动手引入、std::format、Ranges 这些真正改变写法的新特性。