C++函数模板在分离式编译中的坑

C++ 中 模板泛型 编程的基础,但是模板在 分离式编译 中又有许多坑~

函数模板

函数模板(Function Template)虽然也定义了函数实现的具体过程,但是它并不能完全等同于普通函数。

在 C++ 中,类型数据 的两个主要特征。普通函数限定了数据的类型,只对数据的值进行操作。

当我们需要对不同类型数据的值进行相同的操作时,就需要为每一种数据类型各自定义对应的普通函数,这意味着大量冗余重复的代码。

函数模板 就是为了解决这样一个问题:针对不同数据类型的相同操作只定义一次函数。

函数模板实际上是在普通函数的基础上,将数据的 类型 看作了参数,从而建立起了一个 模板,这个模板可以通过指定特定的数据类型而 实例化 为具体的普通函数。模板中传递数据类型的参数叫做 模板参数(Template Parameters):

1
2
template <class T1, class T2>
returnType funcName(T1 t1, T2 t2) { ... }

通过 template 关键字定义一个模板,T1T2 是模板参数,class 也可替换成 typename

实例化

函数模板与函数的区别在于:函数在定义时就已经自动实例化(实例化的意思就是编译时产生了对应的二进制代码),因为函数只处理数据的值,而数据的类型是已知的;函数模板需要指定模板参数转化为普通函数才能被实例化,这就意味着如果只是定义一个模板,编译器会忽略它,这是理所应当的,因为编译器不知道模板的具体数据类型。模板参数的值只有当函数模板被 调用 时才能确定,也就是说,调用函数模板会自动实例化一个特定数据类型的普通函数,这种依赖于实际调用和传输参数数据类型的函数模板实例化过程叫做 隐式实例化(Implicit Instantiation)。

除了隐式实例化之外,我们还可以在函数模板发生实际调用之前主动对函数模板进行实例化,即主动告诉编译器我们需要对哪些特定的数据类型(模板参数)生成普通函数,这种方式叫做 显式实例化(Explicit Instantiation)。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 定义函数模板
template <class T> void swap(T &x, T &y) {
T temp = x;
x = y;
y = temp;
}

// 显式实例化
template void swap(int&, int&);

// 隐式实例化
float x = 1.23, y = 3.21;
swap(x, y);

特化

有时候,函数模板并不适用于某些特殊的数据类型。例如上面的 swap 函数,如果 T 是复杂的类,而我们想交换的是对象的两个属性,则上面定义的模板并不能实现这个目的。此时我们可以针对特殊的数据类型对函数模板进行修改,即函数模板的 特化(Specialization)。

特化实现的是普通函数,它需要重写函数的定义过程,并自动实例化(因为数据的类型指定了)。

特化实际上相当于函数的重载:相同的函数名,形参具有不同的数据类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 函数模板
template <class T> void swap(T &x, T &y) { ... }

// 特化
template <> void swap(Books &x, Books &y) {
string title = x.title;
x.title = y.title;
y.title = title;
}

class Books {
public:
string title;
}

分离式编译

分离式编译(Separate Compilation):C++ 以 cpp 文件为基本编译单元,将每个 cpp 文件单独编译成目标文件,最后通过连接器连接所有的目标文件生成可执行文件。

为什么要连接?一个目标文件中的函数/类/变量的定义可能在其它的目标文件中,并且程序的执行入口只在其中某一个目标文件中。

每一个 cpp 文件在被独立编译时都会 包含(include)相关的头文件,如果两个 cpp 文件同时引入了相同的头文件,意味着这个头文件会被编译两次,这表示头文件中的对象会被多次声明,这将导致 redefinition 的错误。正确的做法是在头文件中使用 条件编译

条件编译相关宏指令:#if, #ifdef, #ifndef, #endif, #else, #elif 和 #define

因为函数模板的定义实际上不会被编译成二进制代码(实例化),所以函数模板的分离式编译就会出现一些问题。

下面是一个经典的分离式编译,三个文件分别实现了函数的声明、定义和调用:

  • 函数声明 swap.h

    1
    2
    3
    #ifndef SWAP_H
    #define SWAP_H
    template <class T> void swap2(T&, T&);
  • 函数定义 swap.cpp

    1
    2
    3
    4
    5
    6
    #include "swap.h"
    template <class T> void swap2(T &x, T &y) {
    T temp = x;
    x = y;
    y = temp;
    }
  • 函数调用 main.cpp

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #include <iostream>
    #include "swap.h"
    using namespace std;

    int main() {
    int x = 3, y = 5;
    swap2(x, y);
    cout << "x = " << x << endl;
    cout << "y = " << y << endl;
    return 0;
    }

编译器分别编译 swap.cpp 和 main.cpp 两个文件。

由于两个文件都包含了 swap.h 文件,因此需要使用条件编译。

编译器在编译 swap.cpp 时虽然会检查函数模板的语法,但是却不会生成相应的函数代码,因为模板参数是未知的。

main.cpp 中调用了函数 swap2<int>,编译器只能在 swap.h 中找到函数模板 swap2<T> 的声明,而没有模板的定义。这导致函数模板不能被正确实例化,报错信息提示未定义的函数 void swap2<int>(int&, int&)

1
undefined reference to void swap2<int>(int&, int&)

因为 main.cpp 包含了 swap.h 文件,如果我们在 swap.h 文件中实现了函数模板,则不会报错:

1
2
3
4
5
6
7
8
9
#ifndef SWAP_H
#define SWAP_H
template <class T> void swap2(T&, T&);
template <class T> void swap2(T &x, T &y) {
T t = x;
x = y;
y = t;
}
#endif

因为此时编译 main.cpp 时能够在 swap.h 中找到 函数模板的定义,并且根据实际调用时的变量类型对函数模板进行 隐式实例化 生成 void swap2<int>(int&, int&) 相关的代码。但是,这种写法明显不符合分离式编译的规范:在头文件中声明,在源文件中定义。

一种较好的做法是在 swap.cpp 中对函数模板进行 显式实例化

1
2
3
4
5
6
7
#include "swap.h"
template <class T> void swap2(T &x, T &y) {
T t = x;
x = y;
y = t;
}
template void swap2<int>(int&, int&);

没想明白的是,显式实例化语句写在头文件中会提示找不到模板的定义,而写在源文件中的任何位置都可以~

进行显式实例化后,就可以正常编译啦:

1
2
3
> g++ main.cpp swap.cpp && a.exe && del a.exe
Before swapping: x = 3, y = 5
After swapping: x = 5, y = 3

类模板

类模板函数模板 类似:

  • books.h

    1
    2
    3
    4
    #ifndef BOOKS_H
    #define BOOKS_H
    template <class T> class Books;
    #endif
  • books.cpp

    1
    2
    3
    #include "books.h"
    template <class T> class Books { ... };
    template class Books<int>;

参考资料

为什么C++编译器不能支持对模板的分离式编译

C++ 函数模板 实例化和具体化

----- For reprint please indicate the source -----
0%