【C++基础】第九十三课:[模板与泛型编程]定义模板

template,typename,函数模板,类模板

Posted by x-jeff on February 18, 2024

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.定义模板

假定我们希望编写一个函数来比较两个值,并指出第一个值是小于、等于还是大于第二个值。在实际中,我们可能想要定义多个函数,每个函数比较一种给定类型的值。我们的初次尝试可能定义多个重载函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
//如果两个值相等,返回0,如果v1小返回-1,如果v2小返回1
int compare(const string &v1, const string &v2)
{
	if(v1 < v2) return -1;
	if(v2 < v1) return 1;
	return 0;
}
int compare(const double &v1, const double &v2)
{
	if(v1 < v2) return -1;
	if(v2 < v1) return 1;
	return 0;
}

2.函数模板

我们可以定义一个通用的函数模板(function template),而不是为每个类型都定义一个新函数。一个函数模板就是一个公式,可用来生成针对特定类型的函数版本。compare的模板版本可能像下面这样:

1
2
3
4
5
6
7
template <typename T>
int compare(const T &v1, const T &v2)
{
	if(v1 < v2) return -1;
	if(v2 < v1) return 1;
	return 0;
}

模板定义以关键字template开始,后跟一个模板参数列表(template parameter list),这是一个逗号分隔的一个或多个模板参数(template parameter)的列表,用小于号(<)和大于号(>)包围起来。

在模板定义中,模板参数列表不能为空。

模板参数列表的作用很像函数参数列表。函数参数列表定义了若干特定类型的局部变量,但并未指出如何初始化它们。在运行时,调用者提供实参来初始化形参。

类似的,模板参数表示在类或函数定义中用到的类型或值。当使用模板时,我们(隐式地或显式地)指定模板实参(template argument),将其绑定到模板参数上。

我们的compare函数声明了一个名为T的类型参数。在compare中,我们用名字T表示一个类型。而T表示的实际类型则在编译时根据compare的使用情况来确定。

2.1.实例化函数模板

当我们调用一个函数模板时,编译器(通常)用函数实参来为我们推断模板实参。即,当我们调用compare时,编译器使用实参的类型来确定绑定到模板参数T的类型。例如,在下面的调用中:

1
cout << compare(1, 0) << endl; //T为int

实参类型是int。编译器会推断出模板实参为int,并将它绑定到模板参数T。

编译器用推断出的模板参数来为我们实例化(instantiate)一个特定版本的函数。当编译器实例化一个模板时,它使用实际的模板实参代替对应的模板参数来创建出模板的一个新“实例”。例如,给定下面的调用:

1
2
3
4
5
//实例化出int compare(const int&, const int&)
cout << compare(1,0) << endl; //T为int
//实例化出int compare(const vector<int>&, const vector<int>&)
vector<int> vec1{1,2,3}, vec2{4,5,6};
cout << compare(vec1, vec2) << endl; //T为vector<int>

编译器会实例化出两个不同版本的compare。对于第一个调用,编译器会编写并编译一个compare版本,其中T被替换为int:

1
2
3
4
5
6
int compare(const int &v1, const int &v2)
{
	if(v1 < v2) return -1;
	if(v2 < v1) return 1;
	return 0;
}

对于第二个调用,编译器会生成另一个compare版本,其中T被替换为vector<int>。这些编译器生成的版本通常被称为模板的实例(instantiation)。

2.2.模板类型参数

我们的compare函数有一个模板类型参数(type parameter)。一般来说,我们可以将类型参数看作类型说明符,就像内置类型或类类型说明符一样使用。特别是,类型参数可以用来指定返回类型或函数的参数类型,以及在函数体内用于变量声明或类型转换:

1
2
3
4
5
6
7
//正确:返回类型和参数类型相同
template <typename T> T foo(T* p)
{
	T tmp = *p; //tmp的类型将是指针p指向的类型
	//...
	return tmp;
}

类型参数前必须使用关键字class或typename:

1
2
//错误:U之前必须加上class或typename
template <typename T, U> T calc(const T&, const U&);

在模板参数列表中,这两个关键字的含义相同,可以互换使用。一个模板参数列表中可以同时使用这两个关键字:

1
2
//正确:在模板参数列表中,typename和class没有什么不同
template <typename T, class U> calc(const T&, const U&);

2.3.非类型模板参数

除了定义类型参数,还可以在模板中定义非类型参数(nontype parameter)。一个非类型参数表示一个值而非一个类型。我们通过一个特定的类型名而非关键字class或typename来指定非类型参数。

当一个模板被实例化时,非类型参数被一个用户提供的或编译器推断出的值所代替。这些值必须是常量表达式,从而允许编译器在编译时实例化模板。

