对于模板函数一直存在一个疑问?为什么模板函数定义要和模板函数声明放在一起而且必须是头文件中?如果把模板函数定义放在单独分开的源文件中,则必须进行显式地实例化定义,否则链接阶段会报错undefined reference,这次自己从编译链接项目的翻译单元去理解这样做的含义。
函数定义和模板定义
首先要消除一个理解误区,函数定义和模板定义不是一回事,模板定义是用来生成函数定义的模具,模板函数声明是告诉编译器在翻译本源文件时,存在这么一个模板可以使用,用来生成那些 编译器在后续翻译过程中遇到没有显式定义的、但可以通过参数列表和返回值推断的引用的函数定义,那么告诉了编译器有这样一个模板,实际的模具在哪里呢?就是一般博客提到的在头文件声明了模板函数后,紧接着在下方写出的模板定义。所以模板定义是用来生成函数定义的。
查看编译出的符号表
如果模板函数的模板定义和模板声明分开在不同的文件中(模板定义在.cpp源文件中,模板声明在.h头文件中)会怎样?一般情况下编译之前,IDE并不会提示任何错误,因为IDE只需要检测到声明,就可以使用这个符号,IDE一般是提示未声明的符号而不是未定义的符号。然而编译时大概率就是会在引用了模板函数的行报未定义错误。
使用三个简单的文件来查看编译时到底发生了什么,分别是存放模板函数声明的头文件,存放模板函数模板定义的源文件,和一个入口源文件main.cpp
templatetest.h
#ifndef TEMPLATE_TEST_H
#define TEMPLATE_TEST_H
template <typename Type>
auto add(const Type& a, const Type& b);
#endif // templatetest.h
templatetest.cpp
#include "templatetest.h"
#include <string>
template <typename Type>
auto add(const Type& a, const Type& b)
{
return a + b;
}
template <>
auto add(const std::string& a, const std::string& b)
{
return a + b;
}
main.cpp
#include <iostream>
#include "templatetest.h"
template<typename Type>
auto mul(const Type& a, const Type& b)
{
return a * b;
}
// template<typename Type>
// auto add(const Type& a, const Type& b)
// {
// return a + b;
// }
int main() {
auto result1 = mul(2, 3); // result 的类型将根据 mul 函数的返回值类型进行推导
// auto result2 = add(2, 3);
auto result3 = add(std::string("a"), std::string("b"));
std::cout << result1 << std::endl; // 输出 6
// std::cout << result2 << std::endl;
std::cout << result3 << std::endl;
return 0;
}
上述示例文件是一个最小可工作示例(Minimum Work Example),使用g++编译这三个文件(是的,头文件也算是源文件的一种,毕竟 #include 只是把头文件的内容复制过来),查看它们的符号表(使用objdump -t file.cpp.o),关于符号表如何查看,可以谷歌objdump输出结果的解析,也可以参考我的另一篇日志(C multiple definition of 报错)。
列值 | 值释义 |
---|---|
l ,g , ,! | 内部链接性,外部连接性,都不是,同时具有外部和内部链接性 |
w , | 弱链接符号,强链接符号 |
C , | 构造函数的符号,普通符号 |
W , | 警告⚠符号,普通符号(警告符号是什么意思???) |
I , | 该符号是对另一个符号的间接引用,普通符号 |
d ,D , | 用于调试的符号,动态符号,普通符号 |
F ,f ,O , | 函数名称的符号,文件的符号,对象的符号,普通符号 |
先来看 templatetest.cpp 文件经过编译后在转换为机器码之前的符号表 templatetest.cpp.o:
$ objdump -t build/src/CMakeFiles/insrc.dir/templatetest.cpp.o
build/src/CMakeFiles/insrc.dir/templatetest.cpp.o: file format elf64-x86-64
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 templatetest.cpp
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .text._ZStplIcSt11char_traitsIcESaIcEENSt7__cxx1112basic_stringIT_T0_T1_EERKS8_SA_ 0000000000000000 .text._ZStplIcSt11char_traitsIcESaIcEENSt7__cxx1112basic_stringIT_T0_T1_EERKS8_SA_
0000000000000000 l d .gcc_except_table._ZStplIcSt11char_traitsIcESaIcEENSt7__cxx1112basic_stringIT_T0_T1_EERKS8_SA_ 0000000000000000 .gcc_except_table._ZStplIcSt11char_traitsIcESaIcEENSt7__cxx1112basic_stringIT_T0_T1_EERKS8_SA_
0000000000000000 l d .debug_info 0000000000000000 .debug_info
0000000000000000 l d .debug_abbrev 0000000000000000 .debug_abbrev
0000000000000000 l d .debug_aranges 0000000000000000 .debug_aranges
0000000000000000 l d .debug_ranges 0000000000000000 .debug_ranges
0000000000000000 l d .debug_line 0000000000000000 .debug_line
0000000000000000 l d .debug_str 0000000000000000 .debug_str
0000000000000000 l d .data.rel.local.DW.ref.__gxx_personality_v0 0000000000000000 .data.rel.local.DW.ref.__gxx_personality_v0
0000000000000000 l d .note.GNU-stack 0000000000000000 .note.GNU-stack
0000000000000000 l d .eh_frame 0000000000000000 .eh_frame
0000000000000000 l d .comment 0000000000000000 .comment
0000000000000000 l d .group 0000000000000000 .group
0000000000000000 l d .group 0000000000000000 .group
0000000000000000 g F .text 0000000000000031 _Z3addINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEDaRKT_S8_
0000000000000000 *UND* 0000000000000000 _GLOBAL_OFFSET_TABLE_
0000000000000000 w F .text._ZStplIcSt11char_traitsIcESaIcEENSt7__cxx1112basic_stringIT_T0_T1_EERKS8_SA_ 0000000000000061 _ZStplIcSt11char_traitsIcESaIcEENSt7__cxx1112basic_stringIT_T0_T1_EERKS8_SA_
0000000000000000 w O .data.rel.local.DW.ref.__gxx_personality_v0 0000000000000008 .hidden DW.ref.__gxx_personality_v0
0000000000000000 *UND* 0000000000000000 _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEC1ERKS4_
0000000000000000 *UND* 0000000000000000 _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE6appendERKS4_
0000000000000000 *UND* 0000000000000000 _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEED1Ev
0000000000000000 *UND* 0000000000000000 _Unwind_Resume
0000000000000000 *UND* 0000000000000000 __gxx_personality_v0
因为开启了调试(g++编译时带-g参数的话),所以很多符号的属性中有 d 这个属性,代表debug调试符号,我们主要应该关注的是 .text 段的符号,至于.bss,.data这些数据段无需关心。关键的一行符号是这行:
0000000000000000 g F .text 0000000000000031 _Z3addINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEDaRKT_S8_
可以看到编译器重新命名的 add 函数名称是_Z3addINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEDaRKT_S8_,显然 basic_string 代指 std::string,也就是说,我们在 templatetest.cpp 中通过显式函数模板实例化的函数定义代码已经的符号已经在符号表中出现,并且具有属性 g,意味着这个符号在链接阶段是具有外部可见性的(global),那么 main.cpp 在编译后存在未定义符号时尝试去其他已被编译器翻译的单元(.c/.cpp源文件)的符号表查找导出符号时,这个符号:
_Z3addINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEDaRKT_S8_
是可以被发现并链接的,这个符号实际指向的内容在 .text 段的偏移量为0x0000000000000031 字节,编译器会去这里把被实例化的函数定义代码用于最终编译目标 main 的机器码。
注意:
#include预处理操作对于每个源文件(翻译单元)是独立的,就算源文件a和源文件b一起作为源文件列表参与编译同一个目标,它们仍然需要各自使用一次#include来包含所需的声明,因为每个源文件的翻译过程是独立进行的,并不会被其他源文件的翻译过程所影响。
如果不显式实例化
接下来把 templatetest.cpp 中显式实例化的模板函数注释掉:
#include "templatetest.h"
#include <string>
template <typename Type>
auto add(const Type& a, const Type& b)
{
return a + b;
}
// template <>
// auto add(const std::string& a, const std::string& b)
// {
// return a + b;
// }
然后重新编译,我发现 auto
关键字还会导致一个问题:
[ 68%] Building CXX object app/CMakeFiles/auto.dir/auto.cpp.o
/home/guest/workspace/temp/app/auto.cpp: In function ‘int main()’:
/home/guest/workspace/temp/app/auto.cpp:19:58: error: use of ‘auto add(const Type&, const Type&) [with Type = std::__cxx11::basic_string<char>]’ before deduction of ‘auto’
19 | auto result3 = add(std::string("a"), std::string("b"));
| ^
make[2]: *** [app/CMakeFiles/auto.dir/build.make:76: app/CMakeFiles/auto.dir/auto.cpp.o] Error 1
make[2]: Leaving directory '/home/guest/workspace/temp/build'
make[1]: *** [CMakeFiles/Makefile2:204: app/CMakeFiles/auto.dir/all] Error 2
make[1]: Leaving directory '/home/guest/workspace/temp/build'
make: *** [Makefile:136: all] Error 2
make: Leaving directory '/home/guest/workspace/temp/build'
因为此时只有模板函数的声明,并且声明的返回值我们使用了auto希望在编译阶段自动推导,然而由于 templatetest.cpp 中显式实例化模板被注释,此时所有源文件并未提供用于生成兼容调用的实例化函数定义的模板定义,因此编译器只知道这行代码是:
auto result3 = auto add(const std::string&, const std::string&)
而 auto 自动类型推导是需要右值的类型的,因此我们不能使用auto去推导auto。此时我们为了实验,先将模板函数声明修改为不使用auto推断返回值类型:
templatetest.h
#ifndef TEMPLATE_TEST_H
#define TEMPLATE_TEST_H
template <typename Type>
Type add(const Type& a, const Type& b);
#endif // templatetest.h
此时再执行编译链接,报错在源文件 main.cpp 中,模板函数的调用没有找到对应兼容的实例化函数定义:
[ 68%] Building CXX object app/CMakeFiles/auto.dir/auto.cpp.o
[ 75%] Linking CXX executable /home/guest/workspace/temp/bin/Debug/SHARED/auto
/usr/bin/ld: CMakeFiles/auto.dir/auto.cpp.o: in function `main':
/home/guest/workspace/temp/app/auto.cpp:19: undefined reference to `std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > add<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)'
collect2: error: ld returned 1 exit status
注意,此时我们的 templatetest.cpp 源文件中是已经为头文件中的函数模板准备了模板定义的:
#include "templatetest.h"
#include <string>
template <typename Type>
auto add(const Type& a, const Type& b)
{
return a + b;
}
// template <>
// auto add(const std::string& a, const std::string& b)
// {
// return a + b;
// }
只是显式实例化定义被注释了,按道理来说不应该能使用该模板定义为 main.cpp 源文件的编译中未定义符号提供导出符号查找来链接吗?
看一下这次编译后,templatetest.cpp.o 符号表:
$ objdump -t ./build/src/CMakeFiles/insrc.dir/templatetest.cpp.o
./build/src/CMakeFiles/insrc.dir/templatetest.cpp.o: file format elf64-x86-64
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 templatetest.cpp
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .debug_info 0000000000000000 .debug_info
0000000000000000 l d .debug_abbrev 0000000000000000 .debug_abbrev
0000000000000000 l d .debug_aranges 0000000000000000 .debug_aranges
0000000000000000 l d .debug_line 0000000000000000 .debug_line
0000000000000000 l d .debug_str 0000000000000000 .debug_str
0000000000000000 l d .note.GNU-stack 0000000000000000 .note.GNU-stack
0000000000000000 l d .comment 0000000000000000 .comment
相比于第一次编译出来的符号表,干净了很多。但是太干净了,在这张符号表上没有任何具有属性g的可导出链接符号。也就是说,该源文件由于没有显式地使用模板函数,因此就算提供了模板定义,编译器也不会进行任何通过函数模板来生成函数定义代码的生成操作,自然最终的符号表就没有实例化的函数定义的符号了。而模板定义本身是用来生成其他各种版本的函数定义的,并不是某种具体的实现,其自身并不会生成可供链接的符号,只是一个模具,就算尝试转译到目标可执行文件的机器码,也没有对应的指令可以转译,因为缺少参数的类型(比如int或者double,但是模板给了一个typename Type,编译器不知道Type是什么类型)。
总结
这就是为什么一般情况下推荐大家在使用模板函数的时候,把模板的声明和定义放在一起(注意,是模板的定义,不要把模板定义和函数定义混淆了),因为编译的时候,每个源文件都是单独进行的(但是使用make -j<n>
确实是并行编译,我并不了解并行编译目标文件时依赖关系如何解决),因此把模板定义放到单独的源文件中由于编译时模板定义没有生成导出符号,其他使用了模板函数的源文件只知道声明而找不到模板定义来生成合适的函数定义。
如果真的需要分开存放,比如为了可读性之类的需求,请在使用模板函数的源文件中进行显式模板实例化,或者把显式模板实例化函数定义和模板定义一起放在同一个源文件中,这样至少能提供导出符号供其他源文件链接,但是这样做似乎已经失去了模板函数的本意了,因为每个不同参数列表版本函数的调用都要用户手动实现其显式实例化函数定义,这和不使用模板函数而每个版本都实现一个重载好像也没有太大区别了。
最后,记得显式模板实例化时,在函数前添加空模板 template <> ,显式地告诉编译器,这是一个显式模板实例化,而不是一个普通的函数定义。因为我们声明的是一个模板,而不是函数,不要使用普通函数定义去匹配模板函数调用时实例化的函数定义!