1.3 如何处理函数模板中的函数体

既然编译器是在需要生成模板实例时自动生成,这就带来一个与传统C/C++编程习惯的冲突,即函数模板中的函数体应该放在哪里。

1.3.1 HPP文件还是CPP文件

按照C++语言习惯,普通函数及类的声明应该放在一个头文件(通常以h、hpp或者hh为扩展名)里,而将其实现放在一个主代码文件(通常以c、cpp或者cc为扩展名)里,这样便于将代码分散编译到多个目标文件中,最后通过链接形成一个完整的目标文件。但是由于模板的实现是随用随生成,并不存在真实的函数实现代码,如果还是按照“头文件放声明,主文件放实现”的做法,则会导致编译失败。

例如一个最简单的模板函数声明如下所示:


// 文件名func.hpp  template<typename T> T const& func(T const &v);

这个声明放在一个名为“func.hpp”的头文件中,其实现放在名为“func.cpp”的文件中,代码如下所示:


// 文件名func.cpp  template<typename T> T const& func(T const &v) {return v;}

在一个名为“main.hpp”的文件中定义一个main函数用来调用func函数模板,代码如下所示:


// 文件名main.cpp  #include "func.hpp"  int main() {func(0);}

正如我们通常安排普通函数的代码那样,如果单独编译这两个CPP文件都没有问题,但是在链接两个目标文件时链接器就会报错。在笔者的编译环境下出现的错误如下:


