前言
类模板(class template)是 C++ 泛型编程的地基:std::vector、std::map、std::unique_ptr全都是类模板。它的基本语法只要几分钟就能学会,但真正写起来,坑集中在三个地方——类外定义成员函数时要重复的模板头、依赖名(dependent name)的查找与typename/template关键字、以及模板定义必须对实例化点可见。
最常见的误解是「模板就是一种代码生成器,编译器见到就会生成所有代码」。实际情况是:类模板本身不是类型,它是一份「配方」;编译器在你真正写出具体参数(比如Box<int>)时才生成代码,这个过程叫实例化(instantiation)。而且默认的隐式实例化只生成被用到的成员函数——这意味着某个成员函数里写了语法错误,只要没人调用它,程序照样编得过。这个特性既方便(降低编译量和二进制体积),也是 bug 的藏身处。
本文按「基本形态 → 名字查找 → 特化与推导」的顺序讲清类模板的用法,代码以 C++17 为基准,涉及 C++20 特性的地方会单独标注。
一、类模板的基本形态与类外成员定义
// box.cpp 编译:g++ -std=c++17 box.cpp #include <cstddef> #include <iostream> #include <string> template <typename T> // 模板参数列表:类型参数 T class Box { public: explicit Box(const T& value) : value_(value) {} T& get() { return value_; } const T& get() const { return value_; } // 重载:按对象的 const 性选择 void set(const T& value); // 类内声明,类外定义 std::size_t size_hint() const { return sizeof(T); } private: T value_; }; // 类外定义成员函数:必须补上模板头,类名要写成 Box<T> template <typename T> void Box<T>::set(const T& value) { value_ = value; } int main() { Box<int> bi(3); bi.set(5); std::cout << bi.get() << ' ' << bi.size_hint() << '\n'; Box<std::string> bs("hi"); bs.set("hello"); std::cout << bs.get() << ' ' << bs.size_hint() << '\n'; return 0; }几个必须记住的点:
Box在类模板内部是「注入类名」(injected-class-name),在类体里写Box等价于Box<T>;但一旦出了类体(比如定义成员函数),就必须写全Box<T>::。漏掉尖括号里的T会得到一个找不到成员的错误。- 类外定义的每个成员函数都要单独写
template <typename T>,模板头不会自动继承。 - 模板参数可以不止一个,也可以混用类型参数和非类型参数:
#include <cstddef> #include <iostream> template <typename T, std::size_t N> class FixedArray { public: T& operator[](std::size_t i) { return data_[i]; } const T& operator[](std::size_t i) const { return data_[i]; } static constexpr std::size_t size() { return N; } private: T data_[N]; }; int main() { FixedArray<int, 4> a{}; // 值初始化,元素全为 0 a[0] = 1; std::cout << a[0] << ' ' << FixedArray<int, 4>::size() << '\n'; // 输出 1 4 return 0; }非类型模板参数(non-type template parameter)在 C++17 里只允许整型、枚举、指针、左值引用、nullptr_t等;浮点数和字符串字面量不能作为非类型模板参数,template <double D> class X;在 C++17 里是非法的(C++20 放宽了限制,允许浮点和满足结构类型要求的类类型)。
静态成员也是「每个实例化各一份」。C++17 之前的写法必须在类外再定义一次:
template <typename T> class Counter { public: static int value; // 类内只是声明 }; template <typename T> int Counter<T>::value = 0; // 类外定义,每个 T 一份C++17 引入了inline变量,可以省掉类外定义,直接写:
template <typename T> class Counter { public: static inline int value = 0; // C++17 起:类内定义,链接器负责合并 };要注意的是,Counter<int>::value和Counter<double>::value是两个完全不同的对象,各自独立。这是模板静态成员容易和人脑中的「全局变量」搞混的地方。
二、依赖名、typename 与 this->
这是类模板最反直觉的部分,也是编译错误信息最难看懂的部分。
规则来自「两阶段名字查找」(two-phase name lookup):模板被解析时分两个阶段——定义点(模板还没被实例化时)和实例化点。不依赖模板参数的名字在第一阶段就查找完毕;依赖模板参数的名字推迟到实例化时再查。预处理器式的「先记下来以后再说」是不存在的,第一阶段必须能确定这个名字是类型还是值。
#include <iostream> #include <vector> template <typename C> void print_first(const C& c) { // C::const_iterator 是依赖名,必须写 typename 才能当成类型使用 typename C::const_iterator it = c.begin(); if (it != c.end()) { std::cout << *it << '\n'; } } int main() { std::vector<int> v{1, 2, 3}; print_first(v); // C 推导为 std::vector<int> return 0; }为什么不写typename编译器不认识?因为C::const_iterator里的C是模板参数,编译器在第一阶段无从知道const_iterator是个类型名还是个静态数据成员——它连C是什么都不知道。标准规定这种场合默认按「不是类型」处理,于是typename C::const_iterator it = ...会被解析成「C::const_iterator乘以it」,报错就出在这个乘法上。
继承自模板基类时也有类似问题:
#include <iostream> template <typename T> class Base { public: void hello() const { std::cout << "base\n"; } using value_type = T; }; template <typename T> class Derived : public Base<T> { public: // 在类内引入一个别名,之后就能像普通成员一样使用 using value_type = typename Base<T>::value_type; void call() const { // hello(); // ❌ 编译错误:非依赖查找找不到 hello this->hello(); // ✅ 写法一 // Base<T>::hello(); // ✅ 写法二 } // value_type make() const; // ❌ 不引入别名时,value_type 也找不到 value_type make() const { return value_type{}; } // ✅ }; int main() { Derived<int> d; d.call(); return static_cast<int>(d.make()); }原因是Base<T>是依赖基类(dependent base class),它的成员在非依赖查找中被刻意忽略(否则每次特化都可能改变基类成员,第一阶段的结果就不可信了)。解决办法就是显式加this->或Base<T>::,把它变成依赖名;如果要反复使用基类里的类型名,用一句using value_type = typename Base<T>::value_type;把它引入派生类自己的作用域更省事。
成员模板在依赖上下文里调用时还需要template关键字来消歧:
#include <iostream> template <typename T> class Wrapper { public: explicit Wrapper(T v) : v_(v) {} template <typename U> U convert() const { return static_cast<U>(v_); } private: T v_; }; // 参数类型依赖模板参数,w.convert 是依赖名 template <typename T> long demo(const Wrapper<T>& w) { // return w.convert<long>(); // ❌ < 被当成小于号,解析失败 return w.template convert<long>(); // ✅ } int main() { Wrapper<int> w(42); // 这里 w 的类型是具体的,不依赖任何模板参数,因此不需要 template std::cout << w.convert<long>() << '\n'; std::cout << demo(w) << '\n'; return 0; }对照这两个调用:demo里的w类型依赖T,<有歧义,必须写w.template convert<long>();main里的w类型确定,不需要。很多人在泛型代码里被这个错误折磨过,记住「对象类型依赖模板参数 + 调用的是成员模板」这一组合就够了。
三、特化、部分特化与 C++17 的类模板实参推导
全特化(explicit / full specialization)给出某个具体参数下的完整实现,部分特化(partial specialization)只限定模板参数的一部分形态。
#include <cstddef> #include <iostream> // 主模板 template <typename T> class Box { public: explicit Box(const T& value) : value_(value) {} const T& get() const { return value_; } private: T value_; }; // 全特化:T 就是 const char* template <> class Box<const char*> { public: explicit Box(const char* value) : value_(value ? value : "") {} const char* get() const { return value_; } private: const char* value_; }; // 部分特化:T 是「某个类型的指针」 template <typename T> class Box<T*> { public: explicit Box(T* value) : value_(value) {} T* get() const { return value_; } T& deref() const { return *value_; } private: T* value_; }; int main() { Box<int> a(1); // 用主模板 Box<const char*> b("hi"); // 用全特化 int n = 7; Box<int*> c(&n); // 用部分特化 Box<T*> std::cout << a.get() << ' ' << b.get() << ' ' << c.deref() << '\n'; Box d{42}; // C++17 CTAD:推导为 Box<int> std::cout << d.get() << '\n'; return 0; }关于特化的几条硬规则:
- 全特化必须出现在「首次使用该参数组合」之前。如果编译器已经隐式实例化了
Box<const char*>,再写全特化就是「特化在实例化之后」的错误。稳妥做法是:特化定义紧跟在主模板之后。 - 全特化是一个完全独立的类,主模板的成员函数、静态成员、构造函数不会自动带过来。上面
Box<const char*>里没有size_hint()就是示例——要在特化里用就必须重新写一遍。 - 部分特化不能只限定「T 是
int」这种具体类型,那个场合应该用全特化;部分特化限定的是参数的形态,比如是指针、是const限定、或者是某个类模板的一个实例化。
CTAD(class template argument deduction,类模板实参推导)是 C++17 新增的(需要 C++17 及以上)。它让Box d{42};这种写法不用再写Box<int> d{42};,编译器从构造函数的实参推出模板参数,相当于把函数模板的实参推导搬到了类模板上。推导依据是「隐式推导指引」(由构造函数自动生成)加上显式写的推导指引:
#include <string> template <typename T> class Box { public: explicit Box(T value) : value_(value) {} private: T value_; }; Box b{42}; // Box<int> Box c{3.14}; // Box<double> // 显式推导指引:从字符串字面量推出 Box<std::string> Box(const char*) -> Box<std::string>; Box d{"hi"}; // 推导为 Box<std::string>这里Box d{"hi"};有两个候选:隐式推导指引(由构造函数生成,推到Box<const char*>)和手写的显式指引(推到Box<std::string>)。两者对实参的转换序列一样好,最终由「函数模板偏序」判定——参数是const char*的那个更特殊,显式指引胜出。
C++14 之前没有 CTAD,必须写全模板实参;如果需要 C++17 之前的等价写法,可以用一个工厂函数绕过去:
template <typename T> Box<T> make_box(const T& value) { return Box<T>(value); } // C++11 起可用 auto b = make_box(42); // Box<int>这条路线(std::make_pair、std::make_tuple就是它的产物)在 C++17 之后依然值得保留,因为函数模板的推导规则更自然,也更容易写重载。
最后是别名模板(alias template,C++11)和变量模板(variable template,C++14):
#include <cstddef> template <typename T> class Box { public: explicit Box(const T& value) : value_(value) {} private: T value_; }; template <typename T> using BoxPtr = Box<T*>; // 别名模板:只是给已有类型起新名字 template <typename T> constexpr T pi = static_cast<T>(3.1415926535897932385L); // 变量模板:C++14 起 BoxPtr<int> p(nullptr); // 等价于 Box<int*> constexpr double d = pi<double>; constexpr int truncated = pi<int>; // 3,注意会被截断别名模板不能特化,也不需要typename——它本身就是类型;变量模板每个T一个独立实例,pi<int>和pi<double>是不同的对象。
常见坑点
| 场景 | ❌ 错误写法 | ✅ 正确写法 |
|---|---|---|
| 类外定义成员 | void Box::set(...) {}(漏模板头和T) | template <typename T> void Box<T>::set(...) {} |
| 依赖名当类型 | C::const_iterator it = c.begin(); | typename C::const_iterator it = c.begin(); |
| 调用基类成员 | 继承Base<T>后直接写hello(); | this->hello();或Base<T>::hello(); |
| 调用成员模板 | w.convert<long>()(w类型依赖T) | w.template convert<long>() |
| 模板分离编译 | 定义放.cpp、声明放.h,链接时报未定义符号 | 定义放头文件;或在.cpp里显式实例化template class Box<int>; |
| 全特化位置 | 把全特化写在首次使用之后 | 全特化紧跟主模板,先于任何隐式实例化 |
| 全特化想复用主模板成员 | 以为Box<const char*>自动拥有size_hint() | 全特化是独立类,需要的成员要重新写 |
| 成员函数的隐式实例化 | 依赖X<int>的成员有语法错误没被发现,换个 T 才炸 | 写测试时把每个要用的T都实例化一遍 |
最后一条单独说明:模板的隐式实例化按需进行,一个成员函数没被调用就不会被实例化,里面即使有编译错误也不会报出来。这在 MSVC / GCC / Clang 上是一致的(标准行为),不是某个编译器偷懒。这就是为什么泛型组件一定要有多实例化的测试用例:只在main里用了Box<int>,Box<std::string>分支未必编得过。
总结
| 形态 | 语法要点 | 是否可特化 | 典型用途 |
|---|---|---|---|
| 主模板 | template <typename T> class Box { ... }; | —— | 通用实现 |
| 部分特化 | template <typename T> class Box<T*> { ... }; | 可继续特化 | 对某一类形态做特殊处理 |
| 全特化 | template <> class Box<const char*> { ... }; | 不可 | 对具体类型给出完全不同的实现 |
| 别名模板 | template <typename T> using P = Q<T*>; | 不可 | 简化类型名、做策略封装 |
| 变量模板 | template <typename T> constexpr T pi = ...; | 可特化 | 按类型参数化的常量(C++14 起) |
| 成员模板 | 类内再写template <typename U> | —— | 容器式的泛型成员函数 |
类模板用起来分三层:语法层只要记住「类外定义补模板头、类名写全Box<T>」;名字查找层记住三条触发条件——遇到依赖名当类型用加typename,访问依赖基类成员加this->,调用依赖对象的成员模板加template;架构层则是记住模板定义必须对实例化点可见,所以定义放头文件,或者用显式实例化把实例集中到少数几个.cpp里。C++17 之后,Box d{42};这样的 CTAD 写法配合inline静态成员,能省掉相当多的样板代码。