错误使用 C++ 模板特化产生的坑

2023-10-20 08:53:44 浏览数 (1)

今天在群里看到了一个错误使用 C 模板特化产生的坑,有点意思,这里记录一下。

问题是这样的:

有一个名为 A 的库,包含如下的头文件 a.h 和代码文件 a.cc

代码语言:javascript复制
// 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 使用了这个库:

代码语言:javascript复制
#include "a.h"
int main() {
    A<int> a{};
    a.print();
}

那么请问,我们编译这个库和这个代码文件之后,输出结果会是什么呢?

答案是……不一定。这要看你是怎么链接的。这听起来很奇怪是吧,不过确实是这样:

链接方式 1:

代码语言:javascript复制
g   -c a.cc
g   -o main main.cc a.o

链接方式 2:

代码语言:javascript复制
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 的时候,编译器无论如何都会去进行链接,因此就还是用了特化的版本。

简单来说,正确的模板特化写法应该是将特化声明写在头文件里,必须在使用该模板之前出现对应声明,否则编译器就会进行自动实例化:

代码语言:javascript复制
// 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() 被定义了两次,链接不应该通过才对,这又是为什么?为了解决这个问题,我们将编译过程再改一下,变成这样:

代码语言:javascript复制
g   -c a.cc
g   -c main.cc
g   -o main main.o a.o

此时,编译过程会产生 main.oa.o 两个 object 文件,我们可以用 nm 命令查看其中的内容,我们可以先看看之前错误的版本中,main.oa.o 二者的符号情况:

代码语言:javascript复制
> 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.oa.o 中都存在这个符号,不过再仔细看一下,会发现这两个符号前面的类型不同,main.o 前面的标记是 W,这意味着这个符号是一个弱符号,当强符号和弱符号同时链接的时候,并不会产生冲突,编译器会优先使用强符号。如果两个都是强符号,那么就会出现冲突了。

那么,后续正确版本的 main.o 的符号又是怎样的呢?我们可以编译一下然后再调用 nm 命令看看:

代码语言:javascript复制
> 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 的符号就是未定义的了。

至此,这次的问题算是可以完整地合理解释了:

  1. 链接的时候,.o 文件必然链接,.a 文件只会在符号找不到的时候链接
  2. 模板自动实例化出来的版本是弱符号,手写特化的是强符号,当二者同时参与链接时会选择强符号而不是产生冲突
  3. 当模板使用前没有声明特化时,编译器不知道这个模板有特化的版本,会实例化一个基础版本(弱符号)
  4. 当模板使用前有声明特化时,编译器会去外部查找这个特化版本的定义,而非自己实例化
  5. 模板特化声明必须写在头文件中,在使用之前必须让编译器看到这个特化声明,否则会出问题
  6. 模板特化声明必须写在头文件中,在使用之前必须让编译器看到这个特化声明,否则会出问题
  7. 模板特化声明必须写在头文件中,在使用之前必须让编译器看到这个特化声明,否则会出问题

0 人点赞