
前言「类模板」和「模板类」这两个词在中文教材里经常被混着用但它们指的不是同一件事。类模板class template是带有模板参数的类定义本身不是一个类型模板类是口语中对「由类模板实例化出来的类」的称呼。标准使用 class template 及其 specialization特化与 instantiation实例化来讨论并不把「模板类」当作独立术语。把两者分清是理解模板报错信息的第一步。第二个常见误解是以为类模板的成员函数能像普通类一样在头文件之外单独编译。模板实例化需要看到完整定义所以成员函数的定义通常也必须放在头文件里否则会出现「声明有、定义找不到」的链接错误。本文按实例化机制、类外定义与依赖名字、特化、编译模型四部分展开最后给出一个带全特化的完整示例代码以 C17 为基准。一、类模板与实例化1.1 定义与使用类模板以template开头参数可以是类型、非类型值或模板模板参数。使用时写出实参编译器据此生成一个具体的类这个过程叫实例化instantiation。// 适用于 C17#include cstddef#include stdexcept#include utility#include vectortemplate typename Tclass Stack {public:void push(const T value);void push(T value);T pop();const T top() const;bool empty() const noexcept { return data_.empty(); }std::size_t size() const noexcept { return data_.size(); }private:std::vectorT data_;};template typename Tvoid StackT::push(const T value) {data_.push_back(value);}template typename Tvoid StackT::push(T value) {data_.push_back(std::move(value));}template typename TT StackT::pop() {if (data_.empty()) {throw std::out_of_range(pop from empty stack);}T value std::move(data_.back());data_.pop_back();return value;}template typename Tconst T StackT::top() const {if (data_.empty()) {throw std::out_of_range(top from empty stack);}return data_.back();}Stackint与Stackdouble是两个完全无关的类型各自拥有独立的成员函数和静态成员。它们之间不存在继承或转换关系除非你显式提供。1.2 成员函数的实例化时机类模板被实例化时并非所有成员函数立刻生成代码。普通非虚成员函数只有被使用时才实例化因此可以只使用部分成员只要不触碰对当前类型不合法的成员就不会报错。但虚成员函数是例外虚函数表需要完整的函数集合实现通常会在类实例化时就把虚成员函数生成出来。因此类模板里的虚函数不能依赖「反正不会被调用」来逃避类型检查。另外成员函数模板不能被声明为virtual。1.3 静态成员按实例化区分每个实例化都有自己的一份静态数据成员。C17 起可以用inline static在类内定义并初始化省去类外定义。// 需要 C17#include cstddeftemplate typename Tclass Counter {public:Counter() { alive_; }~Counter() { --alive_; }static std::size_t alive() { return alive_; }private:inline static std::size_t alive_ 0;};int main() {Counterint a;Counterint b;Counterdouble c;return Counterint::alive() 2 Counterdouble::alive() 1 ? 0 : 1;}Counterint::alive_和Counterdouble::alive_是两块独立存储互不影响。C17 之前必须在类外写template typename T std::size_t CounterT::alive_ 0;。1.4 类模板实参推导C17 引入类模板实参推导class template argument deduction, CTAD允许在构造对象时省略模板实参由构造函数参数推导。// 需要 C17#include utilitytemplate typename Tclass Box {public:explicit Box(T value) : value_(std::move(value)) {}const T get() const { return value_; }private:T value_;};int main() {Box b1(42); // 推导为 BoxintBox b2(3.14); // 推导为 BoxdoubleBox b3(b1); // 推导为 Boxint调用隐式拷贝构造return b1.get() b3.get() ? 0 : 1;}CTAD 依赖构造函数和隐式推导指引deduction guide。如果构造函数无法唯一确定参数推导就会失败这时需要显式写出模板实参或自定义推导指引。二、类外定义与依赖名字2.1 类外定义成员函数在类外定义成员函数时必须重复模板头并用类名参数限定函数名。少写任何一处都会变成语法错误或另一个函数。// 适用于 C17#include cstddeftemplate typename T, std::size_t Nclass Array {public:T operator[](std::size_t i) { return data_[i]; }const T operator[](std::size_t i) const;constexpr std::size_t size() const noexcept { return N; }private:T data_[N]{};};// 类外定义模板头 类名参数::成员名template typename T, std::size_t Nconst T ArrayT, N::operator[](std::size_t i) const {return data_[i];}注意template typename T, std::size_t N这一行不能省ArrayT, N::中的实参列表也不能省。如果被定义的成员本身又是模板成员函数模板则要写两层模板头外层是类的内层是函数的。2.2 依赖类型必须写 typename在模板内部如果某个类型依赖于模板参数编译器在解析时无法判断Container::value_type是类型还是静态成员默认按非类型处理。要把它当类型用必须加typename。// 适用于 C17#include cstddeftemplate typename Containertypename Container::value_type sum(const Container c) {typename Container::value_type total{};for (const auto item : c) {total item;}return total;}返回类型处的typename也是必需的。C20 起在某些上下文里可以省略但为了可移植性写全更稳妥。2.3 依赖基类的成员要加 this-派生类模板的基类依赖于模板参数时基类成员在模板定义阶段不会参与普通名字查找。直接写成员名会找不到必须用this-或BaseT::限定或者用 using 声明引入。// 适用于 C17template typename Tclass Base {public:void hello() const {}protected:int value_ 0;};template typename Tclass Derived : public BaseT {public:void run() {// hello(); // 错误依赖基类的成员不参与普通查找this-hello(); // 正确this-value_ 1; // 正确}};这是两阶段查找two-phase lookup的直接后果模板定义时先做一次不依赖参数的名字查找实例化时再做依赖名字的查找。BaseT在定义时还不确定编译器不会去里面找hello。2.4 依赖名字中的模板消歧如果通过依赖类型访问一个成员模板需要用.template或-template告诉编译器后面跟的是模板名而不是小于号。// 适用于 C17template typename Tvoid rebind_demo() {// T::Rebind 是 T 的成员模板时的写法typename T::template Rebindint r;(void)r;}这段代码要求T提供成员模板Rebind写法的重点是typename与template同时出现缺一不可。三、全特化与偏特化3.1 全特化全特化explicit specialization为某一组具体实参提供完全独立的实现用template 开头。// 适用于 C17#include cstddef#include stdexcept#include vectortemplate typename Tclass Stack {public:void push(const T value) { data_.push_back(value); }T pop() {if (data_.empty()) throw std::out_of_range(empty);T value data_.back();data_.pop_back();return value;}bool empty() const noexcept { return data_.empty(); }private:std::vectorT data_;};template class Stackbool {public:void push(bool value) { bits_.push_back(value); }bool pop() {if (bits_.empty()) throw std::out_of_range(empty);bool value bits_.back();bits_.pop_back();return value;}bool empty() const noexcept { return bits_.empty(); }private:std::vectorbool bits_;};全特化版本可以拥有完全不同的成员列表不必与主模板一致。Stackbool使用std::vectorbool的位压缩存储接口按位操作设计这就是特化的意义。3.2 偏特化偏特化partial specialization只固定一部分模板实参或对实参形式加限制。类模板支持偏特化函数模板不支持。// 适用于 C17#include cstddeftemplate typename Tstruct TypeName {static const char* name() { return T; }};// 偏特化所有指针类型template typename Tstruct TypeNameT* {static const char* name() { return pointer; }};// 全特化inttemplate struct TypeNameint {static const char* name() { return int; }};偏特化按最特殊者优先匹配TypeNameint*匹配指针偏特化TypeNameint匹配全特化多个偏特化同样特殊时程序非良构。3.3 特化必须在使用之前显式特化必须声明在「第一次会导致隐式实例化的使用」之前。否则程序非良构且标准不要求编译器给出诊断——这意味着它可能悄悄生成主模板的代码表现为实际行为与预期不符。// 适用于 C17错误示例template typename Tstruct Traits { static int value() { return 0; } };int a Traitsint::value(); // 此处已隐式实例化主模板template struct Traitsint { static int value() { return 1; } }; // 太晚了正确做法是把特化放在所有使用之前通常紧跟主模板定义。四、模板的编译模型模板不能沿用「声明放头文件、定义放源文件」的普通做法。实例化时必须看到完整定义因此类模板及其成员函数一般全部写在头文件里若把定义放进.cpp而使用点在另一翻译单元链接器会找不到符号。如果想控制实例化、减少重复编译可以这样做在一个源文件中写template class Stackint;生成显式实例化定义在其他源文件中写extern template class Stackint;抑制隐式实例化。// 适用于 C17// 放在需要抑制重复实例化的源文件中extern template class Stackint;extern template是 C11 引入的告诉编译器该实例化已在别处生成必须确有翻译单元提供定义否则同样链接失败。五、完整示例下面的程序把主模板、全特化、非类型参数和静态成员放在一起展示固定容量栈的完整形态。// 适用于 C17#include cstddef#include iostream#include stdexcept#include stringtemplate typename T, std::size_t Nclass FixedStack {public:void push(const T value) {if (size_ N) throw std::out_of_range(full);data_[size_] value;}T pop() {if (size_ 0) throw std::out_of_range(empty);return data_[--size_];}const T top() const {if (size_ 0) throw std::out_of_range(empty);return data_[size_ - 1];}bool empty() const noexcept { return size_ 0; }std::size_t size() const noexcept { return size_; }static constexpr std::size_t capacity() noexcept { return N; }private:T data_[N]{};std::size_t size_ 0;};// 对 bool 的全特化用位运算压缩存储template std::size_t Nclass FixedStackbool, N {public:void push(bool value) {if (size_ N) throw std::out_of_range(full);if (value) bits_ | (std::size_t{1} size_);size_;}bool pop() {if (size_ 0) throw std::out_of_range(empty);--size_;bool value (bits_ size_) std::size_t{1};bits_ ~(std::size_t{1} size_);return value;}std::size_t size() const noexcept { return size_; }static constexpr std::size_t capacity() noexcept { return N; }private:std::size_t bits_ 0;std::size_t size_ 0;};int main() {FixedStackint, 4 ints;ints.push(10);ints.push(20);std::cout ints.pop() \n;std::cout FixedStackint, 4::capacity() \n;FixedStackbool, 8 flags;flags.push(true);flags.push(false);std::cout flags.pop() \n;return 0;}FixedStackbool, N是一个偏特化它固定了第一个实参为bool保留容量N作为模板参数。这种「部分固定、部分可变」正是偏特化的典型用法。注意std::size_t{1} size_在移位数达到位宽时行为未定义因此该实现只适用于N不超过std::size_t位宽的场景实际使用应加static_assert限制。常见坑点❌ 把「类模板」和「模板类」当同义词随意替换✅ 类模板是定义本身模板类是实例化产物讨论模板参数、特化时用「类模板」讨论具体类型时用「实例化后的类」。❌ 把类模板成员函数的定义放进.cpp只在头文件留声明✅ 模板定义通常必须对使用点可见把定义放头文件或用显式实例化在某个源文件中生成所需实例。❌ 类外定义成员时漏写template typename T或漏写StackT::✅ 每个类外成员定义都要完整的模板头和类名T::限定成员函数模板还要多一层模板头。❌ 在模板里直接写Container::value_type当类型用✅ 依赖类型前加typename如typename Container::value_type返回类型位置同样需要。❌ 在派生类模板中直接调用依赖基类的成员hello();✅ 写this-hello();或用using BaseT::hello;引入否则两阶段查找找不到该名字。❌ 在首次使用Stackint之后才写template class Stackint✅ 特化必须声明在所有会导致隐式实例化的使用之前否则程序非良构且编译器可能不报错。❌ 用运行时变量作为非类型模板实参如FixedStackint, n其中n是普通变量✅ 非类型模板实参必须是编译期常量表达式用constexpr变量或字面量。❌ 试图对函数模板做偏特化✅ 函数模板只能全特化需要偏特化行为时改为类模板加静态成员函数或使用重载与if constexpr。总结概念含义是否产生新类型典型语法类模板带模板参数的类定义否本身不是类型templateclass T class X;实例化用具体实参生成类是每个实参组合一个类型Xint全特化为全部实参提供独立实现是替换主模板template class Xint;偏特化固定部分实参或限制形式是templateclass T class XT*;显式实例化主动要求生成某个实例是template class Xint;CTAD从构造实参推导模板实参是X x(42);C17类模板的核心是「一份定义、多份实例」理解实例化时机、依赖名字查找和特化顺序就掌握了大部分日常问题。写模板时养成三个习惯定义放头文件、依赖类型加typename、依赖基类成员加this-。特化应克制使用能用重载或if constexpr解决的不必新增分支。