C++ - 分离模板的声明和定义

0x00 前言

当我们第一次编写C++模板的时候,基本上第一反应就是把模板函数/类当作普通函数/类对待: 把模板的声明放到一个.hpp文件中,模板的定义单独放置在一个.cpp文件中,然后其他的文件包括模板头文件。
然后我们兴高采烈地一通编译,发现链接器发出一堆莫名奇妙的“未定义引用”之呻吟……

这究竟是为什么?到底是人性的扭曲,还是道德的沦丧?

本文就来探讨一下,为什么直接分离模板的声明和定义会出错,和一些常用的解决方案。

0x01 原因分析

编译过程

我们首先要清楚,编译器是怎么样编译一个工程的?

编译单元

首先,我们定义一个概念:编译单元。

In C and C++ programming language terminology, a translation unit is the ultimate input to a C or C++ compiler from which an object file is generated. (Wikipedia)

大意是,一个编译单元是让C/C++编译器产生一个目标文件的最终输入。

也就是说,编译出一个目标文件所需要输入的集合,是一个编译单元。 通常情况下,编译器对每一个.cpp文件,生成一个目标文件; 所以我们一般认为:一个.cpp文件就是一个编译单元

编译

对于大型工程而言,一定存在多个编译单元。那么编译器是怎样处理多个编译单元的呢?

思考编译单元的定义,对于一个实打实的.cpp文件,编译器会对它单独生成一个目标文件。 而在处理一个编译单元的过程中,其他单元对编译器是完全不可见的。

这也是编译单元被叫做“单元”的原因:一个编译单元就是一个逻辑块。 对编译器来讲,里面只是包含一大堆函数而已。 编译器的工作只是将每一个函数中的代码转换为机器语言,然后留一个入口标记,方便调用,仅此而已。

可能有人会问,那外部函数怎么办?你说其他单元不可见,那如果其他单元的函数被调用了,怎么办? 编译器表示:这不关我的事。你说有不认识的符号?那考试的时候题不会咋办啊? 就打个标记呗。这题我不会,搁那儿不管了,我继续干我的活。

链接

我们知道,编译完了就该链接了嘛。

链接器把目标文件都读入内存,综合分析编译器编译出来的一个个目标文件,看看怎么给编译器擦屁股。 编译器留下了一大坨它不认识的符号,链接器就说,我先给这些符号标个号呗,到时候好找。 号编完了,链接器就瞅着看着,啊这个符号我认识,在那个目标文件里呢,我写个jmp到时候让CPU跳那里执行去; 啊这个符号好像在运行库里面有,我估摸着给它也编一个号,然后也插个jmp让CPU到那里面执行去……

活干完了,链接器再检查一遍卷子。 如果链接器发现,哟这里还有几个我也不认识,咋办呢?凉拌呢~甩锅给铲(产)bug的那位了,undefined reference走起~ 如果链接器看着符号都有归属了,把平台的启动代码一抄,跟链接完的目标文件一粘,就输出去了。

然后铲bug的那位陷入了运行时错误的魔爪……

模板的特性

模板有什么特(kēng)性(diǎn)呢?

C++标准明确规定,只实例化被使用的模板,且只实例化被使用的类型。 这也就是说,编译器仅在模板被使用时生成相对应的目标代码。 当模板没有被使用时,在对应的目标代码中是看不到对应的代码的。

模板这个概念仅存在于编译期间,如果你没有使用模板(给模板的泛型类型提供一个具体类型), 编译器根本无从下手,因为它要将你的代码编译为机器代码,而机器语言可没有泛型这个概念, 都是实打实的一条条语句。CPU在栈操作和进行指针运算时都需要知道具体的值,如果没有具体类型, 编译器就无法知道这个类型的空间大小,也就没有办法计算那些运算时具体需要的值。

水落石出

答案似乎已经很明显了。

假设你的工程包括如下文件:main.cpp、template.cpp、template.hpp和Makefile

template.hpp中存放了模板对应的声明,template.cpp中存放了模板的定义, 而main.cpp中直接调用了模板函数。template.cpp和main.cpp都包含了template.hpp。