例如,我们可以编写一个compare版本处理字符串字面常量。这种字面常量是const char的数组。由于不能拷贝一个数组,所以我们将自己的参数定义为数组的引用(参见:数组形参)。由于我们希望能比较不同长度的字符串字面常量,因此为模板定义了两个非类型的参数。第一个模板参数表示第一个数组的长度,第二个参数表示第二个数组的长度:

1
2
3
4
5
template<unsigned N, unsigned M>
int compare(const char (&p1)[N], const char (&p2)[M])
{
	return strcmp(p1, p2);
}

当我们调用这个版本的compare时:

1
compare("hi", "mom")

编译器会使用字面常量的大小来代替N和M,从而实例化模板。记住,编译器会在一个字符串字面常量的末尾插入一个空字符作为终结符,因此编译器会实例化出如下版本:

1
int compare(const char (&p1)[3], const char (&p2)[4])

一个非类型参数可以是一个整型,或者是一个指向对象或函数类型的指针或(左值)引用。绑定到非类型整型参数的实参必须是一个常量表达式。绑定到指针或引用非类型参数的实参必须具有静态的生存期。我们不能用一个普通(非static)局部变量或动态对象作为指针或引用非类型模板参数的实参。指针参数也可以用nullptr或一个值为0的常量表达式来实例化。

2.4.inline和constexpr的函数模板

函数模板可以声明为inline或constexpr的,如同非模板函数一样。inline或constexpr说明符放在模板参数列表之后,返回类型之前:

1
2
3
4
//正确:inline说明符跟在模板参数列表之后
template <typename T> inline T min(const T&, const T&);
//错误:inline说明符的位置不正确
inline template <typename T> T min(const T&, const T&);

2.5.编写类型无关的代码

我们最初的compare函数虽然简单,但它说明了编写泛型代码的两个重要原则:

  • 模板中的函数参数是const的引用。
  • 函数体中的条件判断仅使用<比较运算。

通过将函数参数设定为const的引用,我们保证了函数可以用于不能拷贝的类型。大多数类型,包括内置类型和我们已经用过的标准库类型(除unique_ptr和IO类型之外),都是允许拷贝的。但是,不允许拷贝的类类型也是存在的。通过将参数设定为const的引用,保证了这些类型可以用我们的compare函数来处理。而且,如果compare用于处理大对象,这种设计策略还能使函数运行得更快。

你可能认为既使用<运算符又使用>运算符来进行比较操作会更为自然:

1
2
3
4
//期望的比较操作
if(v1 < v2) return -1;
if(v1 > v2) return 1;
return 0;

但是,如果编写代码时只使用<运算符,我们就降低了compare函数对要处理的类型的要求。这些类型必须支持<,但不必同时支持>

实际上,如果我们真的关心类型无关和可移植性,可能需要用less来定义我们的函数:

1
2
3
4
5
6
7
//即使用于指针也正确的compare版本
template <typename T> int compare(const T &v1, const T &v2)
{
	if (less<T>()(v1,v2)) return -1;
	if (less<T>()(v2,v1)) return 1;
	return 0;
}

原始版本存在的问题是,如果用户调用它比较两个指针,且两个指针未指向相同的数组,则代码的行为是未定义的(但据查阅资料,less<T>的默认实现用的就是<,所以这其实并未起到让这种比较有一个良好定义的作用)。

模板程序应该尽量减少对实参类型的要求。

2.6.模板编译

当编译器遇到一个模板定义时,它并不生成代码。只有当我们实例化出模板的一个特定版本时,编译器才会生成代码。当我们使用(而不是定义)模板时,编译器才生成代码,这一特性影响了我们如何组织代码以及错误何时被检测到。

通常,当我们调用一个函数时,编译器只需要掌握函数的声明。类似的,当我们使用一个类类型的对象时,类定义必须是可用的,但成员函数的定义不必已经出现。因此,我们将类定义和函数声明放在头文件中,而普通函数和类的成员函数的定义放在源文件中。

模板则不同:为了生成一个实例化版本,编译器需要掌握函数模板或类模板成员函数的定义。因此,与非模板代码不同,模板的头文件通常既包括声明也包括定义。

函数模板和类模板成员函数的定义通常放在头文件中。

模板和头文件:

模板包含两种名字:

  • 那些不依赖于模板参数的名字
  • 那些依赖于模板参数的名字

当使用模板时,所有不依赖于模板参数的名字都必须是可见的,这是由模板的提供者来保证的。而且,模板的提供者必须保证,当模板被实例化时,模板的定义,包括类模板的成员的定义,也必须是可见的。

用来实例化模板的所有函数、类型以及与类型关联的运算符的声明都必须是可见的,这是由模板的用户来保证的。

