在类模板定义的头文件之外实现类模板的成员函数

模板是一个 C++ 的高级特性,它使得程序员可以编写一个类或者函数,以相同的方式处理不同类型的数据。

前段时间在网上看到,有人认为类模板的成员函数必须实现在类模板的定义当中。这与我一直以来的认知是冲突的——我认为类模板和普通的类其实没有什么差别,除了它需要做额外的实例化之外。于是,我翻看了 C++ Primer,并检索了一些资料,就有了这篇文章。

模板编译

C++ 中的模板,是一个很有意思的概念。说它有意思,是因为这个模板的概念,和现实生活中的模板非常相似。因此,实际上只需要理解了现实生活中的模板,理解 C++ 中的模板的一些特殊性就容易了。

学生在写论文的时候,经常会去网上检索「LaTeX 论文模板」。下载下来之后,论文模板实际上只有一个简单的骨架。学生们需要在这个骨架的基础上,书写、构建自己的内容,才能变成实际的论文。这个过程,我们称其为「实例化」;这是一个从一般到特殊的过程——从一个一般化的模板,填入自己的内容,变成自己的论文。

C++ 中的模板也是一样。当程序员定义了一个模板后,它只是一个骨架。而只有当编译器知道用户需要传入的模板类型参数之后,编译器才会给出一个「有血有肉」的函数(对应函数模板)或者类(对应类模板)。因此,对于模板的编译,我们需要知道:当我们使用模板时(具体来说,是实例化时),编译器才会根据模板的定义生成具体的代码。这个特性,决定了涉及到模板的错误检测时机可能与一般的代码有所差异。

类模板的特殊性

对于普通的类,当我们使用这个类时,编译器必须能够知道类的定义。但是,类中的成员函数,则不必在编译时就能被编译器读到——可以在链接的过程中,再确定具体的类成员函数。因此,一个通常的做法是:在头文件中进行类的定义和类成员函数的声明;而在源文件中实现类的成员函数。

对于类模板来说,当我们使用这个类模板时,会传入模板类型参数,而后编译器会根据类模板和传入的类型参数生成类对应的代码。因此,此时编译器必须能够知道类的定义,也必须知道类中成员函数的实现。类模板的这一特殊性,使得在处理类模板相应的实现的时候,通常会把类模板的定义和成员函数的实现都放在头文件里。

在类模板的定义之外实现成员函数

与普通的类相同,程序员既可以在类定义的内部实现成员函数,此时这些成员函数会被隐式地声明为 inline 的;程序员也可以在类定义的外部实现成员函数。不过,对于后者,会有需要注意的地方。

类模板的成员函数必然是函数模板

这是因为,类模板的成员函数和类模板共享模板参数;并且,类模板的每个实例,其成员函数都是互相独立的。因此,哪怕类模板的某个成员函数,完全不会用到模板参数,它也必须是一个函数模板,并且模板参数和类模板的参数保持一致。

在类模板实例化时,类模板的成员函数必须是可见的

这个原因,在上一节已经讲过了。

两种做法

至此,我们可以讨论一下,在类模板定义的头文件之外实现其成员函数,具体要怎么做了。一般来说,有两种做法。

在头文件末尾引入实现源文件

这样一来,成员函数的实现会被 #include 包含进头文件,实际上在编译器看来和写在头文件内没有本质的差别了。

foo.h
1
2
3
4
5
6
7
8
9
10
#ifndef FOO_
#define FOO_
template<typename T>
class Foo {
T val;
void doSomething(T param);
};

#include "foo.tpp"
#endif
foo.tpp
1
2
3
4
template<typename T>
void Foo<T>::doSomething(T param) {
// do something
}

在实现时显示地实例化类模板

这种情况下,只有实例化的版本才能被使用——因为只有它们真正被编译成了类代码;其它未实例化的版本,实际是没有定义的类。

bar.h
1
2
3
4
5
6
7
8
#ifndef BAR_
#define BAR_
template<typename T>
class Bar {
T val;
void doSomething(T param);
};
#endif
bar.cpp
1
2
3
4
5
6
7
8
#include "bar.h"
template<typename T>
void Bar<T>::doSomething(T param) {
// do something
}

template class Bar<int>;
template class Bar<double>;

当编译过 bar.cpp 之后,其它包含头文件 bar.h 并与其目标文件链接的源文件,就可以使用 Bar<int>Bar<double> 这两个类了。而使用其它版本,则会报错。