当编译器编译main.cpp时,发现里面只有一句函数声明(在已包含的.hpp中),而没有定义, 它认为这个可能是个外部符号,做了一个标记,输出了目标文件,把锅甩给了链接器;

当编译器编译template.cpp时,发现里面有模板的声明和定义,但是好像没有啥地方使用了这个模板函数。 于是,编译器就忽略了这个模板函数,输出了一个空的目标文件;

当链接器拿到这两个目标文件时,傻眼了——编译器给的main.o里面有一个外部符号, 但是在其他任何目标文件(以及系统运行库)中都找不到对应的符号…… 于是它就放弃了,输出来一句“undefined reference”,丢给铲bug的自己分析去。

0x02 解决方案

一般的解决方法

通常的C++入门教程,或者是图省事的答主会让你直接把定义也塞到头文件中, 这样任何.cpp文件都会充斥着模板的身影,也就不会出现找不到符号的问题了。

但是这样做的话,会显著加大头文件的大小,不仅会给阅读和维护代码的人带来很多不便, 也会给预处理器和编译器造成很多负担,从而整个工程的编译都会被拖慢,既浪费时间又浪费算力。

其次,这个做法无法将模板模块化。所有的模板函数都被编译到零零散散的可执行文件和动态链接库中。 这将增加程序二进制层的耦合性,使得反汇编调试更加不容易进行。

进一步的解决方法

有一些教程和书籍会建议将模板的定义放置在一个单独的.ipp文件中, 然后在存放模板声明的.hpp头文件的最后一行包含.ipp文件。

这样做可以降低代码的维护难度, 而且当你需要你的模板能被使用者任意地实例化的话,这似乎是首选的解决方案。 大名鼎鼎的第三方C++库Boost, 就是采用了这种方法——你可以对其中的模板传入任何合理的类型。

但是这样并不能减轻编译器的负担,同样不能解决二进制层耦合的问题。 是否有更好的解决方法呢?

推荐的解决方法

一般推荐的方法是将模板的声明和定义分装到template.hpp和template.cpp文件中, 在template.cpp中将其他.cpp文件中所有需要的、对应类型的模板显式实例化出来, 这样编译器在编译模板定义时,就会产生模板对应的代码,链接器也就不会找不到符号了。

或者你可以发挥创意,将显式实例化语句单独分离到一个temp_instantiation.hpp文件中,然后在template.cpp文件中包含它, 但是这个头文件不能被其他.cpp文件包含。

同时,模板实例化后的目标代码都会存放在对应名字的目标文件中, 二进制层面的耦合性也就被解决了。 你还可以将模板实例化后的代码编译成动态链接库,随着软件分发。

这种方法的唯一缺点就是,你不能任意实例化一个模板。 必须要先手动在模板定义文件中显式实例化对应的类型,才能在程序的其他地方使用。 对于未显式实例化的模板类型,链接器会爆“undefined reference”错误。

示例代码:

Makefile
OUT	=	Test.exe
OBJ = main.o template.o
CC = g++

Test: $(OBJ)
$(CC) -s -o $(OUT) $(OBJ)

main.o: template.hpp
template.o: template.hpp

.PHONY: clean run
clean:
rm -rf *.exe *.o

run:
.\\$(OUT)
main.cpp
#include <iostream>
#include "template.hpp"

int main()
{
using namespace std;
cout << add(1, 2.0f) << endl; // 只能使用add<int, float>,不能使用其他类型
return 0;
}
template.hpp
#ifndef __TEMPLATE_HPP
#define __TEMPLATE_HPP

template <class T1, class T2>
auto add(const T1 &a, const T2 &b) -> decltype (a + b);

#endif
template.cpp
#include "template.hpp"

// 显式实例化
template
auto add<int, float>(const int &a, const float &b) -> decltype (a + b);

template <class T1, class T2>
auto add(const T1 &a, const T2 &b) -> decltype (a + b)
{
return a + b;
}
文章作者: wxx9248
文章链接: https://blog.wxx9248.tk/2020/03/20/CPP-Seperate-Declaration-and-Definifion-of-Templates/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 wxx9248 的博客