1 内联函数

2 函数模板

3 类模板

包含模型

链接器错误:

大多数C和C++程序员会这样组织他们的非模板代码:

  • 类和其他类型都被放在一个头文件中。通常而言,头文件是一个扩展名为.hpp(.h、.hh)的文件。
  • 对于全局变量和(非内联)函数,只有声明放在头文件中,定义则位于.c文件,通常而言,.c文件是指扩展名为.cpp(或者.C、.c、.cc、.cxx)的文件。

这样一切都可以正常运作了。所需的类型定义在整个程序中都是可见的;并且对于变量和函数而言,链接器也不会给出重复定义的错误。

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

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

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 所示。



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

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

那么,链接器如何识别等价的模板实例呢?答案见例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;

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