通过组织良好的程序结构,恰当使用头文件,这些要求都很容易满足。模板的设计者应该提供一个头文件,包含模板定义以及在类模板或成员定义中用到的所有名字的声明。模板的用户必须包含模板的头文件,以及用来实例化模板的任何类型的头文件。

2.7.大多数编译错误在实例化期间报告

模板直到实例化时才会生成代码,这一特性影响了我们何时才会获知模板内代码的编译错误。通常,编译器会在三个阶段报告错误。

第一个阶段是编译模板本身时。在这个阶段,编译器通常不会发现很多错误。编译器可以检查语法错误,例如忘记分号或者变量名拼错等,但也就这么多了。

第二个阶段是编译器遇到模板使用时。在此阶段,编译器仍然没有很多可检查的。对于函数模板调用,编译器通常会检查实参数目是否正确。它还能检查参数类型是否匹配。对于类模板,编译器可以检查用户是否提供了正确数目的模板实参,但也仅限于此了。

第三个阶段是模板实例化时,只有这个阶段才能发现类型相关的错误。依赖于编译器如何管理实例化,这类错误可能在链接时才报告。

当我们编写模板时,代码不能是针对特定类型的,但模板代码通常对其所使用的类型有一些假设。例如,我们最初的compare函数中的代码就假定实参类型定义了<运算符。

1
2
3
if (v1 < v2) return -1; //要求类型T的对象支持<操作
if (v2 < v1) return 1; //要求类型T的对象支持<操作
return 0; //返回int;不依赖于T

当编译器处理此模板时,它不能验证if语句中的条件是否合法。如果传递给compare的实参定义了<运算符,则代码就是正确的,否则就是错误的。例如:

1
2
Sales_data data1, data2;
cout << compare(data1, data2) << endl; //错误:Sales_data未定义<

这样的错误直至编译器在类型Sales_data上实例化compare时才会被发现。

3.类模板

类模板(class template)是用来生成类的蓝图的。与函数模板的不同之处是,编译器不能为类模板推断模板参数类型。如我们已经多次看到的,为了使用类模板,我们必须在模板名后的尖括号中提供额外信息(参见:标准库类型vector)——用来代替模板参数的模板实参列表。

3.1.定义类模板

作为一个例子,我们将实现StrBlob的模板版本。我们将此模板命名为Blob,意指它不再针对string。类似StrBlob,我们的模板会提供对元素的共享(且核查过的)访问能力。与类不同,我们的模板可以用于更多类型的元素。与标准库容器相同,当使用Blob时,用户需要指出元素类型。

类似函数模板,类模板以关键字template开始,后跟模板参数列表。在类模板(及其成员)的定义中,我们将模板参数当作替身,代替使用模板时用户需要提供的类型或值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <typename T> class Blob {
public:
	typedef T value_type; //不是必须的
	typedef typename std::vector<T>::size_type size_type; //typename关键字用于告诉编译器,后面的表达式是一个类型而不是其他东西
	//构造函数
	Blob();
	Blob(std::initializer_list<T> il);
	//Blob中的元素数目
	size_type size() const { return data->size(); }
	bool empty() const { return data->empty(); }
	//添加和删除元素
	void push_back(const T &t) { data->push_back(t); }
	//移动版本
	void push_back(T &&t) { data->push_back(std::move(t)); }
	void pop_back();
	//元素访问
	T& back();
	T& operator[](size_type i);
private:
	std::shared_ptr<std::vector<T>> data;
	//若data[i]无效,则抛出msg
	void check(size_type i, const std::string &msg) const;
};

3.2.实例化类模板

我们已经多次见到,当使用一个类模板时,我们必须提供额外信息。我们现在知道这些额外信息是显式模板实参(explicit template argument)列表,它们被绑定到模板参数。编译器使用这些模板实参来实例化出特定的类。

例如,为了用我们的Blob模板定义一个类型,必须提供元素类型:

1
2
Blob<int> ia; //空Blob<int>
Blob<int> ia2 = {0, 1, 2, 3, 4}; //有5个元素的Blob<int>

ia和ia2使用相同的特定类型版本的Blob(即Blob<int>)。从这两个定义,编译器会实例化出一个与下面定义等价的类:

1
2
3
4
5
6
7
8
9
10
template <> class Blob<int> {
	typedef typename std::vector<int>::size_type size_type;
	Blob();
	Blob(std::initializer_list<int> il);
	//...
	int& operator[](size_type i);
private:
	std::shared_ptr<std::vector<int>> data;
	void check(size_type i, const std::string &msg) const;
};

对我们指定的每一种元素类型,编译器都生成一个不同的类:

