C++是静态语言,你需要将其经过编译和生成可执行文件才能运行。
C++有很多种编译器,比如gcc, g++, clang, msvc等等。gcc会分别区分c和cpp文件来编译,而g++则统一当作cpp文件来编译。
以g++为例,一个简单的编译C++源文件的命令如下:
g++ test.cpp -o test.o
编译后生成一个test.o的目标文件,如果目标文件包含main函数则可以直接执行。在Windows系统下,可以改为输出test.exe。
到 g++ 8.3.0 为止,g++的编译时使用的默认标准是C++11,如果你使用了更新标准的特性,需要手动指定其版本。
g++ test.cpp -o test.o -std=c++17
C++鼓励程序员将一些对象,通用函数放到独立的文件中,就是那些所谓的.h文件。而.h文件只是定义,不负责实现。事实上,如果你在学习C++之前,学习过python或java等面向对象语言,你可能会疑惑,为什么要有头文件呢?
关于这一点是有多种原因的,
- C语言比较落后,若不用头文件,便要用额外内存把接口信息存储到元数据(meta data)中,Java等后来出现的语言就是这么干的。虽然C++已经很现代,但仍然沿用了头文件的设计。
- 避免环形调用。比如A文件调用B文件的方法,B文件又去调用A文件的方法,在python里面就不能这么干。
- 为了能够减少编译。C++编译器既编译程序,也管理链接器。如果只修改了一个文件,则可以只重新编译该文件,生成一个目标文件,然后将它与其他无改动的目标文件进行链接。这使得大程序的管理更便捷。
大多数C++环境都提供了其他工具来帮助管理。例如,UNIX和Linux系统都具有make程序,可以跟踪程序依赖的文件以及这些文件的最后修改时间。运行make时,如果它检测到上次编译后修改了源文件,make将记住重新构建程序所需的步骤。
构建一个coordin.h文件。其中#ifndef和#endif是为了使得一个头文件被导入多个头文件后,在编译时不会被多次编译,为这个作用域赋予了COORDIN_H_的名称。在头文件顶部加上#pragma once则可以防止整个文件被重复导入。
#ifndef COORDIN_H_
#define COORDIN_H_
struct polar {
double angle;
};
struct rect {
double x;
};
polar rec_to_polar(rect xypos);
void show_polar(polar dapos);
#endif那么当多个文件是如何使用coordin.h的呢?
从编译命令可以看出,我们指定的是.cpp源文件,而非.h头文件,意味着编译是针对源文件里编译,而且每个源文件是单独编译的,编译过程主要为:
- 预处理阶段:这个阶段主要分析宏指令,包括解析
#define,#include等,每个源文件都是单独处理的。以#include为例,对于每个源文件,直接把头文件里面的声明和定义插入到源代码中。 - 编译阶段:这个阶段主要把源代码进行编译,即词法分析、语法分析、中间代码生成,最终生成每个cpp对应的
.s汇编文件。 - 汇编阶段:机器码生成,把
.s文件中的汇编语言转换为机器语言,最后生成的是每个cpp对应的.o目标文件。 - 链接阶段:上一阶段每个源文件会生成一个与之对应的
.o文件,即目标文件,链接则是把所有目标代码文件合成一个可执行文件。因为一个源文件调用的函数的定义可能包含在另一个源文件里,所以需要把函数链接至正确的地址。
从图上我们可以看出,file1.cpp通过头文件coordin.h只知道声明而不知道方法的具体定义就可以通过编译阶段了,我们在file2.cpp中将头文件中的两个方法实现了,只要在链接阶段,file1.cpp正确找到到两个方法的定义即可,这就是上面所说的链接。
值得注意的是,我们使用coordin.h,而不是<coodin.h>。如果文件名包含在尖括号中,则C++编译器将在存储标准头文件的主机系统的文件系统中查找;但如果文件名包含在双引号中,则编译器将首先查找当前的工作目录或源代码目录(或其他目录,这取决于编译器)。
前面我们提到,在头文件写上#ifndef和#endif是为了使得一个头文件被多个头文件所导入后,不会发生重复定义问题,但这么做只能限制头文件之间的导入,而无法防止头文件同时被多个源文件导入的情况。正如上面的过程里讲到,对于宏的解析,只是在预处理阶段,而真正查找具体定义是在链接阶段。如果你的头文件只包含声明而不包含定义,自然没有问题,因为C++允许重复声明,如果头文件里面包含了定义则会导致重复定义问题,因为在链接过程中会发现不同目标文件里存在多个定义,链接器不知道该链接至哪个定义。
重复定义不只是针对重复导入的问题,而是同一个声明在不同源文件中有不同的定义也是重复定义问题。
在头文件里面的定义而不会发生重复定义问题的有以下几种情况,
(1)对于static的变量和函数,不会发生重复定义问题,static具有只执行一次的特性。
(2)inline函数,inline函数必须在头文件里定义,它在预处理阶段会进行代码替换,并非真正的函数,不会发生重复定义问题。
(3)对于头文件里面的类,类定义不会有重复定义问题,类中可以带有对函数的定义实现,内部实现被视为inline函数。
(4)模板函数,它和宏一样是通过代码生成实现的,实际上也是在预处理阶段就生成好的。进一步的说,模板函数必须在头文件里面定义好,因为定义的链接发生在预处理阶段之后,如果不在头文件里面定义好,预处理阶段时根本不知道你的定义在哪里,自然也无法帮你生成函数实现了,进而无法产生链接,在链接阶段会报未定义错误。
前面说过,模板函数必须写在头文件里面,但有时候我们就是想保持头文件的纯粹性,只声明接口,不做定义。这时我们可以尝试用一个hpp头文件来定义h里面声明的模板函数,定义完后只要在h文件最后导入hpp文件即可。
// mymath.h
#pragma once
template <typename T>
T MyMax(T a, T b);
// 在最后导入hpp
#include "mymath.hpp"// mymath.hpp
#pragma once
#include "mymath.h"
template <typename T>
T MyMax(T a, T b) {
return a < b ? b : a;
}当两个头文件相互导入时就会产生循环引用问题,这时是无法通过编译的,但编译器不会直接告诉你是因为循环引用导致问题,或者说,编译不负责检测循环引用,只是在编译到有问题时才会报错,所以实际上循环引用导致的报错可能是多样的。如果这个循环引用跨了很多个头文件,头文件定义了很多东西,这时可能会报非常多错。
举一个例子,两个头文件都想使用对方声明的类型,这种情况下总有一边无法找到声明。因为include头文件都是写在头部的,include之后就会把代码段插入到include的位置,就可能会导致include进来的接口使用了一个后面才声明的类型。这种情况下一般会报error: 'XXX' has not been declared。
// player.h
#pragma once
#include "region.h" // 导入region
class Player {
public:
bool IsInRegion(Region* region);
};// region.h
#pragma once
#include "player.h" // 导入player
class Region {
public:
bool IsPlayerExist(Player* player);
};如果cpp文件先导入了player.h,那么执行到#include "region.h"就会把region.h代码段插入到player.h的前面,region.h中尝试导入player.h但已经被导入所以不会重复导入,而Region的接口IsPlayerExist(Player*)使用了Player类型,但这个类型此时还未声明。
#include "player.h"
int main() {
}修正方式很简单,因为C++允许重复声明,那么我们只要在region.h顶部手动声明一个Player的class即可。
// region.h
#pragma once
class Player; // 手动声明Player
class Region {
public:
bool IsPlayerExist(Player* player);
};但要注意,这里只是预声明了一下,实际上目前还不知道player声明了哪些接口,所以你不能在头文件这里直接使用player的接口。我们可以在cpp文件里面导入双方头文件,把双方的声明都插入进来,然后在后面具体实现时就能使用双方声明的接口了。
// region.cpp
#include "region.h"
#include "player.h" // 导入player声明
bool Region::IsPlayerExist(Player* player) {
return player->IsInRegion(this); // 已知player的声明,可以使用接口
}我们一般把没有main函数的目标文件称为库,所谓的库你可以理解为若干目标文件的集合,其中仅包含变量、函数或类的定义。其实我们的程序可以以3种形式编译:
- 把所有源文件和main函数文件打包到一个可执行文件里
- 把部分源文件打包成共享库,然后编译可执行文件时指定共享库的位置,最终生成的可执行文件不包含共享库,只是一个引用了共享库的定义而已。从操作系统层面去理解的话,共享库代码段会以共享虚拟内存页面的方式映射到各个进程虚拟地址空间里,进程对共享库的访问实际上是对共享内存的访问
- 把部分源文件打包成静态库,然后编译可执行文件时指定静态库的位置,最终生成的可执行文件会把静态库一起打包到可执行文件里
三种方式各有各的好处,
- 一次性打包所有源文件。这是比较干脆的方式,一般用在整个项目都是一体的情况下,不需要单独提供接口给其他项目使用。
- 共享库。基础模块会被很多进程使用,则基础模块可以作为共享库,这样可能节省重复代码段占用的内存,可执行文件的大小也会小一点。
- 静态库。静态库其实就是提供一个不需要暴露源代码的目标文件,如果你写了一个库又不想开源,则可以考虑向他人提供目标文件和头文件即可。这个也可以用作单独编译,每次修改部分文件则只需编译生成部分静态库。
假设我们的项目有main.cpp, test.h, test.cpp,在main.cpp里调用test.h声明的接口。
// main.cpp
#include <iostream>
#include "test.h"
int main() {
output();
return 0;
}// test.h
#pragma once
void output();// test.cpp
#include <iostream>
#include "test.h"
void output() {
std::cout << "output" << std::endl;
}先演示第一种情况,把所有源文件都打包到一个可执行文件里,
g++ main.cpp test.cpp -o main1.exe
执行测试,
> main1.exe
output
指定-shared生成共享目标文件,不同系统的库的命名规范不同,linux是libXX.so,而windows则是XX.dll,
# linux
g++ test.cpp -shared -o libtest.so
# windows
g++ test.cpp -shared -o test.dll编译可执行文件,链接共享库,
g++ -ltest -L. main.cpp -o main2.exe
-l表示要链接的库名称,指定时可省略空格-L表示库所在的路径
指定了-L会导致可执行文件查找共享库的位置写死,如果指定路径下没有对应的共享库,程序会出错,比如有些软件会要求把dll和exe放在同一目录下,就是这个原因。对于常用库文件,一般我们不指定-L,而是放在系统目录,编译器会自动从系统目录里查找。linux的目录为/usr/lib,也可以通过LD_LIBRARY_PATH环境变量指定共享库目录。windows的目录为C:\Windows\system32,可以在PATH环境变量自定义目录。
g++默认情况下会直接生成可执行文件,如果我们只需要编译一个库而已,则不需要main函数,也不需要马上去链接静态库本身的依赖库的定义,我们不会直接运行一个库,依赖库的定义并不关心,实际上链接是在最后打包成可执行文件时才需要做的,现在只需要把自己的定义打包到一个目标文件里。
g++命令带上-c参数,不进行最后的链接阶段,生成目标文件,
g++ -c test.cpp -o test.o
把若干目标文件打包到静态库文件libXXX.a里,.a文件没有那么神秘,你可以理解为一个压缩文件,ar命令相当于tar,
ar -rc libtest.a test.o
打包可执行文件时导入静态库,
g++ main.cpp -ltest -L. -static -o main3.exe
-l表示要链接的库名称,默认省略掉前面的lib-L表示库所在的路径-static表示以静态库方式链接
一般共享库都是加载时动态链接,即程序开始前找到动态库信息。而共享库的另一个别称是动态库,它支持程序运行时进行动态链接。这部分的API没有做到跨平台,windows和linux用的是不同的API。windows的API在windows.h上,而linux的API在dlfcn.h。
如果你想用C++实现代码热更新,可以用动态链接的方式,每次更新只要把动态库替换的就可以了,但意味着每次函数的调用需要通过字符串从dll里面获取具体函数地址,然后再调用。
