为什么C++编译器不能支持对模板的分离式编译

本文详细解析了C++中的模板实例化过程及其与编译、链接的关系,尤其是在分离式编译环境中模板如何被处理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

刘未鹏 (pongba)

C++ 的罗浮宫 (http://blog.youkuaiyun.com/pongba)

 

首先,一个 编译单元 translation unit )是指一个 .cpp 文件以及它所 #include 的所有 .h 文件, .h 文件里的代码将会被扩展到包含它的 .cpp 文件里,然后编译器编译该 .cpp 文件为一个 .obj 文件( 假定我们的平台是 win32 ),后者拥有 PE Portable Executable ,即 windows 可执行文件)文件格式,并且本身包含的就已经是二进制码,但是不一定能够执行,因为并不保证其中一定有 main 函数。当编译器将一个工程里的所有 .cpp 文件以分离的方式编译完毕后,再由连接器( linker )进行连接成为一个 .exe 文件。

 

举个例子:

 

 

在这个例子中, test. cpp main.cpp 各自被编译成不同的 .obj 文件(姑且命名为 test.obj main.obj ),在 main.cpp 中,调用了 f 函数,然而当编译器编译 main.cpp 时,它所仅仅知道的只是 main.cpp 中所包含的 test.h 文件中的一个关于 void f(); 的声明,所以,编译器将这里的 f 看作外部连接类型,即认为它的函数实现代码在另一个 .obj 文件中,本例也就是 test.obj ,也就是说, main.obj 中实际没有关于 f 函数的哪怕一行二进制代码,而这些代码实际存在于 test.cpp 所编译成的 test.obj 中。在 main.obj 中对 f 的调用只会生成一行 call 指令,像这样:

 

call f [C++ 中这个名字当然是经过 mangling[ 处理 ] 过的 ]

 

在编译时,这个 call 指令显然是错误的,因为 main.obj 中并无一行 f 的实现代码。那怎么办呢?这就是连接器的任务,连接器负责在其它的 .obj 中(本例为 test.obj )寻找 f 的实现代码,找到以后将 call f 这个指令的调用地址换成实际的 f 的函数进入点地址。需要注意的是:连接器实际上将工程里的 .obj 连接 成了一个 .exe 文件,而它最关键的任务就是上面说的,寻找一个外部连接符号在另一个 .obj 中的地址,然后替换原来的 虚假 地址。

 

这个过程如果说的更深入就是:

 

call f 这行指令其实并不是这样的,它实际上是所谓的 stub ,也就是一个 jmp 0xABCDEF 。这个地址可能是任意的,然而关键是这个地址上有一行指令来进行真正的 call f 动作。也就是说,这个 .obj 文件里面所有对 f 的调用都 jmp 向同一个地址,在后者那儿才真正 call f 。这样做的好处就是连接器修改地址时只要对后者的 call XXX 地址作改动就行了。但是,连接器是如何找到 f 的实际地址的呢(在本例中这处于 test.obj 中),因为 .obj .exe 的格式是一样的,在这样的文件中有一个符号导入表和符号导出表( import table export table )其中将所有符号和它们的地址关联起来。这样连接器只要在 test.obj 的符号导出表中寻找符号 f (当然 C++ f 作了 mangling )的地址就行了,然后作一些偏移量处理后(因为是将两个 .obj 文件合并,当然地址会有一定的偏移,这个连接器清楚)写入 main.obj 中的符号导入表中 f 所占有的那一项即可。

 

这就是大概的过程。其中关键就是:

 

编译 main.cpp 时,编译器不知道 f 的实现,所以当碰到对它的调用时只是给出一个指示,指示连接器应该为它寻找 f 的实现体。这也就是说 main.obj 中没有关于 f 的任何一行二进制代码。

 

编译 test.cpp 时,编译器找到了 f 的实现。于是乎 f 的实现(二进制代码)出现在 test.obj 里。

 

连接时,连接器在 test.obj 中找到 f 的实现代码(二进制)的地址(通过符号导出表)。然后将 main.obj 中悬而未决的 call XXX 地址改成 f 实际的地址。完成。

 

然而,对于 模板 ,你知道,模板函数的代码其实并不能直接编译成二进制代码,其中要有一个 实例化 的过程。举个例子:

 

 

 

也就是说,如果你在 main.cpp 文件中没有调用过 f f 也就 得不到实例化 ,从而 main.obj 中也就没有关于 f 的任意一行二进制代码!如果你这样调用了:

 

f(10); // f<int> 得以实例化出来

f(10.0); // f<double> 得以实例化出来

 

这样 main.obj 中也就有了 f<int> f<double> 两个函数的二进制代码段。以此类推。

 

然而实例化要求编译器知道模板的 定义 ,不是吗?

 

看下面的例子(将模板的声明和实现分离):

 

 

