C++中的类模板详解及示例 前言类模板class template是 C 泛型编程的骨架。它把「类型」变成参数让一份代码适配多种元素类型同时保留编译期类型检查。标准库里的std::vector、std::map、std::unique_ptr全都是类模板。理解类模板等于理解了 STL 一半的设计思路。初学时有几个高频误解。第一把类模板当成类型——std::vector不是类型std::vectorint才是模板本身不生成代码只有被实例化时才生成。第二以为模板的成员函数会全部编译——实际上只有被用到的成员函数才会实例化没用的成员即使有语法错误也可能不被发现不同编译器检查力度不同。第三把成员函数定义写进.cpp文件然后困惑于链接错误——模板定义通常必须对使用它的翻译单元可见。本文按「定义与实例化—成员与依赖名—特化—现代写法」四部分讲最后给出一个可以放进项目的固定长度数组模板并列出常见坑点。代码基准是 C17涉及 C20 的约束会单独标注。一、定义、实例化与「为什么放在头文件」类模板的定义以template开头尖括号里是模板参数列表// 适用于 C17template typename Tclass Box {public:explicit Box(T value) : value_(value) {}T get() const { return value_; }void set(const T value) { value_ value; }private:T value_;};typename和class在模板参数列表里等价习惯上typename更强调「可以是任何类型包括内置类型」。模板参数可以有默认值也可以是非类型参数如std::size_t N或模板模板参数。实例化发生在编译器看到具体用法时。写Boxint b(1);编译器用int替换T生成一个具体的类。同一个模板用不同实参实例化出的类彼此独立Boxint和Boxdouble没有继承关系也不能互相赋值。这一点常被误解为「模板是运行期的泛化」实际上它完全是编译期的代码生成。为什么定义要放在头文件编译器在实例化Boxint时必须看到Box的完整定义包括成员函数的函数体。如果成员函数定义在另一个.cpp里那个翻译单元编译时并不知道将来会有哪些实例化于是不会为Boxint::get生成代码使用方那边只看到声明只能留下一个未解析的符号链接阶段报错。这就是「模板定义通常必须放在头文件」的原因。C11 起可以用显式实例化template class Boxint;把某些实例化集中到一个源文件但需要预先枚举所有用到的类型灵活性差。成员函数按需实例化只有被调用的成员才会生成代码。这意味着一个模板里某个成员函数写错了只要没人调用它程序可能照常通过编译。不同编译器对这个规则的执行不完全一致不能依赖「没报错就是对的」。二、类外定义、依赖名与typename成员函数可以在类外定义写法是在类名后加上模板实参// 适用于 C17template typename Tclass Box {public:explicit Box(T value);T get() const;private:T value_;};template typename TBoxT::Box(T value) : value_(value) {}template typename TT BoxT::get() const { return value_; }注意BoxT::Box里的BoxT不能省略成Box。构造函数名与类名相同但作用域限定必须带模板实参。在模板内部引用依赖于模板参数的类型时编译器默认不知道Container::iterator是一个类型还是一个静态成员必须加typename消歧// 适用于 C17template typename Containervoid dump(const Container c) {typename Container::const_iterator it c.begin();for (; it ! c.end(); it) {// 使用 *it}}typename Container::const_iterator告诉编译器「这是一个类型」。如果漏掉编译器会按「非类型」解析报出看起来莫名其妙的错误。规则可以简记为在模板里凡是形如T::something且T依赖模板参数并且你要把它当类型用就加typename。与之对称的是template关键字。当通过一个依赖名的对象调用成员模板时也需要消歧// 适用于 C17template typename Tvoid call_get(T obj) {auto value obj.template getint(); // 告诉编译器 get 是成员模板}如果obj的类型不依赖模板参数就不需要这个template。另一个历史细节C03 里连续两个右尖括号会被当成右移运算符std::vectorstd::vectorint 必须留一个空格C11 起可以被正确解析std::vectorstd::vectorint是合法写法。三、全特化与偏特化模板特化specialization是为特定实参提供单独实现。全特化full specialization指定所有模板参数// 适用于 C17template typename Tclass Printer {public:void print(const T value) { /* 通用实现 */ }};template class Printerbool {public:void print(bool value) { /* 针对 bool 的专门实现 */ }};template 表示参数列表为空Printerbool是完全确定的类型。偏特化partial specialization只指定一部分参数或对参数形式加限制。它只能用于类模板函数模板不能偏特化函数模板只能全特化通常更推荐用重载代替。// 适用于 C17template typename Tclass PrinterT* { // 只匹配指针类型public:void print(T* value) { /* 解引用后打印 */ }};template typename T, typename Uclass Pair { /* 通用 */ };template typename Tclass PairT, T { // 两个参数相同的情况/* 专门实现 */};偏特化的匹配发生在重载决议之后编译器会选择「最特殊」的那个版本。全特化优先于偏特化偏特化优先于主模板。特化版本必须在使用之前声明否则可能悄悄用了主模板。特化的一个实际用途是为特殊类型优化存储。例如std::vectorbool就是标准库提供的一个特化它按位存储而不是按字节代价是operator[]返回代理对象而不是bool。这也解释了为什么std::vectorbool的行为和普通vector不完全一样工程上常建议避免使用它。四、非类型参数、默认参数与 CTAD类模板的参数不限于类型。非类型参数non-type template parameter可以是整数、枚举、指针、引用等C17 起还允许auto推导。固定长度数组是最典型的应用// 适用于 C17template typename T, std::size_t Nclass FixedArray {// ...};非类型参数必须在编译期确定FixedArrayint, n里的n不能是运行期变量。模板参数可以有默认值// 适用于 C17template typename T, typename Allocator std::allocatorTclass MyVector {// ...};C17 引入了类模板实参推导class template argument deduction, CTAD允许省略模板实参由构造函数推导// 适用于 C17#include utilitystd::pair p{1, 2.5}; // 推导为 std::pairint, doublestd::vector v{1, 2, 3}; // 推导为 std::vectorintCTAD 依赖推导指引deduction guide标准库为常用类型提供了指引自定义类也可以写自己的指引。C20 起可以用concepts约束模板参数把「这个类型必须支持什么操作」写进接口错误信息比传统的 SFINAE 清晰得多但需要 C20 及以上且各编译器支持程度不同。五、完整示例固定长度数组模板下面的FixedArray把类型和长度都作为模板参数提供边界检查的at、无检查的operator[]、迭代器和front。成员函数在类外定义用来演示正确的作用域写法。// 适用于 C17#include cstddef#include iostream#include stdexcepttemplate typename T, std::size_t Nclass FixedArray {static_assert(N 0, FixedArray must have at least one element);public:using value_type T;using iterator T*;using const_iterator const T*;constexpr std::size_t size() const noexcept { return N; }constexpr bool empty() const noexcept { return false; }T operator[](std::size_t index) noexcept { return data_[index]; }const T operator[](std::size_t index) const noexcept { return data_[index]; }T at(std::size_t index) {if (index N) {throw std::out_of_range(FixedArray::at);}return data_[index];}T front() { return data_[0]; }const T front() const { return data_[0]; }iterator begin() noexcept { return data_; }iterator end() noexcept { return data_ N; }const_iterator begin() const noexcept { return data_; }const_iterator end() const noexcept { return data_ N; }private:T data_[N]{};};// 类外定义作用域限定必须写成 FixedArrayT, Ntemplate typename T, std::size_t NT FixedArrayT, N::front() { return data_[0]; }int main() {FixedArrayint, 4 values;for (std::size_t i 0; i values.size(); i) {values[i] static_castint(i * i);}values.front() 100;for (int value : values) {std::cout value ;}std::cout \n;try {std::cout values.at(10) \n;} catch (const std::out_of_range e) {std::cout caught: e.what() \n;}return 0;}这段代码里有几个值得注意的点。T data_[N]{};的花括号保证数组元素被值初始化对int是清零。static_assert在N 0时直接编译失败比运行期检查更早。begin/end返回原始指针指针满足随机访问迭代器的要求所以范围for可以直接用。at抛std::out_of_rangeoperator[]不检查——这和标准库容器的约定一致。类外定义的front必须重复模板参数列表并且用FixedArrayT, N::限定。如果给FixedArray加一个全特化比如针对bool的按位存储要在主模板之后、第一次使用之前声明template class FixedArraybool, N——但非类型参数N让全特化必须逐长度写实际并不实用这也说明了特化不是越多越好。常见坑点1. 把成员函数定义放进.cpp❌ 头文件只留声明成员函数体写在box.cpp使用方链接时报未定义符号。✅ 定义放在头文件确需分离时用显式实例化并在头文件里声明。2. 漏写typename❌Container::iterator it c.begin();编译器把iterator当静态成员。✅typename Container::iterator it c.begin();。3. 调用成员模板时漏写template❌obj.getint();在依赖上下文里被解析成比较运算。✅obj.template getint();。4. 以为类模板可以自动推导❌ C14 里写std::pair p{1, 2};编译失败。✅ C17 起 CTAD 可用更早的标准必须写全std::pairint, int。5. 试图偏特化函数模板❌template typename T void f(T*);想对指针单独实现写成偏特化形式。✅ 函数模板用重载template typename T void f(T* p);是独立的重载不是偏特化。6. 以为每个实例共享静态成员❌ 认为Boxint::count和Boxdouble::count是同一个变量。✅ 每个实例化都有自己的一份静态成员需要跨类型共享时用基类或外部变量。7. 在类模板里写虚函数模板❌template typename U virtual void f(U);编译失败。✅ 虚函数不能是模板需要泛型行为时用虚函数 类型擦除或改用非虚的模板成员。8. 让模板参数名遮蔽外层类型❌ 在类模板里再用T作为成员模板的参数名导致内层T遮蔽外层。✅ 内层换一个名字如U或用完整限定名。总结主题要点实例化编译期按实参生成具体类不同实参互不兼容定义位置通常放头文件否则链接期缺符号成员实例化只实例化被使用的成员未用的错误可能不报依赖名类型前加typename成员模板调用前加template特化全特化指定全部参数偏特化只用于类模板非类型参数编译期常量可用于固定长度CTADC17 起可从构造函数推导类模板实参C20concepts可约束模板参数需 C20 及以上类模板的核心是「编译期生成」它不占运行期开销但要求定义可见、类型可推导、依赖名可消歧。把这三条记牢再配合特化处理少数特殊类型就能写出既通用又可控的组件。