简介:《ISO/IEC 14882:2020(E) Programming languages — C++》是C++语言的国际标准第六版,发布于2020年12月。标准面向C++开发者、编译器实现者及教学研究人员,系统规定了语言的语法、语义及实现合规要求,帮助读者理解语言边界与平台一致性。资源为单个PDF文档,约7.86MB,内容完整,适合离线查阅或作为权威参考;目前已有1214人学习下载。标准详细覆盖了实现合规、文档结构、语法记法、词法约定、翻译阶段、字符集、预处理令牌、注释等基础规范,同时对声明、定义、作用域、单一定义规则等核心机制给出精确描述。相比先前版本,其中可能包含新特性与修正,可帮助读者掌握C++20标准的新变化,并提升跨平台代码的可靠性与可移植性,是深入研读语言规范、排查编译问题或开展编译器相关工作的正式参考材料。
1. 同一个C++程序,为什么换个编译器就翻车:标准到底约束了什么
同一个C++程序,在A编译器上编译运行一切正常,换到B编译器却出现诡异的内存错误;甚至同一个编译器,把优化级别从默认改成 -O2,行为就变了——这通常不是编译器坏了,而是程序落进了ISO/IEC 14882:2020(E)(C++语言第六版国际标准,2020-12发布)明确划出的“未定义行为”区域。这份标准不是一本语法手册,而是一份语言与编译器之间的契约:九个翻译阶段、词法规则、模板实例化、异常机制、模块的可见性与可达性,全部被写成可判定、可诊断的条款。搞懂这份标准,不是为了背诵条款,而是为了回答一个问题:这段代码为什么是这个行为,以及哪些行为根本不值得去猜。
2. 翻译的九个阶段:源代码从文本变成程序的流水线
标准第5.2节“Phases of translation”定义的处理顺序,总是被初学者忽略,却又最能解释一类“莫名其妙”的编译错误。它把从磁盘上的源文件到可执行程序的全过程拆成九个阶段:字符映射、行拼接、预处理记号化、预处理执行、字符集转换、字符串拼接、记号化、语义分析、链接。
任何一个实际编译器都不会笨到真的分九趟跑,但标准要求可观察行为必须等价于这个顺序。理解它的价值在于:词法、预处理和语法分析之间不是“先后关系”,而是阶段之间的硬约束——前一个阶段已经成型的东西,后一个阶段无权再解释。
2.1 阶段1~2:字符映射与行拼接,发生在任何词法分析之前
阶段1做两件事:把物理源文件字符映射到基本源字符集,并把行尾规范成统一的换行符。这意味着宽字符字面量在不同平台上的表示差异在这一步被抹平。阶段2则处理反斜杠后跟换行符的序列:直接删除,把两行拼成一行。
这一步的优先级高得惊人。拼接发生在注释识别之前,所以下面这段代码的行为会让不少人翻车:
// 这是注释的第一行 \ 这行也被“拼”进了注释 int x = 1; // 这一行不会参与编译阶段2先删除“反斜杠 + 换行”,于是第二行文本变成了注释的一部分,int x = 1整体被阶段3的“注释替换为空格”规则吞掉。想当然地以为反斜杠拼接只影响宏和字符串,就会在这里踩坑。反过来,宏定义中跨行的写法依赖的正是这个机制:
#define LONG_MACRO(x) \ ((x) + 1)这里的反斜杠在阶段2被删除,预处理器在阶段4看到的是一个完整的宏定义行。注意字符串字面量内部也可以这样续行,但续行后字符串里不会出现换行符,因为换行序列已经被删除了。这属于“先删除、后拼接”的语义,标准对跨平台换行(CRLF 与 LF)的差异也通过阶段1统一。
2.2 阶段3~4:预处理记号化与宏展开的边界
阶段3把源文件分解成预处理记号(preprocessing token)和空白序列,同时把所有注释替换为一个空格。这个“替换成空格”的细节经常决定代码能不能编译:
int/*这是注释*/x = 42;如果注释被直接删除,int/*...*/x会拼成intx,变成一个未声明的标识符;但按照阶段3,注释变成空格,结果等价于int x = 42。另一个方向上的问题是:如果注释发生在预处理记号中间,比如1/*c*/2,它不会被拼成12,因为阶段3是在分解记号之后才替换注释的。实际上标准在这里的顺序是先做初步分解,注释替换后重新检查,最终1/*c*/2会成为两个独立的预处理记号1和2——表现形式是编译错误。这里的“替换为空格”保证了两个记号不会意外粘连。
阶段4是预处理指令的主场:#include展开、宏替换、条件编译、_Pragma操作符,全部在这一阶段完成。宏替换的对象是阶段3产出的预处理记号,而不是字符,所以宏不能“看到”字符串字面量内部的内容,也不能用拼接##去制造一个已经在注释里被吃掉的记号。这是预处理器和词法分析器职责分离的根基:宏只操作记号序列,不做语法分析。
2.3 阶段5~7:字面量转换、字符串拼接与真正的“分词”
阶段5把字符字面量和字符串字面量里的字符从源字符集映射到执行字符集;阶段6把相邻的字符串字面量拼接成一个;阶段7才把预处理记号转换成真正的 token(关键字、标识符、运算符都被归并成带类型的词法单元)。
const char* msg = "Hello, " "world!";这段代码中的两个字符串字面量在阶段6被拼成一个,最终msg指向的是完整的"Hello, world!"。如果不理解阶段顺序,很容易误以为这是编译器的“优化”,实际上它是标准规定的语义:相邻串在语义分析开始前就合并了。而阶段7之所以存在,是因为词法层面需要区分identifier和keyword——同一个拼写在不同的分词环境下可能有不同身份,例如operator在普通代码里是关键字,但经过宏替换后它依然是同一个预处理记号。
2.4 阶段8~9:模板实例化、ODR 检查与链接
阶段8对每个翻译单元做语法和语义分析,模板在这一阶段被实例化,ODR(一条定义规则)的检查也在语义层面完成。阶段9才是链接:对外部实体的引用被解析成地址,跨翻译单元的符号得以关联。
这里有一个值得注意的点:模板实例化被放在阶段8意味着它发生在预处理和词法之后、链接之前,所以模板的查找规则(两阶段查找)由名字查找和实例化上下文共同决定,这是理解模板行为的关键。九个阶段看起来是“初始化、预处理、编译、链接”的粗略划分,但其中任何一个阶段都不是可以随意挪动的:把宏展开挪到字符串拼接之后,相邻字符串拼接的结果就会被宏污染;把注释移除挪到记号化之后,int/*c*/x就会变成intx。
3. 词法约定:最大吞噬、替代记号与字符集的底层逻辑
标准第5章“Lexical conventions”几乎很少被完整阅读,但它是所有后续分析的基础。词法层决定了一个字符序列如何被切分成记号,这个决定不依赖语法上下文——绝大多数情况下——所以它必然设定出一条“在多个合法切分中选哪个”的铁律:最大吞噬。另有替代记号和字符集的规定,表面上无关紧要,实际上会影响你在模板里写>>和<::时的体验。
3.1 最大吞噬:为什么 a>>b 在不同上下文里结果不同
最大吞噬规则的内容是:当某个字符序列可以被切分成多个合法记号时,必须选择最长的那一个。直观表现是三个大于号:
std::vector<std::vector<int>> v; // C++11 之后合法在C++11之前,>>会先被最大吞噬规则咬成一个右移运算符,于是vector<vector<int>>被解析成两个右尖括号以外的东西,模板嵌套需要写成> >带空格。C++11开始,标准为模板实参列表引入了一个上下文敏感的特例:在处理模板实参的>序列时,如果>>被识别为两个连续的>才能匹配,就把它拆开。这个特例并没有推翻最大吞噬规则,而是规则之外的一个修正,因为它只在模板实参列表的上下文中生效。a >> b依然是一次右移。标准选择“最大吞噬 + 特例修正”而不是“干脆禁止>>作为记号”,是为了保持运算符优先级和模板语法的兼容,这是一种典型的取舍:不破坏既有的表达式语义,只修补模板解析的死角。
同样的特例还有<::。词法上<:是替代记号[,所以std::vector<::foo>在字面上会被切分成<、:、:、foo、>,恰好语法不对;标准对<::这个具体序列禁止了<:的匹配,保证::总是作为一个整体出现。这些特例不多,但每一个都是为了解决实际代码里“按规则切分却不符合直觉”的问题。
3.2 替代记号:为什么 and、or、not 这些词还在
标准5.5节“Alternative tokens”规定了一组替代记号:and、or、not、xor、bitand、bitor、compl等,它们与对应符号(&&、||、!、^、&、|、~)在词法上是同一个 token,不是宏,也不是关键字。这意味着你可以在重载operator&&时用and来写定义,效果完全等价。
这些替代记号的历史背景是早期键盘上没有这些符号的字符集,保留至今则让代码在只支持ASCII字符的受限环境里依然可写。它们也带来了一个有意思的副作用:not是 token,所以not_eq也就成了!=的合法拼写。理解替代记号的关键在于:它们并不是预处理宏,不存在“展开”过程,词法阶段直接产出同一个记号类型。
3.3 预处理记号与 token:为什么字面量是最后才定型的
阶段3中产生的到底是什么?标准5.4节定义了预处理记号(preprocessing token)的类型:header-name、identifier、pp-number、字符字面量、字符串字面量、预处理运算符或标点,以及“无法归属到以上任何类的单个非空白字符”。
预处理记号比语法分析真正使用的 token 更粗糙,最典型的是 pp-number。一个形如1e+2的序列足以让词法分析阶段把它当作一个 pp-number 整体保留,因为此时还不知道它最终是浮点字面量还是别的记号;到了阶段7,它才被细分成正式的浮点字面量 token。类似地,1'000'000中的数字分隔符'允许出现在 pp-number 中,但1'000'000'后面再接'就会切分失败。预处理记号的这种“宽容”,是给预处理器留下的缓冲空间——宏展开阶段可以放心地拼接记号,而不用担心部分拼出的符号在词法上不完整。
3.4 字符集与通用字符名:源文件的“编码假设”
阶段1把物理字符映射到基本源字符集,标准只要求在基本源字符集和通用字符名的集合范围内定义该映射。基本源字符集包含大小写字母、数字、空白以及若干标点,一共96个字符;字符字面量和字符串中还可以用通用字符名(UCN)来表示更大的 Unicode 范围。
int \u00e9 = 1; // 标识符里写 é这条代码在编译器支持 UCN 规范的情况下是合法的:\u00e9在标识符中按通用字符名处理,等同于直接写 é。实际工程里,源文件的编码(UTF-8、GBK 等)必须能被编译器的阶段1映射识别,否则注释和字符串中的多字节字符会先被替换掉,导致乱码或编译错误。这不是“编码问题”,而是阶段1映射规则的具体体现:源字符集转换发生在预处理之前,所以注释里的编码错误也可能让预处理器吃进不想要的记号。
4. 模块机制:import 凭什么终结头文件玄学
C++20 最重量级的机制是模块。标准第10章“Modules”(以及第15.4节“Module directive”、第15.5节“Header unit importation”)给出了一个不靠文本复制、而靠编译单元边界来组织代码的方案。要理解它为什么能让构建变快、为什么能终结一部分“头文件玄学”,得先看头文件模型的问题在哪。
4.1 头文件的文本复制模型与模块的编译边界
#include从 C 语言继承,它的工作方式是文本复制:预处理器把头文件内容原样插入到每个翻译单元里。于是头文件里的每个声明在每个包含它的源文件中都要被重新解析一遍。假设一个项目里有 100 个源文件、每个都包含同一份几千行的头文件,这段头文件代码就被解析了100次;模板的实例化还可能叠加在这100次之上。编译器对头文件做了大量缓存和预编译优化,但语义模型始终是“每个 TU 各自有一份声明副本”。
模块改变了这个语义:模块接口单元只被编译一次,产生一份模块接口快照(常见实现思路是把这份快照做成类似预编译产物的东西,各编译器具体格式不同),导入方拿到的是编译后的接口,而不是源码文本。下面的接口单元定义了一个简单的数学模块:
// math.cppm export module math; export long double pi = 3.14159265358979323846L; export long double area(long double r) { return pi * r * r; }导入方只需写:
import math; int main() { return area(2.0L) > 12.0L ? 0 : 1; }import math;不是文本替换,它把模块接口单元的编译产物引入当前翻译单元。这里的细节是:模块中通过export导出的名称,导入方可以直接使用;没有export的名称(比如某个内部辅助函数)在模块外不可见,但它可以正常参与本模块内部的计算。所以pi和area对导入方可见,而模块内如果有未导出的辅助实现,导入方既不能调用它,它的定义也不会被重复实例化。export module math;声明了模块接口单元;export关键字作用于后面的声明,表示把实体导出到模块外部。模块名不需要对应文件系统里的某个头文件路径,模块名与文件名的绑定由实现定义,这是与#include的一个关键差异。
4.2 可见性与可达性:模块语义的两个核心概念
模块机制中最难把握的两个词是“可见”(visible)和“可达”(reachable),标准在10.6节“Instantiation context”和10.7节“Reachability”分别定义。一个实体如果可以在名字查找中被找到,就说它可见;如果它不能被直接命名,但程序的行为依赖于它(比如它被实例化、被 ODR 使用),就说它可达。
export module container; struct Internal { int value; }; export Internal make_internal() { return Internal{42}; }导入方可以调用make_internal(),并把返回值用于初始化auto推导的变量,但由于Internal没有被export,导入方在名字查找中看不到Internal这个名字。这个类型对导入方是可达但不可见的。这意味着导入方不能显式声明一个Internal类型的变量,也不能访问value成员——除非模块重新导出了该类型的公有成员。可达但不可见是模块实现封装性的基础:实现细节可以被编译进接口,但在源文层面不暴露给使用方。
4.3 全局模块片段与私有模块片段:标准给出的逃生口
把存量代码迁到模块时,最大的障碍是现有头文件里充满了#include和宏。标准为此提供了全局模块片段(global module fragment):
module; #include <vector> #include <string> export module mylib; export std::vector<std::string> names();module;之后、export module mylib;之前的内容属于全局模块片段,其中的#include仍然是文本复制,且这些头文件里的实体不会被导出到模块外。它本质上是预处理与模块之间的缓冲带:如果你必须依赖某些传统头文件,就把它们放进全局模块片段,而你的模块接口仍然享受模块的编译边界。
私有模块片段(private module fragment)则解决“模块接口单元必须包含所有实现”的痛点,形式是:
export module mylib; export int compute() { return helper(); } module : private; int helper() { return 42; }module : private;之后的内容只可能出现在模块接口单元中,而且对导入方完全不可见。它的设计动机是让“模块接口与实现分离”的写法不必额外拆出一个模块实现单元,同时也让编译器知道:私有模块片段里的声明不会影响模块的对外接口,因此导入方无需感知这些代码的存在。这是一个非常典型的粒度设计:可见性可以精细到每个声明级别。
4.4 实例化上下文:模板在模块化世界里的行为变化
模板向来是“头文件驱动”的,因为模板的定义必须对实例化点可见。模块化后,这一需求由“可达性”来满足:只要模块接口中导出的模板定义对导入方可达,导入方就可以实例化它,即使模板中的某些类型不可命名为推导。
实例化上下文(instantiation context)决定了模板实例化时哪些名字查找是有效的:在模块环境下,导入方实例化一个模块中定义的模板时,模板定义上下文中的可见实体与导入方的可见实体都会参与查找,但只有两边的“并集”中被适当导出的部分才会生效。这里最容易犯的错是:模板定义里调了一个模块内的私有函数,作者以为导入方实例化时也能看到它,实际上实例化发生在导入方的上下文中,私有函数对导入方不可见,实例化会失败或产生与预期不同的重载结果。理解实例化上下文,是使用模块化模板库之前必须补的一课。
5. 避坑指南:读标准时最容易误判的四类条款
标准文本不像教程,它经常用“行为未定义(undefined)”“无需诊断(no diagnostic required)”这类看似随意的话来给编译器放权。实际工程里很多“玄学问题”,追到根上都是对这几类条款的误判。
5.1 “未定义行为”不是“会崩”,而是“实现可以干任何事”
现象:同一段代码在一个编译器的 -O2 下输出正确,在另一个编译器的默认模式下输出错误,甚至不输出而是直接崩溃。
原因:未定义行为不要求实现给出任何确定结果,程序的行为完全在标准约束之外;优化器利用这种自由度,对所有不发生 UB 的路径做激进假设。经典的整型溢出、数组越界、悬空引用都属于这个范畴。“它崩溃了”只是常见的结果之一,恰好优化出你预期行为同样是合法结果。
解决:遇到这类问题时不要试图“调对”,先把代码改成严格良构(well-formed)。如果无法立即定位,可以用代码审查、内存检测工具去圈定嫌疑范围;标准条款的作用是给你一个判断依据——先确认这段代码是不是 UB,再决定要不要修。
5.2 ODR 的例外:为什么重复定义有时合法有时翻车
现象:同一个类定义、模板定义或 inline 函数出现在多个翻译单元里,链接正常;而某个非 inline 的普通函数在头文件里定义了,却报出多重定义错误。
原因:一条定义规则(ODR)允许类定义、模板、inline 函数等多个实体在多个翻译单元中重复存在,前提是每个定义必须相同。这里的“相同”有严格条件:不仅词法相同,还要求被查找的名称在所有定义中指向同一个实体。多翻译单元里的普通函数定义显然不满足该豁免条件。
解决:判断一个实体能否放在头文件,关键看它是否属于 ODR 豁免列表——类、模板、inline 函数都是,普通函数和全局变量不是。另一个隐蔽问题是:两个翻译单元里“长得一样”的模板定义,经过不同宏展开后其实不相同,这会构成 ODR 违规,而且标准常常不要求诊断。
5.3 模板的两阶段查找:定义上下文与实例化上下文割裂
现象:模板函数内部调用了一个在模板定义点还不存在的自由函数,实例化时函数已经声明了,编译器仍然报“找不到标识符”。
原因:模板的非依赖名称(non-dependent name)在模板定义上下文里就完成查找;只有依赖名称(dependent name,参数类型依赖模板参数)才推迟到实例化时。看这个例子:
template <typename T> void g(T t) { f(t); // 依赖调用:实参类型是 T,实例化时查找 f(42); // 非依赖调用:实参类型是 int,定义时查找 } void f(int); // 声明在模板定义之后实例化g<int>时,f(t)能通过 ADL 找到f(int)并正常调用;但f(42)在g的定义点进行查找时f还不可见,于是编译器报错。标准在这里还可能不要求诊断,也就是说有些编译器直到实例化时才报,有些在定义点就报。
提示:判断是否依赖,最简单的办法看名字是否与模板参数直接相关——实参类型是模板参数的调用通常是依赖的。
解决:分析模板代码时先区分“名字是否依赖模板参数”。想让调用在实例化点查找,要么让函数成为依赖表达式的一部分,要么依靠 ADL。这两类查找机制是模板库设计的基本功,模块化世界里的实例化上下文让这条割裂变得更加明显。
5.4 模块不是头文件:import 之后宏全没了
现象:从传统头文件迁移到模块时,#include <someheader>改成import someheader;后,代码里使用头文件里定义的一个宏,编译器提示宏未定义。
原因:宏是预处理实体,而import发生在预处理阶段之后。模块导出的是声明和定义,不会导出宏;宏只存在于预处理阶段,模块的接口快照里根本不包含它们的记号。C++20 还规定了被导入的模块中的宏不会导入到导入方,即使该模块单元是在全局模块片段里#include出来的——因为全局模块片段只在当前模块内有效。
// helpers.cppm module; #define UNITS "px" export module helpers; export std::string unit() { return UNITS; } // 导入方 import helpers; std::string u = unit(); // OK,UNITS 在模块内部完成了展开 std::string v = UNITS; // 错误:宏不可见解决:如果必须依赖宏,把它放进全局模块片段:module;之后的#include在该模块内部仍然有效,但这个模块对外不传递任何宏。跨模块共享宏没有官方通道,这正是模块设计希望杜绝的“全局文本污染”——从文本替换到编译产物的切换,本质上就是要把预处理依赖逐步从模块语义里挤出去。
6. 实现自由度:标准为什么不把所有行为都钉死
把标准里关于“程序到底合不合法、行为是否被约束”的分类并排看,会更清楚:
| 分类 | 含义 | 例子 |
|---|---|---|
| 良构(well-formed) | 符合全部语法和语义规则 | 普通函数调用 |
| 病构(ill-formed) | 违反规则,实现应给出诊断 | 语法错误、类型错误 |
| 病构且无需诊断(IFNDR) | 违反规则但编译器不保证发现 | 跨 TU 的 ODR 违规 |
| 未定义行为(UB) | 行为无约束,可做任何事 | 有符号整型溢出 |
| 实现定义(implementation-defined) | 实现必须从合法行为里选一个并文档化 | sizeof(int)的具体值 |
C++20 的 4.1 “Implementation compliance”是整份标准里最被低估的条款。它不规定语言长什么样,而是规定实现必须把哪些东西说清楚:实现定义的行为要写成文档,诊断消息要存在。这等于把“编译器质量”本身纳入了合规检查的框架。
在设计上,C++ 保留这么多自由度不是偷懒,而是历史兼容与性能两头的必然。如果标准把所有整型溢出都定义为“抛出异常”,每次加法运算在溢出检查上付出的代价都会翻倍;如果所有行为都必须跨平台完全一致,语言就无法在内存模型、字长差异悬殊的硬件上通用。UB 是现代编译器优化器最锋利的武器,它允许优化器在“无 UB 的世界”里重新排列代码;代价是程序员的程序一旦跨出这条线,整个世界的承诺都失效。
验证一段代码的真实行为,我一般这么走:先看它是否良构,再看它是否落入实现定义、未指明(unspecified)、未定义三类;如果是实现定义,去查编译器的文档;如果是未指明,编译器可能在不同优化级别下选不同结果;如果是未定义,直接放弃“预测”,回到降级修改本身。从那以后,我每接手一套跨平台代码,都会强制先过一遍这个“行为分类”流程,再讨论编译器差异、优化级别差异和预处理依赖——这套习惯把很多夜里的“玄学”变成了白天可查的条款。希望帮到你。
本文还有配套的精品资源,点击获取