1
2
3
//下面的定义实例化出两个不同的Blob类型
Blob<string> names; //保存string的Blob
Blob<double> prices; //不同的元素类型

一个类模板的每个实例都形成一个独立的类。类型Blob<string>与任何其他Blob类型都没有关联,也不会对任何其他Blob类型的成员有特殊访问权限。

3.3.类模板的成员函数

与其他任何类相同,我们既可以在类模板内部,也可以在类模板外部为其定义成员函数,且定义在类模板内的成员函数被隐式声明为内联函数。

类模板的成员函数本身是一个普通函数。但是,类模板的每个实例都有其自己版本的成员函数。因此,类模板的成员函数具有和模板相同的模板参数。因而,定义在类模板之外的成员函数就必须以关键字template开始,后接类模板参数列表。

与往常一样,当我们在类外定义一个成员时,必须说明成员属于哪个类。而且,从一个模板生成的类的名字中必须包含其模板实参。当我们定义一个成员函数时,模板实参与模板形参相同。即,对于StrBlob的一个给定的成员函数:

1
ret-type StrBlob::member-name(parm-list)

对应的Blob的成员应该是这样的:

1
2
template <typename T>
ret-type Blob<T>::member-name(parm-list)

3.4.check和元素访问成员

我们首先定义check成员,它检查一个给定的索引:

1
2
3
4
5
6
template <typename T>
void Blob<T>::check(size_type i, const std::string &msg) const
{
	if( i >= data->size())
		throw std::out_of_range(msg);
}

下标运算符和back函数用模板参数指出返回类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
T& Blob<T>::back()
{
	check(0, "back on empty Blob");
	return data->back();
}
template <typename T>
T& Blob<T>::operator[](size_type i)
{
	//如果i太大,check会抛出异常,阻止访问一个不存在的元素
	check(i, "subscript out of range");
	return (*data)[i];
}

pop_back函数与原StrBlob的成员几乎相同:

1
2
3
4
5
template <typename T> void Blob<T>::pop_back()
{
	check(0, "pop_back on empty Blob");
	data->pop_back();
}

3.5.Blob构造函数

与其他任何定义在类模板外的成员一样,构造函数的定义要以模板参数开始:

1
2
3
4
5
template <typename T>
Blob<T>::Blob(): data(std::make_shared<std::vector<T>>()) { }

template <typename T>
Blob<T>::Blob(std::initializer_list<T> il): data(std::make_shared<std::vector<T>>(il)) { }

3.6.类模板成员函数的实例化

默认情况下,一个类模板的成员函数只有当程序用到它时才进行实例化。例如,下面代码:

1
2
3
4
5
//实例化Blob<int>和接受initializer_list<int>的构造函数
Blob<int> squares = {0,1,2,3,4,5,6,7,8,9};
//实例化Blob<int>::size() const
for (size_t i = 0; i != squares.size(); ++i)
	squares[i] = i * i; //实例化Blob<int>::operator[](size_t)

如果一个成员函数没有被使用,则它不会被实例化。成员函数只有在被用到时才进行实例化,这一特性使得即使某种类型不能完全符合模板操作的要求(参见:容器库概览),我们仍然能用该类型实例化类。

3.7.在类代码内简化模板类名的使用

当我们使用一个类模板类型时必须提供模板实参,但这一规则有一个例外。在类模板自己的作用域中,我们可以直接使用模板名而不提供实参:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//若试图访问一个不存在的元素,BlobPtr抛出一个异常
template <typename T> class BlobPtr {
public:
	BlobPtr(): curr(0) { }
	BlobPtr(Blob<T> &a, size_t sz = 0): wptr(a.data), curr(sz) { }
	T& operator*() const
	{
		auto p = check(curr, "dereference past end");
		return (*p)[curr]; //(*p)为本对象指向的vector
	}
	//递增和递减
	BlobPtr& operator++(); //前置运算符
	BlobPtr& operator--();
private:
	//若检查成功,check返回一个指向vector的shared_ptr
	std::shared_ptr<std::vector<T>> check(std::size_t, const std::string&) const;
	//保存一个weak_ptr,表示底层vector可能被销毁
	std::weak_ptr<std::vector<T>> wptr;
	std::size_t curr; //数组中的当前位置
};

BlobPtr的前置递增和递减成员返回BlobPtr&,而不是BlobPtr<T>&。当我们处于一个类模板的作用域中时,编译器处理模板自身引用时就好像我们已经提供了与模板参数匹配的实参一样。即,就好像我们这样编写代码一样:

1
2
BlobPtr<T>& operator++();
BlobPtr<T>& operator--();

3.8.在类模板外使用类模板名

