C++ Template-2 类模板
运用大量例子介绍了类模板。你可以学习到如何声明和定义类模板,如何使用类模板,如何特化和偏特化类模板,类模板的参数推导等相关的知识。
这不是本系列第一篇文章,推荐阅读完前面的文章再看本文,以下是本系列文章目录。
2. 类模板
跟函数类似,类也可以被实例化成一个或者多个类型。这个特性的典型例子就是可以用于管理特性类型元素的容器类(container classes)。通过使用类模板,你可以实现适用于多个模板的容器类,在本章中,我们将以栈(stack)为示例介绍类模板的使用。
2.1. Stack 类模板的实现
我们可以像函数模板一样声明和定义 Stack<>
1 |
|
如你所见,这个类模板是通过 C++ 标准库中的 vector<>
来实现的。这样我们就不必自己实现内存管理,拷贝构造和拷贝赋值运算符了,从而可以专注于这个类模板的接口实现上面。
2.1.1 声明类模板
声明类模板和声明函数模板类似:正在定义之前,你必须先声明一个或多个作为模板类型参数的标志符。同样,这个标志符通常用 T
表示
template <typename T>
class Stack{
…
};
在这里同样可以用 class
取代 typename
(但不推荐)
template <class T>
class Stack{
…
};
在类模板内部,T
可以像普通类型一样被用来声明成员变量和成员函数。在这个例子中,T
被用来声明 vector
中元素的类型,用于声明成员函数 push()
的参数类型,也用于声明了成员函数 top()
的返回类型:
1 |
|
这个类的类型是 Stack<T>
, 其中 T
是模板参数。因此,你必须在你声明 Stack<T>
时,除非可以推断出模板参数的类型,否则必须使用 Stack<T>
。不过,在类模板中使用不带模板参数的类型名的类(如 Stack
),说明了这个成员类的模板参数和类模板的模板参数类型相同。
如果你需要定义自己的拷贝构造和拷贝复制运算符,通常应该定义成这样子:
1 |
|
它和下面的定义时等价的:
1 |
|
但是一般 <T>
暗示要对一些特殊的模板参数做一些特定处理,所以最好还是使用第一种方式。
但是,如果在类模板外,就需要这样定义:
1 |
|
注意在 Stack
只适用于仅需要类名称而不是类类型的地方。这和声明构造函数和析构函数的情况相同。
同样注意,不像非模板类,你不可以在函数内部或者块作用域内声明和定义类模板。总之,模板只能定义在 global/namespace 作用域中(细节后续再讲)。
2.1.2 成员函数的实现
定义类模板的成员函数时,你必须显示指出他是一个模板,而且你必须使用类模板的所有类型限制。因此,要像下面这样定义 Stack<T>
中的成员函数 push()
:
1 |
|
这里调用了vector
类型成员 push_back()
方法,它用于在 vector
尾部追加一个元素。
注意 vector
的 pop_back()
方法只是删除掉尾部的元素,并不会返回这一元素。这主要是为了异常安全(exception safety)。实现一个异常安全并且能够返回被删除元素的 pop()
方法是不可能的。不过如果忽略掉这一风险,我们依然可以实现一个返回被删除元素的 pop()
。为了达到这一目的,我们只需要用 T
定义一个和 vector
元素有相同类型的局部变量就可以了:
1 |
|
由于 vector
的 back()
( 返回其最后一个元素)和 pop_back()
( 删除最后一个元素)方法在 vector
为空的时候行为未定义,因此需要对 vector
是否为空进行测试。在程序中我们断言(assert) vector
不能为空,这样可以确保不会对空的 Stack
调用 pop()
方法。在 top()
(返回但是不删除首元素)中也是如此:
1 |
|
当然,如同其他成员函数一样,你也可以把类模板的成员函数以内联函数的形式实现在类模板的内部,比如:
1 |
|
2.2. Stack 类模板的使用
在 C++17 之前,在使用类模板的时候都需要显式地指明模板参数,下面的例子展示了该如何使用 Stack<>
类模板:
1 |
|
通过声明 Stack<int>
类型,在类模板内部 int
会被用作类型 T
。因此,被创建的对象 intStack
会使用一个存储int
类型的 vector
,所用调用的成员函数都会被用 int
实例化。同样的,对于用 Stack<std::string>
定义的对象,它会使用一个存储 std::string
的 vector
,所有调用的成员函数也会被 std::string
实例化。
注意,模板(成员)函数只有在调用时才会被实例化。对于类模板来说,成员函数只有被调用时才会实例化。这样节省了时间和空间的消耗,也允许了对类模板进行局部使用,在 2.3 节会讨论到。
在这个例子中,默认构造函数,push()
和 top()
被实例化为 int
和 std::string
。但是, pop()
仅被实例化为 std::string
。如果类模板有静态成员,对于每一个用到类模板的类型,相应的静态成员也只会被实例化一次。
被实例化的类模板类型可以向常规类型一样使用。你可以使用 const
或者 volatile
来修饰它,也可以创建相应的数组和引用。你可以通过 typedef
和 using
将他作为类型定义的一部分(2.8 节会讨论到)或者可以用它来实例化其他的模板类型,比如:
1 |
|
模板参数可以使任意类型,比如指向 float
的指针,甚至是存储 int
的 stack
:
1 |
|
模板参数唯一要求是:他要支持模板中此类型所有相关的操作。
在 C++11 之前,在两个相邻的模板尖括号之前必须要有空格
1 |
|
如果你不这么做,>>
会被解析成调用 >>
运算符,这会导致语法错误:
1 |
|
这种旧行为使用的原因是,它可以帮助编译器在第一次 pass 源码时,不依赖语意就能对源代码进行正确的标记。但是,漏掉空格是一个很典型的 bug
,需要有相关的错误信息,所以对代码语意分析已经被越来越多地考虑进来。因此,从 C++11 开始,通过 “angle bracket hack”技术(13.3.1 节(未写)会讨论到),两个相邻的模板尖括号之间就不需要在用空格隔开了。
2.3. 类模板的局部使用
一个类模板通常会对用来实例化的模板参数进行多种操作(包含构造函数和析构函数)。这可能会让你以为,要为模板参数提供所有被模板成员函数用到的操作。但是事实不是这样:模板参数只需要提供那些会被用到的操作(而不是可能会被用到的操作)。
比如 Stack<>
类可能提供了一个成员函数 PrintOn()
来打印整个 stack 的内容,它会调用 operator <<
来依次打印每一个元素:
1 |
|
你仍然可以使用那些没有提供 operator <<
运算符的元素:
1 |
|
只有在调用 printOn()
的时候,才会导致错误,因为它无法为这一类型实例化出对 operator<<
的调用:
1 |
|
2.3.1 Concept
这样就有一个问题:我们如何才能知道为了实例化一个模板需要哪些操作?名词 concept 通常被用来表示一组反复被模板库要求的限制条件。例如 C++ 标准库是基于这样一些 concepts 的:可随机进入的迭代器(random access iterator)和可默认构造的(default constructible)。
目前(C++17),concepts 还只是或多或少的出现在文档当中(比如代码注释)。这会导致严重的问题,因为不遵守这些限制会导致让人难以理解的错误信息(参考 9.4 节(未写))。近年来有一些方法和尝试,试图在语言特性层面支持对 concepts 的定义和检查。但是直到 C++17,还没有哪一种方法得以被标准化。从 C++11 开始,你至少可以通过关键字 static_assert
和其它一些预定义的 type traits 来做一些简单的检查。比如:
1 |
|
即使没有这个 static_assert
,如果需要 T
的默认构造函数的话,依然会遇到编译错误。只不过这个错误信息可能会包含整个模板实例化过程中所有的历史信息,从实例化被触发的地方直到模板定义中引发错误的地方(参见 9.4 节(未写))。
然而还有更复杂的情况需要检查,比如模板类型 T
的实例需要提供一个特殊的成员函数,或者需要能够通过operator <
进行比较。这一类情况的详细例子请参见 19.6.3 节(未写)。
C++20 增加对 Concept 的支持,笔者能力有限就不叙述了,请自行查阅 cpp reference 了解。
2.4. 友元
相比于通过 printOn()
来打印 stack 的内容,更好的办法是去重载 stack 的 operator <<
运算符。而且和非模板类的情况一样,operator<<
应该被实现为非成员函数,在其实现中可以调用 printOn()
:
1 |
|
注意在这里 Stack<>
的 operator<<
并不是一个函数模板(对于在模板类内定义这一情况),而是在需要的时候,随类模板实例化出来的一个常规函数。
但是如果想要在类外定义友元函数的话,就比较复杂了。实际上,我们有两种选择:
我们可以隐式地定义一个新的函数模板,并使用不同的模板参数,比如
U
:1
2
3
4
5
6
7template <typename T>
class Stack
{
...
template <typename U>
friend std::ostream& operator<<(std::ostream&, Stack<U> const&);
};无论是继续使用
T
抑或省略模板参数声明,都不可行(因为内部的T
覆盖了外部的T
,或者在当前命名空间域内定义了一个非模板函数)。也可以现将
Stack<T>
的输出运算符声明为一个模板,这需要我们先要对Stack<T>
进行声明:1
2
3
4template <typename T>
class Stack;
template <typename T>
std::ostream& operator<<(std::ostream&, Stack<T> const&);接着我们就可以为这一模板函数声明为
Stack<T>
的友元:1
2
3
4
5
6template<typename T>
class Stack
{
...
friend std::ostream& operator<< <T> (std::ostream&, Stack<T> const&);
};注意这里在
operator<<
后面用了<T>
,这相当于声明了一个特化的非成员函数模板作为友元。如果没有T
就相当于定义了一个新的非模板函数。具体细节请见 12.5.2 节(未写)。
无论如何,你依然可以使用这个没有定义 operator<<
的类模板,只是你在调用 operator<<
的时候会遇到错误:
1 |
|
2.5. 类模板特化
你可以对类模板的确定的模板参数进行特化。这和重载函数模板类似(参考1.5节),类模板特化允许你对一特定的类型进行优化,或者修正类模板对于一特定类型实例化后的一些行为。不过,如果你优化了类模板,你必须也优化所有成员函数。虽然允许值特例化类模板的一个成员函数,不过一旦你这么做的话,你就无法再特化其他成员了。
为了特化一个类模板,你必须在类声明之前加上 template <>
, 并且显式地指出需要特化的类型。这些特化的类型作为模板参数,并且必须紧跟在类名之后:
1 |
|
对于这些特化的模板,所有的成员函数都应该被定义为“常规”成员函数,也就是说,所有出现 T
的地方都需要被替换为特化的类型:
1 |
|
下面是一个用 std::string
特化 Stack<>
类模板的完整例子:
1 |
|
在这个例子中,特化的类在向 push()
传递参数的时候运用了引用语意,对于 std::string
这一类型很有意义,提升了性能(其实最好使用 forwarding reference , 6.1 节(未写)我们会讨论到)。
另外一个不同是使用了 deque
而不是 vector
来存储 stack 里面的元素。虽然这么做在这里没有好处,但是它确实说明了模板特例化之后的实现可能和类模板原始的实现有很大不同。
2.6. 偏特化
类模板可以被偏特化(partial specialization)。你可以为某些特殊情况提供特殊的实现,不过使用这必须定义某些模板参数。例如,我们可以定义一个特化的 Stack<>
类来专门处理指针:
1 |
|
通过
1 |
|
我们定义了一个类模板,它的参数依旧是 T
,但是被特化为处理指针的类模板 (Stack<T*>
)。
同样注意,特化后的函数接口可能会有些许不同。在这个例子中, pop()
返回的是一个指针,因此如果这个指针是通过 new
创建的,使用者可以对这个被删除的值使用 delete
:
1 |
|
多模板参数的偏特化
类模板也可以特化多个模板参数的关系。比如下面这个类模板:
1 |
|
如下的特化都是可以的:
1 |
|
下面的例子展示了各种类模板被使用的情况:
1 |
|
如果有不止一个特化的模板匹配程度相同的话,这个定义就是有歧义的:
1 |
|
为了消除第二个歧义,你可以提供一个额外一个偏特化的版本处理相同类型的指针:
1 |
|
更多关于偏特化的信息,请参见 16.4 节(未写)。
2.7. 缺省类模板参数
就像函数模板一样,你也可以给类模板的模板参数指定默认值。例如,在 Stack<>
类中你可以定义用来容纳元素的容器的类型作为第二个模板参数,使用 std::vector<>
作为默认值:
1 |
|
注意现在有两个模板参数,因此每个成员函数的定义也应该包含两个模板参数:
1 |
|
你可以想之前一样使用 Stack<>
。如果你只提供第一个模板参数的类型,那么 vector
会用来处理 Stack<>
中的元素:
1 |
|
另外,你也可以指定 stack 使用的容器的类型:
1 |
|
通过 Stack<double, std::deque<double>>
你定义了存储 double
类型的 stack,其使用的容器是 std::deque<>
。
2.8. 类型别名
你可以为类模板定义一个别名,从而更方便地使用它。
Typedefs and Alias Declarations
你有两种方式可以为模板定义一个新名称:
使用关键字
typedef
:1
2
3typedef Stack<int> IntStack; //typedef
void foo(IntStack const& s); //s is stack of ints
IntStack istack[10]; //istack is array of 10 stacks of ints通过使用关键字
using
(C++11 开始):1
2
3using IntStack = Stack<int>; //alias declaration
void foo(IntStack const& s); //s is stack of ints
IntStack istack[10]; //istack is array of 10 stacks of ints按照 [DosReisMarcusAliasTemplates] 的说法,这一过程叫做 alias declaration。
注意在这两种情况下我们都只是为一个已经存在的类型定义了一个新的名称,并没有重新定义一个新类型。因此在 typedef Stack<int> IntStack;
或者 using IntStack = Stack<int>;
之后,IntStack
和 Stack<int>
是两个对于同一类型的等效的符号。
作为可用来为已存在类型定义别名的两种方式,都称为 type alias declaration。这个新的名称被称为 type alias。
由于使用 alias declaration (使用 using
,新的名称在 =
符号的左侧)可读性更好,所以我们优先使用这种方法。
Alias Templates
不同于 typedef
,alias declaration 可以被模板化。这样就可以为一组类型取一个方便的名称。这一特性从 C++11 开始生效,被称为 alias templates。
下面例子中的 alias template DequeStack
是被模板参数 T
参数化的,代表 Stack
中的元素存储在 std::deque
容器中:
1 |
|
因此,类模板和 alias templates 都是可以被参数化的类型。同样的,alias template 只是简单地给一个已经存在的类型一个新名字,原来的类型名还是可以使用的。DequeStack<int>
和 Stack<int, std::deque<int>>
代表了同一个类型。
再次注意,模板只可以被定义在 global/namespace scope 或者在类的声明之中。
Alias Templates for Member Types
使用 alias templates 可以很方便的给类模板的成员类型一个快捷名称(short cut):
1 |
|
或者
1 |
|
之后,下面这样的定义:
1 |
|
允许我们使用 MyTypeIterator<int> pos;
来取代 typename MyType<T>::iterator pos;
。
Type Traits Sufflix_t
从 C++14 开始,标准库使用这一技术为所有的 type trait 定义了一个快捷名称(short cut)。比如能用这种写法
1 |
|
来替代
1 |
|
标准库做了如下的定义:
1 |
|
2.9. 类模板参数推导
在 C++17 之前,使用类模板时必须显式地支持所有模板参数的类型(除非它们有默认值)。C++17 开始,这一要求就没有那么严格了。如果类模板的构造函数可以推导出所有的参数类型(不含有默认值的),就不必需要显式地指明模板参数的类型了。
比如在前面的例子中,你可以使用拷贝构造函数而不指定模板参数的参数类型:
1 |
|
通过提供一个接受初始参数的构造函数,就可以推导出 stack 元素的类型。例如我们可以定义一个可以被一个参数初始化的 stack:
1 |
|
然后你就可以像这样声明一个 stack:
1 |
|
通过用整数 0
来初始化 Stack
类型时,模板参数 T
被推导为 int
,这样就实例化出一个 Stack<int>
。
注意下面这些细节:
由于定义了接受
int
类型的模板构造函数,你需要请求一个默认的构造函数及其默认行为,这是因为默认构造函数只有在其他构造函数没有定义时才会生成,方法如下:1
Stack() = default;
参数
elem
传递给elems
时用大括号{}
包裹起来,通过初始化列表(initializer list)的方式只用一个参数初始化vector
类型的elems
:1
: elems({elem});
这是因为
vector
类型没有用一个参数直接初始化元素的构造函数。
注意,不同于函数模板,类模板参数可能无法部分推导其类型(通过显式地指明其中一部分模板参数的类型)。参见 15.12 节(未写)。
类模板对字符串常量的参数推导:
原则上,你甚至可以通过字符串常量来初始化 stack:
1 |
|
但是,这会带来一大堆问题:一般,当通过引用来传递模板类型 T
的参数是,这个参数类型不会被 decay (将 raw array 转换为相应的 raw pointer)。这说明我们的确定义了这个类型的 Stack
:
1 |
|
当使用 T
的时候都会被实例化为 char const[7]
。这样我们就不能放入不同长度的字符串了,因为这是不同的类型。详细的讨论参见 7.4 节(未写)。
但是,如果我们按值传递模板参数 T
的话,参数类型就会被 decay ,也就是将 raw array 转换为相应的 raw pointer 。这样,调用带有模板参数 T
的构造函数就会将模板参数 T
的类型推导成 char const*
,模板也被推导为 Stack<char const*>
。
基于以上原因,可能有必要讲构造函数声明成按值传递参数的形式:
1 |
|
这样下面的初始化方式就可以正常运行:
1 |
|
在这个例子中,我们最好使用 move 将临时变量 elem
添加至 stack 中,避免不必要的拷贝:
1 |
|
推导指引(Deduction Guides)
除了将构造函数声明成按值传递,还有一个解决方案:因为在容器中处理 raw pointers 可能会导致很多问题,我们应该禁止容器类将类型自动推导成 raw character pointers。
我们可以通过定义推导指引来提供额外的模板参数推导规则,或者修改已有的模板参数推导规则。例如,你可以定义当一个字符串常量或者 C 类型字符串被传递时,stack 类型被实例化为 std::string
:
1 |
|
这个指引语句也必须出现在模板类定义的 scope(namespace) 内。通常它紧跟着模板类的定义。->
后面的类型被称为推导指引(deduction guide)的 guided type。
现在,根据这个定义
1 |
|
stack 被推导为 Stack<std::string>
。但是,下面的语句仍旧是无效的:
1 |
|
模板参数类型被推导为 std::string
,也实例化出了 Stack<std::string>
:
1 |
|
但是根据语言规则,除了 std::string
,你不能对字符串常量使用拷贝初始化(用 =
初始化)来初始化一个对象。因此你必须用如下的初始化方式:
1 |
|
跟类模板参数推导类似,在声明 stringStack
的类型是 Stack<std::string>
之后,下面的初始化声明了同一个类型(调用拷贝构造函数)而不是用类型为 Stack<std::string>
的元素对 stack 进行初始化(推导出的类型不会是 Stack<std::string>
):
1 |
|
更多内容,参见 15.12 节(未写)。
2.10. 模板聚合
聚合类(classes/structs which没有用户定义的显式的或者是继承的构造函数,没有 private 或者 protected 的非静态成员,没有虚函数,没有 virtual, private, or protected 方式继承的父类)也可以是模板。例如:
1 |
|
定义了一个成员 value
的类型被参数化了的聚合类。可以像定义其它类模板的对象一样定义一个聚合类的对象:
1 |
|
从C++17 开始,可以为聚合类的类模板定义推导指引:
1 |
|
没有“推导指引”的话,就不能使用上述初始化方法,因为 ValueWithComment
没有相应的构造函数来完成相关类型推导。
标准库的 std::array<>
类也是一个聚合类,其元素类型和尺寸都是被参数化的。C++17 也给它定义了“推导指引”,在 4.4.4 节(未写)会做进一步讨论。
2.11. 小结
- 类模板是有一个或者多个参数待定的类
- 使用类模板是,你需要传递模板参数的类型,之后类模板会对这些类型进行实例化。
- 对于类模板,成员函数只有被使用时才会被实例化。
- 你可以针对特定类型对类模板特化。
- 你可以针对特定类型对类模板偏特化。
- 从 C++17 开始,类模板的模板参数可以通过其构造函数进行自动推导。
- 你可以定义聚合类的类模板
- 调用参数如果是按值传递的话,相应的模板类型会被 decay.
- 模板只可以被定义在 global/namespace scope 或者在类的声明之中。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!