错误使用 C++ 模板特化产生的坑
今天在群里看到了一个错误使用 C++ 模板特化产生的坑,有点意思,这里记录一下。
问题是这样的:
有一个名为 A
的库,包含如下的头文件 a.h
和代码文件 a.cc
// a.h
#pragma once
#include <iostream>
template <class T>
struct A {
void print() { std::cout << "normal" << std::endl; }
};
// a.cc
#include "a.h"
template <> void A<int>::print() {
std::cout << "specialization" << std::endl;
}
有如下代码文件 main.cc
使用了这个库:
#include "a.h"
int main() {
A<int> a{};
a.print();
}
那么请问,我们编译这个库和这个代码文件之后,输出结果会是什么呢?
答案是……不一定。这要看你是怎么链接的。这听起来很奇怪是吧,不过确实是这样:
链接方式 1:
g++ -c a.cc
g++ -o main main.cc a.o
链接方式 2:
g++ -c a.cc
ar -r a.a a.o
g++ -o main main.cc a.a
已经知道两个链接方式会产生区别了,那执行 ./main
后的输出分别是什么呢?
答案是:链接方式 1 产生的 main
输出 specialization
,链接方式 2 产生的 main
输出 normal
。
这看起来完全不讲道理啊,凭什么同样一个库,链接 .a
和链接 .o
的结果不一样?这就要说到,编译器在链接 .a
和 .o
时的行为差别了。当编译器链接 .o
的时候,它会将 .o
中的符号全部链接进最终文件中,而当链接 .a
的时候,编译器则是会看当前链接结果是否存在未定义的符号,如果没有,那就不链接这个 .a
文件里面的内容。而如果有需要链接的符号,则尝试在 .a
文件中查找,如果找到了,就链接这个 .a
里面的内容,否则就跳过。
仔细看一下代码就会发现,这里的特化声明没有声明在头文件里,因此在编译 main.cc
的时候,编译器会实例化 A<int>::print()
,这会导致后续链接的时候产生问题。在链接 .a
的时候,编译器发现我已经有 A<int>::print()
了,不需要去链接 .a
,因此就跳过了这个库,这就导致了最终输出的是编译器实例化出来的版本,而不是我们定义的特化版本。而在链接 .o
的时候,编译器无论如何都会去进行链接,因此就还是用了特化的版本。
简单来说,正确的模板特化写法应该是将特化声明写在头文件里,必须在使用该模板之前出现对应声明,否则编译器就会进行自动实例化:
// a.h
#pragma once
#include <iostream>
template <class T>
struct A {
void print() { std::cout << "normal" << std::endl; }
};
// 注意这里声明了一个特化版本
template <> void A<int>::print();
// a.cc
#include "a.h"
template <> void A<int>::print() {
std::cout << "specialization" << std::endl;
}
这样一来,无论是链接 .o
还是链接 .a
,结果都是输出 specialization
了。
问题虽然就这样解决了,但是刚刚的描述好像有点不对劲。我们说之前错误的写法会导致编译器自动实例化模板,而链接 .o
文件的时候,又会将 .o
中的符号链接进最终结果里,那这个时候怎么就没产生符号冲突呢?理论上 A<int>::print()
被定义了两次,链接不应该通过才对,这又是为什么?为了解决这个问题,我们将编译过程再改一下,变成这样:
g++ -c a.cc
g++ -c main.cc
g++ -o main main.o a.o
此时,编译过程会产生 main.o
和 a.o
两个 object 文件,我们可以用 nm
命令查看其中的内容,我们可以先看看之前错误的版本中,main.o
和 a.o
二者的符号情况:
> nm main.o
# U __cxa_atexit
# U __dso_handle
# U _GLOBAL_OFFSET_TABLE_
# 000000000000008f t _GLOBAL__sub_I_main
# 0000000000000000 T main
# U __stack_chk_fail
# 0000000000000042 t _Z41__static_initialization_and_destruction_0ii
# 0000000000000000 W _ZN1AIiE5printEv
# U _ZNSolsEPFRSoS_E
# U _ZNSt8ios_base4InitC1Ev
# U _ZNSt8ios_base4InitD1Ev
# U _ZSt4cout
# U _ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
# 0000000000000000 r _ZStL19piecewise_construct
# 0000000000000000 b _ZStL8__ioinit
# U _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc
> nm a.o
# U __cxa_atexit
# U __dso_handle
# U _GLOBAL_OFFSET_TABLE_
# 0000000000000088 t _GLOBAL__sub_I__ZN1AIiE5printEv
# 000000000000003b t _Z41__static_initialization_and_destruction_0ii
# 0000000000000000 T _ZN1AIiE5printEv
# U _ZNSolsEPFRSoS_E
# U _ZNSt8ios_base4InitC1Ev
# U _ZNSt8ios_base4InitD1Ev
# U _ZSt4cout
# U _ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
# 0000000000000000 r _ZStL19piecewise_construct
# 0000000000000000 b _ZStL8__ioinit
# U _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc
其中,_ZN1AIiE5printEv
就是我们要找的符号,可以看到,确实在 main.o
和 a.o
中都存在这个符号,不过再仔细看一下,会发现这两个符号前面的类型不同,main.o
前面的标记是 W
,这意味着这个符号是一个弱符号,当强符号和弱符号同时链接的时候,并不会产生冲突,编译器会优先使用强符号。如果两个都是强符号,那么就会出现冲突了。
那么,后续正确版本的 main.o
的符号又是怎样的呢?我们可以编译一下然后再调用 nm
命令看看:
> nm main.o
# U __cxa_atexit
# U __dso_handle
# U _GLOBAL_OFFSET_TABLE_
# 000000000000008f t _GLOBAL__sub_I_main
# 0000000000000000 T main
# U __stack_chk_fail
# 0000000000000042 t _Z41__static_initialization_and_destruction_0ii
# U _ZN1AIiE5printEv
# U _ZNSt8ios_base4InitC1Ev
# U _ZNSt8ios_base4InitD1Ev
# 0000000000000000 r _ZStL19piecewise_construct
# 0000000000000000 b _ZStL8__ioinit
可以看到,这里的 _ZN1AIiE5printEv
前面标记了 U
,这说明这是一个未定义的符号,需要在外部查找,这就是为什么在正确实现的版本中,编译器会去查找 .a
文件中的定义。
另外,这顺便也能解释另一件事情:如果 main
依赖于 liba.a
,而 liba.a
依赖于 libb.a
,那么我们在链接库的时候就需要先链接 liba.a
再链接 libb.a
,否则就会出现符号未定义的问题。这是因为如果我们先链接 libb.a
,那么由于 main
没有直接依赖 libb.a
中的符号,此时 libb.a
会被直接跳过,当链接 liba.a
之后,libb.a
中的符号就再也不会被链进来了,此时 liba.a
中依赖于 libb.a
的符号就是未定义的了。
至此,这次的问题算是可以完整地合理解释了:
- 链接的时候,
.o
文件必然链接,.a
文件只会在符号找不到的时候链接 - 模板自动实例化出来的版本是弱符号,手写特化的是强符号,当二者同时参与链接时会选择强符号而不是产生冲突
- 当模板使用前没有声明特化时,编译器不知道这个模板有特化的版本,会实例化一个基础版本(弱符号)
- 当模板使用前有声明特化时,编译器会去外部查找这个特化版本的定义,而非自己实例化
- 模板特化声明必须写在头文件中,在使用之前必须让编译器看到这个特化声明,否则会出问题
- 模板特化声明必须写在头文件中,在使用之前必须让编译器看到这个特化声明,否则会出问题
- 模板特化声明必须写在头文件中,在使用之前必须让编译器看到这个特化声明,否则会出问题