当我们在类模板外定义其成员时,必须记住,我们并不在类的作用域中,直到遇到类名才表示进入类的作用域(参见:作用域和定义在类外部的成员):

1
2
3
4
5
6
7
8
9
10
//后置:递增/递减对象但返回原值
template <typename T>
BlobPtr<T> BlobPtr<T>::operator++(int)
{
	//此处无须检查;调用前置递增时会进行检查
	BlobPtr ret = *this; //保存当前值
	//等价于:BlobPtr<T> ret = *this;
	++*this; //推进一个元素;前置++检查递增是否合法
	return ret; //返回保存的状态
}

在一个类模板的作用域内,我们可以直接使用模板名而不必指定模板实参。

个人理解:operator++(int)为重载后置递增运算符,函数内++*this;调用了前置递增运算符,参见:区分前置和后置运算符

3.9.类模板和友元

当一个类包含一个友元声明时,类与友元各自是否是模板是相互无关的。如果一个类模板包含一个非模板友元,则友元被授权可以访问所有模板实例。如果友元自身是模板,类可以授权给所有友元模板实例,也可以只授权给特定实例。

3.10.一对一友好关系

为了引用(类或函数)模板的一个特定实例,我们必须首先声明模板自身。一个模板声明包括模板参数列表:

1
2
3
4
5
6
7
8
9
10
//前置声明,在Blob中声明友元所需要的
template <typename> class BlobPtr;
template <typename> class Blob; //运算符==中的参数所需要的
template <typename T> bool operator==(const Blob<T>&, const Blob<T>&);
template <typename T> class Blob {
	//每个Blob实例将访问权限授予用相同类型实例化的BlobPtr和相等运算符
	friend class BlobPtr<T>;
	friend bool operator==<T> (const Blob<T>&, const Blob<T>&);
	//其他成员定义与之前相同
};

友元的声明用Blob的模板形参作为它们自己的模板实参。因此,友好关系被限定在用相同类型实例化的Blob与BlobPtr相等运算符之间:

1
2
Blob<char> ca; //BlobPtr<char>和operator==<char>都是本对象的友元
Blob<int> ia; //BlobPtr<int>和operator==<int>都是本对象的友元

BlobPtr<char>的成员可以访问ca(或任何其他Blob<char>对象)的非public部分,但ca对ia(或任何其他Blob<int>对象)或Blob的任何其他实例都没有特殊访问权限。

3.11.通用和特定的模板友好关系

一个类也可以将另一个模板的每个实例都声明为自己的友元,或者限定特定的实例为友元:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//前置声明,在将模板的一个特定实例声明为友元时要用到
template <typename T> class Pal;
class C { //C是一个普通的非模板类
	friend class Pal<C>; //用类C实例化的Pal是C的一个友元
	//Pal2的所有实例都是C的友元;这种情况无须前置声明
	template <typename T> friend class Pal2;
};
template <typename T> class C2 { //C2本身是一个类模板
	//C2的每个实例将相同实例化的Pal声明为友元
	friend class Pal<T>; //Pal的模板声明必须在作用域之内
	//Pal2的所有实例都是C2的每个实例的友元,不需要前置声明
	template <typename X> friend class Pal2;
	//Pal3是一个非模板类,它是C2所有实例的友元
	friend class Pal3; //不需要Pal3的前置声明
};

为了让所有实例成为友元,友元声明中必须使用与类模板本身不同的模板参数。

3.12.令模板自己的类型参数成为友元

在新标准中,我们可以将模板类型参数声明为友元:

1
2
3
4
template <typename Type> class Bar {
friend Type; //将访问权限授予用来实例化Bar的类型
	//...
};

值得注意的是,虽然友元通常来说应该是一个类或是一个函数,但我们完全可以用一个内置类型来实例化Bar。这种与内置类型的友好关系是允许的,以便我们能用内置类型来实例化Bar这样的类。

3.13.模板类型别名

类模板的一个实例定义了一个类类型,与任何其他类类型一样,我们可以定义一个typedef来引用实例化的类:

1
typedef Blob<string> StrBlob;

由于模板不是一个类型,我们不能定义一个typedef引用一个模板。即,无法定义一个typedef引用Blob<T>。但是,新标准允许我们为类模板定义一个类型别名:

1
2
3
4
template<typename T> using twin = pair<T,T>;
twin<string> authors; //authors是一个pair<string, string>
twin<int> win_loss; //win_loss是一个pair<int,int>
twin<double> area; //area是一个pair<double,double>

当我们定义一个模板类型别名时,可以固定一个或多个模板参数:

