一个编译单元,指一个.cpp文件以及它所#include的所有.h文件,.h文件里的代码将会被扩展到包含它的.cpp文件中,然后编译器编译该.cpp文件,生成一个*.obj文件(假定我们的平台是win32),*.obj 拥有PE(Portable Executable,windows可执行文件)文件格式,采用二进制码存储,但不一定能够执行,因为并不保证其中一定有main函数。当编译器将一个工程里的所有.cpp文件以分离的方式编译完毕后,再由连接器(linker)进行连接。最后就生成了一个.exe文件。
举个例子:
void f();
#include ”test.h”
void f()
{
}
#include”test.h”
int main()
{
f();
}
此例子中,test. cpp和main.cpp被编译成test.obj和main.obj。在main.cpp中,main函数调用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的实际地址的呢?因为.obj与.exe的格式是一样的,在这样的文件中有一个符号导入表和符号导出表(import table和export table)其中将所有符号和它们的地址关联起来。这样连接器只要在test.obj的符号导出表中寻找符号f的地址就行了,然后作一些偏移量处理后(因为是将两个.obj文件合并,当然地址会有一定的偏移,这个连接器清楚)写入main.obj中的符号导入表中f所占有的那一项即可。
普通编译链接
(1)编译main.cpp时,编译器并不知道f函数的具体实现,所以当碰到对其调用时,能给出的仅是一个编译指示(call XXX),指示连接器应寻找f的真实实现体。即main.obj中没有关于f的二进制代码实现。
(2)编译test.cpp时,编译器找到了f函数实现。本例中f的函数实现出现在test.obj里。
(3)连接时,连接器在test.obj中找到f函数的实现代码的地址(通过符号导出表)。然后将main.obj中悬而未决的call XXX地址改成f真实地址。
模板的链接过程
对于模板,模板函数代码并不直接编译成二进制代码,其中要有一个“实例化”的过程。举个例子:
template<class T>
void f(T t)
{
}
int main()
{
f(10);
}
如果在main.cpp文件中,没有调用过f,f也不会被实例化,main.obj也没有关于f的二进制代码。但如果你这样调用了:
f(10);
f(10.0);
main.obj就有了f、f两函数的二进制代码段了。
实例化要求编译器知道模板的定义,是这样的吗? 看下面的例子:
template<class T>
class A
{
public:
void f();
};
#include”test.h”
template<class T>
void A<T>::f()
{
}
#include”test.h”
int main()
{
A<int> a;
a.f();
}
编译器在#1处并不知道A::f的定义,因为它不在test.h里面,于是编译器只好寄希望于连接器,希望它能够在其他.obj里面找到A::f的定义,然而,test.obj真有A::f的二进制代码实现吗?
答案是没有,因为C++标准明确指出,当一个模板不被用到时,不会被实例化,所以A::f就不会被实例化。test.obj中也就没有关于A::f的二进制实现。连接器无奈只好报出一个连接错误。但是如果test.cpp写一个函数调用了A::f,则编译器会将其实例化出来,于是编译器知道模板的定义了,所以能够实例化,test.obj的符号导出表中就有了A::f这个符号的地址,连接器就能够完成任务。
上述的实现方式可称为分离编译,编译器编译.cpp文件时,不知道另一个.cpp文件的存在,也不会去查找。在没有模板的情况下,这种模式运行良好,但遇到模板时就出现问题了,因为模板仅在需要的时候才会被实例化。
当编译器只看到模板声明时,它不能实例化该模板,只能创建一个具有外部连接的符号并期待连接器能够将符号的地址决议出来。然而当实现该模板的.cpp文件中没有用到模板的实例时,编译器懒得去实例化,所以,在整个工程的.obj中,就找不到模板实例的二进制代码,连接器也黔驴技穷了。
头文件(h)保存类的声明;定义文件(cpp)保存程序实现。允许在一个编译单元(.cpp)中定义函数、类型、类对象等,而另一个编译单元引用它们。编译器处理完所有的编译单元后,链接器会处理所有指向extern符号的引用,通过这种方式可完成链接,生成可执行文件(exe)。
模板类型编译
模板类不是一种实类型,必须等到类型绑定后,才能确定最终类型,所以实例化一个模板时,必须要能够让编译器“看到”在哪里使用了模板,而且必须看到模板确切的定义,否则不能顺利地产生编译代码。因此,标准会要求模板的实例化与定义体放到同一编译单元中。实现模板类型有三种方法:
模板的声明和定义都放置在同一个.h文件
template <class T>
class Temp
{
public:
void SetValue(const T & rT);
private:
T m_value;
};
template <class T>
void Temp<T>:: SetValue (const T & rT)
{
m_value = rT;
}
声明和实现分离,包含.cpp文件
#include "Temp.cpp"
template <class T>
class Temp
{
public:
void SetValue (const T & rT);
private:
T m_value;
};
template <class T>
void Temp<T>:: SetValue (const T & rT)
{
m_value = rT;
}
#include "Temp.h"
使用export使声明实现分离
template<class T>
class Temp
{
public:
void SetValue(const T & rT);
private:
T m_value;
};
#include "Temp.h"
export template <class T>
void Temp<T>:: SetValue (const T & rT)
{
m_value = rT;
}
#include "Temp.h"
注意
- 并非所有编译器都支持export关键字,且性能太差。
- export原理:在编译过程中,编译器会像.NET和Java那样,为模板实体生成一个“中间伪代码IPC”,使其他编译单元在实例化时可以找到定义体;而在遇到实例化时,则根据指定的实参再将此IPC重新编译,从而达到分离编译的目的。
请谨记
|