简介:《ISO/IEC 14882:2020(E)》是 C++ 编程语言第六版国际标准的正式文本,面向 C++ 开发者、编译器与解析器实现者、高校教学人员及标准研究者,为语法、语义和实现合规性提供权威依据,帮助读者厘清跨平台实现的一致性与可移植性边界。资源包仅含 1 个 PDF 文件,约 7.86MB,单册完整收录标准正文,目录层级清晰,便于按章节定位查阅。已有 1216 人学习。正文从实现合规、文档结构、语法记法讲起,继而展开词法约定,包括翻译阶段、字符集、预处理标记、替代标记、令牌、注释、头文件名、预处理数字、标识符、关键字、运算符与字面量等基础规则;这些内容可直接用于查证语言边界、规避平台依赖写法,也可作为编译器前端与语法解析开发的参考。对需要深入理解 C++ 标准条文、提升代码规范性的读者,这份文本具备长期查阅价值。
1. 从「两份编译器跑出两个结果」说起:标准文本到底锁死了什么
C++20 标准(14882 号文档,2020 年 12 月发布的第六版)被很多人当成一本查函数签名的工具书,真正逼着自己读进去,往往是在某个跨平台崩溃的深夜:同一段代码,在一套工具链上跑得好好的,换一套工具链就直接翻车。这时候翻开标准会发现它压根不是在教你写 C++,而是在划一条线,线内是实现必须保证的行为,线外实现怎么干都合法。同一段代码给出两个结果,很可能两边都没 bug,是代码自己站到了线外。
搞懂这条线怎么划的,有三个直接收益:你能判断一个怪异行为该找编译器还是找自己;你能读懂错误信息背后的标准依据,而不是靠猜;你在做跨平台、跨编译器的库时,知道哪些东西必须自己兜底。后面几章就按「标准怎么规定合规 → 翻译阶段怎么切分 → ODR 和名字查找怎么产生链接期问题 → 实际踩坑怎么定位」这条线,把这份文本的底层骨架拆开。
2. 实现合规:标准为什么要把「没规定」写成正式条款
很多人第一次翻到讲合规性的那一章会困惑:一份语言标准,为什么花整整一节去解释「哪些行为我们不管」?答案藏在标准的定位里。它不是编译器的操作手册,而是一份双向约定:对程序员承诺哪些行为可靠,对实现者承诺哪些地方可以自由发挥。这套约定之所以要写得这么啰嗦,是因为 C++ 的抽象机器模型和真实硬件之间隔着一层优化器,任何一处「必须保证」都会变成优化器头上的紧箍咒。
理解这一章的关键,是把标准里的行为分成四档,它们不是程度差别,而是性质差别:
| 行为类别 | 标准的态度 | 典型例子 | 出问题时找谁 |
|---|---|---|---|
| 良好定义 | 必须严格实现 | std::vector::size()的返回值 | 编译器或库实现 |
| 实现定义 | 必须选一种并写进文档 | sizeof(int)、char是否有符号 | 查实现文档 |
| 未指定 | 从合法集合里任选,不必文档化 | 函数实参的求值顺序 | 代码自己不能依赖 |
| 未定义 | 不做任何要求 | 越界访问、有符号溢出 | 自己的代码 |
这张表是排查问题的第一把尺子。看到「某个表达式在不同编译器上结果不同」,先别急着报 bug,先确认它落在哪一档。
2.1 可诊断规则与「无需诊断」的未定义行为
标准把规则分成两类:可诊断的(diagnosable)和无需诊断的(no diagnostic required,常缩写 NDR)。前者要求合规实现在违反时至少给出一条诊断信息,比如少写分号、类型不匹配;后者明确说实现可以一声不吭地把程序编译通过,然后运行时随便怎么崩。
这个区分不是偷懒,而是有硬约束的。跨翻译单元的 ODR 违规就是典型:编译器一次只看到一个源文件,它没有全局视野去判断「另一个 .cpp 里是不是定义了同名函数」。标准不能要求实现做它做不到的事,于是只能把这类违规划到 NDR 一侧。
提示:遇到「编译通过、运行诡异」的问题,先怀疑 NDR 类违规。这类问题的共同特征是:单个源文件看完全合法,问题只在多个翻译单元拼在一起时才暴露。
血泪经验是:把 NDR 当成「大概没事」是最大的误解。NDR 的意思是「实现不欠你一条错误信息」,不是「这么写没关系」。程序一旦踩进去,行为在标准层面就是未定义的,优化器完全有权利基于「这不会发生」做推导。
2.2 as-if 规则:为什么你的空循环会被整段删掉
标准对实现的约束可以浓缩成一句话:只要程序的可观察行为与标准描述的一致,实现可以用任何方式执行。这就是常说的 as-if 规则。可观察行为的清单很短,大致包括对 volatile 对象的读写、对 I/O 函数的调用、以及标准明确规定的求值顺序。
于是下面这段代码被优化掉是完全合规的:
// 编译器可以把整个循环删掉,因为它没有可观察行为 for (int i = 0; i < 1000000; ++i) { int tmp = i * 2; // 纯计算,结果未使用 } // 这个不能被删:volatile 访问属于可观察行为 volatile int flag = 0; flag = 1;第一段里tmp的计算结果没有被任何可观察行为使用,实现可以直接判定为死代码。第二段里volatile的写操作必须真实发生,顺序也不能被重排到其他 volatile 访问之外。
这条规则解释了一类很常见的困惑:调试版能跑、发布版崩溃。不是发布版有 bug,是发布版认真执行了 as-if 规则,把你代码里那条「其实依赖了未定义行为」的隐式假设给抹掉了。
2.3 实现定义与未指定:sizeof(int)为什么不能写死
标准故意不规定int的宽度,只规定它至少 16 位、sizeof(char)恒为 1、各整型之间满足一定的秩关系。理由很实际:C++ 要跑在 8 位单片机、DSP、大型机上,强行规定 32 位会把一整类平台排除在外。
但「不规定」和「不管」是两回事。标准要求实现把选择写进文档,这就是实现定义行为。而未指定行为连文档都不用写,只要每次选择落在合法集合内即可。函数实参的求值顺序就是后者:
int f() { std::cout << "f"; return 1; } int g() { std::cout << "g"; return 2; } // 先打印 f 还是先打印 g,标准未指定 int r = f() + g();这段代码在 C++17 之后依然不能依赖输出顺序。很多人以为 C++17 规定了求值顺序就万事大吉,实际上它规定的是a = b、a[b]、a << b这类表达式里操作数的先后,两个函数调用的相对顺序仍然是未指定的。
把这三档区分清楚之后,再去看标准文本里那些「implementation-defined」「unspecified」「undefined」的措辞,就能读出不同的分量。它们不是同义反复,而是三种完全不同的责任划分。
3. 翻译阶段:宏为什么比类型系统更早生效
写 C++ 的人大多知道「预处理 → 编译 → 链接」这条流水线,但很少人意识到这不是工具链的实现细节,而是标准正文里明确定义的九个阶段。这份文本把一次翻译拆成编号的阶段,每个阶段有明确的输入输出,后面所有关于宏、关于名字查找、关于模板实例化的规则,都建立在这个切分之上。
这个切分最反直觉的后果是:宏展开发生在任何类型信息存在之前。预处理器看到的不是「一个 int 变量」,而是一串 token。它不知道min是标准库函数还是你自己的变量名,只要名字匹配就替换。这不是设计缺陷,是分层的必然代价。
3.1 九个阶段与翻译单元的边界
标准列出的阶段可以粗分为几段,每段的产物形态不同:
第一阶段做字符映射,把源文件的字节序列映射到标准的翻译字符集,同时统一行尾。第二阶段处理反斜杠换行,把物理行拼成逻辑行。第三阶段把字符流切成预处理 token 和空白序列,注释在这一步被替换成一个空格。第四阶段执行预处理指令、展开宏、递归处理#include。第五阶段把预处理 token 转换成真正的 token,处理字符和字符串字面量的编码。第六阶段拼接相邻的字符串字面量。第七阶段做语法和语义分析、模板实例化,产出翻译单元。第八阶段确定需要外部链接的实体,第九阶段做链接。
理解这个顺序能解释很多现象。比如为什么#include是纯文本展开:它在第四阶段发生,此时还没有任何类型信息,头文件的内容就是一段被粘进来的字符。这直接导致了宏污染问题——你在自己的源文件里#define min(a,b) ...,之后包含的任何头文件里出现的min标识符都会被替换。
// 阶段 4:宏展开,此时 min 只是一个 token #define min(a, b) ((a) < (b) ? (a) : (b)) #include <algorithm> // 头文件里的 std::min 声明被破坏 // 结果:一大片看不懂的语法错误这里报的错通常离真正的原因很远,因为错误发生在阶段 7 的语法分析,而根因在阶段 4。
3.2 预处理 token 与 token 之间隔着一层语义
标准区分「预处理 token」和「token」两个概念,这个区分不是学术洁癖。预处理 token 的种类很少:标识符、预处理数字、字符字面量、字符串字面量、标点、头文件名,以及「其他字符」。它的切分规则刻意写得宽松,目的是让预处理器不需要理解语法。
预处理数字(pp-number)是最典型的例子。规则大致是:以数字开头,后面可以跟数字、标识符字符、小数点、以及带符号的指数部分。它不检查这个数字是否合法,只负责切出来:
// 这些都是单个 pp-number,phase 3 就切好了 1e+3 // 合法的浮点字面量 0x1p-3 // 合法的十六进制浮点 1.2.3 // 不是合法数字,但先被切成一个 pp-number,到 phase 7 才报错 1'000'000 // 数字分隔符,C++14 起因为切分在类型系统之前,宏拼接可以造出原本不存在的 token。##运算符就是在阶段 4 把两个预处理 token 粘成一个,粘出来的东西到阶段 5 才被赋予真实语义。这也是为什么用宏生成标识符是可行的,而用模板做同样的事很别扭——模板工作在阶段 7,那时候 token 已经定死了。
3.3 字符集与 UCN:源字符集到执行字符集的两次映射
标准定义了多套字符集:源字符集、翻译字符集、执行字符集、执行宽字符集。源文件读进来先映射到翻译字符集,字面量里的内容在阶段 5 再映射到执行字符集。中间那次映射是很多编码问题的根源。
通用字符名(universal character name,UCN)是这条链上的另一个细节。\u00e9这种写法在阶段 1 就被识别,但它的语义要等到阶段 5 才确定——在标识符里它代表一个字符,在字符串字面量里它按执行字符集编码。C++20 引入了char8_t和u8字面量的强制 UTF-8 语义,把这条链上最模糊的一环给钉死了。
// 阶段 5 决定这些字面量的编码 const char* a = u8"中文"; // C++20 起类型是 const char8_t* const char* b = "中文"; // 编码由执行字符集决定,实现相关 const wchar_t* c = L"中文"; // 宽度由实现定义,跨平台不可移植b这一行的类型没变,但内容在不同平台上可能是 GBK、可能是 UTF-8、也可能是别的,标准只说「由执行字符集决定」。这就是为什么跨平台传输文本时,最好在边界上统一用char8_t或显式转码,别指望默认字面量。
4. 一个定义规则与名字查找:链接期错误的标准依据
链接期报错是很多人最怕的一类问题,因为它离源码太远。multiple definition、undefined reference这些信息背后其实是标准里两条规则的交互:一个定义规则(ODR)和名字查找。把这两条搞明白,链接错误就不再是黑匣子。
ODR 的核心思想是:C++ 采用分离编译模型,每个翻译单元独立编译,链接器负责拼装。为了让「同一个实体在多个翻译单元里出现」这件事不至于崩掉,标准允许某些实体在多个翻译单元里重复定义,但要求这些定义完全一致。不满足这个要求,程序就是未定义的,而且通常无需诊断。
4.1 ODR 的三个层次与「无需诊断」的代价
ODR 大致分三层。第一层是单个翻译单元内,任何变量、函数、类类型、枚举、模板至多有一个定义。第二层是整个程序范围内,非 inline 函数和非 inline 变量有且仅有一个定义。第三层是允许重复定义的实体(类类型、模板、inline 函数和变量),在多个翻译单元里的定义必须 token 序列完全相同,且对应的名字必须查到同一个实体。
第三层是隐蔽性最高的。看下面两种写法:
// 写法 A:inline 函数,所有翻译单元共享同一份 // widget.h inline int next_id() { static int id = 0; return ++id; } // 写法 B:static 函数,每个翻译单元各有一份 // widget.h static int next_id() { static int id = 0; return ++id; }写法 A 里,next_id是外部链接的 inline 函数,ODR 保证所有翻译单元里它是同一个实体,函数内的static局部变量也只有一份,计数器行为全局一致。写法 B 里,static给了函数内部链接,每个包含这个头文件的翻译单元都得到一份独立副本,各自有自己的计数器。
这两种写法都能编译通过,行为却完全不同。区别不在语法,在链接属性,而链接属性由 ODR 的规则决定。很多「单例不单例」「计数器各算各的」的诡异现象,根子都在这里。
更危险的是第三层里「token 序列必须相同」这条。如果两个翻译单元看到了同名类但成员布局不同的定义(比如被不同的宏开关影响),编译器各自按自己看到的布局生成代码,链接器照常拼装,运行时就是内存踩踏。这类问题标准明确说无需诊断,因为链接器无从判断。
注意:不要用宏去控制头文件里的类定义布局。一旦不同翻译单元看到不同版本,ODR 违规就是静默的,通常表现为「换一下包含顺序就崩」或者「debug 版正常 release 版崩」。
4.2 名字查找的层次与两阶段查找
名字查找的规则决定了「一个名字指的是哪个实体」。标准定义了非限定查找、限定查找、实参依赖查找(ADL)几种方式,作用域则分块作用域、类作用域、命名空间作用域、全局作用域。对普通函数来说这些规则还算直观,一旦扯上模板就变得微妙。
模板的查找是两阶段的:非依赖名在模板定义点查找,依赖名在实例化点查找。这个设计的动机是分离编译——模板定义所在的翻译单元不该知道所有可能的实例化类型,所以依赖类型的部分必须推迟。
namespace lib { struct Widget {}; void draw(Widget); // 定义在 Widget 的关联命名空间 } template <class T> void render(T t) { draw(t); // 依赖调用,实例化时用 ADL 查找 helper(0); // 非依赖调用,定义点查找,找不到直接报错 } void helper(int); // 实例化点:draw 通过 ADL 在 lib 命名空间里被找到 lib::Widget w; render(w);draw(t)能编译通过,是因为实参t的类型依赖模板参数,整个调用表达式被判定为依赖,查找推迟到实例化点,ADL 会在T的关联命名空间里搜一遍。helper(0)的实参是字面量0,不依赖模板参数,所以必须在定义点就能找到helper,哪怕实例化点之后有声明也没用。
这条规则是模板库作者的基本功,也是「为什么我的模板在 GCC 上能编、在另一套工具链上报找不到函数」这类问题的标准答案。修复方式通常是把非依赖调用改成依赖调用,或者把所需的声明提前到模板定义之前。
4.3 模块如何把「文本包含」换成「语义可达」
C++20 引入模块,最根本的动机就是拆掉#include的文本展开模型。头文件的问题是它把「我依赖你」表达成「我把你的字符复制过来」,于是宏会泄漏、编译时间随包含深度指数增长、ODR 违规无法检查。
模块把「可见」从文本层面提升到语义层面。一个模块接口单元用export标记对外可见的声明,导入方只能看到被导出的部分,其余声明在模块的私有范围内,外部不可达。标准用「可达性」这个术语描述这件事:只有可达的声明参与名字查找。
// math.cppm —— 模块接口单元 export module math; export int add(int a, int b); // 导出,外部可达 int internal_helper(int x); // 未导出,外部不可达 // main.cpp import math; int main() { return add(1, 2); // 可以 // internal_helper(1); // 不可达,编译错误 }对于还离不开传统头文件的代码,标准提供了全局模块片段:用module;开头的一段代码被归到全局模块,用来包住#include。这段代码里的声明不属于当前模块的私有范围,因为它们的本意就是全局可见。
module; #include <cstdint> // 传统头文件放进全局模块片段 export module widgets; namespace widgets { export std::int32_t make_id(); // 导出的只是 widgets::make_id 这个名字 }注意这里export的是widgets::make_id,不是std::int32_t。std::int32_t来自全局模块,任何翻译单元都能通过 include 看到它,不需要也不应该被模块导出。
5. 常见排查:五个看起来像编译器 bug 的标准行为
有些问题反复出现,每次都被当成编译器抽风,其实标准里写得明明白白。这一章挑几个高频的,按「现象 → 原因 → 解决」拆开讲。
5.1 宏与前处理带来的问题
第一个坑是宏名撞上标准库标识符。现象是包含某个头文件之后,原本正常的代码冒出一堆看不懂的语法错误,错误位置还指向标准库内部。原因在第四阶段:宏展开时没有任何类型信息,只要 token 匹配就替换。比如定义了min宏之后再包含算法头,std::min里的min也被换掉。
解决方式有三种:一是不用宏,改用 inline 函数或模板;二是在包含头文件前#undef;三是在构建配置里加NOMINMAX这类开关(如果库提供了)。对于需要兼容旧代码的项目,第二种最稳。
第二个坑是宏参数多次求值。现象是带副作用的实参被算了两次,计数器多加一次。原因还是宏的文本替换本质:
#define SQUARE(x) ((x) * (x)) int i = 3; int r = SQUARE(i++); // 展开成 ((i++) * (i++)) // 两处修改同一个对象且未排序,属于未定义行为解决方式是改用 constexpr 函数,或者用模板封装。宏只应该在确实需要「在类型系统之外操作 token」的场合使用,比如生成代码、条件编译、字符串化。
5.2 链接与 ODR 的坑
第三个坑是头文件里定义普通函数。现象是单文件编译通过,链接时冒出一堆multiple definition,或者更糟——链接通过但行为诡异。原因是非 inline 函数在程序范围内只允许一个定义,头文件被多个翻译单元包含就产生多份。
解决方式很明确:要么加inline,要么改成模板,要么只留声明把定义放进.cpp。加inline不只是「允许重复」,还保证了多份定义被合并成同一个实体,函数内的static局部变量也只有一份。
第四个坑是不同翻译单元看到了不同的类定义。现象是「换个文件包含顺序就崩」「release 版内存错乱、debug 版正常」。原因是有宏或条件编译让两个翻译单元看到的类布局不一致,各自按自己的布局生成代码,链接器照常拼装。这类问题标准明确说无需诊断。
解决方式是把所有影响布局的开关从宏改成模板参数或配置类型,让布局差异在类型层面暴露出来。一旦两个版本的类型不同,编译期就会报错,而不是等到运行时。
5.3 优化与浮点的坑
第五个坑是 NaN 检查在开启激进浮点优化后失效。现象是x != x恒为 false,原本用来判断 NaN 的代码不再生效。原因是 as-if 规则允许实现假设程序里没有 NaN,一旦做了这个假设,x != x就可以被优化成常量 false。
bool is_nan(double x) { return x != x; } // 在允许假设无 NaN 的优化选项下,可能被编译成 return false; // 更稳妥的写法是配合标准库,并确保不启用破坏 IEEE 语义的选项 #include <cmath> bool is_nan_safe(double x) { return std::isnan(x); }解决方式不是换一种写法规避优化器,而是关掉会破坏 IEEE 语义的浮点选项。把x != x换成std::isnan并不解决问题,如果编译器被允许假设无 NaN,两者都会被优化掉。
这五个坑的共同点是:单看代码都合法,问题出在「标准允许实现怎么做」。排查这类问题的顺序应该是:先确认行为落在四档中的哪一档,再去找对应的标准条款,最后才怀疑编译器。
6. 设计取舍:可机械检验的文本,与它留给实现的空间
读这份文本久了会注意到一个规律:凡是能用形式化手段表达的部分,标准都写得很硬;凡是需要人来判断的部分,标准都写得很软。语法用扩展巴科斯范式描述,可以机械地生成解析器;语义用带编号段落的自然语言描述,只能靠人理解。这个取舍不是偶然的,而是因为语义一旦形式化,标准就会变成一份不可维护的证明文档。
C++20 里最值得琢磨的一个取舍是约束和概念的引入位置。在 C++20 之前,模板的参数约束靠 SFINAE 表达,错误信息是一团嵌套的替换失败记录,编译器不会告诉你是哪一条要求没满足。标准把约束提升为语言特性,动机不是让代码更好写,而是让失败信息有地方可挂。requires子句在重载决议阶段被求值成布尔值,参与偏序比较,编译器因此可以输出「约束 A 满足、约束 B 不满足」这种结构化信息。
这个设计的代价也很明显:约束的求值顺序、原子约束的同一性判定、概念之间的包含关系,都变成了标准里相当复杂的条款。我一般会这么理解:概念不是为了消灭模板报错,而是把报错从「编译器内部实现细节」提升到「语言层面的可诊断行为」。
另一个取舍是不规定 ABI。名字修饰、异常传播、虚表布局、迭代器实现,标准一概不碰。这让不同编译器可以各自优化,也让跨编译器链接二进制库变成一件麻烦事。标准的选择是只管语义,把二进制兼容的责任交给平台和工具链。
最后是不规定错误信息的格式。标准只要求「至少一条诊断」,不要求说清楚哪里错了。这解释了为什么同一段代码在不同编译器下报错质量差这么多。
验证一个行为时,我通常先写最小复现,把无关代码全删掉;然后在标准文本里搜关键词,比如undefined、odr、reachability;再用static_assert在编译期探测 constexpr 的边界,用-fsanitize=undefined在运行期抓未定义行为。这套流程走下来,绝大多数「编译器 bug」最后都能定位到自己代码里的某条越界假设。
从那以后我每次怀疑是工具链的问题,都会先强制走一遍「最小复现 + 查标准条款 + 双编译器对照」的流程,十次里有九次是我自己写错了。希望帮到你。
本文还有配套的精品资源,点击获取