1
2
3
4
template <typename T> using partNo = pair<T,unsigned>;
partNo<string> books; //books是一个pair<string,unsigned>
partNo<Vehicle> cars; //cars是一个pair<Vehicle,unsigned>
partNo<Student> kids; //kids是一个pair<Student,unsigned>

partNo的用户需要指出pair的first成员的类型,但不能指定second成员的类型。

3.14.类模板的static成员

与任何其他类相同,类模板可以声明static成员

1
2
3
4
5
6
7
8
template <typename T> class Foo {
public:
	static std::size_t count() { return ctr; }
	//其他接口成员
private:
	static std::size_t ctr;
	//其他实现成员
};

每个Foo的实例都有其自己的static成员实例。即,对任意给定类型X,都有一个Foo<X>::ctr和一个Foo<X>::count成员。所有Foo<X>类型的对象共享相同的ctr对象和count函数。例如,

1
2
3
4
//实例化static成员Foo<string>::ctr和Foo<string>::count
Foo<string> fs;
//所有三个对象共享相同的Foo<int>::ctr和Foo<int>::count成员
Foo<int> fi,fi2,fi3;

与任何其他static数据成员相同,模板类的每个static数据成员必须有且仅有一个定义。但是,类模板的每个实例都有一个独有的static对象。因此,与定义模板的成员函数类似,我们将static数据成员也定义为模板:

1
2
template <typename T>
size_t Foo<T>::ctr = 0; //定义并初始化ctr

与非模板类的静态成员相同,我们可以通过类类型对象来访问一个类模板的static成员,也可以使用作用域运算符直接访问成员。当然,为了通过类来直接访问static成员,我们必须引用一个特定的实例:

1
2
3
4
Foo<int> fi; //实例化Foo<int>类和static数据成员ctr
auto ct = Foo<int>::count(); //实例化Foo<int>::count
ct = fi.count(); //使用Foo<int>::count
ct = Foo::count(); //错误:使用哪个模板实例的count?

类似任何其他成员函数,一个static成员函数只有在使用时才会实例化。

4.模板参数

类似函数参数的名字,一个模板参数的名字也没有什么内在含义。我们通常将类型参数命名为T,但实际上我们可以使用任何名字:

1
2
3
4
5
6
template <typename Foo> Foo calc(const Foo& a, const Foo& b)
{
	Foo tmp = a; //tmp的类型与参数和返回类型一样
	//...
	return tmp; //返回类型和参数类型一样
}

4.1.模板参数与作用域

模板参数遵循普通的作用域规则。一个模板参数名的可用范围是在其声明之后,至模板声明或定义结束之前。与任何其他名字一样,模板参数会隐藏外层作用域中声明的相同名字。但是,与大多数其他上下文不同,在模板内不能重用模板参数名:

1
2
3
4
5
6
typedef double A;
template <typename A, typename B> void f(A a, B b)
{
	A tmp = a; //tmp的类型为模板参数A的类型,而非double
	double B; //错误:重声明模板参数B
}

由于参数名不能重用,所以一个模板参数名在一个特定模板参数列表中只能出现一次:

1
2
//错误:非法重用模板参数名V
template <typename V, typename V> //...

4.2.模板声明

模板声明必须包含模板参数:

1
2
3
//声明但不定义compare和Blob
template <typename T> int compare(const T&, const T&);
template <typename T> class Blob;

与函数参数相同,声明中的模板参数的名字不必与定义中相同:

1
2
3
4
5
6
//3个calc都指向相同的函数模板
template <typename T> T calc(const T&, const T&); //声明
template <typename U> U calc(const U&, const U&); //声明
//模板的定义
template <typename Type>
Type calc(const Type& a, const Type& b) { /*...*/ }

当然,一个给定模板的每个声明和定义必须有相同数量和种类(即,类型或非类型)的参数。

4.3.使用类的类型成员

我们用作用域运算符(::)来访问static成员和类型成员。在普通(非模板)代码中,编译器掌握类的定义。因此,它知道通过作用域运算符访问的名字是类型还是static成员。例如,如果我们写下string::size_type,编译器有string的定义,从而知道size_type是一个类型。

但对于模板代码就存在困难。例如,假定T是一个模板类型参数,当编译器遇到类似T::mem这样的代码时,它不会知道mem是一个类型成员还是一个static数据成员,直至实例化时才会知道。但是,为了处理模板,编译器必须知道名字是否表示一个类型。例如,假定T是一个类型参数的名字,当编译器遇到如下形式的语句时:

1
T::size_type * p;

它需要知道我们是正在定义一个名为p的变量还是将一个名为size_type的static数据成员与名为p的变量相乘。

默认情况下,C++语言假定通过作用域运算符访问的名字不是类型。因此,如果我们希望使用一个模板类型参数的类型成员,就必须显式告诉编译器该名字是一个类型。我们通过使用关键字typename来实现这一点:

