分离编译
分离编译是一种将程序分解为多个逻辑上独立的部分(如模块或文件)的方法,这样做可以更好地管理程序的组成部分。在 C++ 中,每个源代码文件通常包含一个或多个逻辑组件,而物理结构则是这些逻辑组件在文件系统中的组织方式。
当源文件提交给编译器后,首先进行预处理,处理宏和包含头文件。预处理的结果是编译单元(translation unit),这是编译器实际处理的内容,也是 C++ 语言规则所描述的内容。
分离编译的目标是实现接口(如函数声明)与实现(如函数定义)的完全分离,这有助于代码的模块化和重用。
链接器是将分离编译的多个部分绑定在一起的程序。它检查一致性问题,确保程序的声明在整个程序中保持一致。
链接可以在程序开始运行前全部完成,也可以在程序运行中将新代码添加进来,这称为动态链接。
链接
在 C++ 中,除非已显式声明为局部名字,否则函数名、类名、模板名、变量名等必须在所有编译单元中保持一致。
程序员需要确保每个名字空间、类、函数等在其出现的每个编译单元中都正确声明,且对应相同实体的声明都是一致的。
// file1.cpp
int x = 1;
int f() { /* 进行一些操作 */ }
// file2.cpp
extern int x;
int f();
void g() {
x = f();
}
在上述例子中,file2.cpp
中的 g()
使用的 x
和 f()
就是 file1.cpp
中所定义的实体。关键字 extern
指出 file2.cpp
中 x
的声明仅仅是一个声明而已,而非一个定义。
注意,如果全局作用域中或名字空间中的变量定义不带初始值,则该变量会使用默认初始值。非 static 局部变量或创建在自由存储上的对象则不会使用默认初始值。
如果一个名字在其定义处之外的编译单元(translation unit)中也可以使用,我们称其具有外部链接。如果一个名字只能在其定义所在的编译单元中被引用,我们称其具有内部链接。
在名字空间作用域(包括全局作用域)中使用关键字 static
(有些不合逻辑)表示“不能在其他源文件中访问”(即内部链接)。
关键字 const
暗示默认内部链接,因此如果你希望其具有外部链接,就需要在其定义前加上 extern
:
static int x1 = 1; // 内部链接,其他编译单元不可见
const char x2 = 'a'; // 内部链接,其他编译单元不可见
int x3 = 1; // 外部链接,其他编译单元可见
extern const char x4 = 'a'; // 外部链接,其他编译单元可见
链接器看不到的名字,例如局部变量名,被称为无链接。
inline 函数在其应用的所有编译单元中都必须有完全等价的定义。
// file1.cpp
inline int f(int i) { return i; }
// file2.cpp
inline int f(int i) { return i + 1; } // 错误:两个定义不一致
上述例子中,f()
函数在两个文件中的定义不一致,这是不合法的。
为了保持 inline
函数定义的一致性,推荐将它们放在头文件中。这样,所有包含该头文件的源文件都会使用相同的 inline
函数定义。
默认情况下,名字空间中的 const
对象、constexpr
对象、类型别名以及任何声明为 static
的实体都具有内部链接。这意味着它们在不同编译单元中可以有不同的定义。
文件内名字
在 C++ 编程中,通常建议避免使用全局变量,因为它们可能会导致维护问题。全局变量的使用使得追踪变量的使用位置变得困难,并且在多线程程序中可能会引起数据竞争,从而导致难以发现的错误。
如果必须使用全局变量,可以通过以下两种方法限制它们只在单一源文件中使用:
- 使用无名名字空间
- 使用
static
关键字
头文件
同一个对象、函数、类等的所有声明都要保持类型一致。因此,提交给编译器并随后链接在一起的源码必须保持一致。实现不同编译单元声明一致性的一种不完美但很简单的方法是:在包含可执行代码或数据定义的源文件中 #include
包含接口信息的 头文件
(header file)。
一般原则是,头文件可包含:
类型 | 代码示例 |
---|---|
具名的名字空间 | namespace N { /* ... */ } |
inline 名字空间 | inline namespace N { /* ... */ } |
类型定义 | struct Point { int x, y; }; |
模板声明 | template<typename T> class Z; |
模板定义 | template<typename T> class V { /* ... */ }; |
函数声明 | extern int strlen(const char*); |
inline 函数定义 | inline char get(char* p) { /* ... */ } |
constexpr 函数定义 | constexpr int fac(int n) { return (n<2) ? 1 : n*fac(n-1); } |
数据声明 | extern int a; |
const 定义 | const float pi = 3.141593; |
constexpr 定义 | constexpr float pi2 = pi*pi; |
枚举 | enum class Light { red, yellow, green }; |
名字声明 | class Matrix; |
类型别名 | using value_type = long; |
编译时断言 | static_assert(4<=sizeof(int),"small ints"); |
包含指令 | #include <algorithm> |
宏定义 | #define VERSION 12.03 |
条件编译指令 | #ifdef __cplusplus |
注释 | /* check for end of file */ |
不应包含:
类型 | 代码示例 |
---|---|
普通函数定义 | char get(char* p) {return *p++; } |
数据定义 | int a; |
集合定义 | short tb[] = { 1, 2, 3 }; |
无名名字空间 | namespace { /*...*/ } |
using 指示 | using namespace Foo; |
单一定义规则(One-Definition Rule, ODR)
在 C++ 程序中,每个类、枚举、模板等只能定义一次。这一规则被称为单一定义规则(ODR)。虽然 C++ 语言规则较为复杂,但 ODR 提供了一种更复杂和微妙的方式来确保定义的唯一性。具体来说,一个类、模板或内联函数的两个定义被认为是相同的唯一定义,当且仅当满足以下条件:
- 它们出现在不同的编译单元中;
- 它们的源码逐单词对应,完全一样;
- 这些单词在两个编译单元中的含义完全一样。
标准库头文件
C++ 标准库的特性是通过一组标准头文件提供的。这些头文件使用 #include<...>
语法来包含,而不是 #include"..."
。这种区分表明标准头文件的身份,并且没有 .h
后缀并不意味着这些头文件在存储上有什么特殊之处。
每个 C 标准库头文件 <X.h>
都有一个对应的标准 C++ 头文件 <cX>
。例如,#include<cstdio>
提供了 #include<stdio.h>
的功能。
链接非 C++ 代码
C++ 提供了 extern
声明来指定使用哪种链接(linkage)规范。
extern"C" 声明
extern"C"
声明允许 C++ 代码调用 C 语言库中的函数,这些函数遵循 C 的链接规范。例如,声明 C 标准库函数 strcpy()
并指定它采用 C 链接规范:
extern "C" char* strcpy(char*, const char*);
这个声明与普通声明不同:
extern char* strcpy(char*, const char*);
两者的主要区别在于调用 strcpy()
时所采用的链接规范。
链接块(Linkage Block)
为了简化为大量声明添加 extern"C"
的过程,C++ 允许通过链接块为一组声明指定链接规范。例如:
extern "C" {
char* strcpy(char*, const char*);
int strcmp(const char*, const char*);
int strlen(const char*);
// ...
}
这种构造可以用于封装完整的 C 头文件,使其适用于 C++ 程序,如:
extern "C" { #include<string.h> }
另一种创建 C 和 C++ 共用头文件的技术是条件编译:
#ifdef __cplusplus
extern "C" {
#endif
char* strcpy(char*, const char*);
int strcmp(const char*, const char*);
int strlen(const char*);
// ...
#ifdef __cplusplus
}
#endif
这里使用预定义宏 __cplusplus
确保当头文件用在 C 程序中时,文件中的 C++ 构造会被忽略。
任何声明都可以放在链接块中,变量的作用域和存储类不会受到影响。例如:
extern "C" {
int g1; // 定义
extern int g2; // 声明,非定义
}
extern "C" int g3; // 声明,非定义
extern "C" { int g4; } // 定义
这么做,也是为了兼容 C,如果想要封装
#include<xxx.h>
,这样不需要对原有代码做任何更改
采用 C 链接规范的名字可以声明在命名空间中。命名空间影响 C++ 程序中访问名字的方式,但不会影响链接器处理名字的方式。这个机制允许我们在命名空间中包含采用 C 链接的库,而不会污染全局命名空间。
包含保护
在 C++ 编程中,多头文件方法将每个逻辑模块表示为一个一致的、自包含的单元。这导致了许多为了保证逻辑单元完整性而设计的声明实际上是冗余的,尤其是在大型程序中。冗余的包含(即在同一个编译单元中多次包含同一个头文件)可能导致错误。因此,我们有两种选择来处理这个问题:
- 重组程序:去掉冗余,但这在实际规模的程序中可能非常繁琐且不实用。
- 允许重复包含头文件:找到一种方法允许头文件被重复包含而不会导致错误。
包含保护的实现
包含保护是通过预处理器宏来实现的。以下是一个典型的包含保护示例:
// error.h
#ifndef CALC_ERROR_H
#define CALC_ERROR_H
namespace Error {
// 头文件内容
}
#endif // CALC_ERROR_H
在这个例子中,如果 CALC_ERROR_H
宏已经定义,那么 #ifndef
和 #endif
之间的内容就会被预处理器忽略。
头文件可能在任意上下文中被包含,而且没有名字空间避免宏名冲突。因此,通常选择很长很丑的名字作为包含保护(如,头文件名的全大写)。
程序
程序是由一组分离编译的单元通过链接器组合而成的。在程序中,每个函数、对象、类型等都必须是唯一定义的。程序必须包含一个名为 main()
的函数,这是程序执行的主要入口点。main()
函数的返回类型是 int
,并且有两种标准形式:
-
int main()
(不接受任何参数) -
int main(int argc, char* argv[])
(接受命令行参数)
程序只能使用这两种形式中的一个。main()
函数返回的 int
值作为程序执行的结果被传递给系统,非零值通常表示错误。
非局部变量初始化
定义在任何函数之外的变量(全局变量、名字空间变量以及类 static
变量)在 main()
被调用前初始化。这些变量在同一个编译单元中按照它们的定义顺序进行初始化。如果没有显式的初始化器,则它们初始化为其类型的默认值,例如内置类型和枚举类型的默认初始化值为 0。
不同编译单元中全局变量的初始化顺序是不确定的,因此在不同编译单元的全局变量间建立依赖关系是不明智的。全局变量初始化时抛出的异常也是不可被捕获的。
初始化和并发
考虑以下代码:
int x = 3;
int y = sqrt(++x);
在单线程环境下,直观的答案是 x
的值为 3,y
的值为 2。这是因为 x
的初始化是一个常量表达式,在链接时完成,所以 x
的值是 3。然而,y
的初始化依赖于 x
的运行时值,因为 sqrt()
函数没有 constexpr
版本,所以 y
的值在运行时确定。根据 15.4.1 节,同一编译单元内的静态分配对象的初始化顺序与它们的定义顺序一致,因此 y
的值是 2。
然而,如果程序是多线程的,每个线程都会执行运行时初始化,系统不会隐含地应用互斥机制来防止数据竞争。这意味着在一个线程中 sqrt(++x)
可能在另一个线程递增 x
之前或之后发生,导致 y
的值可能是 sqrt(4)
或 sqrt(5)
。
为了避免这种初始化中的数据竞争,以下是一些推荐的实践:
- 减少静态分配对象的使用:尽量减少全局变量和静态变量的使用,并保持它们的初始化尽可能简单。
- 避免依赖动态初始化的对象:不要依赖其他编译单元中的动态初始化的对象。
- 使用常量表达式进行初始化:在链接时,不带初始化器的内置类型被初始化为零,标准容器和
string
被初始化为空。 - 使用没有副作用的表达式进行初始化:这样可以确保在多线程环境下不会有意外的副作用。
- 在单线程的启动阶段进行初始化:在程序的启动阶段,通常是单线程的,进行必要的初始化。
- 使用互斥机制:如果必须在多线程环境中进行复杂的初始化,使用互斥锁(mutex)来确保只有一个线程可以执行初始化。
程序终止
程序终止方法
-
从
main()
返回:程序通过从main()
函数返回来正常结束。 -
调用
exit()
:通过调用标准库函数exit()
来终止程序。- 当使用
exit()
终止程序时,会调用已构造的静态对象的析构函数。exit()
的类型为void exit(int);
,其参数作为程序的结果返回给系统,0 表示成功结束。在一个析构函数中调用exit()
会导致无限递归。
- 当使用
-
调用
abort()
:通过调用abort()
来强制终止程序。-
abort()
:与exit()
不同,使用abort()
终止程序时不会调用析构函数。
-
-
抛出未捕获的异常:程序因未捕获的异常而终止。
-
违反
noexcept
:违反noexcept
声明导致程序终止。 -
调用
quick_exit()
:类似于exit()
,但不调用析构函数
调用 exit()
意味着调用函数的局部变量及其调用者不会执行各自的析构函数。调用 exit()
终止程序没有给其所在函数的调用者处理此问题的机会。
抛出一个异常并捕获它可确保局部变量被正确销毁。因此,抛出异常通常是离开一个上下文的更好方式。