$ g++ func.o main.o  main.o: In function 'main':  main.cpp:(.text+0x17): undefined reference to `int const& func<int>(int const&)'  collect2: ld 返回 1

链接器报的错误是func<int>即func函数模板的某个实例未定义。按常理,这样一个函数的实现应该是在func.cpp编译出的目标文件中定义。但如果查看该目标文件(笔者用的是func.o)就会发现其中空空如也,并无任何函数定义。

回想一下模板的工作原理就不难理解这一现象。编译器在编译func.cpp时,只是读到了func函数模板的实现,并没有读到任何需要生成函数模板实例的语句,所以不会生成任何func函数的实例。而在编译main.cpp时,虽然用到了一个函数模板实例,但因为main.cpp只是将func.hpp头文件包含进来,而后者只有一个func函数模板的声明,并无具体函数体实现,此时编译器也无法生成func函数模板实例,只好预留一个调用链接,期望在最后的链接过程中可以找到函数实现。但很遗憾这样的实现并不存在,于是最后链接时出错。

稍微修改func.cpp中的代码,使其生成一个func<int>的函数实现,如例1.6所示。

例1.6


// 文件名func2.cpp  template<typename T> T const& func(T const &v) {return v;}   template int const& func(int const &v);

例1.6中用到一种尚未介绍过的语法——明确生成模板实例。当关键字template后没有模板参数列表,而是一个函数声明时,意味着指示编译器根据此函数声明寻找合适的模板实现。当然,所声明函数必须与某一已知模板函数同名,并且其参数可用模板匹配。

例1.6中将函数声明为T=int,从而在编译func2.cpp时,会在目标文件中生成func<int>的代码而不会在链接时产生错误。但这只是权宜之计,倘若还需要func<float>或者func<char>,那么在代码文件中还得增加相应的语句,以促使编译器生成相应函数模板实例。如此一来,又变成由人工生成模板实例,违背了当初由编译器随用随生成的初衷。

可见,虽然模板中的函数也可以有自己的声明和实现,但编译器不会在读到模板实现时立刻生成实际代码,因为具体的模板参数类型还未知,无法进行编译。对于编译器来说,模板实现也是一种声明,声明如何自动生成代码。所以模板的实现也应该放在头文件内,这样,在其他代码文件中可以直接将模板的实现也包含进来,当需要生成模板实例时,编译器可根据已知模板实现当场生成,而无需依赖在别的目标文件中生成的模板实例。

但这样会带来另一个问题,即重复模板实例。

1.3.2 链接器如何识别重复模板实例

假设将例1.6中函数模板func的实现也放在头文件func.hpp中,并且文件caller.cpp及main.cpp中各有函数caller及main都调用func生成实例func<int>,易知编译后的目标文件caller.o及main.o中各自都有func<int>实例。两个函数由同一模板生成,完全等价,则这两个函数为重复模板实例。

如果在最后链接步骤中不做特殊处理,则会在最终目标代码中存在多个等价的模板实例,造成目标文件尺寸的增加,尤其是在大量用到模板库时,这种情况会愈发严重。对此问题,C++标准中给出的解决方案是:在链接时识别及合并等价的模板实例。

那么,链接器如何识别等价的模板实例呢?答案见例1.7。

例1.7


// ======================================  // 文件名caller1.cpp  #include <iostream>  template<typename T> void func(T const &v)  {      std::cout << "func1: " << v << std::endl;  }   void caller1() {      func(1);      func(0.1);  }   // ======================================  // 文件名caller2.cpp  #include <iostream>  template<typename T> void func(T const &v)  {      std::cout << "func2: " << v << std::endl;  }   void caller2() {      func(2);      func(0.2f);  }   // ======================================  // 文件名main.cpp  void caller1();  void caller2();   int main()  {      caller1();      caller2();      return 0;  }

例1.7中用到3个代码文件。其中caller1.cpp和caller2.cpp中都有一个名为func的函数模板,且两个同名模板的模板参数也相同,都只有一个类型模板参数。但两个函数模板内容不同,区别在于打印出的前导字符串。此外,caller1.cpp和caller2.cpp中还分别声明两个函数caller1及caller2,其中都用到各自文件的func模板生成函数实例并调用。

细看代码便知,caller1.cpp编译所得目标文件中有func<int>及func<double>两个函数模板实例,而caller2.cpp编译所得目标文件中有func<int>及func<float>两个函数模板实例。这两个目标文件再与main.cpp编译所得目标文件共同链接成可执行文件后会出现什么情况呢?还是以笔者所用GCC编译器为例,如果用以下命令行编译:


$ g++ caller1.o caller2.o main.o -o a.out

执行程序的输出如下:


$ ./a.out  func1: 1  func1: 0.1  func1: 2  func2: 0.2

很有趣,在函数caller2()中本意是调用caller2.cpp中的func<int>,所以应该输出“func2: 2”。但是由于caller1.cpp与caller2.cpp中均有func<int>实例,并且函数参数列表也相同(都为空),那么在链接时链接器基于函数名、模板实参列表以及参数列表判断两个函数模板实例等价,而将caller2.cpp中的func<int>除名。所有func<int>的调用都被链接到caller1.cpp中的func<int>实例。所以在以上程序输出第三行才会打印“func1: 2”。而caller1()和caller2()中还分别调用了func<double>(无修饰浮点常数默认是double型)及func<int>。由于模板参数类型不同,这是两个不同的函数。链接器在链接时可以区分二者而做出如我们所想的链接。由此例的运行结果可以推知,链接器不考虑函数具体内容,仅仅通过函数名、模板实参列表以及参数列表等“接口”信息来判断两个函数是否等价。

实际上,编译器在编译函数模板实例时,将根据函数名、函数参数类型以及模板参数值等信息来重命名编译所生成的目标函数名,这一处理方式称为Name-Mangling。如果发现“接口”等价的函数(即编译后的函数名相同),则在最终可执行代码中只保留等价函数之一作为链接候选,而放弃其他等价函数。具体保留哪个函数是随机的,可能与用户输入有关。

比如在写链接命令时,将caller2.o放在caller1.o之前,如下所示:


$ g++ caller2.o caller1.o main.o -o a.out.2

程序运行结果会变为如下所示:


$ ./a.out.2  func2: 1  func1: 0.1  func2: 2  func2: 0.2

显然,因为命令行中文件顺序的关系,导致caller2.o中的func<int>先出现,而使得caller1.o中的func<int>实例被编译器放弃。

通常情况下,根据函数接口判断等价函数实例并在链接时合并的简单方法,可以有效解决重复模板实例的问题。但正如例1.7中所演示那样,使用这种方法也有弊端。倘若有不同的作者在写不同的模板库时,碰巧用到同一函数名以及相同的模板参数列表和函数形参列表,对于一些简单函数,这也是非常有可能的。又碰巧两个模板库用在同一项目的不同代码文件之中,则在最终链接时,有可能因为链接器的去重复功能而导致意外的链接结果,使得最终程序工作异常。降低落入这一陷阱的可能性,最好的方法就是避免使用相同的函数名。此时,C++中的命名空间(namespace)机制就显得异常重要。

模板库作者最好为自己的作品起一个独特的名字,并将所有模板库代码放在此命名空间内,例如所有的C++标准模板库代码都放在std命名空间内。即使名字很长,库的用户也可以通过为空间改名或者利用using语句显示引用所需函数等办法来降低代码量。只要两个库的命名空间不一样,库中的函数名就不会重复。除非用户采用以下方式强行将两库命名空间内的所有元素引入自己的空间,人为地制造命名冲突:


using namespace libA;  using namespace libB;

因此,无论是库开发者还是用户,管理命名的习惯至关重要。这不仅为了提高代码可读性,更是关系到编译结果是否正确。

转自http://book.51cto.com/art/201310/412181.htm