1
2
3
4
5
6
7
8
template <typename T>
typename T::value_type top(const T& c)
{
	if (!c.empty())
		return c.back();
	else
		return typename T::value_type();
}

当我们希望通知编译器一个名字表示类型时,必须使用关键字typename,而不能使用class。

4.4.默认模板实参

就像我们能为函数参数提供默认实参一样,我们也可以提供默认模板实参(default template argument)。在新标准中,我们可以为函数和类模板提供默认实参。而更早的C++标准只允许为类模板提供默认实参。

例如,我们重写compare,默认使用标准库的less函数对象模板:

1
2
3
4
5
6
7
8
//compare有一个默认模板实参less<T>和一个默认函数实参F()
template <typename T, typename F = less<T>>
int compare(const T &v1, const T &v2, F f = F())
{
	if (f(v1, v2)) return -1;
	if (f(v2, v1)) return 1;
	return 0;
}

在这段代码中,我们为模板添加了第二个类型参数,名为F,表示可调用对象的类型;并定义了一个新的函数参数f,绑定到一个可调用对象上。

与函数默认实参一样,对于一个模板参数,只有当它右侧的所有参数都有默认实参时,它才可以有默认实参。

4.5.模板默认实参与类模板

无论何时使用一个类模板,我们都必须在模板名之后接上尖括号。尖括号指出类必须从一个模板实例化而来。特别是,如果一个类模板为其所有模板参数都提供了默认实参,且我们希望使用这些默认实参,就必须在模板名之后跟一个空尖括号对:

1
2
3
4
5
6
7
8
9
template <class T = int> class Numbers { //T默认为int
public:
	Numbers(T v = 0): val(v) { }
	//对数值的各种操作
private:
	T val;
};
Numbers<long double> lots_of_precision;
Numbers<> average_precision; //空<>表示我们希望使用默认类型

5.成员模板

一个类(无论是普通类还是类模板)可以包含本身是模板的成员函数。这种成员被称为成员模板(member template)。成员模板不能是虚函数。

5.1.普通(非模板)类的成员模板

作为普通类包含成员模板的例子,我们定义一个类,类似unique_ptr所使用的默认删除器类型。类似默认删除器,我们的类将包含一个重载的函数调用运算符,它接受一个指针并对此指针执行delete。与默认删除器不同,我们的类还将在删除器被执行时打印一条信息。由于希望删除器适用于任何类型,所以我们将调用运算符定义为一个模板:

1
2
3
4
5
6
7
8
9
10
//函数对象类,对给定指针执行delete
class DebugDelete {
public:
	DebugDelete(std::ostream &s = std::cerr): os(s) { }
	//与任何函数模板相同,T的类型由编译器推断
	template <typename T> void operator()(T *p) const 
		{ os << "deleting unique_ptr" << std::endl; delete p; }
private:
	std::ostream &os;
};

与任何其他模板相同,成员模板也是以模板参数列表开始的。每个DebugDelete对象都有一个ostream成员,用于写入数据;还包含一个自身是模板的成员函数。我们可以用这个类代替delete:

1
2
3
4
5
6
double* p = new double;
DebugDelete d; //可像delete表达式一样使用的对象
d(p); //调用DebugDelete::operator()(double*),释放p
int* ip = new int;
//在一个临时DebugDelete对象上调用operator()(int*)
DebugDelete()(ip);

由于调用一个DebugDelete对象会delete其给定的指针,我们也可以将DebugDelete用作unique_ptr的删除器。为了重载unique_ptr的删除器,我们在尖括号内给出删除器类型,并提供一个这种类型的对象给unique_ptr的构造函数(参见:向unique_ptr传递删除器):

1
2
3
4
5
6
//销毁p指向的对象
//实例化DebugDelete::operator()<int>(int *)
unique_ptr<int, DebugDelete> p(new int, DebugDelete());
//销毁sp指向的对象
//实例化DebugDelete::operator()<string>(string*)
unique_ptr<string, DebugDelete> sp(new string, DebugDelete());

unique_ptr的析构函数会调用DebugDelete的调用运算符。因此,无论何时unique_ptr的析构函数实例化时,DebugDelete的调用运算符都会实例化:因此,上述定义会这样实例化。

1
2
3
//DebugDelete的成员模板实例化样例
void DebugDelete::operator()(int *p) const {delete p;}
void DebugDelete::operator()(string *p) const {delete p;}

5.2.类模板的成员模板

对于类模板,我们也可以为其定义成员模板。在此情况下,类和成员各自有自己的、独立的模板参数。

