C++模板的定义是否只能放在头文件中?

C++模板的定义是否只能放在头文件中?答案是否定的,你也可以放在.cpp源文件中。不过,你最好还是放在头文件中,下面我会解释为什么。我不了解编译器的实现细节,无法从原理上进行解释,但可以从行为上进行探究,此处使用的编译器为gcc 5.4.0。

情况1

就以一个最简单的加法函数的模板为例,一般我们会把定义放在头文件中,就像这样:

// add.h
template <typename T>
T add(const T &a, const T &b)
{
    return a + b;
}

// main.cpp
#include "add.h"
int main()
{
    int i = add(1, 1);
    return 0;
}

可以编译链接通过。

情况2

假如像普通函数一样,如果我们不想把定义放在头文件中,一般会这么做:

// add.h
template <typename T>
T add(const T &a, const T &b);

// add.cpp
#include "add.h"
template <typename T>
T add(const T &a, const T &b)
{
    return a + b;
}

// main.cpp
#include "add.h"
int main()
{
    int i = add(1, 1);
    return 0;
}

试试能不能编译通过:

$ gcc -c main.cpp          # main.cpp编译通过
$ gcc -c add.cpp           # add.cpp编译通过
$ gcc -o main main.o add.o # 链接失败!
main.o: In function `main':
main.cpp:(.text+0x34): undefined reference to `int add<int>(int const&, int const&)'
collect2: error: ld returned 1 exit status

结果编译通过,但是链接失败了。

分析

我们可以利用gcc的-S参数,看看gcc在编译时生成了什么代码。
首先是情况1的汇编代码:

    .file   "main.cpp"
    .text
    .globl  main
    .type   main, @function
main:
    (略)
    call    _Z3addIiET_RKS0_S2_
    (略)
.text._Z3addIiET_RKS0_S2_,"axG",@progbits,_Z3addIiET_RKS0_S2_,comdat
    .weak   _Z3addIiET_RKS0_S2_
    .type   _Z3addIiET_RKS0_S2_, @function
_Z3addIiET_RKS0_S2_:
    (略)
.LFE2:
    .size   _Z3addIiET_RKS0_S2_, .-_Z3addIiET_RKS0_S2_
    .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609"
    .section    .note.GNU-stack,"",@progbits

可见gcc生成了add<int>的定义,其mangle后的函数名为_Z3addIiET_RKS0_S2_,因此链接时能够找到函数定义。
而情况2又生成了什么代码呢?
首先是main.cpp汇编后代码:

    .file   "main.cpp"
    .text
    .globl  main
    .type   main, @function
main:
    (略)
    call    _Z3addIiET_RKS0_S2_
    (略)
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609"
    .section    .note.GNU-stack,"",@progbits

意料之中的,就像调用了一个普通外部函数一样,没有add<int>的代码。
那add.cpp生成了什么汇编代码呢:

    .file   "add.cpp"
    .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609"
    .section    .note.GNU-stack,"",@progbits

空空如也!啥可执行代码也没有。
下面是解释环节:


程序员:编译add.cpp为什么没有生成add<int>函数定义?
编译器:为什么要生成add<int>函数定义?
程序员:因为你能生成啊?
编译器:能生成就要生成吗,我还能生成add<float> add<double> add<long long>呢,怎么样,要不试试你的内存能放得下多少个add函数的实例?
程序员:因为main.cpp用到了啊。
编译器:main.cpp用到了,所以编译add.cpp函数就要生成吗?
程序员:难道不是吗?
编译器:不是。
程序员:为什么?
编译器:没人告诉我要生成。
程序员:……
编译器:难道为了编译这一个源文件,你要我把你其他所有源文件都读一遍吗?
程序员:不行吗?
编译器:如果这里有一千万个源文件呢?
程序员:……
编译器:如果你的时间和电费都充足的话,我可以考虑这么做(当然得你亲手改造我啦)。
程序员:……算了吧,你还是告诉我怎么告诉你才会生成add<int>函数定义
编译器:这么说就好了:

template int add(const int &a, const int &b)

