深入模板基础
参数化的声明#
C++支持四种基本模板:类模板、函数模板、变量模板和别名模板,每一种都可以出现在命名空间的作用域中和类作用域中。
template<typename T> // 类模板的命名空间
class Data {
public:
static constexpr bool copyable = true;
// ...
};
template<typename T> // 函数模板的命名空间
void log(T x) {
// ...
}
template<typename T> // 变量模板的命名空间
T zero = 0;
template<typename T> // 变量模板的命名空间
bool flag = Data<T>::copyable;
template<typename T> // 别名模板的命名空间
using DataList = Data<T*>;
虚成员函数#
众所周知,C++的类有着虚函数的概念,能够实现强大的多态。但是,成员函数模板并不能被声明为虚函数。主要原因还是在于虚函数的底层实现机制上,虚函数的调用机制普遍采用的是一张固定大小的表(虚表),每个条目保存了一个虚函数的入口(函数地址),然而,成员函数模板在编译前是不知道有多少个实例的,那么就不能用传统固定大小的虚表来保存成员函数模板的实例了。如果要支持虚成员函数模板,就需要编译器和链接器支持一种全新的机制。
当然,类模板中的普通成员仍然可以是虚函数,因为无论在哪个类模板的实例中,其数量总是确定的。
template<typename T>
class Dynamic {
public:
virtual ~Dynamic(); // 正确
template<typename U>
virtual void foo(const U&); // 错误
};
模板参数#
基本类型的模板参数有3种:
- 类型参数;
- 非类型参数;
- 模板的模板参数。
类型参数是最常用的,即传入一个类型;非类型参数是一个常量值,可以是整型、枚举、指针类型、左值引用和nullptr_t,非左值引用都始终是纯右值,不可以获取其地址,而且非类型参数不允许传入右值引用。
模板的模板参数,顾名思义就是允许一个模板作为另一个模板的类型参数,可以显式指定,或者编译器根据入参进行类型推导。
template<
template<typename T, typename A = MyAllocator> typename Container>
class Adaptation {
Container<int> storage; // 隐式等价于 Container<int, MyAllocator>
};
模板参数包#
通过在模板参数的名称前加上省略号(...)来表示是一个模板参数包,模板参数包允许传入任意数量的参数。
template<typename... Types>
class Tuple;
主类模板、变量模板和别名模板最多只能有一个模板参数包,而且必须作为最后一个模板参数。函数模板放宽了这个约束,也可以有多个模板参数包,但前提是模板参数包后面的每个模板参数都是可推导出来的。
template<typename... Types, typename Last>
class LastType; // 错误
template<typename... Types, typename T>
void foo(T value); // 正确,T可以被推导
template<unsigned...>
struct Tensor;
template<unsigned... Dims1, unsigned... Dims2>
auto compose(Tensor<Dims1...>, Tensor<Dims2...>); // 正确,每个参数包都可以被事先声明的Tensor推导出来
顺便一提,省略号放在名称左边表示声明一个参数包,如果放在名称右边(比如例子中的Dims1...
和Dims2...
),则表示对这个名称所指的参数包的展开,下文会再详细描述。
模板实参#
模板实参是指实例化模板时用来替换模板形参的值。
一般可以通过以下的方式来确定这些值:
- 显式模板实参:紧跟在模板名称后面,由角括号标识。所组成的整个实体被称为模板id。
- 注入式类名:对于具有模板参数T1,T2等的类模板X,在其作用域内,只使用模板名称X等价于使用模板id X<T1,T2,...>。
- 默认模板实参:如果默认的模板实参,实例化时可以省略。但是在类模板或者别名模板中,即使所有的实参都有默认值,也不可以省略角括号(此时角括号内部为空)。
- 实参推导:对于函数模板的实参,可以通过函数的入参进行推导。
变参模板#
变参模板是至少包含了一个模板参数包的模板。
当为变参模板确定模板实参时,对于变参模板中的每个模板参数包,都将匹配一个由0个或多个模板实参组成的序列。这个模板实参序列称为实参包。
template<typename... Types>
class Tuple {
public:
static constexpr std::size_t length = sizeof...(Types);
};
int main() {
Tuple<> t0; // 空的列表
Tuple<int> t1; // int
Tuple<int, float> t2; // int, float
}
包扩展#
sizeof...
可以计算变参模板的当前实例的参数包中有多少参数,正好是包扩展的一个示例。
一般的模板参数包使用Types...
进行包扩展,它将生成一个模板参数列表,其中的每个参数都用于替换Types中的参数包。包扩展可以理解为是一种语法扩展,其中模板参数包被替换为了恰好符合当前所需数量的模板参数。
理论上,由于C语言在语法中提供了逗号分割列表,包扩展基本上可以在C语言的任何位置使用。可能出现的场景如下:
- 基类列表中。
- 构造函数的基类初始化列表中。
- 调用参数列表中。
- 初始化列表中。
- 类、函数或别名模板的模板参数列表中。
- 函数可以抛出的异常列表中。
- 在属性中,如果属性本身支持包扩展。
- 当指定声明的对齐方式时。
- 当指定lambda表达式的捕获列表时。
- 在函数类型的参数列表中。
- 使用声明时。
但并不是所有场景都是那么实用的,常用的还是在函数列表或者模板参数列表中的使用。另外,在基类列表中使用时,会有比较神奇的效果:
template<typename... Mixins>
class Point : public Mixins... { // 基类包扩展
public:
Point() : Mixins()... { } // 基类初始化包扩展
template<typename Visitor>
void visitMixins(Visitor visitor) {
visitor(static_cast<Mixins&>(*this)...); // 调用参数包扩展
}
private:
double x, y, z;
};
struct Color { char red, green, blue };
struct Label { std::string name; };
Point<Color, Label> p;
Point使用包扩展将参数包中的每个类型都展开作为基类继承,然后使用基类初始化列表中的包扩展,对每个基类进行初始化。
最有趣的是visitMixins
,它将*this
强转为每一个基类Mixins的类型(包扩展会生成调用参数),如果编写一个Visitor,就可以通过一个Visitor访问任意数量的函数调用参数。
函数参数包#
模板参数包和函数参数包统称为参数包,它们看起来差不多,但还是稍有不同,函数参数包只能通过包扩展的方式来展开。
零长度包扩展#
参数包的扩展允许是零长度的,即展开后的列表是个空列表。这是合理的,不过有时也会产生错误,比如上面我们定义的Point
类,如果参数列表为空,就会报错,形式上类似于这样:
template<>
class Point {
Point() : { }
};
构造函数的冒号后应该要有初始化列表,而由于我们给的模板参数为空,展开的初始化列表也是空,这就出问题了。
友元#
友元的基本思想就是给予函数或者类可以访问友元声明所属类内部的权限。但事情有时并不简单。
友元类#
友元类的声明不能是类定义。在模板的上下文中,友元类声明的唯一变化在于是将一个特定的类模板的实例命名为友元。
template<typename T>
class Node;
template<typename T>
class Tree {
friend class Node<T>;
};
因此,如果类模板的某个实例被声明为其他类或类模板的友元时,这个类模板必须是可见的,即在此之前已经声明过了。如果是普通类的话就没有这个约束,普通类被声明为友元的位置,可以是首次出现的位置。
友元函数#
友元函数也是同理,函数模板被声明为友元时,也必须指定是特定的某个实例。
如果存在重载的情况下,友元函数模板的匹配度往往低于普通函数。
如果友元函数有域作用符::,则友元声明的位置不能是函数定义的位置,如果没有域作用符,友元声明的位置可以是函数定义的位置。
友元函数也可以在类模板中定义,这种通常是要求友元函数是以自身的类型使用类模板本身,且只会在实际使用时才实例化。
template<typename T>
class Creator {
friend void feed(Creator<T>) { // 类模板的每一个实例都会生成一个::feed函数
}
};
友元模板#
有时,我们希望让模板的所有实例都是类的友元,就可以使用友元模板,这和普通友元的声明类似,不过加上一个模板命名空间。
只有在友元模板声明的是一个非受限的函数名称(没有域作用符的限制),并且后面紧跟着角括号的情况下,该友元模板声明才可以是定义。
友元模板只能声明主模板和主模板的成员,一旦声明,任何该友元模板的特化和偏特化都会被视为友元。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构