一般说来,C++应用程序的编译过程分为三个阶段。模板也是一样的。
- 在cpp文件中展开include文件。
- 将每个cpp文件编译为一个对应的obj文件。
- 连接obj文件成为一个exe文件(或者其它的库文件)。
1.include文件的展开。
include文件的展开是一个很简单的过程,只是将include文件包含的代码拷贝到包含该文件的cpp文件(或者其它头文件)中。被展开的cpp文件就成了一个独立的编译单元。在一些文章中我看到将.h文件和.cpp文件一起看作一个编译单元,我觉得这样的理解有问题。至于原因,看看下面的几个注意点就可以了。
1):没有被任何的其它cpp文件或者头文件包含的.h文件将不会被编译。也不会最终成为应用程序的一部分。先看一个简单的例子:
==========test.h文件==========
// 注意,后面没有分号。也就是说,如果编译的话这里将产生错误。
void foo()
// ===========test.h============
// 定义一个变量
int i;
// ===========test1.h===========
// 包含了test.h文件
#include "test.h"
// ===========main.cpp=========
// 这里同时包含了test.h和test1.h,
// 也就是说同时定义了两个变量i。
// 将发生编译错误。
#include "stdafx.h"
#include "test.h"
#include "test1.h"
void foo();
void foo();
int _tmain(int argc, _TCHAR* argv[])
{
return 0;
}
// ===========test.h============
// 声明一个函数。注意后面没有分号。
void foo()
// ===========test1.h===========
// 仅写了一个分号。
;
// ===========main.cpp=========
// 注意,这里按照test.h和test1.h的顺序包含了头文件。
#include "stdafx.h"
#include "test.h"
#include "test1.h"
int _tmain(int argc, _TCHAR* argv[])
{
return 0;
}
有的人也许看见了,上面的示例中虽然声明了一个函数,但没有实现且仍然能通过编译。这就是下面cpp文件编译时的内容了。
大家都知道,C++的编译实际上分为编译和链接两个阶段,由于这两个阶段联系紧密。因此放在一起来说明。在编译的时候,编译器会为每个cpp文件生成一个obj文件。obj文件拥有PE[Portable Executable,即windows可执行文件]文件格式,并且本身包含的就已经是二进制码,但是,不一定能够执行,因为并不保证其中一定有main函数。当所有的cpp文件都编译好了之后将会根据需要,将obj文件链接成为一个exe文件(或者其它形式的库)。看下面的代码:
// ============test.h===============
// 声明一个函数。
void foo();
// ============test.cpp=============
#include "stdafx.h"
#include <iostream>
#include "test.h"
// 实现test.h中定义的函数。
void foo()
{
std::cout<<"foo function in test has been called."<<std::endl;
}
// ============main.cpp============
#include "stdafx.h"
#include "test.h"
int _tmain(int argc, _TCHAR* argv[])
{
foo();
return 0;
}
[butbueatiful@xt myhello]$ nm -a hello.o
00000000 b .bss
00000000 n .comment
00000000 d .data
00000000 n .note.GNU-stack
00000000 t .text
00000000 a hello.c
00000000 T main
U puts
[butbueatiful@xt myhello]$ chmod +x hello.o
[butbueatiful@xt myhello]$ ./hello.o
-bash: ./hello.o: cannot execute binary file
其实这时 puts 前面的
U
表示这个符号的地址还没有定下来,
T
表示这个符号属于代码段。
ld连接的时候会为这些带U的符号确定地址
。
简要的说来,编译main.cpp时,编译器不知道f的实现,所有当碰到对它的调用时只是给出一个指示,指示连接器应该 为它寻找f的实现体 。这也就是说main.obj中没有关于f的任何一行二进制代码。编译test.cpp时,编译器找到了f的实现。于是乎foo的实现[二进制代码]出现在test.obj里。连接时,连接器在test.obj中找到foo的实现代码[二进制]的地址[通过符号导出表]。然后将main.obj中悬而未决的 jump XXX地址改成foo实际的地址 。
二:模板的编译过程。
在明白了C++程序的编译过程后再来看模板的编译过程。大家知道,模板需要被模板参数实例化成为一个具体的类或者函数才能使用。但是,类模板成员函数的调用且有一个很重要的特征,那就是成员函数只有在被调用的时候才会被初始化。正是由于这个特征,使得类模板的代码不能按照常规的C++类一样来组织。先看下面的代码:
// =========testTemplate.h=============
template<typename T>
class MyClass{
public:
void printValue(T value);
};
// =========testTemplate.cpp===========
#include "stdafx.h"
#include "testTemplate.h"
template<typename T>
void MyClass<T>::printValue(T value)
{
//
}
#include <iostream>
#include "testTemplate.h"
int main()
{
// 1:实例化一个类模板。
// MyClass<int> myClass;
// 2:调用类模板的成员函数。
// myClass.printValue(2);
std::cout << "Hello world!" << std::endl;
return 0;
}
1):我们将 testTemplate.cpp 文件从工程中拿掉,即删除 testTemplate.cpp 的定义。然后直接编译上面的文件,能编译通过。这说明编译器在展开 testTemplate.h 后编译main.cpp文件的时候并没有去检查模板类的实现。它只是记住了有这样的一个模板声明。由于没有调用模板的成员函数,编译器链接阶段也不会在别的obj文件中去查找类模板的实现代码。因此上面的代码没有问题。
2):把main.cpp文件中,第7行的注释符号去掉。即加入类模板的实例化代码。在编译工程,会发现也能够编译通过。回想一下这个过程, testTemplate.h 被展开,也就是说main.cpp在编译是就能找到 MyClass<T> 的声明。那么,在编译第7行的时候就能正常的实例化一个类模板出来。这里注意: 类模板的成员函数只有在调用的时候才会被实例化(这是编译时候的静态绑定么?) 。因此,由于没有对类模板成员函数的调用,编译器也就不会去查找类模板的实现代码。所以,上面的函数能编译通过。
3):把上面第10行的代码注释符号去掉。即加入对类模板成员函数的调用。这个时候再编译,会提示一个 链接错误 。找不到printValue的实现。道理和上面只有函数的声明,没有函数的实现是一样的。即,编译器在编译main.cpp第10行的时候发现了对myClass.PrintValue的调用,这时它在当前文件内部找不到具体的实现,因此会做一个标记,等待链接器在其他的obj文件中去查找函数实现。同样,连接器也找不到一个包括MyClass<T>::PrintValue声明的obj文件。因此报告链接错误。
4):既然是由于找不到 testTemplate.cpp 文件,那么我们就将 testTemplate.cpp 文件包含在工程中。再次编译,在VS中会提示一个链接错误,说找不到外部类型_thiscall MyClass<int>::PrintValue(int)。也许你会觉得很奇怪,我们已经将 testTemplate.cpp 文件包含在了工程中了阿。先考虑一个问题,我们说过模板的编译实际上是一个实例化的过程, 它并不编译产生二进制代码 。另外,模板成员函数也只有在被调用的时候才会初始化。在 testTemplate.cpp 文件中,由于包含了 testTemplate.h头文件, 因此这是一个独立的可以编译的类模板。但是,编译器在编译这个 testTemplate.cpp 文件的时候由于没有任何成员函数被调用,因此并没有实例化PrintValue成员。也许你会说我们在main.cpp中调用了PrintValue函数。但是要知道 testTemplate.cpp 和main.cpp是两个独立的编译单元,他们相互间并不知道对方的行为。因此, testTemplate.cpp 在编译的时候实际上还是只编译了 testTemplate.h 中的内容,即再次声明了模板,并没有实例化PrintValue成员。所以,当main.cpp发现需要PrintValue成员,并在testTemplate.obj中去查找的时候就会找不到目标函数。从而发出一个链接错误。
5):由此可见,模板代码不能按照常规的C/C++代码来组织。 必须得保证使用模板的函数在编译的时候就能找到模板代码 , 从而实例化模板 。在网上有很多关于这方面的文章。主要将模板编译分为 包含编译 和 分离编译 。其实,不管是包含编译还是分离编译,都是为了一个目标:使得实例化模板的时候就能找到相应的模板实现代码。
最后,作一个小总结。C++应用程序的编译一般要经历展 开头文件->编译cpp文件->链接 三个阶段。在编译的时候如果需要外部类型,编译器会做一个标记,留待连接器来处理。连接器如果找不到需要的外部类型就会发生链接错误。对于模板,单独的模板代码是不能被正确编译的,需要一个实例化器产生一个模板实例后才能编译。因此,不能寄希望于连接器来链接模板的成员函数,必须保证在实例化模板的地方模板代码是可见的。
编译器使用模板,通过更换模板参数来创建数据类型。这个过程就是模板实例化(Instantiation)。
从模板类创建得到的类型称之为特例(specialization)。
模板实例化取决于编译器能够找到可用代码来创建特例(称之为实例化要素,point of instantiation)。
要创建特例,编译器不但要看到模板的声明,还要看到模板的定义。
模板实例化过程是迟钝的,即只能用函数的定义来实现实例化。
再回头看上面的例子,可以知道array是一个模板,array<int, 50>是一个模板实例 - 一个类型。从array创建array<int, 50>的过程就是实例化过程。实例化要素体现在main.cpp文件中。如果按照传统方式,编译器在array.h文件中看到了模板的声明,但没有模板的定义,这样编译器就不能创建类型array<int, 50>。但这时并不出错,因为编译器认为模板定义在其它文件中,就把问题留给链接程序处理。
现在,编译array.cpp时会发生什么问题呢?编译器可以解析模板定义并检查语法,但不能生成成员函数的代码。它无法生成代码,因为要生成代码,需要知道模板参数,即需要一个类型,而不是模板本身。
这样,链接程序在main.cpp 或 array.cpp中都找不到array<int, 50>的定义,于是报出无定义成员的错误。
至此,我们回答了第一个问题。但还有第二个问题,在array.cpp中有4个成员函数,链接器为什么只报了3个错误?回答是:实例化的惰性导致这种现象。在main.cpp中还没有用上operator[],编译器还没有实例化它的定义。
解决方法
认识了问题,就能够解决问题:
- 在实例化要素中让编译器看到模板定义。
- 用另外的文件来显式地实例化类型,这样链接器就能看到该类型。
- 使用export关键字。
前二种方法通常称为包含模式,第三种方法则称为分离模式。
第一种方法意味着在使用模板的转换文件中不但要包含模板声明文件,还要包含模板定义文件。在上例中,就是第一个示例,在array.h中用行内函数定义了所有的成员函数。或者在main.cpp文件中也包含进array.cpp文件。这样编译器就能看到模板的声明和定义,并由此生成 array<int, 50>实例。这样做的缺点是编译文件会变得很大,显然要降低编译和链接速度。
第二种方法,通过显式的模板实例化得到类型。最好将所有的显式实例化过程安放在另外的文件中。在本例中,可以创建一个新文件
templateinstantiations.cpp:
// templateinstantiations.cpp
#include "array.cpp"
template class array <int, 50>; // 显式实例化
array<int, 50>类型不是在main.cpp中产生,而是在templateinstantiations.cpp中产生。这样链接器就能够找到它的定义。用这种方法,不会产生巨大的头文件,加快编译速度。而且头文件本身也显得更加“干净”和更具有可读性。但这个方法不能得到惰性实例化的好处,即它将显式地生成所有的成员函数。另外还要维护templateinstantiations.cpp文件。
第三种方法是在模板定义中使用export关键字,剩下的事就让编译器去自行处理了。当我在
Stroustrup的书中读到export 时,感到非常兴奋。但很快就发现VC 6.0不支持它,后来又发现根本没有编译器能够支持这个关键字(第一个支持它的编译器要在2002年底才问世)。自那以后,我阅读了不少关于export 的文章,了解到它几乎不能解决用包含模式能够解决的问题。欲知更多的export关键字,建议读读Herb Sutter撰写的文章。
结论
要开发模板库,就要知道模板类不是所谓的"原始类型",要用其它的编程思路。本文目的不是要吓唬那些想进行模板编程的程序员。恰恰相反,是要提醒他们避免犯下开始模板编程时都会出现的错误。