程序员:感谢!
编译器:噢,不对,还少了个分号。


好吧,开始正式的解释。编译器在编译main.cpp这个源文件时,实际上是没办法为add<int>实例化的,因为在本编译单元(或者叫做翻译单元)中,编译器没有得到模板函数定义,也就没有办法为其实例化了。但是也只是仅此而已,没办法实例化不代表会编译错误。由于函数名的mangle规则是确定的,如果这个函数在其他编译单元实例化了,名字我们是知道的,就是_Z3addIiET_RKS0_S2_。因此编译器生成了函数调用的代码call _Z3addIiET_RKS0_S2_,期望在链接时能够找到。而在编译add.cpp时又发生了什么呢?就像上面程序员和编译器的对话一样,虽然有能力实例化add<int>,但是本编译单元无人调用该函数,而编译模板定义代码本身是不会生成任何可执行代码的,编译add.cpp时没有生成可执行代码。

分离声明与定义

最终只要把add.cpp改成这样,就可以正常编译链接了,从而实现了声明与定义的分离:

#include "add.h"
template <typename T>
T add(const T &a, const T &b)
{
    return a + b;
}
template int add(const int &a, const int &b);

每在其他源文件中调用了其他版本的add函数时,你都要在这个文件里面再加一行。嗯,很烦吧,所以你还是把模板定义放在头文件吧。

不会重复定义吗?

看到这里,你也许会有疑问:如果是普通函数(非内联)放在头文件定义的话,如果多个源文件引用了该头文件,链接时会报重复定义错误,就像这样:

/tmp/ccSzQ3R4.o: In function `foo()':
foo.cpp:(.text+0x0): multiple definition of `foo()'
/tmp/ccUjQFDg.o:main.cpp:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status

模板函数不会吗?
当然不会,仔细观察之前生成的汇编代码:

.text._Z3addIiET_RKS0_S2_,"axG",@progbits,_Z3addIiET_RKS0_S2_,comdat
    .weak   _Z3addIiET_RKS0_S2_
    .type   _Z3addIiET_RKS0_S2_, @function

请注意第二行的.weak,它表明了了_Z3addIiET_RKS0_S2_是一个弱符号,遇到重复定义的情况会随机选择一个实现,并不会报重复定义错误。
随机选择一个实现意味着什么呢?意味着会选择一个,然后丢掉其他的,这在大多数情况下都不会有什么问题,毕竟模板对一个类型实例化的代码都是一样的。但是要我手动构造出来一个异常情况也是很容易的,首先我们写一个“bad”版的add函数,虽然函数名是add,但实际做的是减法:

// bad_add.h
template <typename T>
T add(const T &a, const T &b)
{
    return a - b;     //减法
}

然后写一个函数“bad_call”调用这个模板函数:

// bad_call.h
int bad_call(const int &a, const int &b)

// bad_call.cpp
#include "bad_call.h"
#include "bad_add.h"
int bad_call(const int &a, const int &b)
{
    return add(a, b);
}

然后在main.cpp中调用正常的add和被bad_call包装过的异常版的add:

#include "add.h"
#include "bad_call.h"
#include <iostream>

int main()
{
    int i = add(1, 1);
    int j = bad_call(1, 1);
    std::cout << "i: " << i << std::endl;
    std::cout << "j: " << j << std::endl;
    return 0;
}

它会输出什么呢:

i: 2
j: 2

哈哈,一切正常,并没有调到异常版add(当然我们不能期望每次都这样),实际上异常版add已经被丢弃了(不然多浪费空间)。那我们改改,把对正常版add的调用注释掉:

#include "add.h"
#include "bad_call.h"
#include <iostream>
int main()
{
    //int i = add(1, 1);
    int j = bad_call(1, 1);
    //std::cout << "i: " << i << std::endl;
    std::cout << "j: " << j << std::endl;
    return 0;
}

它的输出变成了:

j: 0

So bad。不知道这样的情况是不是真的有人遇到过,别人注释一行代码,结果自己代码的功能变了。

猜你喜欢

转载自blog.csdn.net/imred/article/details/80261632
今日推荐