编译器在 #1 处并不知道 A<int>::f 的定义,因为它不在 test.h 里面,于是编译器只好寄希望于连接器,希望它能够在其他 .obj 里面找到 A<int>::f 的实例,在本例中就是 test.obj ,然而,后者中真有 A<int>::f 的二进制代码吗? NO !!!因为 C++ 标准明确表示, 当一个模板不被用到的时侯它就不该被实例化出来 test.cpp 中用到了 A<int>::f 了吗?没有!!所以实际上 test.cpp 编译出来的 test.obj 文件中关于 A::f 一行二进制代码也没有,于是连接器就傻眼了,只好给出一个连接错误。但是,如果在 test.cpp 中写一个函数,其中调用 A<int>::f ,则编译器会将其实例化出来,因为在这个点上( test.cpp 中),编译器知道模板的定义,所以能够实例化,于是, test.obj 的符号导出表中就有了 A<int>::f 这个符号的地址,于是连接器就能够完成任务。

 

关键是:在分离式编译的环境下,编译器编译某一个 .cpp 文件时并不知道另一个 .cpp 文件的存在,也不会去查找(当遇到未决符号时它会寄希望于连接器)。这种模式在没有模板的情况下运行良好,但遇到模板时就傻眼了,因为模板仅在需要的时候才会实例化出来,所以,当编译器只看到模板的声明时,它不能实例化该模板,只能创建一个具有外部连接的符号并期待连接器能够将符号的地址决议出来。然而当实现该模板的 .cpp 文件中没有用到模板的实例时,编译器懒得去实例化,所以,整个工程的 .obj 中就找不到一行模板实例的二进制代码,于是连接器也黔驴技穷了。

### 在 VSCode 中配置 C++ 分离式编译 在 VSCode 中配置 C++分离式编译,可以通过创建多个源文件并正确配置 `tasks.json` `launch.json` 文件来实现。以下是详细的说明: #### 1. 创建项目结构 首先,确保项目的目录结构清晰。例如: ```plaintext project/ ├── src/ │ ├── main.cpp │ └── utils.cpp ├── include/ │ └── utils.h ├── .vscode/ │ ├── tasks.json │ └── launch.json ``` - `main.cpp` 是主程序入口。 - `utils.cpp` 包含辅助函数的实现。 - `utils.h` 是辅助函数的头文件。 #### 2. 配置 `tasks.json` `tasks.json` 文件用于定义如何编译项目。以下是一个示例配置[^2]: ```json { "version": "2.0.0", "tasks": [ { "label": "build project", "type": "shell", "command": "g++", "args": [ "-g", "${workspaceFolder}/src/main.cpp", "${workspaceFolder}/src/utils.cpp", "-I${workspaceFolder}/include", "-o", "${workspaceFolder}/bin/main" ], "group": "build", "problemMatcher": ["$gcc"] } ] } ``` - `-g` 参数用于生成调试信息。 - `${workspaceFolder}/src/main.cpp` `${workspaceFolder}/src/utils.cpp` 是需要编译的源文件。 - `-I${workspaceFolder}/include` 指定头文件的搜索路径。 - `-o ${workspaceFolder}/bin/main` 指定输出可执行文件的位置。 #### 3. 配置 `launch.json` `launch.json` 文件用于定义调试配置。以下是一个示例配置[^3]: ```json { "version": "0.2.0", "configurations": [ { "name": "Debug Project", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/bin/main", "args": [], "stopAtEntry": false, "cwd": "${workspaceFolder}", "environment": [], "externalConsole": true, "MIMode": "gdb", "setupCommands": [ { "description": "Enable pretty-printing for gdb", "text": "-enable-pretty-printing", "ignoreFailures": true } ], "preLaunchTask": "build project" } ] } ``` - `"program": "${workspaceFolder}/bin/main"` 指定运行的可执行文件。 - `"preLaunchTask": "build project"` 确保在调试之前自动构建项目。 #### 4. 处理中文乱码问题 如果程序中涉及中文字符输出,可以在代码中添加以下内容以确保正确显示[^1]: ```cpp #include <windows.h> #include <iostream> using namespace std; int main() { SetConsoleOutputCP(65001); // 设置控制台输出编码为 UTF-8 cout << "中文正常显示" << endl; system("pause"); return 0; } ``` #### 5. 安装必要的插件工具 确保安装了以下工具插件: - **C/C++ 扩展**:Microsoft 提供的 C/C++ 插件。 - **MinGW 或 GCC**:用于编译 C++ 程序。 - **C/C++ Project Generator** 插件(可选):简化项目创建过程[^2]。 --- ### 注意事项 - 确保所有源文件都包含正确的头文件引用,并且路径设置无误。 - 如果使用 Windows 平台,推荐安装 MinGW 工具链以支持 `g++` 编译器。 - 在调试时,可以启用外部控制台 (`"externalConsole": true`) 以便更好地查看输出。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值