一、引言
通常C++代码会分成.h
和.cpp
进行编写。
其中.h
放类和方法的声明,提供给其他文件包含。而.cpp
中放方法的定义。
这样分离式编译的优点自然是逻辑清晰,类的结构在.h
中一目了然,而且在编译时也进行了解耦(非本文重点,此处略)。
而对于类模板,即类型参数化了的类,例如 STL中的容器类,如果按照上述.h
和.cpp
分离的方式编写,会导致编译不通过。
举个栗子:
在demo.h
中声明个Demo
类和构造方法
template <typename T>
class Demo
{
public:
Demo(T i);
private:
T m_i;
};
在demo.cpp
中实现 Demo
类的构造方法
#include "demo.h"
template <typename T>
Demo<T>::Demo(T i): m_i(i)
{
}
然后在main
函数中创建个Demo
对象
#include "demo.h"
int main()
{
Demo<int> a(1);
return 0;
}
按照以往的习惯,看起来似乎没什么不对,接下来编译一下
g++ main.cpp demo.h demo.cpp
, 报错了:
/usr/bin/ld: /tmp/ccNJit1i.o: in function `main':
main.cpp:(.text+0x15): undefined reference to `Demo<int>::Demo(int)'
collect2: error: ld returned 1 exit status
在链接的时候找不到Demo<int>
类型的构造方法.
二、怎么办
先说解决方案:
目前来说,由于编译器不支持类模板的分离式编译,
最简单的解决方案就是将类模板的声明和定义放在同一文件中。像STL里做的那样。
三、为什么
再说为什么:为什么编译器不支持类模板的分离编译?
这要先说说 C++类模板的实现方式
类模板不等同于类的定义,而是用于生成具体类代码的代码,生成类代码的过程也就是模板的实例化。
因为类模板可以实例化出各种类型的类代码,所以在编译时,出于性能考虑,会根据程序中实际用到类模板的地方,生成指定实例化类型的类代码。
举个栗子:
在demo.h
中声明并定义Demo
类的构造和func
方法
template <typename T>
class Demo
{
public:
Demo(T i) : m_i(i) {}
void func() {}
private:
T m_i;
};
在main.cpp
中创建两个Demo
对象,其中一个调用了func
方法
#include "demo.h"
int main()
{
Demo<int> d1(1);
d1.func();
Demo<double> d2(2);
return 0;
}
然后编译 g++ main.cpp demo.h -o template.out
生成可执行文件template.out
。
接下来为了更直观,会使用objdump
反汇编工具一探这个.out
文件的究竟。(可以通过objdump -h
查看选项说明,此处略)
执行objdump -SC template.out > template.asm
,然后打开template.asm
,大致浏览一下,会看到有这么三个方法的反汇编代码:
这就是编译器通过类模板实例化所生成的代码。
分别是
Demo<int>
类的构造、func
方法和Demo<double>
类的构造方法,而没有Demo<double>
类的func
方法,不难猜测,这是因为在
main
函数中,Demo<double>
对象实际并没有调用func
,如果生成该方法会产生冗余代码,这是C++所不能容忍的。
通过此番查看,证实了一个想法:编译器只会根据实际使用到的模板参数类型和方法,对类模板进行实例化,即生成类代码。
再说C++的分离编译
C++ 代码是怎么分离编译的?
由于编译这个话题很深,涉及很广,此处只大致描述,细节推荐读物 《深入了解计算机系统》 《程序员的自我修养 : 链接、装载与库》
每个cpp
文件为一个编译单元,C++代码从一堆cpp
文件到一个可执行程序的过程,可以认为大致分为三步:
1. 预处理
这步主要是把宏和#include
替换掉,
对于每个编译单元,即每个.cpp
中,会把由#include
指定的每个.h
文件的内容都放到cpp
文件的顶部,替换掉原来的#include
语句。
2. 编译
依旧是对于每个编译单元,会进行单独编译,各自首先将C++代码转换成汇编代码,然后转换成相应的.o
二进制文件。
可以发现,直到第二步编译完成,生成.o
文件, 各cpp
文件之间是没有交集的,一直在单独处理各自文件中的代码,
并不知道其他.o
中是否使用了自己定义的方法,或者自己使用了的.h
中声明的方法是否在其他.o
中被定义。
3. 链接
链接就是把所有.o
连接到一起,生成最终可执行程序的过程。
对于每个.o
中引用了其他.o
中的方法,会在这个阶段确定具体的函数跳转地址。
如果有.o
中使用的方法在其他所有.o
中都没有定义,则会报错未定义的引用
,并无法生成可执行文件。
这就是C++分离编译的大致流程。
所以,为什么不支持类模板分离编译?
现在回头看引言中的栗子:
demo.h
中声明了个Demo
类和构造方法
#pragma once
template <typename T>
class Demo
{
public:
Demo(T i);
private:
T m_i;
};
demo.cpp
中实现 了Demo
类的构造方法
#include "demo.h"
template <typename T>
Demo<T>::Demo(T i): m_i(i)
{
}
然后在main
函数中创建个Demo
对象
#include "demo.h"
int main()
{
Demo<int> a(1);
return 0;
}
现在再考虑编译时发生了什么,在执行g++ main.cpp demo.h demo.cpp
进行编译时, demo.cpp
会被视为一个编译单元,引入了demo.h
,单独进行编译。
但是在这个编译单元中,并没有使用这个类模板,即不会由编译器对类模板进行实例化,不会生成任何参数类型的Demo
类代码。
所以在链接阶段,main.cpp
这个编译单元所生成的main.o
,与demo.cpp
这个编译单元所生成的demo.o
文件进行链接时,找不到Demo<int>::Demo(int)
方法的定义,这就是引言中报错信息undefined reference to Demo<int>::Demo(int)
的根因。
那么真的是这样吗?验证一下,将demo.h
和demo.cpp
编译成demo.o
,看看里面是不是真的没有定义任何方法。
执行g++ -c demo.h demo.cpp -o demo.o
,其中加上-c
选项,就只编译不链接。
然后像反汇编.out
文件一样查看demo.o
文件,执行objdump -SC demo.o > demo.asm
可以发现,demo.asm
文件中确实什么定义都没有。
所以现在C++编译器的编译机制,确实不支持类模板的分离编译。
QA:
那为什么声明和定义写在同一个文件中就可以?
不难理解,如果声明和定义写在同一个文件中,被#include
进使用了该类模板的其他.cpp
文件,类模板就可以和使用了它的方法处在同一个编译单元,在编译时完成模板的实例化。
那如果非要类模板声明和定义分离,自己单独一个编译单元,有什么办法吗?
有。在类模板的.h
或.cpp
中,调用类模板,在该编译单元中,提前实例化外部需要的类型和方法。
当然,前提是确认了外部所有要使用该类模板的实例化类型,与具体调用的方法,并在后续项目扩展时如果实例化了新类型,也需要在这个编译单元中提前调用进行实例化。
四、结语
C++依旧在发展,C++编译器也依旧在发展,虽然现在普遍不支持类模板的分离编译,但是说不定,下一版C++标准中就提出了类模板分离编译的解决方案,这篇文章中的很多内容自然也就过时了,共勉 ~