ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++类模板从入门到工程实践:实例化、特化与常见陷阱

C++类模板从入门到工程实践:实例化、特化与常见陷阱 学 C 的人几乎没有不碰类模板template的。STL 容器、智能指针、标准库算法随便拉一个出来都是类模板或函数模板的产物。但我发现一个现象很多人能看懂 vector 、unique_ptr 的用法一转身要自己定义一个类模板就开始手足无措要么把 template 关键字到处乱放要么干脆不知道成员函数该怎么写在类外面。这篇文章我想把 C 类模板从语法骨架到工程实践捋一遍重点不是背书而是回答“为什么这样写”“为什么我自己写的时候会编译失败”以及“什么样的代码风格能让模板类长期可维护”。无论你是刚学完类和对象、第一次看到 template 的初学者还是写过一阵子模板却总是被编译错误折腾的开发者都值得花十几分钟从头过一遍。1. 类模板要解决的最原始痛点为类型写同一份实现1.1 没有模板时代复制粘贴带来的是长期的维护负债想象一下你要写一个整数栈、一个字符串栈、一个浮点数栈。不用模板最直接的做法是复制三份类改成不同的类型名。类体可能七八十行复制三份之后看起来也还好对吧可问题出在改需求的时候。比如某一天你想给栈加一个clear()方法或者想把底层存储从std::vector换掉你就得同步修改三份代码少改一份某条业务分支就会在运行时崩溃或者行为不一致。再往后添新类型又得复制第四份、第五份。这就是典型的“复制粘贴式开发”的长期维护负债。短时间内爽后续每次改动都在还债。类模板的思路本质上很朴素既然这三份类的差别只在于元素类型那我把“类型”这个词挖掉留一个占位符出来。写一次逻辑让编译器在遇到Stackint、Stackstd::string、Stackdouble的时候分别按你说的类型再去生成对应版本。这个概念就是“用同一份代码模板描述一类对象”也就是类模板的核心价值。1.2 类模板的思路把“类型”本身变成参数普通函数的参数是变量传入的是值。类模板的参数是类型或者稍后会提到的编译期常量。你可以把模板理解成给编译器的一张图纸templatetypename T class Stack { // 凡是元素类型都用 T 占位 };当你写下Stackint的时候编译器拿到这张图纸把里面所有T替换成int生成一套真正可以实例化的类。当你写下StackMyUser的时候再替换一次生成另一套独立的类。这跟 C 语言里宏展开的思想有点像但区别在于模板有严格的语法和类型检查不是无脑文本替换。关键词typename和class在这里可以互换老代码里更常见写class新代码大家逐渐习惯用typename含义完全一致。至于参数名叫T也不是强制规定只是惯例你叫ElemType、ValueType都行只要在模板体里保持一致。1.3 顺带澄清类模板和模板类不是同一个名词初学者问“类模板和模板类有什么区别”的频率非常高。简单说类模板是那个带templatetypename T的类定义本身它是一种“图纸”模板类指的是“已经指定了具体类型参数的类”比如Stackint此时可以说是一个模板类实例。这个区分在口语里经常混用但写文章、做设计时搞清楚有助于沟通。真正重要的是类模板本身不是一个可用的类型你没法声明Stack s;必须给出类型参数得到实际类型后才能创建对象。2. 一步步写一个 Stack 从定义到实例化的完整骨架2.1 类内实现版本最直观的第一版先看一段完整的StackT类内实现。作为示例我直接依赖std::vectorT做底层存储这样聚焦在模板语法上而不是数据结构细节。#include vector #include stdexcept templatetypename T class Stack { public: void push(const T value) { data_.push_back(value); } void pop() { if (data_.empty()) { throw std::out_of_range(Stack::pop(): empty stack); } data_.pop_back(); } T top() { if (data_.empty()) { throw std::out_of_range(Stack::top(): empty stack); } return data_.back(); } bool empty() const { return data_.empty(); } size_t size() const { return data_.size(); } private: std::vectorT data_; };注意std::vectorT里的T。当T是int时成员变量就是std::vectorint当T是自定义类型Product时成员变量就是std::vectorProduct。模板参数一旦传入编译器会沿整个类定义把所有出现T的地方都替换掉包括普通函数参数、返回值、局部变量和成员变量声明。使用方式很直接Stackint intStack; intStack.push(1); Stackstd::string strStack; strStack.push(hello);这里有个容易误解的地方Stackint和Stackstd::string是两个完全无关的类型它们之间没有继承、也没有隐式转换关系。你写一个普通函数void foo(Stackint)传Stackstd::string进去编译器会直接报类型不匹配因为编译器确实生成了两套独立的类。2.2 成员函数在类外定义时的重复写法实际项目里为了让类的接口更清晰、降低编译期依赖经常把声明和定义分开。普通类在类外定义成员函数时只需要写ClassName::functionName。模板类就不一样每个类外定义的成员函数都必须重新带上templatetypename T同时要写StackT::。templatetypename T void StackT::push(const T value) { data_.push_back(value); } templatetypename T void StackT::pop() { if (data_.empty()) { throw std::out_of_range(Stack::pop(): empty stack); } data_.pop_back(); } templatetypename T T StackT::top() { if (data_.empty()) { throw std::out_of_range(Stack::top(): empty stack); } return data_.back(); }在头文件里这样写的时候templatetypename T这行声明必须紧跟对应定义。有人会把templatetypename T写漏或者写成了templatetypename T但函数名却写Stack而不是StackT编译器就会报类似“缺少模板实参”的错。还有一个容易忽略的点普通类可以一个::走天下模板类在嵌套作用域里更要小心比如构造函数写在类外templatetypename T StackT::Stack() default;在 C11 之后即便想 default不在类内写也要把这个声明放在类里类外才补定义。如果干脆不写任何构造编译器生成默认构造你也不需要额外操心。2.3 交给编译器的“实例化”到底发生在什么时候很多初学者背下了模板语法却搞不明白模板代码什么时候被真正编译。实际上模板定义里的语法错误在编译器看到templatetypename T class Stack {...}时就会检查一部分但和类型相关的逻辑错误往往要等到你写出Stackint这样的实例化表达式之后编译器才会把T替换成int再全量检查一遍成员函数体。这就是为什么有人写了一个模板类从来没使用过它编译通过了一旦真用起来报了一堆错。真实原因是你没有实例化它编译器没必要去检查“把T替换为某个类型后函数体里的所有操作是否合法”。只要T是未知的很多操作没法提前判定。这个机制既方便又危险方便在于模板代码可以写得很通用危险在于如果你只用某一套类型实例化另一条函数分支可能从来没被检查过等线上某个怪异的类型触发时才炸。2.4 构造函数的模板参数推导C17 带来的改变在 C17 之前写Stack这类模板你必须显式给出类型参数Stackint s;哪怕构造函数里已经传了int进去编译器也不会替你猜。C17 引入了类模板实参推导CTAD一部分场景下可以直接简写templatetypename T class AutoStack { public: AutoStack(T seed) : data_{seed} {} private: std::vectorT data_; }; AutoStack s{42}; // C17 推导出 AutoStackint这对减少冗余非常有帮助但推导也不是永远符合直觉。构造函数有多个参数、参数类型不完全一致、或者有默认构造时推导规则会变复杂。后面第 6 部分我再单独展开。3. 非类型参数、默认参数和特化类模板不止能传类型3.1 非类型模板参数把一个编译期常量传给类类型可以作为模板参数整数、枚举、指针等编译期常量也可以。最常见的就是std::arrayT, N第二个参数N是std::size_t类型的非类型模板参数。templatetypename T, size_t Capacity class FixedBuffer { public: T operator[](size_t index) { return data_[index]; } size_t capacity() const { return Capacity; } private: T data_[Capacity]; }; FixedBufferint, 256 buf;写T data_[Capacity]意味着Capacity必须是一个编译期已知的常量表达式不能是变量也不能是函数返回的运行时值。联系到上一节的实例化机制FixedBufferint, 256和FixedBufferint, 512也是两个不同的类型因为生成的内部数组大小不同。非类型模板参数的类型限制在不同标准版本里有变化。早期只允许整型、枚举、指针和左值引用到了 C20结构体类型的字面量也可以作为非类型模板参数。实际工程里最常见的仍然是非负整数和指针别在模板参数列表里塞浮点数double——浮点数在编译期比较多少存在精度和等价性问题标准过去明确禁止现在某些场景仍容易踩坑。3.2 默认模板参数什么时候能用、什么时候不能用类模板和函数类似也可以给模板参数一个默认值templatetypename T, typename Container std::vectorT class Stack { public: void push(const T value) { data_.push_back(value); } private: Container data_; }; Stackint s1; // 相当于 Stackint, std::vectorint Stackint, std::dequeint s2;给第二个模板参数默认值之后就能在不指定Container的情况下直接使用Stackint。需要注意默认值的展开是“参数相关”的std::vectorT里的T是第一个模板参数C 的默认模板参数允许引用前面的模板参数这是合法的而且非常实用。不过你不能让一个带默认值的参数出现在不带默认值的参数前面除非编译器能推断出该参数的实参。规则和函数默认参数很像一旦某个参数开始使用默认值后面所有参数也必须提供默认值。类模板构造时如果想显式指定Container但让第一个参数用默认值那是做不到的因为模板实参按位置匹配没办法“跳过”。3.3 全特化与偏特化当同一套代码覆盖不了特殊情况类模板的通用实现是针对绝大多数类型的但某些具体类型可能需要完全不同的处理。比如你要写一个通用的类型信息打印工具TypeDescriberT对普通类型返回名字对int和std::string单独给出更具体的信息。这时就可以做全特化。#include string templatetypename T struct TypeDescriber { static const char* name() { return unknown; } }; template struct TypeDescriberint { static const char* name() { return int; } }; template struct TypeDescriberstd::string { static const char* name() { return std::string; } };写法是先写template表示这是一个对某个具体类型的全特化后面跟struct TypeDescriberint。注意template后面是空的尖括号不是templatetypename T。当你调用TypeDescriberdouble::name()时走通用版本调用TypeDescriberint::name()时走特化版本。偏特化则更进一步你并不把所有模板参数都确定下来而是对某一类形态做定制。比如“所有指针类型”是一个形态“所有 const 限定类型”是另一个形态templatetypename T struct TypeDescriberT* { static const char* name() { return pointer; } }; templatetypename T struct TypeDescriberconst T { static const char* name() { return const value; } };偏特化最常见的形态包括指针类型、引用类型、std::arrayT, N这种容器类型。本质上编译器在模板匹配时会优先选择更特化的版本。它能支持非常复杂的模式匹配比如templatetypename T, typename U struct X;可以偏特化出templatetypename U struct Xint, U这意味着“当第一个参数是int时就用这套特别的实现”。掌握这一招写类型萃取和编译期分发会轻松很多。3.4 特化误用警告不要试图偏特化函数模板这里想特别提醒一句类模板可以偏特化函数模板不能偏特化。很多人学到类模板偏特化后会顺手在函数模板上试// 不要这样写编译器会报错或者行为不符合预期 templatetypename T void foo(T value) {} templatetypename T void fooT*(T* value) {} // 函数模板不能偏特化函数模板如果要对指针做特殊处理更常规的做法是使用函数重载或者通过 C20 的requires约束实现。你不必非要“修正”这一限制。理解了类模板偏特化和全特化的区别工程中就够用了。4. 成员函数、友元和继承类模板组合玩法中的容易出事点4.1 依赖类型必须写 typename写类模板时成员变量或者函数体里经常会用到“依赖类型”也就是依赖于模板参数的类型。比如你希望T内部包含一个iterator类型templatetypename T class CollectionHelper { public: void iterate() { // 这里 T::iterator 是依赖类型 typename T::iterator it container_.begin(); while (it ! container_.end()) { it; } } private: T container_; };T::iterator为什么必须加typename因为在编译器眼里T是未知的T::iterator可能是一个类型也可能是T里的一个静态变量名。C 语法规定在模板中默认把这种限定名当作值处理除非你显式用typename告诉它“这是一个类型”。不加typename时编译错误往往很绕比如“需要类型说明符”或“缺少;”。这个错误在类模板里非常高频我在很多代码评审里都见过。同样的规则还体现在类型别名上templatetypename T using IteratorType typename T::iterator;4.2 友元函数在类模板内部的两种写法普通类的友元很好理解类模板里的友元稍微复杂。最常见、也最不容易出错的是“在类内部直接定义友元函数”此时友元函数和当前模板实例绑在一起templatetypename T class Number { public: Number(T value) : value_(value) {} friend Number operator(const Number lhs, const Number rhs) { return Number(lhs.value_ rhs.value_); } private: T value_; };这样每次实例化Numberint时会同时生成一个对应的operator(const Numberint, const Numberint)函数。它只接受与当前实例化完全匹配的类型避免了跨类型的隐式转换问题。很多运算符重载的模板实现都用这种内联友元写法。如果你想在类外定义友元函数需要先声明模板函数再在友元声明处用模板参数来“特指”写法比较繁琐而且很容易出现“友元函数模板匹配不上”的谜之错误。对于大多数业务代码我更推荐优先用成员函数或者内联友元只有在需要支持形如T U这样两个不同类型同时参与运算时才考虑外部模板函数配合友元。4.3 继承类模板注意依赖基类名字的坑类模板也可以继承其它类模板。这个场景在实现“自定义容器适配器”或者“策略模式”时经常遇到。比如基类是一个带有某个成员函数的模板templatetypename T class Base { public: void baseMethod() { // ... } }; templatetypename T class Derived : public BaseT { public: void derivedMethod() { baseMethod(); // 这里可能编译失败 } };在模板派生类里直接写baseMethod()可能报错因为编译器在解析非依赖名字时不会去依赖基类BaseT中查找——在模板定义阶段它对BaseT一无所知不能确定baseMethod是否存在。解决办法是用this-让它变成依赖表达式或者显式写BaseT::baseMethod()templatetypename T class Derived : public BaseT { public: void derivedMethod() { this-baseMethod(); BaseT::baseMethod(); } };这个看起来像“多此一举”的this-实际上是模板两阶段查找机制的直接后果。只有理解了这个规则遇到这类报错时才会想到去加this-而不是怀疑编译器坏了。5. 模板代码为什么必须放头文件编译模型与链接错误5.1 编译器要看到完整的模板定义才能“按实参生成代码”普通类的做法是头文件放声明源文件放实现调用方编译时只需看到类声明。但类模板不能这么干。原因很简单编译器在Stackint s;这一行需要知道Stackint的完整定义包括成员变量大小、成员函数函数体才能生成代码。如果头文件里只有templatetypename T class Stack;这行声明没有实现实例化时就会报“未定义类型”或链接阶段提示缺少函数实现。一种常见但很糟糕的做法是试图把模板实现写在.cpp里然后让另一个.cppinclude 它。这么做经常导致多份定义或者链接错误。正确做法是把模板定义完全放在头文件里或者至少让实现对使用方可见。这不是 C 的缺陷而是模板实例化模型决定的——它不像 Java 泛型那样在运行时抹除类型而是在编译期替使用方把具体类型生成出来。5.2 extern template 与显式实例化大型工程的务实手段当同一个模板被几十个源文件使用同一组参数实例化时每编译一个.cpp编译器都生成一份相同的实例化代码链接器再合并去重。编译时间会变得很难看。C11 提供了extern template帮助缓解这个问题。基本起步是在某一个.cpp里显式实例化你需要的类型// stack_inst.cpp #include Stack.h template class Stackint; template class Stackdouble;然后在头文件里声明这些实例在其它翻译单元已经存在// Stack.h extern template class Stackint; extern template class Stackdouble;这样其它.cpp看到Stackint时会相信“它的已经完全实例化编译这一份时别再生成第 N 遍”从而减少编译时间。代价是你必须提前手动列出所有用到的类型组合这对类型不定、用户会自由实例化的泛型组件来说并不现实。所以extern template通常用于库内部已知的热点实例比如标准库就大量用于std::string、std::vector...的显式实例化。普通业务项目不必一开始就逼自己用等编译时间真的大到影响开发效率时再针对性优化。5.3 两阶段查找模板代码乱序带来的常见编译问题模板的名字查找分为两个阶段。第一阶段是解析模板定义本身时查找不依赖模板参数的名字第二阶段是在实例化时查找依赖模板参数的名字。这正是上一节派生类调用基类成员需要this-的深层原因。举一个更直白的例子同一段模板代码放在被调用函数之前或之后结果可能不同void helper() { // ... } templatetypename T struct Test { void call() { helper(); // 第一阶段就能找到非依赖名字 helper T::action(); // 第二阶段实例化时查找 } };如果helper在模板定义处之后才声明第一阶段就找不到即使你实例化模板时helper已经存在也不行。非依赖名字必须在模板定义处可见。理解了这一点排查“为什么模板内调用的全局函数找不到”这类问题就知道该往哪个方向查了。6. 现代 C 给类模板带来的新变化CTAD、可变参数模板与工程习惯6.1 C17 类模板实参推导简化了日常使用前面提过 C17 的 CTADClass Template Argument Deduction。它最实用的场景是配合构造函数推导类型参数减少手写冗余声明。比如templatetypename T struct Point { Point(T x, T y) : x_(x), y_(y) {} T x_; T y_; }; Point p(1.5, 2.5); // 推导出 Pointdouble如果两个参数类型不一致比如Point p(1, 2.5)那么推导会失败因为没有唯一确定的T。这时候编译器报错反而能帮我们发现设计问题到底是该传两个int还是应该把其中一个隐式转换模板推导的理想是尽量自动但不会替你决定潜在歧义。6.2 通过 deduction guide 纠正推导结果CTAD 虽然方便但推导结果偶尔不理想。典型场景是你希望模板参数类型是“去掉引用和 const 后的类型”或者想对字符串字面量做特殊处理。C17 允许自定义推导指引deduction guide直接告诉编译器“遇到这种构造方式时推导成什么类型”templatetypename T struct MyString { MyString(const T value) : value_(value) {} T value_; }; // 当传入 const char* 时不要推导成 const char*而希望保存为 std::string MyString(const char*) - MyStringstd::string;这样写MyString s hello;时推导结果不是MyStringconst char*而是MyStringstd::string。这个技巧在写各种包装类、适配器类时很常用。需要区分推导指引只影响推导并不会改变真实的构造函数行为。6.3 可变参数类模板C11 引入的模板参数包让类模板也能接收数量不定的类型参数。最典型的例子就是std::tupletemplatetypename... Ts struct MyTuple { MyTuple(Ts... args) : values_(args...) {} std::tupleTs... values_; }; MyTuple t(1, 2.5, std::string(hello));这里的Ts...是一个类型参数包展开后等价于把多个类型塞进模板参数列表。你需要区分两个展开位置类型展开和表达式展开。std::tupleTs...是类型展开MyTuple(Ts... args)是把参数包展开成构造函数参数args...是表达式展开。可变参数模板很容易把代码风格推向“高级 but 难读”的极端。我用下来的体会是尽量把参数包展开集中在一两个内部工具函数里不要让展开逻辑散布整个类外部。std::apply、折叠表达式这些机制也能显著简化展开代码C17 以后写起来舒服多了。6.4 我写类模板时坚持的几个习惯最后分享几条实际经验都是踩过坑才总结出来的。第一类模板的对外接口尽量保持小。模板代码一旦编译错误报错信息动辄几百行接口越复杂理解成本越高。能拆成普通函数就别把所有逻辑都塞进模板类里保证只有真正需要类型参数的地方才用模板。第二所有依赖类型都显式写typename。这会让代码啰嗦一点但极大降低编译期歧义。代码是写给其他人读的typename T::iterator一眼就能看出“这是类型别名”不加则要猜半天。第三模板定义按“先声明、后定义”组织把非模板逻辑部分尽量抽到普通函数。模板实现放头文件不假但头文件内部依然可以分成接口声明区、实现区。大型项目里还可以考虑把模板实现拆到_impl.h文件里主头文件在末尾 include 它至少从观赏性上会清爽很多。第四别轻易用特化做“性能 hack”。很多人拿到模板第一反应是给特定类型做特化来优化。特化本身没问题但每多一个特化维护成本就多一层。业务没有实际性能压力时先把通用实现写对再说。真要优化用 C20 的 concepts 和requires约束来分派不同实现代码语义比满天飞的template清晰得多。模板是 C 最有表达力也最考验人的一个部分。类模板作为模板体系的基石值得每个人花时间把实例化、特化、友元、继承这些边界都过一遍。真正掌握它的标志不是能背语法而是面对一条报错时能在脑子里快速定位这多半是依赖类型没写typename还是模板实现没被实例化到还是名字查找阶段就出了问题。有了这个定位能力模板世界的门槛就基本被跨过了。
返回列表