例如,我们将为Blob类定义一个构造函数,它接受两个迭代器,表示要拷贝的元素范围。由于我们希望支持不同类型序列的迭代器,因此将构造函数定义为模板:

1
2
3
4
template <typename T> class Blob {
	template <typename It> Blob(It b, It e);
	//...
};

与类模板的普通函数成员不同,成员模板是函数模板。当我们在类模板外定义一个成员模板时,必须同时为类模板和成员模板提供模板参数列表。类模板的参数列表在前,后跟成员自己的模板参数列表:

1
2
3
4
template <typename T> //类的类型参数
template <typename It> //构造函数的类型参数
	Blob<T>::Blob(It b, It e):
		data(std::make_shared<std::vector<T>>(b, e)) { }

5.3.实例化与成员模板

为了实例化一个类模板的成员模板,我们必须同时提供类和函数模板的实参。与往常一样,我们在哪个对象上调用成员模板,编译器就根据该对象的类型来推断类模板参数的实参。与普通函数模板相同,编译器通常根据传递给成员模板的函数实参来推断它的模板实参:

1
2
3
4
5
6
7
8
9
int ia[] = {0,1,2,3,4,5,6,7,8,9};
vector<long> vi = {0,1,2,3,4,5,6,7,8,9};
list<const char*> w = {"now", "is", "the", "time"};
//实例化Blob<int>类及其接受两个int*参数的构造函数
Blob<int> a1(begin(ia), end(ia));
//实例化Blob<int>类的接受两个vector<long>::iterator的构造函数
Blob<int> a2(vi.begin(), vi.end());
//实例化Blob<string>及其接受两个list<const char*>::iterator参数的构造函数
Blob<string> a3(w.begin(), w.end());

当我们定义a1时,显式地指出编译器应该实例化一个int版本的Blob。构造函数自己的类型参数则通过begin(ia)和end(ia)的类型来推断,结果为int*。因此,a1的定义实例化了如下版本:

1
Blob<int>::Blob(int*, int*);

6.控制实例化

当模板被使用时才会进行实例化这一特性意味着,相同的实例可能出现在多个对象文件中。当两个或多个独立编译的源文件使用了相同的模板,并提供了相同的模板参数时,每个文件中就都会有该模板的一个实例。

在大系统中,在多个文件中实例化相同模板的额外开销可能非常严重。在新标准中,我们可以通过显式实例化(explicit instantiation)来避免这种开销。一个显式实例化有如下形式:

1
2
extern template declaration; //实例化声明
template declaration; //实例化定义

declaration是一个类或函数声明,其中所有模板参数已被替换为模板实参。例如:

1
2
3
//实例化声明与定义
extern template class Blob<string>; //声明
template int compare(const int&, const int&); //定义

当编译器遇到extern模板声明时,它不会在本文件中生成实例化代码。将一个实例化声明为extern就表示承诺在程序其他位置有该实例化的一个非extern声明(定义)。对于一个给定的实例化版本,可能有多个extern声明,但必须只有一个定义。

由于编译器在使用一个模板时自动对其实例化,因此extern声明必须出现在任何使用此实例化版本的代码之前:

1
2
3
4
5
6
7
8
9
//Application.cc
//这些模板类型必须在程序其他位置进行实例化
extern template class Blob<string>;
extern template int compare(const int&, const int&);
Blob<string> sa1, sa2; //实例化会出现在其他位置
//Blob<int>及其接受initializer_list的构造函数在本文件中实例化
Blob<int> a1 = {0,1,2,3,4,5,6,7,8,9};
Blob<int> a2(a1); //拷贝构造函数在本文件中实例化
int i = compare(a1[0], a2[0]); //实例化出现在其他位置

compare<int>函数和Blob<string>类将不在本文件中进行实例化。这些模板的定义必须出现在程序的其他文件中:

1
2
3
4
//templateBuild.cc
//实例化文件必须为每个在其他文件中声明为extern的类型和函数提供一个(非extern)的定义
template int compare(const int&, const int&);
template class Blob<string>; //实例化类模板的所有成员

当编译器遇到一个实例化定义(与声明相对)时,它为其生成代码。因此,文件templateBuild.o将会包含compare的int实例化版本的定义和Blob<string>类的定义。当我们编译此应用程序时,必须将templateBuild.o和Application.o链接到一起。

6.1.实例化定义会实例化所有成员

一个类模板的实例化定义会实例化该模板的所有成员,包括内联的成员函数。当编译器遇到一个实例化定义时,它不了解程序使用哪些成员函数。因此,与处理类模板的普通实例化不同,编译器会实例化该类的所有成员。即使我们不使用某个成员,它也会被实例化。因此,我们用来显式实例化一个类模板的类型,必须能用于模板的所有成员。