文章目录
- 1.定义和单一定义规则
- C++ 中的定义和单一定义规则 (ODR)
- 1. **定义**
- 2. **单一定义规则 (ODR)**
- 2.1 **ODR 的基本要求**
- 2.2 **ODR 的例外情况**
- 2.3 **ODR 使用 (ODR-use)**
- 2.4 **ODR 使用的正式定义**
- 2.5 **命名函数的 ODR 使用**
- 3. **总结**
- 2.名称查找
- C++ 中的名称查找
- 1. **非限定名称查找**
- 2. **限定名称查找**
- 3. **依赖于参数的查找 (ADL)**
- 4. **模板参数推断**
- 5. **重载解析**
- 6. **结构黑客(类型/非类型隐藏)**
- 7. **查找类型**
- 8. **总结**
- C++ 中的限定名称查找
- 1. **基本概念**
- 2. **全局命名空间中的查找**
- 3. **类成员的查找**
- 4. **命名空间成员的查找**
- 5. **枚举器的查找**
- 6. **伪析构函数调用**
- 7. **限定名称查找的特殊情况**
- 8. **总结**
- C++ 中的非限定名称查找
- 1. **基本概念**
- 2. **文件作用域中的查找**
- 3. **命名空间作用域中的查找**
- 4. **在命名空间外部定义中的查找**
- 5. **非成员函数定义中的查找**
- 6. **类定义中的查找**
- 7. **注入类名**
- 8. **成员函数定义中的查找**
- 9. **虚拟继承中的支配规则**
- 10. **总结**
- C++ 中的友元函数定义与查找规则
- 1. **类内部定义的友元函数**
- 2. **类外部定义的友元函数**
- 3. **友元函数声明中的名称查找**
- 4. **默认参数中的名称查找**
- 5. **静态数据成员定义中的名称查找**
- 6. **枚举器声明中的名称查找**
- 7. **函数 try 块的处理程序中的名称查找**
- 8. **重载运算符中的名称查找**
- 9. **模板定义中的名称查找**
- 10. **总结**
- 3/未定义行为
- C++ 中的未定义行为 (Undefined Behavior, UB)
- 1. **C++ 标准中的分类**
- 2. **未定义行为的常见例子**
- 3. **未定义行为与优化**
- 4. **未定义行为的后果**
- 5. **如何避免未定义行为**
- 6. **总结**
- 4.翻译过程
- 5. 预处理器
- C++ 预处理器指令总结
- 1. **条件编译指令**
- 示例:
- C++23 新增的 `#elifdef` 和 `#elifndef`:
- 2. **宏定义指令**
- 示例:
- 取消定义宏:
- 3. **文件包含指令**
- 示例:
- 4. **编译器控制指令**
- 示例:
- 5. **字符串化和粘贴操作符**
- 示例:
- 6. **C++23 新特性**
- 示例:
- 7. **总结**
1.定义和单一定义规则
C++ 中的定义和单一定义规则 (ODR)
C++ 语言中的定义和**单一定义规则(ODR)**是确保程序正确性和一致性的关键概念。下面我们将详细解释这两个概念,并提供一些具体的例子来帮助理解。
1. 定义
在 C++ 中,定义是指完全定义声明引入的实体的声明。换句话说,定义不仅声明了实体的存在,还提供了其实现或初始化。以下是一些常见的定义和非定义的例子:
-
函数定义:包含函数体的声明。
int f(int x) { return x + 1; } // 定义 int g(int); // 声明,不是定义
-
变量定义:带有初始化器的声明。
extern int a; // 声明,不是定义 int b = 42; // 定义
-
静态数据成员:类内部声明的静态数据成员需要在类外部定义。
struct S {static int i; // 声明,不是定义inline static int x; // 定义 };int S::i = 0; // 定义
-
枚举:不透明声明(即没有枚举值列表)的枚举不是定义。
enum Color : int; // 声明,不是定义 enum Color : int { Red, Green, Blue }; // 定义
-
模板参数:模板参数的声明不是定义。
template<typename T> // 声明,不是定义 void f(T t) {} // 定义
-
类名的声明:前向声明或在另一个声明中使用 elaborated 类型说明符的类名不是定义。
struct S; // 声明,不是定义 class Y f(class T p); // 声明 Y 和 T,但不是定义
-
typedef 和 using 声明:这些声明不是定义。
typedef int IntType; // 声明,不是定义 using IntType = int; // 声明,不是定义
-
using 指令:不是定义。
using namespace std; // 不是定义
-
显式实例化声明:
extern template
声明不是定义。extern template void f<int>(); // 声明,不是定义
-
显式特化:不带定义的显式特化不是定义。
template<> struct A<int>; // 声明,不是定义
-
asm 声明:虽然不是定义任何实体,但它被归类为定义。
asm("nop"); // 定义
2. 单一定义规则 (ODR)
单一定义规则 (ODR) 是 C++ 中的一个重要规则,它确保在整个程序中,每个变量、函数、类类型、枚举类型、概念(自 C++20 起)或模板只能有一个定义。违反 ODR 的程序行为是未定义的,编译器不需要诊断这种违规行为。
2.1 ODR 的基本要求
- 每个翻译单元中:在一个翻译单元中,只允许对任何变量、函数、类类型、枚举类型、概念或模板进行一次定义。
- 整个程序中:对于每个被 odr-used(见下文)的非内联函数或变量,程序中必须有且只有一个定义。
- 内联函数和变量:对于内联函数或内联变量(自 C++17 起),在每个 odr-used 它的翻译单元中都需要一个定义。
2.2 ODR 的例外情况
某些实体可以在多个翻译单元中定义,只要满足以下条件:
- 类类型、枚举类型、内联函数、内联变量、模板化实体:这些实体可以在多个翻译单元中定义,前提是:
- 每个定义出现在不同的翻译单元中。
- 定义不附加到命名模块(自 C++20 起)。
- 每个定义由相同的标记序列组成(通常出现在同一个头文件中)。
- 从每个定义内部进行名称查找都会找到相同的实体。
- 具有内部或无链接的常量可以引用不同的对象,只要它们不被 odr-used 并且在每个定义中具有相同的值。
- lambda 表达式由用于定义它们的标记序列唯一标识。
- 重载运算符、转换、赋值和释放函数在每个定义中都引用相同的函数。
- 相应的实体在每个定义中都具有相同的语言链接。
- 如果 const 对象在任何定义中被常量初始化,则它在每个定义中都被常量初始化。
- 所有这些要求适用于模板的定义点和实例化点的依赖名称。
2.3 ODR 使用 (ODR-use)
ODR 使用是指在程序中某个实体被实际使用的情况。如果一个实体被 ODR 使用,则其定义必须存在于程序中的某个位置。以下是 ODR 使用的具体情况:
- 读取或写入对象的值:如果读取或写入对象的值(除非它是编译时常量),则该对象被 ODR 使用。
- 获取对象的地址:如果获取对象的地址,则该对象被 ODR 使用。
- 将引用绑定到对象:如果将引用绑定到对象,则该对象被 ODR 使用。
- 函数调用:如果对函数进行了函数调用或获取了函数的地址,则该函数被 ODR 使用。
- 虚成员函数:如果虚成员函数不是纯虚成员函数,则它被 ODR 使用。
- 分配和释放函数:类的非放置分配或释放函数由该类的构造函数或析构函数的定义 ODR 使用。
- 赋值运算符:类的赋值运算符由其他类的隐式定义的复制赋值或移动赋值函数 ODR 使用。
- 构造函数和析构函数:类的构造函数和析构函数由选择它的初始化或可能调用它的代码 ODR 使用。
2.4 ODR 使用的正式定义
根据 C++ 标准,ODR 使用的正式定义如下:
-
变量的 ODR 使用:
- 在可能被求值的表达式
ex
中的变量x
被 ODR 使用,除非:- 对
x
应用左值到右值转换会产生一个不调用非平凡函数的常量表达式。 x
不是对象(即x
是引用),或者,如果x
是对象,它是较大表达式e
的潜在结果之一,其中该较大表达式要么是丢弃值表达式,要么对其应用了左值到右值转换。
- 对
- 在可能被求值的表达式
-
this
的 ODR 使用:- 如果
this
作为可能被求值的表达式出现(包括非静态成员函数调用表达式中的隐式this
),则*this
被 ODR 使用。
- 如果
-
结构化绑定的 ODR 使用(自 C++17 起):
- 如果结构化绑定作为可能被求值的表达式出现,则它被 ODR 使用。
-
函数的 ODR 使用:
- 如果函数由可能被求值的表达式或转换命名,则该函数被 ODR 使用。
- 如果虚成员函数不是纯虚成员函数,则它被 ODR 使用。
- 类的非放置分配或释放函数由该类的构造函数或析构函数的定义 ODR 使用。
- 类的非放置释放函数由该类的析构函数的定义 ODR 使用。
- 类的赋值运算符由其他类的隐式定义的复制赋值或移动赋值函数 ODR 使用。
- 类的构造函数由选择它的初始化 ODR 使用。
- 如果类的析构函数可能被调用,则它被 ODR 使用。
2.5 命名函数的 ODR 使用
- 函数由表达式或转换命名:
- 如果函数的名称作为表达式或转换出现(包括命名函数、重载运算符、用户定义的转换、用户定义的放置形式的
operator new
、非默认初始化),并且它被重载决议选择,则该函数由该表达式命名,除非它是不合格的纯虚成员函数或指向纯虚成员函数的成员指针。 - 类的分配或释放函数由表达式中出现的
new
或delete
表达式命名。 - 即使发生复制省略,选择用于复制或移动对象的构造函数也被认为是由表达式或转换命名的。
- 命名
constexpr
函数的可能常量求值表达式或转换会使其成为常量求值所需,这会触发默认函数的定义或函数模板特化的实例化,即使表达式未求值。
- 如果函数的名称作为表达式或转换出现(包括命名函数、重载运算符、用户定义的转换、用户定义的放置形式的
3. 总结
- 定义:是指完全定义声明引入的实体的声明。定义不仅声明了实体的存在,还提供了其实现或初始化。
- 单一定义规则 (ODR):确保在整个程序中,每个变量、函数、类类型、枚举类型、概念或模板只能有一个定义。违反 ODR 的程序行为是未定义的。
- ODR 使用:是指在程序中某个实体被实际使用的情况。如果一个实体被 ODR 使用,则其定义必须存在于程序中的某个位置。
理解 ODR 和定义的概念对于编写正确的 C++ 程序至关重要,尤其是在处理跨多个翻译单元的代码时。遵循这些规则可以避免链接错误和其他未定义行为,确保程序的正确性和一致性。
2.名称查找
C++ 中的名称查找
名称查找是 C++ 编译器在编译过程中将代码中的名称与其声明关联起来的过程。这是确保程序中使用的每个名称都能正确解析为其定义的关键步骤。名称查找涉及到多个方面,包括非限定名称查找、限定名称查找、依赖于参数的查找(ADL)、模板参数推断以及重载解析等。
1. 非限定名称查找
非限定名称查找是指在不使用作用域解析运算符 ::
或其他限定符的情况下查找名称。编译器会从当前作用域开始,逐步向外层作用域扩展,直到找到匹配的声明。如果在所有作用域中都没有找到匹配的声明,则编译失败。
-
作用域层次:
- 局部作用域:函数内部或块内的变量和函数。
- 类作用域:类内部的成员变量和成员函数。
- 命名空间作用域:命名空间内的声明。
- 全局作用域:全局命名空间中的声明。
-
示例:
int x = 10;void f() {int x = 20; // 局部作用域的 x 隐藏了全局作用域的 xstd::cout << x << std::endl; // 输出 20 }int main() {f();std::cout << x << std::endl; // 输出 10 }
2. 限定名称查找
限定名称查找是指使用作用域解析运算符 ::
或其他限定符来指定名称的作用域。这种方式可以明确地告诉编译器在哪里查找名称。
-
命名空间限定:
- 使用
::
来访问全局命名空间中的名称。 - 使用
namespace::name
来访问特定命名空间中的名称。
- 使用
-
类成员限定:
- 使用
class::member
来访问类的静态成员。 - 使用
object.member
或pointer->member
来访问对象的成员。
- 使用
-
示例:
namespace N {int x = 30; }void f() {int x = 40;std::cout << x << std::endl; // 输出 40std::cout << ::x << std::endl; // 输出 10std::cout << N::x << std::endl; // 输出 30 }
3. 依赖于参数的查找 (ADL)
依赖于参数的查找 (Argument-Dependent Lookup, ADL) 是一种特殊的名称查找机制,用于在调用函数时,根据函数参数的类型自动查找相关的命名空间。ADL 主要用于重载操作符和模板函数。
-
规则:
- 如果函数调用中包含某个类型的参数,编译器会在该类型的命名空间中查找匹配的函数。
- ADL 仅适用于未限定的函数调用(即没有使用作用域解析运算符
::
的调用)。 - ADL 不会跨越命名空间边界,即不会在父命名空间中查找。
-
示例:
namespace N {struct S {};void f(S) { std::cout << "N::f(S)\n"; } }void g(N::S s) {f(s); // ADL 查找 N::f }int main() {N::S s;g(s); // 输出 "N::f(S)" }
4. 模板参数推断
模板参数推断是指编译器根据函数调用时传递的实际参数类型,自动推断模板参数的过程。模板参数推断与名称查找密切相关,因为模板函数的重载解析依赖于推断出的模板参数。
-
规则:
- 编译器会根据函数调用时传递的实际参数类型,推断模板参数的类型。
- 如果模板参数无法通过参数推断得出,则必须显式指定模板参数。
- 模板参数推断与重载解析结合使用,以选择最合适的函数模板实例。
-
示例:
template<typename T> void f(T t) {std::cout << "f(" << t << ")\n"; }int main() {f(42); // 推断 T 为 intf(3.14); // 推断 T 为 double }
5. 重载解析
重载解析是指编译器在找到多个同名的函数或函数模板后,选择最合适的一个进行调用的过程。重载解析基于以下几个因素:
-
参数匹配:编译器会比较函数的参数列表与实际调用时传递的参数,选择最匹配的函数。
-
转换规则:如果存在多个可能的匹配函数,编译器会选择需要最少类型转换的函数。
-
模板特化:如果有模板特化版本,编译器会优先选择特化版本。
-
用户定义的转换:编译器会考虑用户定义的转换函数,但这些转换通常被视为次优选择。
-
示例:
void f(int) { std::cout << "f(int)\n"; } void f(double) { std::cout << "f(double)\n"; } void f(const char*) { std::cout << "f(const char*)\n"; }int main() {f(42); // 调用 f(int)f(3.14); // 调用 f(double)f("hello"); // 调用 f(const char*) }
6. 结构黑客(类型/非类型隐藏)
结构黑客(也称为“类型/非类型隐藏”)是指在一个作用域内,某些名称可能同时表示类型和非类型实体(如变量、函数等)。在这种情况下,非类型实体会隐藏类型名称,除非使用详细的类型说明符来访问类型。
-
规则:
- 如果在同一个作用域内,某个名称既表示类型又表示非类型实体,则非类型实体会隐藏类型名称。
- 为了访问被隐藏的类型名称,必须使用详细类型说明符(如
struct
、class
、enum
等)。
-
示例:
struct X {};void f() {int X = 42; // 非类型实体 X 隐藏了类型 XX x; // 错误:X 被解释为 int 变量struct X x; // 正确:使用详细类型说明符访问类型 X }
7. 查找类型
当名称紧随作用域解析运算符 ::
的右侧出现时,编译器会查找类型名称。这种查找方式通常用于访问全局命名空间中的类型,或者访问嵌套在其他命名空间或类中的类型。
- 示例:
namespace N {class A {}; }void f() {N::A a; // 查找 N 命名空间中的类型 A }
8. 总结
C++ 中的名称查找是一个复杂的过程,涉及多个步骤和规则。以下是名称查找的主要要点:
- 非限定名称查找:从当前作用域开始,逐步向外层作用域扩展,直到找到匹配的声明。
- 限定名称查找:使用作用域解析运算符
::
或其他限定符来指定名称的作用域。 - 依赖于参数的查找 (ADL):根据函数参数的类型自动查找相关的命名空间。
- 模板参数推断:根据函数调用时传递的实际参数类型,自动推断模板参数。
- 重载解析:在找到多个同名的函数或函数模板后,选择最合适的一个进行调用。
- 结构黑客:在同一作用域内,非类型实体会隐藏类型名称,除非使用详细类型说明符。
- 查找类型:当名称紧随作用域解析运算符
::
的右侧出现时,编译器会查找类型名称。
理解名称查找的规则对于编写正确的 C++ 程序至关重要,尤其是在处理复杂的命名空间、模板和重载函数时。通过掌握这些规则,可以避免常见的错误,确保程序的正确性和可维护性。
C++ 中的限定名称查找
限定名称查找是指在使用作用域解析运算符 ::
时,编译器如何查找和解析名称。通过 ::
,可以明确指定名称的作用域,从而避免名称冲突或隐藏问题。限定名称查找可以用于访问类成员、命名空间成员、枚举器等。以下是关于限定名称查找的详细说明和示例。
1. 基本概念
- 限定名称:是指紧随作用域解析运算符
::
右侧出现的名称。 - 作用域解析运算符
::
:用于显式指定名称的作用域。它可以出现在全局命名空间、命名空间、类或枚举的作用域中。 - 查找顺序:在执行
::
右侧名称的查找之前,必须先完成其左侧名称的查找。左侧名称可以是命名空间、类、枚举或模板特化为类型的名称。
2. 全局命名空间中的查找
如果 ::
左侧没有内容,则查找只考虑 全局命名空间 中的声明。这使得即使名称被局部声明隐藏,也可以引用这些名称。
- 示例:
namespace M {const char* fail = "fail\n"; }using M::fail;namespace N {const char* ok = "ok\n"; }using namespace N;int main() {struct std {}; // 局部声明隐藏了全局命名空间中的 std// 错误:非限定查找找到局部声明的 struct stdstd::cout << ::fail; // Error: unqualified lookup for 'std' finds the struct// 正确:::std 找到全局命名空间中的 std::std::cout << ::ok; }
3. 类成员的查找
如果 ::
左侧的名称是类/结构体或联合的名称,则在该类的作用域中查找 ::
右侧的名称。这样可以找到该类或其基类的成员声明。
-
构造函数和析构函数:
- 如果
::
右侧的名称与左侧名称相同,则该名称指定该类的 构造函数。此类限定名称只能在构造函数声明中和在用于继承构造函数的using
声明中使用。 - 对于 析构函数,
~
后面的名称在::
左侧名称的作用域中查找。
- 如果
-
示例:
struct A {static int n; };int main() {int A; // 局部声明隐藏了类 A// 正确:非限定查找忽略局部声明的变量 A,找到类 A 的静态成员A::n = 42;// 错误:非限定查找找到局部声明的变量 AA b; // Error: unqualified lookup of A finds the variable A// 构造函数和析构函数struct B { ~B(); };typedef B AB;AB x;x.AB::~AB(); // 正确:~AB 在当前作用域中查找,找到 ::AB }
-
继承构造函数:
- 使用
using
声明可以将基类的构造函数引入派生类中。 - 示例:
struct Base {Base(int) {} };struct Derived : Base {using Base::Base; // 继承构造函数 };int main() {Derived d(42); // 调用继承的构造函数 }
- 使用
4. 命名空间成员的查找
如果 ::
左侧的名称是指命名空间,或者 ::
左侧没有任何内容(在这种情况下它指的是全局命名空间),那么 ::
右侧出现的名称将在该命名空间的作用域中查找。
-
内联命名空间:
- 内联命名空间的成员会递归地包含在其父命名空间中。
- 示例:
namespace Outer {inline namespace Inner {void f() {}} }int main() {Outer::f(); // 正确:Inner 是 Outer 的内联命名空间 }
-
using 指令:
using namespace
指令会将指定命名空间中的所有声明引入当前作用域。- 示例:
namespace N {void f() {} }using namespace N;int main() {f(); // 正确:f 是 N::f }
-
多个 using 指令:
- 如果多个命名空间通过
using
指令引入,且它们都包含同名的声明,则会导致歧义错误。 - 示例:
namespace A {void f(int) {} }namespace B {void f(char) {} }namespace AB {using namespace A;using namespace B; }int main() {AB::f(1); // 错误:A::f 和 B::f 都匹配 }
- 如果多个命名空间通过
-
模板参数:
- 模板参数中的名称在 当前作用域 中查找,而不是在模板名称的作用域中。
- 示例:
namespace N {template<typename T>struct foo {};struct X {}; }N::foo<X> x; // 错误:X 被查找为 ::X,而不是 N::X
5. 枚举器的查找
如果 ::
左侧的名称是指枚举(作用域内的或非作用域内的),则 ::
右侧的名称必须返回属于该枚举的枚举器,否则程序格式不正确。
- 示例:
enum Color { Red, Green, Blue };int main() {Color c = Color::Red; // 正确:Red 是 Color 枚举的枚举器 }
6. 伪析构函数调用
当 ::
后面紧跟着字符 ~
,而 ~
后面紧跟着标识符(即,它指定析构函数或伪析构函数)时,~
后面的名称在 ::
左侧名称所在的相同作用域中查找。
- 示例:
struct C { typedef int I; };typedef int I1, I2;extern int *p, *q;struct A { ~A(); };typedef A AB;int main() {p->C::I::~I(); // 正确:I 在 C 的作用域中查找,找到 C::Iq->I1::~I2(); // 正确:I2 在当前作用域中查找,找到 ::I2AB x;x.AB::~AB(); // 正确:AB 在当前作用域中查找,找到 ::AB }
7. 限定名称查找的特殊情况
-
构造函数和析构函数:
::
右侧的名称与左侧名称相同时,表示构造函数或析构函数。- 示例:
struct A { A(); };struct B : A { B(); };A::A() {} // 正确:A::A 表示构造函数 B::B() {} // 正确:B::B 表示构造函数B::A ba; // 错误:A::A 不是类型,而是构造函数 struct A::A a2; // 正确:A::A 表示类 A
-
虚函数调用:
- 对限定成员函数的调用永远不会是虚函数调用,而是静态分派。
- 示例:
struct B { virtual void foo(); };struct D : B { void foo() override; };int main() {D x;B& b = x;b.foo(); // 调用 D::foo(虚函数调用)b.B::foo(); // 调用 B::foo(静态分派) }
8. 总结
限定名称查找是 C++ 中确保名称解析正确性的关键机制。通过使用作用域解析运算符 ::
,可以明确指定名称的作用域,从而避免名称冲突或隐藏问题。以下是限定名称查找的主要要点:
- 全局命名空间:
::
左侧没有内容时,查找只考虑全局命名空间中的声明。 - 类成员:
::
左侧是类/结构体或联合的名称时,在该类的作用域中查找::
右侧的名称。 - 命名空间成员:
::
左侧是命名空间的名称时,在该命名空间的作用域中查找::
右侧的名称。 - 枚举器:
::
左侧是枚举的名称时,::
右侧的名称必须是该枚举的枚举器。 - 伪析构函数调用:
::
后面紧跟着~
时,~
后面的名称在::
左侧名称的作用域中查找。 - 构造函数和析构函数:
::
右侧的名称与左侧名称相同时,表示构造函数或析构函数。 - 虚函数调用:对限定成员函数的调用永远不会是虚函数调用,而是静态分派。
理解这些规则对于编写正确的 C++ 程序至关重要,尤其是在处理复杂的命名空间、类层次结构和模板时。通过掌握限定名称查找的规则,可以避免常见的错误,确保程序的正确性和可维护性。
C++ 中的非限定名称查找
非限定名称查找是指编译器在不使用作用域解析运算符 ::
的情况下,查找程序中使用的名称的过程。这种查找方式会从当前作用域开始,逐步向外层作用域扩展,直到找到匹配的声明。如果在所有作用域中都没有找到匹配的声明,则编译失败。
1. 基本概念
- 非限定名称:是指未出现在作用域解析运算符
::
右侧的名称。 - 查找顺序:从当前作用域开始,逐步向外层作用域扩展,直到找到匹配的声明。查找会在找到第一个匹配的声明后停止,不再继续检查其他作用域。
- using 指令:由
using
指令指定的命名空间中的所有声明,看起来就像是在包含using
指令和指定命名空间(直接或间接)的最近封闭命名空间中声明的一样。
2. 文件作用域中的查找
对于在全局(顶层命名空间)作用域中使用的名称,在任何函数、类或用户声明的命名空间外部,会检查名称使用之前的全局作用域。
- 示例:
int n = 1; // 声明 n int x = n + 1; // OK: 查找找到 ::nint z = y - 1; // 错误:查找失败 int y = 2; // 声明 y
3. 命名空间作用域中的查找
对于在用户声明的命名空间中,在任何函数或类外部使用的名称,会在名称使用之前搜索该命名空间,然后搜索在声明该命名空间之前包含该命名空间的命名空间,等等,直到到达全局命名空间。
- 示例:
int n = 1; // 声明namespace N {int m = 2;namespace Y {int x = n; // OK, 查找找到 ::nint y = m; // OK, 查找找到 ::N::mint z = k; // 错误:查找失败}int k = 3; }
4. 在命名空间外部定义中的查找
对于在命名空间成员变量的定义中使用的名称,该定义在命名空间外部,查找方式与在命名空间内部使用的名称相同。
- 示例:
namespace X {extern int x; // 声明,不是定义int n = 1; // 找到 1st }int n = 2; // 找到 2nd int X::x = n; // 找到 X::n,设置 X::x 为 1
5. 非成员函数定义中的查找
对于在函数的定义中使用的名称,无论是在函数体中还是作为默认参数的一部分,只要该函数是用户声明或全局命名空间的成员,都会在名称使用之前搜索使用该名称的块,然后搜索该块开始之前的外层块,等等,直到到达作为函数体的块。然后会搜索声明该函数的命名空间,直到找到使用该名称的函数的定义(不一定是声明),然后搜索外层命名空间,等等。
- 示例:
namespace A {namespace N {void f();int i = 3; // 找到 3rd (如果 2nd 不存在)}int i = 4; // 找到 4th (如果 3rd 不存在) }int i = 5; // 找到 5th (如果 4th 不存在)void A::N::f() {int i = 2; // 找到 2nd (如果 1st 不存在)while (true) {int i = 1; // 找到 1st: 查找完成std::cout << i;} }
6. 类定义中的查找
对于在类定义中的任何地方使用的名称(包括基类说明符和嵌套类定义),除了在成员函数体内部、成员函数的默认参数、成员函数的异常说明或默认成员初始化器内部,该成员可能属于嵌套类,其定义在封闭类的体内,会搜索以下作用域:
a) 使用该名称的类的体,直到使用该名称的位置。
b) 其基类(如果存在)的整个主体,如果未找到声明,则递归至它们的基类。
c) 如果此类是嵌套的,则从包含类的定义处到此类定义处的所有主体,以及包含类的基类(如果存在)的整个主体。
d) 如果此类是局部的,或者嵌套在一个局部类中,则搜索定义此类的块作用域,直到定义点。
e) 如果此类是命名空间的成员,或者嵌套在一个是命名空间成员的类中,或者是在命名空间成员函数中定义的局部类,则搜索命名空间的作用域,直到找到类、包含类或函数的定义;查找将继续搜索包含该命名空间的命名空间,直到全局作用域。
- 示例:
namespace M {class B {static const int i = 3; // 找到 3rd (但不会通过访问检查)}; }namespace N {class Y : public M::B {class X {static const int i = 1; // 找到 1stint a[i]; // 使用 i};}; }
7. 注入类名
对于在该类或类模板的定义中使用的类或类模板的名称,或者从它派生的名称,未限定名称查找将找到正在定义的类,就好像名称是由成员声明(具有公共成员访问权限)引入的一样。
- 示例:
struct X { void f(); };struct B1 : virtual X { void f(); };struct B2 : virtual X {};struct D : B1, B2 {void foo() {X::f(); // OK, 调用 X::f (限定查找)f(); // OK, 调用 B1::f (非限定查找)} };
8. 成员函数定义中的查找
对于在成员函数体、成员函数的默认参数、成员函数的异常说明或默认成员初始化器中使用的名称,搜索的作用域与类定义中相同,不同之处在于考虑类的整个作用域,而不仅仅是使用该名称的声明之前的部分。对于嵌套类,搜索包含类的整个主体。
- 示例:
class B {int i; // 找到 3rd };namespace M {namespace N {class X : public B {void f();};void X::f() {i = 16; // 找到 1st}} }
9. 虚拟继承中的支配规则
在检查从其派生类的基类时,遵循以下规则,有时称为 虚拟继承中的支配:
-
如果
A
是B
的基类子对象,则在子对象B
中找到的成员名称将隐藏在任何子对象A
中的相同成员名称。(请注意,这不会隐藏在继承层次结构中任何其他非虚拟A
副本中的名称,这些副本不是B
的基类:此规则仅对虚拟继承有影响。) -
示例:
struct V { int v; };struct B {int a;static int s;enum { e }; };struct B1 : B, virtual V {}; struct B2 : B, virtual V {}; struct D : B1, B2 {};void f(D& pd) {++pd.v; // OK: 只有一个 v 因为只有一个虚拟基类子对象++pd.s; // OK: 只有一个静态 B::s,即使在 B1 和 B2 中都能找到int i = pd.e; // OK: 只有一个枚举器 B::e,即使在 B1 和 B2 中都能找到++pd.a; // 错误,模糊:B::a 在 B1 和 B2 中都找到了 }
10. 总结
非限定名称查找是 C++ 编译器在编译过程中将代码中的名称与其声明关联起来的关键步骤之一。它是从当前作用域开始,逐步向外层作用域扩展,直到找到匹配的声明。查找会在找到第一个匹配的声明后停止,不再继续检查其他作用域。以下是非限定名称查找的主要要点:
- 文件作用域:在全局作用域中使用的名称会检查名称使用之前的全局作用域。
- 命名空间作用域:在命名空间中使用的名称会从当前命名空间开始,逐步向外层命名空间扩展,直到到达全局命名空间。
- 命名空间外部定义:在命名空间成员变量的定义中使用的名称,查找方式与在命名空间内部使用的名称相同。
- 非成员函数定义:在函数的定义中使用的名称会从当前块开始,逐步向外层块扩展,直到到达函数体的块,然后搜索声明该函数的命名空间。
- 类定义:在类定义中使用的名称会搜索类的体、基类、嵌套类、局部类和命名空间的作用域。
- 注入类名:在类或类模板的定义中使用的类或类模板的名称会找到正在定义的类。
- 成员函数定义:在成员函数体、默认参数、异常说明或默认成员初始化器中使用的名称会搜索类的整个作用域。
- 虚拟继承中的支配规则:在虚拟继承中,基类子对象中的成员名称会隐藏在任何其他非虚拟基类子对象中的相同成员名称。
理解这些规则对于编写正确的 C++ 程序至关重要,尤其是在处理复杂的命名空间、类层次结构和虚拟继承时。通过掌握非限定名称查找的规则,可以避免常见的错误,确保程序的正确性和可维护性。
C++ 中的友元函数定义与查找规则
在 C++ 中,友元函数(friend function)是一种特殊类型的函数,它不是类的成员,但可以访问该类的私有和保护成员。友元函数可以在类的内部或外部定义,其名称查找规则根据定义的位置有所不同。
1. 类内部定义的友元函数
对于在授予友元关系的类的主体内部定义的友元函数,未限定名称查找将以与成员函数相同的方式进行。这意味着编译器会首先在类的作用域中查找名称,然后向外扩展到外层作用域。
-
示例:
int i = 3; // found 3rd for f1, found 2nd for f2struct X {static const int i = 2; // found 2nd for f1, never found for f2friend void f1(int x) {// int i; // found 1sti = x; // finds and modifies X::i}friend int f2(); };void f2(int x) {// int i; // found 1sti = x; // finds and modifies ::i }
- 在
f1
中,i
被解析为X::i
,因为f1
是在类X
内部定义的友元函数。 - 在
f2
中,i
被解析为全局变量::i
,因为f2
是在类外部定义的。
- 在
2. 类外部定义的友元函数
对于在类主体外部定义的友元函数,未限定名称查找将以与命名空间中的函数相同的方式进行。这意味着编译器会从当前作用域开始,逐步向外层作用域扩展,直到找到匹配的声明。
-
示例:
int i = 3; // found 3rd for f1, found 2nd for f2struct X {static const int i = 2; // found 2nd for f1, never found for f2friend void f1(int x);friend int f2(); };void X::f1(int x) {// int i; // found 1sti = x; // finds and modifies X::i }void f2(int x) {// int i; // found 1sti = x; // finds and modifies ::i }
- 在
X::f1
中,i
被解析为X::i
,因为f1
是类X
的友元函数。 - 在
f2
中,i
被解析为全局变量::i
,因为f2
是在类外部定义的。
- 在
3. 友元函数声明中的名称查找
对于在将另一个类的成员函数设为友元的友元函数声明的声明符中使用的名称,如果该名称不是声明符标识符中的任何模板参数的一部分,则未限定查找将首先检查成员函数类的整个作用域。如果在该作用域中没有找到(或者如果该名称是声明符标识符中的模板参数的一部分),则查找将继续进行,就好像它是授予友元关系的类的成员函数一样。
-
示例:
template<class T> struct S;struct A {typedef int AT;void f1(AT);void f2(float);template<class T>void f3();void f4(S<AT>); };struct B {typedef char AT;typedef float BT;friend void A::f1(AT); // lookup for AT finds A::AT (AT found in A)friend void A::f2(BT); // lookup for BT finds B::BT (BT not found in A)friend void A::f3<AT>(); // lookup for AT finds B::AT (no lookup in A, because AT is in the declarator identifier A::f3<AT>) };template<class AT> struct C {friend void A::f4(S<AT>); // lookup for AT finds A::AT (AT is not in the declarator identifier A::f4) };
- 在
B
中,A::f1(AT)
中的AT
被解析为A::AT
,因为在A
中找到了AT
。 - 在
A::f2(BT)
中的BT
被解析为B::BT
,因为在A
中找不到BT
,因此继续在外层作用域中查找。 - 在
A::f3<AT>()
中的AT
被解析为B::AT
,因为AT
是模板参数的一部分,不会在A
中查找。 - 在
C
中,A::f4(S<AT>)
中的AT
被解析为A::AT
,因为AT
不是模板参数的一部分。
- 在
4. 默认参数中的名称查找
对于在函数声明中的默认参数中使用的名称,或者在构造函数的成员初始化器的表达式部分中使用的名称,函数参数名称将首先被找到,然后才检查包含块、类或命名空间的作用域。
-
示例:
class X {int a, b, i, j; public:const int& r;X(int i) : r(a), // initializes X::r to refer to X::ab(i), // initializes X::b to the value of the parameter ii(i), // initializes X::i to the value of the parameter ij(this->i) // initializes X::j to the value of X::i{} };int a; int f(int a, int b = a); // error: lookup for a finds the parameter a, not ::a// and parameters are not allowed as default arguments
- 在构造函数
X
中,r(a)
初始化X::r
为X::a
,b(i)
初始化X::b
为参数i
,i(i)
初始化X::i
为参数i
,j(this->i)
初始化X::j
为X::i
。 - 在
f
中,b = a
是错误的,因为a
被解析为参数a
,而不是全局变量::a
,并且参数不能用作默认参数。
- 在构造函数
5. 静态数据成员定义中的名称查找
对于在静态数据成员的定义中使用的名称,查找将以与在成员函数的定义中使用的名称相同的方式进行。
-
示例:
struct X {static int x;static const int n = 1; // found 1st };int n = 2; // found 2nd int X::x = n; // finds X::n, sets X::x to 1, not 2
- 在
X::x
的定义中,n
被解析为X::n
,因此X::x
被初始化为1
,而不是全局变量::n
的值2
。
- 在
6. 枚举器声明中的名称查找
对于在枚举器声明的初始化部分中使用的名称,先前声明的相同枚举中的枚举器将首先被找到,然后才进行未限定名称查找以检查包含块、类或命名空间的作用域。
-
示例:
const int RED = 7;enum class color {RED,GREEN = RED + 2, // RED finds color::RED, not ::RED, so GREEN = 2BLUE = ::RED + 4 // qualified lookup finds ::RED, BLUE = 11 };
- 在
GREEN = RED + 2
中,RED
被解析为color::RED
,因此GREEN
的值为2
。 - 在
BLUE = ::RED + 4
中,::RED
被解析为全局变量::RED
,因此BLUE
的值为11
。
- 在
7. 函数 try 块的处理程序中的名称查找
对于在处理程序中使用的名称,函数 try 块,查找将像在函数体最外层块的开头使用名称一样进行(特别是,函数参数是可见的,但在最外层块中声明的名称不可见)。
-
示例:
int n = 3; // found 3rd int f(int n = 2) // found 2ndtry {int n = -1; // never found } catch(...) {// int n = 1; // found 1stassert(n == 2); // lookup for n finds function parameter fthrow; }
- 在
catch
块中,n
被解析为函数参数f
的参数n
,而不是局部变量n
或全局变量::n
。
- 在
8. 重载运算符中的名称查找
对于在表达式中使用的运算符(例如,在 a + b
中使用的 operator+
),查找规则略微不同于在显式函数调用表达式中使用的运算符(例如 operator+(a, b)
)。在解析表达式时,将执行两次单独的查找:对于非成员运算符重载和对于成员运算符重载(对于两种形式都允许的运算符)。然后,根据重载解析中的描述,将这些集合与内置运算符重载合并,并将其视为同等。如果使用显式函数调用语法,则将执行常规的未限定名称查找。
-
示例:
struct A {}; void operator+(A, A); // user-defined non-member operator+struct B {void operator+(B); // user-defined member operator+void f(); };A a;void B::f() {operator+(a, a); // error: regular name lookup from a member function// finds the declaration of operator+ in the scope of B// and stops there, never reaching the global scopea + a; // OK: member lookup finds B::operator+, non-member lookup// finds ::operator+(A, A), overload resolution selects ::operator+(A, A) }
- 在
operator+(a, a)
中,operator+
被解析为B::operator+
,因为它是从成员函数B::f
中调用的,查找停止在类B
的作用域中。 - 在
a + a
中,operator+
被解析为::operator+(A, A)
,因为重载解析会选择最佳匹配的运算符。
- 在
9. 模板定义中的名称查找
对于在模板定义中使用的非依赖名称,未限定名称查找将在检查模板定义时发生。在该点做出的绑定不会受到在实例化点可见的声明的影响。对于在模板定义中使用的依赖名称,查找将推迟到模板参数已知时,此时 ADL 将检查从模板定义上下文以及模板实例化上下文中可见的函数声明具有外部链接(直到 C++11),而非 ADL 查找仅检查从模板定义上下文可见的函数声明具有外部链接(直到 C++11)(换句话说,在模板定义之后添加新的函数声明不会使其可见,除非通过 ADL)。如果在 ADL 查找中检查的命名空间中声明的具有外部链接的更好匹配,或者如果在检查这些翻译单元时查找将是模糊的,则行为是未定义的。无论如何,如果基类依赖于模板参数,则未限定名称查找将不会检查其作用域(无论是定义点还是实例化点)。
-
示例:
void f(char); // first declaration of ftemplate<class T> void g(T t) {f(1); // non-dependent name: lookup finds ::f(char) and binds it nowf(T(1)); // dependent name: lookup postponedf(t); // dependent name: lookup postponed }enum E { e }; void f(E); // second declaration of f void f(int); // third declaration of f double dd;void h() {g(e); // instantiates g<E>, at which point// the second and the third uses of the name 'f'// are looked up and find ::f(char) (by lookup) and ::f(E) (by ADL)// then overload resolution chooses ::f(E).// This calls f(char), then f(E) twiceg(32); // instantiates g<int>, at which point// the second and the third uses of the name 'f'// are looked up and find ::f(char) only// then overload resolution chooses ::f(char)// This calls f(char) three times }typedef double A;template<class T> class B {typedef int A; };template<class T> struct X : B<T> {A a; // lookup for A finds ::A (double), not B<T>::A };
- 在
g(e)
中,f(T(1))
和f(t)
的查找被推迟到模板实例化时,此时f(E)
通过 ADL 找到,f(char)
通过普通查找找到,重载解析选择f(E)
。 - 在
g(32)
中,f(T(1))
和f(t)
的查找被推迟到模板实例化时,此时只有f(char)
被找到,因此重载解析选择f(char)
。 - 在
X
中,A
被解析为全局变量::A
,而不是B<T>::A
,因为A
是非依赖名称。
- 在
10. 总结
友元函数定义与查找规则是 C++ 编译器在编译过程中将代码中的名称与其声明关联起来的关键步骤之一。以下是友元函数查找的主要要点:
- 类内部定义的友元函数:未限定名称查找将以与成员函数相同的方式进行,首先在类的作用域中查找名称。
- 类外部定义的友元函数:未限定名称查找将以与命名空间中的函数相同的方式进行,从当前作用域开始,逐步向外层作用域扩展。
- 友元函数声明中的名称查找:首先检查成员函数类的整个作用域,如果未找到,则继续进行,就好像它是授予友元关系的类的成员函数一样。
- 默认参数中的名称查找:函数参数名称将首先被找到,然后才检查包含块、类或命名空间的作用域。
- 静态数据成员定义中的名称查找:查找将以与在成员函数的定义中使用的名称相同的方式进行。
- 枚举器声明中的名称查找:先前声明的相同枚举中的枚举器将首先被找到,然后才进行未限定名称查找。
- 函数 try 块的处理程序中的名称查找:查找将像在函数体最外层块的开头使用名称一样进行。
- 重载运算符中的名称查找:将执行两次单独的查找:对于非成员运算符重载和对于成员运算符重载。
- 模板定义中的名称查找:非依赖名称查找将在检查模板定义时发生,依赖名称查找将推迟到模板参数已知时。
理解这些规则对于编写正确的 C++ 程序至关重要,尤其是在处理复杂的类层次结构、模板和运算符重载时。通过掌握友元函数的查找规则,可以避免常见的错误,确保程序的正确性和可维护性。
3/未定义行为
C++ 中的未定义行为 (Undefined Behavior, UB)
未定义行为(Undefined Behavior, UB)是 C++ 语言中一个重要的概念,它指的是当程序违反了某些规则时,编译器和运行时环境对程序的行为没有任何保证。这意味着程序可能会以任何方式执行,包括但不限于崩溃、产生错误结果、看似正常工作或表现出其他不可预测的行为。更重要的是,编译器可以假设程序中不存在未定义行为,并基于这一假设进行优化,这可能导致与预期不符的结果。
1. C++ 标准中的分类
C++ 标准将程序的行为分为以下几类:
- 格式不正确 (Ill-formed):程序存在语法错误或可诊断的语义错误。符合标准的编译器必须发出诊断信息。
- 格式不正确,不需要诊断 (Ill-formed, no diagnostic required):程序存在语义错误,但在一般情况下可能无法诊断(例如,违反 ODR 或其他仅在链接时才能检测到的错误)。如果执行此类程序,则行为未定义。
- 实现定义的行为 (Implementation-defined behavior):程序的行为在不同的实现之间有所不同,但符合标准的实现必须记录每个行为的影响。例如,
std::size_t
的类型或一个字节中的位数。 - 未指定的行为 (Unspecified behavior):程序的行为在不同的实现之间有所不同,但符合标准的实现不需要记录每个行为的影响。例如,求值顺序、相同的字符串字面量是否不同等。
- 错误行为 (Erroneous behavior):程序的行为是不正确的,建议实现进行诊断。常量表达式的求值永远不会导致错误行为。
- 未定义行为 (Undefined behavior):对程序的行为没有限制。实现不需要诊断未定义行为,且编译后的程序也不需要执行任何有意义的操作。
2. 未定义行为的常见例子
以下是 C++ 中一些常见的未定义行为示例:
-
有符号整数溢出:
int foo(int x) {return x + 1 > x; // 如果 x 是 INT_MAX,x + 1 会导致溢出,行为未定义 }
编译器可能会优化掉这个检查,假设
x + 1 > x
总是为真。 -
数组越界访问:
int table[4] = {}; bool exists_in_table(int v) {for (int i = 0; i <= 4; i++) { // i == 4 时越界if (table[i] == v)return true;}return false; }
编译器可能会优化掉边界检查,假设
i
永远不会等于 4。 -
未初始化标量的使用:
std::size_t f(int x) {std::size_t a;if (x)a = 42;return a; // 如果 x == 0,a 未初始化,行为未定义 }
编译器可能会假设
x
总是非零,从而优化掉if
分支,直接返回 42。 -
空指针解引用:
int foo(int* p) {int x = *p; // 如果 p 是 nullptr,行为未定义if (!p)return x; // 这个分支永远不会被执行elsereturn 0; }
编译器可能会优化掉
if
分支,假设p
永远不是nullptr
。 -
无效标量的使用:
int f() {bool b = true;unsigned char* p = reinterpret_cast<unsigned char*>(&b);*p = 10; // 修改了 b 的底层表示,读取 b 的值行为未定义return b == 0; }
编译器可能会假设
b
的值始终为true
,从而优化掉对b
的读取。 -
多次修改同一个标量(无中间序列点,直到 C++11;从 C++11 开始无序):
int i = 0; i = i++; // 两次修改 i,行为未定义
-
数据竞争 (Data Race):
#include <thread>int x = 0;void increment() {++x; }int main() {std::thread t1(increment);std::thread t2(increment);t1.join();t2.join();return x; // 如果两个线程同时修改 x,行为未定义 }
-
访问传递给
std::realloc
的指针:#include <cstdlib>int main() {int* p = (int*)std::malloc(sizeof(int));int* q = (int*)std::realloc(p, sizeof(int));*p = 1; // 访问已传递给 realloc 的指针,行为未定义*q = 2;if (p == q) // 访问已传递给 realloc 的指针,行为未定义std::cout << *p << *q << '\n'; }
-
没有副作用的无限循环:
bool fermat() {const int max_value = 1000;for (int a = 1, b = 1, c = 1; true; ) {if ((a * a * a) == (b * b * b) + (c * c * c))return true;a++;if (a > max_value) {a = 1;b++;}if (b > max_value) {b = 1;c++;}if (c > max_value)c = 1;}return false; }
编译器可能会优化掉整个循环,假设它永远不会终止。
3. 未定义行为与优化
由于 C++ 标准规定正确的程序不应包含未定义行为,因此编译器可以假设程序中不存在未定义行为,并基于这一假设进行优化。这可能导致包含未定义行为的代码在优化后表现出意外的行为。例如:
-
有符号整数溢出:
int foo(int x) {return x + 1 > x; }
编译器可能会优化为:
mov eax, 1 ret
因为编译器假设
x + 1 > x
总是为真。 -
数组越界访问:
int table[4] = {}; bool exists_in_table(int v) {for (int i = 0; i <= 4; i++) {if (table[i] == v)return true;}return false; }
编译器可能会优化为:
mov eax, 1 ret
因为编译器假设
i
永远不会等于 4。 -
未初始化标量的使用:
std::size_t f(int x) {std::size_t a;if (x)a = 42;return a; }
编译器可能会优化为:
mov eax, 42 ret
因为编译器假设
x
总是非零。
4. 未定义行为的后果
未定义行为的后果可能是灾难性的,尤其是在生产环境中。它可能导致:
- 程序崩溃:未定义行为可能会导致程序在运行时崩溃,尤其是在涉及内存访问或指针操作时。
- 安全漏洞:未定义行为可能会被攻击者利用,导致缓冲区溢出、格式化字符串漏洞等安全问题。
- 难以调试:未定义行为可能会导致程序表现出随机或不可预测的行为,使得调试变得极其困难。
- 性能问题:编译器可能会基于未定义行为的假设进行激进的优化,导致程序性能下降或行为异常。
5. 如何避免未定义行为
为了避免未定义行为,开发者应该:
- 严格遵守 C++ 标准:确保代码符合 C++ 标准的要求,避免使用未定义行为的特性。
- 使用静态分析工具:使用静态分析工具(如 Clang’s AddressSanitizer、UBSanitizer 等)来检测潜在的未定义行为。
- 启用编译器警告:启用所有编译器警告,并尽量修复所有警告,尤其是与未定义行为相关的警告。
- 编写防御性代码:在关键部分添加边界检查、初始化变量、避免空指针解引用等,以减少未定义行为的发生。
- 使用现代 C++ 特性:尽可能使用现代 C++ 提供的安全特性,如智能指针、范围检查容器等,以减少低级错误的可能性。
6. 总结
未定义行为是 C++ 程序中最危险的问题之一,因为它可能导致程序在不同环境下表现出完全不同的行为,甚至在某些情况下编译器可能会优化掉某些代码,导致意想不到的结果。理解未定义行为的概念及其常见原因,对于编写健壮、安全和高效的 C++ 程序至关重要。通过遵循最佳实践、使用工具和编写防御性代码,可以有效避免未定义行为带来的风险。
4.翻译过程
C++程序的翻译过程可以分为以下几个阶段:
-
源字符映射(Phase 1):
- 源代码文件的字节被映射到基本源字符集的字符。
- 操作系统特定的行结束符被替换为换行符。
- 三字符序列被替换为对应的单字符表示(直到C++17)。
- UTF-8编码的文件被保证支持,其他类型的输入文件支持情况由实现定义。
-
行拼接(Phase 2):
- 删除行首的字节顺序标记(U+FEFF)。
- 行尾的反斜杠(\)和空白字符被删除,将两行物理源代码合并为一行逻辑源代码。
- 如果源文件在这一步之后不以换行符结尾,则添加一个换行符。
-
词法分析(Phase 3):
- 源文件被分解成预处理标记和空白字符。
- 识别并替换通用字符名。
- 原始字符串字面量中的转换在这个阶段被撤销。
-
预处理(Phase 4):
- 执行预处理器。
- 通过
#include
指令引入的每个文件都会递归地经历阶段1到4。 - 移除所有预处理指令。
-
确定共同的字符串字面量编码(Phase 5):
- 将字符字面量和字符串字面量中的字符从源字符集转换为编码(可能是多字节字符编码,如UTF-8)。
- 展开并转换字符字面量和非原始字符串字面量中的转义序列和通用字符名。
-
字符串字面量连接(Phase 6):
- 连接相邻的字符串字面量。
-
编译(Phase 7):
- 将每个预处理标记转换为标记。
- 语法和语义分析标记,并作为一个翻译单元进行翻译。
-
模板实例化(Phase 8):
- 检查每个翻译单元以产生所需的模板实例化列表。
- 定位模板定义,并执行所需的实例化以产生实例化单元。
-
链接(Phase 9):
- 将翻译单元、实例化单元和满足外部引用所需的库组件收集到程序映像中。
注意:
- 源文件、翻译单元和翻译后的翻译单元不必存储为文件,也不必与任何外部表示有一一对应关系。
- 某些实现可以通过命令行选项控制阶段5的转换,例如gcc和clang使用
-finput-charset
指定源字符集编码,-fexec-charset
和-fwide-exec-charset
指定普通和宽字面量编码;Visual Studio 2015 Update 2及更高版本使用/source-charset
和/execution-charset
指定源字符集和字面量编码。 - 一些编译器不实现实例化单元,而是在阶段7编译每个模板实例化,将代码存储在对象文件中,然后在阶段9由链接器将这些编译实例化合并为一个。
5. 预处理器
C++ 预处理器指令总结
C++ 的预处理器指令用于在编译之前对源代码进行文本级别的处理。这些指令可以帮助开发者根据不同的编译条件、平台或配置来控制代码的包含和排除。以下是 C++ 中常用的预处理器指令及其用法的详细总结,包括 C++23 引入的新特性。
1. 条件编译指令
条件编译指令允许根据某些条件选择性地包含或排除代码块。常见的条件编译指令有:
#if
:检查常量表达式的值。如果表达式为真(非零),则编译后续代码。#ifdef
:检查宏是否已定义。如果宏已定义,则编译后续代码。#ifndef
:检查宏是否未定义。如果宏未定义,则编译后续代码。#elif
:相当于else if
,用于组合多个条件检查。#elifdef
(C++23):结合#elif
和#ifdef
,检查宏是否已定义。#elifndef
(C++23):结合#elif
和#ifndef
,检查宏是否未定义。#else
:用于处理所有其他情况。#endif
:结束一个条件编译块。
示例:
#define DEBUG#if defined(DEBUG) && !defined(NDEBUG)// 调试模式下的代码
#elif defined(RELEASE)// 发布模式下的代码
#else// 其他模式下的代码
#endif
C++23 新增的 #elifdef
和 #elifndef
:
#define FEATURE_A#if defined(FEATURE_A)// 如果 FEATURE_A 定义了
#elifdef FEATURE_B// 如果 FEATURE_B 定义了
#elifndef FEATURE_C// 如果 FEATURE_C 未定义
#else// 其他情况
#endif
2. 宏定义指令
宏定义指令用于定义符号常量或简单的函数替换。常见的宏定义指令有:
#define
:定义宏。可以定义简单的符号常量或带参数的宏。#undef
:取消定义宏。
示例:
#define PI 3.14159
#define MAX(a, b) ((a) > (b) ? (a) : (b))int main() {double radius = 5.0;double area = PI * radius * radius;int larger = MAX(10, 20);return 0;
}
取消定义宏:
#undef PI
3. 文件包含指令
文件包含指令用于将其他文件的内容插入到当前文件中。常见的文件包含指令有:
#include
:包含指定的头文件。可以使用尖括号< >
来包含标准库头文件,使用双引号""
来包含用户自定义的头文件。
示例:
#include <iostream> // 包含标准库头文件
#include "myheader.h" // 包含用户自定义头文件
4. 编译器控制指令
编译器控制指令用于向编译器传递特定的指令或信息。常见的编译器控制指令有:
#pragma
:用于向编译器传递特定的指令,具体行为依赖于编译器实现。#line
:修改编译器的行号和文件名信息,通常用于调试或生成代码时。#error
:触发编译错误并输出指定的消息。#warning
(C++23):触发编译警告并输出指定的消息。
示例:
#pragma once // 确保头文件只被包含一次#line 100 "custom_file.cpp" // 修改行号和文件名#error "This code is not supported on this platform." // 触发编译错误#warning "This feature is deprecated and will be removed in future versions." // 触发编译警告
5. 字符串化和粘贴操作符
#
:字符串化操作符,将宏参数转换为字符串字面量。##
:粘贴操作符,将两个标记粘合在一起形成一个新的标记。
示例:
#define TO_STRING(x) #x
#define CONCAT(a, b) a##bint main() {std::cout << TO_STRING(Hello World); // 输出: "Hello World"int value123 = CONCAT(value, 123); // 等价于: int value123;return 0;
}
6. C++23 新特性
C++23 引入了一些新的预处理器指令和功能,以增强条件编译和编译时诊断的能力:
#elifdef
和#elifndef
:结合#elif
和#ifdef
或#ifndef
,简化条件编译逻辑。#warning
:允许开发者在编译时发出警告信息,而不终止编译。这对于提醒开发者某些代码可能需要更新或注意非常有用。
示例:
#define FEATURE_A#if defined(FEATURE_A)// 如果 FEATURE_A 定义了
#elifdef FEATURE_B// 如果 FEATURE_B 定义了
#elifndef FEATURE_C// 如果 FEATURE_C 未定义
#else// 其他情况
#endif#warning "This feature is deprecated and will be removed in future versions."
7. 总结
C++ 的预处理器指令提供了强大的工具,帮助开发者根据不同的编译条件、平台或配置来控制代码的行为。通过合理使用这些指令,可以编写更加灵活和可维护的代码。以下是常用的预处理器指令及其用途的简要总结:
指令 | 用途 |
---|---|
#if | 根据常量表达式的值选择性地编译代码 |
#ifdef | 检查宏是否已定义 |
#ifndef | 检查宏是否未定义 |
#elif | 结合 #if 或 #ifdef 使用,相当于 else if |
#elifdef | 结合 #elif 和 #ifdef ,检查宏是否已定义 |
#elifndef | 结合 #elif 和 #ifndef ,检查宏是否未定义 |
#else | 处理所有其他情况 |
#endif | 结束一个条件编译块 |
#define | 定义宏 |
#undef | 取消定义宏 |
#include | 包含其他文件 |
#pragma | 向编译器传递特定指令 |
#line | 修改编译器的行号和文件名信息 |
#error | 触发编译错误并输出消息 |
#warning | 触发编译警告并输出消息(C++23) |
# | 字符串化操作符,将宏参数转换为字符串 |
## | 粘贴操作符,将两个标记粘合在一起 |
通过掌握这些预处理器指令,开发者可以更有效地管理代码的编译过程,确保代码在不同环境下都能正确工作。