类设计者的工具(六):模板与泛型编程
发布日期:2021-05-07 06:32:42 浏览次数:24 分类:原创文章

本文共 36621 字,大约阅读时间需要 122 分钟。

本文为《C++ Primer》的读书笔记

目录

OOP VS 泛型编程

  • OOP 能处理类型在程序运行之前都未知的情况 (运行期多态);泛型编程中,在编译时就能获知类型了 (编译期多态)
  • OOP 中,由类提供显式接口;泛型编程中,由模板定义中模板类型参数用到的接口 (普通函数、成员函数…) 决定隐式接口

标准库 (STL: standard template library): 标准库算法都是函数模板, 标准库容器都是类模板

模板是C++中泛型编程的基础。一个模板就是一个创建类或函数的蓝图

  • 例如,当用vector这样的泛型类型时, 我们用vector<int>来提供足够的信息, 将蓝图转换为特定的类 (这种转换发生在编译时)

函数模板

假定我们希望编写一个compare函数来比较两个值, 我们可能想要定义多个重载函数, 每个函数比较一种给定类型的值。而这些函数的函数体完全一样, 唯一的差异是参数的类型

  • 如果对每种希望比较的类型都不得不重复定义完全一样的函数体, 是非常烦琐且容易出错的
  • 更麻烦的是, 在编写程序的时候, 我们就要确定可能要比较的所有类型。如果希望能在用户提供的类型上使用此函数, 这种策略就失效了

定义函数模板

我们可以定义一个通用的函数模板(function template)

  • 一个函数模板就是一个公式, 可用来生成针对特定类型的函数版本
  • 模板定义以关键字template开始, 后跟一个模板参数列表, 这是一个逗号分隔的一个或多个模板参数的列表, 用<>包围起来
    • 模板参数表示在类或函数定义中用到的类型。当使用模板时, 我们(隐式地或显式地)指定模板实参, 将其绑定到模板参数上
    • 在模板定义中, 模板参数列表不能为空
template <typename T>	// 声明了一个名为`T`的类型参数// 用名字`T`表示一个类型,`T`表示的实际类型则在编译时根据`compare`的使用情况来确定int compare(const T &v1, const T &v2)	{   	if (v1 < v2) return -1;	// < 运算符为隐式接口,T 必须支持 operator< 运算符	if (v2 < v1) return 1;	return 0;}

实例化函数模板

  • 当我们调用一个函数模板时,编译器(通常)用函数实参来推断模板实参,当然也可以显式提供模板实参
  • 编译器用推断出的模板参数来为我们实例化一个特定版本的函数。当编译器实例化一个模板时, 它使用实际的模板实参代替对应的模板参数来创建出一个模板实例
// 实参类型是`int`。编译器会推断出模板实参为`int`, 并将它绑定到模板参数`T`// 最终实例化出 int compare(const int&, const int&)// 也可以写为 compare<int>(1, 0)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>

模板类型参数

  • 一般来说,我们可以将模板类型参数看作类型说明符, 就像内置类型或类类型说明符一样使用。特别是, 类型参数可以用来指定返回类型或函数的参数类型, 以及在函数体内用于变量声明或类型转换
  • 类型参数前必须使用关键字classtypename, 这两个关键字的含义相同, 可以互换使用。一个模板参数列表中也可以同时使用这两个关键字
template <typename T, class U> calc(const T&, const U&);

关键字typename来指定模板类型参数比用class看起来更为直观

模板非类型参数

  • 除了定义类型参数, 还可以在模板中定义非类型参数,表示一个值
  • 我们通过一个特定的类型名来指定非类型参数。一个非类型参数可以是一个整型, 或者是一个指向对象或函数类型的指针(左值)引用
  • 绑定到非类型整型参数的实参必须是一个常量表达式, 从而允许编译器在编译时实例化模板。绑定到指针或引用非类型参数的实参必须具有静态的生存期。指针参数也可以用nullptr或一个值为0 的常量表达式来实例化
  • 当一个模板被实例化时, 非类型参数被一个用户提供的 或 编译器推断出的值所代替 (编译器根据调用的实参来推断模板形参,如果无法推断,则需要用户显式给出)

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

// N 和 M 可以由实参推出template<unsigned N, unsigned M>int compare(const char (&p1)[N], const char (&p2)[M]){   	return strcmp(p1, p2);}
// 使用字面常量的大小来代替`N`和`M`, 从而实例化模板compare("hi", "mom");// 也可以显式地提供模板实参compare<3, 4>("hi", "mom");

inlineconstexpr 的函数模板

  • inlineconstexpr说明符放在模板参数列表之后,返同类型之前
  • inlineconstexpr说明符的先后位置无所谓
// 正确: inline说明符跟在模板参数列表之后template <typename T> inline T min(const T&, const T&);// 错误: inline说明符的位置不正确inline template <typename T> T min(const T&, const T&);

类模板

  • 类模板 (class template) 是用来生成类的蓝图的

定义类模板

// 提供对元素的共享(且核查过的)访问能力template <typename T> class Blob {   public:	typedef T value_type;	typedef typename std::vector<T>::size_type size_type;		// 构造函数	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;	// 指向保存数据的 vector	// 工具函数,若 data[i] 无效, 则抛出 msg	void check(size_type i, const std::string &msg) const;};

注意上面代码中第四行的用法:在std::vector<T>::size_type之前加上了关键字typename,这是为了明确告诉编译器std::vector<T>::size_type就是一个类型,而不是一个静态成员。详细解释见这篇

除了模板参数列表和使用T代替string之外, 此类模板的定义与是一样的

实例化类模板

  • 与函数模板的不同之处是, 编译器不能为类模板推断模板参数类型。为了使用类模板, 我们必须在模板名后的尖括号中提供额外信息,即显式模板实参列表, 它们被绑定到模板参数。编译器使用这些模板实参来实例化出特定的类
Blob<int> ia; 	// 空 Blob<int>Blob<int> ia2 = {   0,1,2,3,4}; 	// 有 5 个元素的 Blob<int>
  • 当编译器从我们的Blob模板实例化出一个类时, 它会重写Blob模板, 将模板参数T的每个实例替换为给定的模板实参
  • 一个类模板的每个实例都形成一个独立的类。类型Blob<string>与任何其他任何Blob类型都没有关联

  • 如果在文件之前的位置已经发生了所需的实例化(已经生成了需要的类),则不会发生实例化
stack<char> a;		// 实例化stack<char> b; 		// 未发生实例化

类模板的成员函数

  • 与其他任何类相同, 我们既可以在类模板内部, 也可以在类模板外部为其定义成员函数, 且定义在类模板内的成员函数被隐式声明为内联函数
  • 类模板的每个实例都有其自己版本的成员函数。因此,定义在类模板之外的成员函数就必须以关键字template开始, 后接类模板参数列表
template <typename T>ret-type Blob<T>::member-name(parm-list)

下面进行Blob模板类的成员函数的定义:

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);}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];}template <typename T> void Blob<T>::pop_back(){   	check(0, "pop_back on empty Blob");	data->pop_back();}// Blob 构造函数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)) {   }

类模板成员函数的实例化

  • 默认情况下, 一个类模板的成员函数只有当程序用到它时才进行实例化。如果一个成员函数没有被使用, 则它不会被实例化。这一特性使得即使某种类型不能完全符合模板操作的要求, 我们仍然能用该类型实例化类
// 实例化Blob<int>和接受initializer_list<int>的构造函数Blob<int> squares = {   0,1,2,3,4,5,6,7,8,9};// 实例化Blob<int>::size() constfor (size_t i = 0; i != squares.size(); ++i){   	squares[i] = i * i; // 实例化Blob<int>::operator[] (size_t)}

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

  • 当我们使用一个类模板类型时必须提供模板实参, 但这一规则有一个例外。在类模板自己的作用域中, 我们可以直接使用模板名而不提供实参
// BlobPtr 为 Blob 类的伴随指针类// 若试图访问一个不存在的元素,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>&。当我们处于一个类模板的作用域中时, 编译器处理模板自身引用时就好像我们已经提供了与模板参数匹配的实参一样。即, 就好像我们这样编写代码一样:
BlobPtr<T>& operator++();BlobPtr<T>& operator--();

  • 当我们在类模板外定义其成员时, 直到遇到类名才表示进入类的作用域
// 后置: 递增/递减对象但返回原值template <typename T>BlobPtr<T> BlobPtr<T>::operator++(int){   	// 此处无须检查;调用前置递增时会进行检查	BlobPtr ret = *this; //保存当前值	++*this; //推进一个元素;前置++检查递增是否合法	return ret; //返回保存的状态}
  • 在函数体内, 我们已经进入类的作用域, 因此在定义ret时无须重复模板实参。如果不提供模板实参, 则编译器将假定我们使用的类型与成员实例化所用类型一致。因此, ret的定义与如下代码等价:
BlobPtr<T> ret = *this;

类模板和友元

  • 如果一个类模板包含一个非模板友元, 则友元被授权可以访问所有模板实例
  • 如果友元自身是模板,类可以授权给所有友元模板实例,也可以只授权给特定实例

一对一友好关系

  • 类模板与另一个(类或函数) 模板间友好关系的最常见的形式是建立对应实例及其友元间的友好关系
    • 例如, 我们的Blob类应该将BlobPtr类和一个模板版本的Blob相等运算符定义为友元
  • 为了引用(类或函数)模板的一个特定实例, 我们必须首先声明模板自身。一个模板声明包括模板参数列表
// 前置声明, 在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>&);	// 其他成员定义, 与之前相同};

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

  • 一个类也可以将另一个模板的每个实例都声明为自己的友元或者限定特定的实例为友元
// 前置声明, 在将模板的一个特定实例声明为友元时要用到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的每个实例将相同实例化的Pal声明为友元	friend class Pal<T>; 	// Pal的模板声明必须在作用域之内	// Pal2的所有实例都是C2的每个实例的友元, 不需要前置声明	// 为了让所有实例成为友元, 友元声明中必须使用与类模板本身不同的模板参数	template <typename X> friend class Pal2;	// Pal3是一个非模板类, 它是C2 所有实例的友元	friend class Pal3;  	// 不需要Pal3的前置声明};

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

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

template <typename Type> class Bar {   	friend Type; //将访问权限授予用来实例化Bar的类型	// ...};
  • 对于某个类型名Foo, Foo将成为Bar<Foo>的友元

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

类模板的 static 成员

template <typename T> class Foo {   public:	// static 成员函数	static std::size_t count() {    return ctr; }	// 其他接口成员private:	// static 数据成员	static std::size_t ctr;	// 其他实现成员};
  • 每个Foo的实例都有其自己的static成员实例。 即, 对任意给定类型X, 都有一个Foo<X>::ctr和一个Foo<X>::count成员
  • 模板类的每个static数据成员必须有且仅有一个定义。但是, 类模板的每个实例都有一个独有的static对象。因此, 与定义模板的成员函数类似, 我们将static数据成员也定义为模板
template <typename T>	size_t Foo<T>::ctr = 0; //定义并初始化ctr
  • 可以通过类类型对象来访问一个类模板的static成员, 也可以使用作用域运算符直接访问成员:
Foo<int> fi; 					// 实例化Foo<int>类和static数据成员ctrauto ct = Foo<int>::count(); 	// 实例化Foo<int>::countct = fi.count(); 				// 使用Foo<int>::countct = Foo::count(); 				// 错误:使用哪个模板实例的count?
  • 类似任何其他成员函数, 一个 static成员函数只有在使用时.才会实例化

模板参数

通常将类型参数命名为T

模板参数与作用域

  • 模板参数遵循普通的作用域规则
    • 一个模板参数名的可用范围是在其声明之后, 至模板声明或定义结束之前
    • 模板参数会隐藏外层作用域中声明的相同名字
  • 但是, 在模板内不能重用模板参数名, 所以一个模板参数名在一个特定模板参数列表中只能出现一次:
typedef double A;template <typename A, typename B> void f(A a, B b){   	A tmp = a; 	// tmp的类型为模板参数A的类型, 而非double	double B; 	// 错误: 重声明模板参数B}
// 错误: 非法重用模板参数名Vtemplate <typename V, typename V> //...

模板声明

  • 模板声明必须包含模板参数
  • 与函数参数相同, 声明中的模板参数的名字不必与定义中相同
template <typename T> int compare(const T&, const T&);template <typename T> class Blob;

一个特定文件所需要的所有模板的声明通常一起放置在文件开始位置,出现于任何使用这些模板的代码之前

使用类的类型成员

  • 我们用作用域运算符:: 来访问static成员和类型成员
  • 在普通(非模板) 代码中,编译器掌握类的定义。因此,它知道通过作用域运算符访问的名字是类型还是static 成员
    • 例如,如果我们写下string::size_type, 编译器有string的定义,从而知道size_type是一个类型
  • 但对模板代码就存在困难
    • 例如, 假定T是一个模板类型参数, 当编译器遇到类似T::mem这样的代码时,它不会知道mem是一个类型成员还是一个static数据成员,直至实例化时才会知道
    • 但是, 为了处理模板,编译器必须知道名字是否表示一个类型。例如,假定T是一个类型参数的名字,当编译器遇到如下形式的语句时,它需要知道我们是正在定义一个名为p的变量还是将一个名为size_typestatic数据成员与名为p的变量相乘:
T::size_type * p;
  • 默认情况下,C++假定通过作用域运算符访问的名字不是类型。因此,如果我们希望使用一个模板类型参数的类型成员,就必须显式告诉编译器该名字是一个类型。我们通过使用关键字 typename 来实现这一点:
    • typename 标识类型名时,不能用在 类派生列表 或 成员初始化列表 中
template <typename T>typename T::value_type top(const T& c){   	if (!c.empty())		return c.back();	else		return typename T::value_type();}

默认模板实参

  • 对于类模板来说,只有当一个模板参数右侧的所有参数都有默认实参时,它才可以有默认实参
  • 对于函数模板来说,默认模板实参的位置不必遵守上述约定,即不要求一个默认模板实参右侧的所有参数都有默认实参

// 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;}
  • 用户调用这个版本的compare时, 可以提供自己的比较操作,但这并不是必需的:
// 编译器无法从函数调用中推断 F 的类型,因此使用默认模板实参// 同时也使用了默认函数实参bool i = compare(0, 42); Sales_data iteml(cin), item2(cin);				bool j = compare(iteml, item2, compareisbn);

模板默认实参与类模板

  • 无论何时使用一个类模板,我们都必须在模板名之后接上尖括号。特别是, 如果一个类模板为其所有模板参数都提供了默认实参,且我们希望使用这些默认实参, 就必须在模板名之后跟一个空尖括号对
template <typename T = int> class Numbers {    // T默认为intpublic:	Numbers(T v = 0): val (v) {    }	// ...private:	T val;};Numbers<long double> lots_of_precision;Numbers<> average_precision; // 使用默认类型

模板参数是另一个类模板

template<typename T1> struct C {       T1 a;};template<template<typename T1> class A, typename T>void func(const T a){       A<T> b{   a};};template<template<typename T1> class A, typename T>struct Foo {       A<T> bar;};int main(void){       Foo<C, int> f;    func<C>(1);    return 0;}

成员模板

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

成员模板不能是虚函数

非模板类的成员模板

  • 下面我们定义一个类, 类似unique_ptr所使用的默认删除器类型。我们的类将包含一个重载的函数调用运算符, 它接受一个指针并对此指针执行delete。与默认删除器不同, 我们的类还将在删除器被执行时打印一条信息。由于希望删除器适用于任何类型, 所以我们将调用运算符定义为一个模板
// 函数对象类, 对给定指针执行 deleteclass 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;};

也可以在类外定义:

template <typename T> void DebugDelete::operator() (T *p) const		{    os << "deleting unique_ptr" << std::endl; delete p; }

我们可以用这个类代替delete:

int* ip = new int;// 在一个临时DebugDelete对象上调用operator()(int*)DebugDelete()(ip);

或者重载unique_ptr的删除器

// 销毁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());

类模板的成员模板

  • 对于类模板, 我们也可以为其定义成员模板。在此情况下, 类和成员各自有自己的、独立的模板参数
template <typename T> class Blob {   	template <typename It> Blob(It b, It e);	template <typename It> Blob(It a);	// ...};
  • 当我们在类模板外定义一个成员模板时, 必须同时为类模板和成员模板提供模板参数列表。类模板的参数列表在前, 后跟成员自己的模板参数列表:
template <typename T> //类的类型参数template <typename It> //构造函数的类型参数	Blob<T>::Blob(It b, It e): 		data(std::make_shared<std::vector<T>>(b, e)) {   }

泛化拷贝构造函数 (智能指针类的设计)

参考 E f f e c t i v e   C + + Effective\ C++ Effective C++

class Top {    ... };class Middle: public Top {    ... };class Bottom: public Middle {    ... };template<typename T>class SmartPtr {   public:	explicit SmartPtr(T* realPtr);	// 智能指针通常以内置指针初始化	...};SmartPtr<Top> pt1 = SmartPtr<Middle>(new Middle);	// 派生类指针向基类指针的隐式转换SmartPtr<Top> pt2 = SmartPtr<Bottom>(new Bottom);SmartPtr<const Top> pct2 = pt1;		// 向 const 指针的隐式转换
  • 我们想让内置指针的隐式转换规则同样适用于智能指针类,但上面的代码却无法通过编译。但同一个 template 的不同实例化对象间并不存在什么固有关系。因此虽然 MiddleTop 的派生类,但 SmartPtr<Middle>SmartPtr<Top> 却完全是两个不同的类
  • 因此为了获得相应的转换能力,我们必须把它们明确的写出来。但这种转换关系是无穷多的,因此这里要用到模板成员函数
template<typename T>class SmartPtr {   public:	template<typename U>	SmartPtr(const SmartPtr<U>& other);	...};
  • 上面的构造函数可根据对象 u 创建对象 t (例如根据 SmartPtr<U> 创建一个 SmartPtr<T>) , 而 uv 的类型是同一个 template 的不同实例化对象,有时我们称之为 泛化 (generalized) 拷贝构造函数。同时该构造函数并没有被声明为 explicit,也就是说它允许隐式转换
  • 但我们只想要合理的隐式转换,类似 SmartPtr<int>SmartPtr<double> 的隐式转换显然是不合理的。因此可以做以下改进:
    • 用内置指针 U* 初始化 T*,这个行为只有当“存在某个隐式转换可将一个U* 指针转为一个 T* 指针”时才能通过编译,而那正是我们想要的
template<typename T>class SmartPtr {   public:	template<typename U>	SmartPtr(const SmartPtr<U>& other) 		: heldPtr(other.get()) {    ... } 			T* get() const {    return heldPtr; }	...private:	T* heldPtr;		// SmartPtr 持有的内置(原始)指针) ;
  • 同时要注意,当我们声明了泛化拷贝构造函数之后,如果 TU 的类型相同,泛化拷贝构造函数就会被实例化为“正常的”拷贝构造函数。但 member templates 并不改变语言规则,而语言规则说,如果程序需要一个 copy 构造函数,你却没有声明它,编译器会为你暗自生成一个。在 class 内声明泛化copy 构造函数(是个 member template) 并不会阻止编译器生成它们自己的 copy 构造函数(一个 non-template) , 所以如果你想要控制 copy 构造的方方面面,你必须同时声明泛化 copy 构造函数和"正常的" copy 构造函数。相同规则也适用于赋值(assignment) 操作

编写类型无关的代码

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

  • 模板中的函数参数是const的引用
    • 通过将函数参数设定为const的引用,我们保证了函数可以用于不能拷贝的类型(如unique_ptr和IO类型)。而且,如果compare用于处理大对象,这种设计策略还能使函数运行得更快
  • 函数体中的条件判断仅使用<比较运算,降低了compare函数对要处理的类型的要求。这些类型必须支持<,但不必同时支持>
    • 如果我们真的关心类型无关和可移植性,可能需要用less ()来定义我们的函数:
// 即使用于指针也正确的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;}

模板编译

  • 当我们调用一个函数时,编译器只需要掌握函数的声明
  • 当我们使用一个类类型的对象时,类定义必须是可用的,但成员函数的定义不必已经出现
  • 因此,我们将类定义和函数声明放在头文件中,而普通函数和类的成员函数的定义放在源文件中
  • 模板则不同:为了生成一个实例化版本,编译器需要掌握函数模板或类模板成员函数的定义。因此,与非模板代码不同,模板的头文件通常既包括声明也包括定义。函数模板和类模板成员函数的定义通常放在头文件中

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

通常,编译器会在三个阶段报告错误。

  • 第一个阶段是编译模板本身时。在这个阶段,编译器可以检查语法错误,例如忘记分号或者变量名拼错等,但也就这么多了
  • 第二个阶段是编译器遇到模板使用时。在此阶段,编译器仍然没有很多可检查的
    • 对于函数模板调用,编译器通常会检查实参数目是否正确、参数类型是否匹配
    • 对于类模板,编译器可以检查用户是否提供了正确数目的模板实参
  • 第三个阶段是模板实例化时,只有这个阶段才能发现类型相关的错误,大多数编译错误在实例化期间报告。依赖于编译器如何管理实例化,这类错误可能在链接时才报告
    • 例如,我们最初的compare函数中的代码就假定实参类型定义了<运算符。而如果传递给compare 的实参未定义<运算符, 则代码错误。但是, 这样的错误直至编译器在该类型上实例化compare 时才会被发现

模板类型别名

  • 类模板的一个实例定义了一个类类型。因此,与任何其他类类型一样, 我们可以为实例化地类模板定义一个类型别名
typedef Blob<string> StrBlob;

  • 由于模板不是一个类型, 我们不能定义一个typedef引用一个模板。即, 无法定义一个typedef引用Blob<T>
  • 但是, 新标准允许我们用using为类模板定义一个类型别名
template<typename T> using twin = pair<T, T>;// 就像使用类模板一样, 当我们使用twin时, 需要指出希望使用哪种特定类型的twintwin<string> authors; // authors是一个pair<string, string>
  • 当我们定义一个模板类型别名时, 也可以固定一个或多个模板参数:
template <typename T> using partNo = pair<T, unsigned>;partNo<string> books; // books是一个pair<string, unsigned>

控制实例化

  • 当模板被使用时才会进行实例化这一特性意味着, 相同的实例可能出现在多个对象文件中。当两个或多个独立编译的源文件使用了相同的模板,并提供了相同的模板参数时, 每个文件中就都会有该模板的一个实例
  • 在大系统中, 在多个文件中实例化相同模板的额外开销可能非常严重。在新标准中,我们可以通过显式实例化(explicit instantiation)来避免这种开销
extern template declaration; 	// 实例化声明template declaration;			// 实例化定义
  • d e c l a r a t i o n declaration declaration 是一个类或函数声明, 其中所有模板参数已被替换为模板实参。例如,
extern template class Blob<string>; //声明template int compare(const int&, const int&); //定义
  • 将一个实例化声明为extern就表示承诺在程序其他位置有该实例化的一个非extern声明(定义)。因此,当编译器遇到extern 模板声明时,它不会在本文件中生成实例化代码;当编译器遇到一个实例化定义(与声明相对)时, 它为其生成代码
  • 对于一个给定的实例化版本, 可能有多个extern声明, 但必须只有一个定义
  • 由于编译器在使用一个模板时自动对其实例化,因此 extern声明必须出现在任何使用此实例化版本的代码之前
// 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]); // 实例化出现在其他位置
// templateBuild.cc// 实例化文件必须为每个在其他文件中声明为extern的类型和函数提供一个(非extern)的定义template int compare(const int&, const int&);template class Blob<string>; // 实例化类模板的所有成员

当我们编译上述应用程序时,必须将templateBuild.oApplication.o链接到一起


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

  • 一个类模板的实例化定义会实例化该模板的所有成员, 包括内联的成员函数。当编译器遇到一个实例化定义时, 它不了解程序使用哪些成员函数。因此, 与处理类模板的普通实例化不同, 编译器会实例化该类的所有成员
  • 因此, 我们用来显式实例化一个类模板的类型, 必须能用于模板的所有成员
    • 例如,我们不能显式实例化vector<NoDefault> (NoDefault 为一个没有默认构造函数的类),原因是显式实例化vector<NoDefault> 时,编译器会实例化接受容器大小参数的构造函数,该构造函数会使用元素类型的默认构造函数来对元素进行值初始化,进而导致编译错误

模板实参推断

  • 对于函数模板,编译器从函数实参来确定模板实参的过程被称为模板实参推断

类型转换 与 模板类型参数

  • 我们在一次调用中传递给函数模板的实参被用来初始化函数的形参。如果一个函数形参的类型使用了模板类型参数, 那么它采用特殊的初始化规则。只有很有限的几种类型转换会自动地应用于这些实参。编译器通常不是对实参进行类型转换,而是生成一个新的模板实例
    • 与往常一样, 顶层const 无论是在形参中还是在实参中,都会被忽略。在其他类型转换中, 能在调用中应用于函数模板的包括如下两项:
    • const转换: 可以将一个非const对象的引用(或指针)传递给一个const的引用(或指针) 形参
    • 数组或函数指针转换:如果函数形参不是引用类型, 则可以对数组或函数类型的实参应用正常的指针转换。一个数组实参可以转换为一个指向其首元素的指针。一个函数实参可以转换为一个该函数类型的指针

其他类型转换,如算术转换、派生类向基类的转换以及用户定义的转换, 都不能应用于函数模板

template <typename T> T fobj(T, T); //实参被拷贝template <typename T> T fref(const T&, const T&); //引用string s1("a value");const string s2("another value");fobj(s1, s2); 		// 调用fobj(string, string); const被忽略fref(s1, s2);		// 调用fref(const string&, const string&)					// 将s1转换为const是允许的int a[10], b[42];fobj(a, b);			// 调用f(int*, int*)fref(a, b);			// 错误:数组类型不匹配					// 如果形参是一个引用, 则数组不会转换为指针。a和b的类型是不匹配的, 因此调用是错误的

使用相同模板参数类型的函数形参

  • 如上所述,由于只允许有限的几种类型转换,因此传递给函数模板形参的实参必须具有相同的类型。如果推断出的类型不匹配, 则调用就是错误的
  • 如果希望允许对函数实参进行正常的类型转换, 我们可以将函数模板定义为两个类型参数
// 实参类型可以不同, 但必须兼容template <typename A, typename B>int flexibleCompare(const A& v1, const B& v2){   	if(v1 < v2) return -1;	if(v2 < v1) return 1;	return 0;}

函数模板显式实参

指定显式模板实参

  • 作为一个允许用户指定使用类型的例子,找们将定义一个名为sum的函数模板。我们希望允许用户指定结果的类型来选择合适的精度:
// 编译器无法推断T1, 它未出现在函数参数列表中template <typename T1, typename T2, typename T3>T1 sum(T2, T3);
  • 在本例中,没有任何函数实参的类型可推断T1的类型。每次调用sum时都必须为T1提供一个显式模板实参(explicit template argument):
// T1是显式指定的,T2和T3是从函数实参类型推断而未的auto val3 = sum<long long>(i, lng); // long long sum(int, long)
  • 显式模板实参按由左至右的顺序与对应的模板参数匹配:第一个模板实参与第一个模板参数匹配, 第二个实参与第二个参数匹配, 依此类推… 只有尾部(最右)参数的显式模板实参才可以忽略, 而且前提是它们可以从函数参数推断出来:
// 糟糕的设计: 用户必须指定所有三个模板参数template <typename T1, typename T2, typename T3>T3 alternative_sum(T2, T1);

正常类型转换应用于显式指定的实参

  • 模板类型参数已经显式指定了的函数实参, 也可以进行正常的类型转换:
long lng;compare(lng, 1024);			// 错误: 摸板参数不匹配compare<long>(lng, 1024);	// 正确: 实例化compare(long, long)compare<int>(lng, 1024);	// 正确: 实例化compare(int, int)auto m = max<double>(1, 2.0);	// 正确

尾置返回类型 与 类型转换

  • 当我们希望用户确定返回类型时,用显式模板实参表示模板函数的返回类型是很有效的。但在其他情况下, 要求显式指定模板实参会给用户增添额外负担
    • 例如, 我们可能希望编写一个函数, 接受表示序列的一对迭代器和返回序列中一个元素的引用:
template <typename It>??? &fcn(It beg, It end){   	// 处理序列	return *beg; //返回序列中一个元素的引用}

我们并不知道返回结果的准确类型, 但知道所需类型是所处理的序列的元素类型。此例中, 可以用decltype(*beg) 来获取此表达式的类型。但是, 在编译器遇到函数的参数列表之前, beg 都是不存在的。为了定义此函数, 我们必须使用尾置返回类型由于尾置返回出现在参数列表之后, 它可以使用函数的参数

// 解引用运算符返回一个左值, 因此通过`decltype` 推断的类型为`beg` 表示的元素的类型的引用template <typename It>auto fcn(It beg, It end) -> decltype(*beg){   	// 处理序列	return *beg; //返回序列中一个元素的引用}

进行类型转换的标准库模板类

#include <type_traits>
  • 有时我们无法直接获得所需要的类型。例如, 我们可能希望编写一个类似fcn 的函数, 但返回一个元素的值而非引用。在此函数中, 我们知道唯一可以使用的操作是迭代器操作, 而所有迭代器操作都不会生成元素, 只能生成元素的引用
  • 为了获得元素类型, 我们可以使用标准库的类型转换模板
    在这里插入图片描述

  • 在本例中,我们可以使用 remove_reference 来获得元素类型。remove_reference模板有一个模板类型参数和一个名为type 的(public) 类型成员。如果我们用一个引用类型实例化remove_reference, 则type 将表示被引用的类型:
remove_reference<decltype(*beg)>::type
  • 因此,给定一个迭代器beg将获得beg引用的元素的类型。同时要注意,为了使用模板参数的成员, 必须用typename
template <typename It>auto fcn2(It beg, It end) ->	typename remove_reference<decltype(*beg)>::type{   	// 处理序列	return *beg; // 返回序列中一个元素的拷贝}

函数指针和实参推断

当我们用一个函数模板初始化一个函数指针或为一个函数指针赋值时, 编译器使用指针的类型来推断模板实参

  • 例如, 假定我们有一个函数指针, 它指向的函数返回int, 接受两个参数, 每个参数都是指向const int的引用。我们可以使用该指针指向compare的一个实例:
template <typename T> int compare(const T&, const T&);// pf1指向实例int compare(const int&, const int&)int (*pf1) (const int&, const int&) = compare;
  • 如果不能从函数指针类型确定模板实参, 则产生错误:
// func的重载版本;每个版本接受一个不同的函数指针类型void func(int(*) (const string&, const string&));void func(int(*) (const int&, const int&));func(compare); //错误: 使用compare的哪个实例?
  • 我们可以通过使用显式模板实参来消除func调用的歧义:
// 正确. 显式指出实例化哪个compare 版本func(compare<int>); //传递compare(const int&, const int&)

模板实参推断和引用

从左值引用函数参数推断类型

  • 当一个函数参数是模板类型参数的一个左值引用时, 绑定规则告诉我们, 只能传递给它一个左值。实参可以是const类型, 也可以不是。如果实参是const的,则T将被推断为const类型:
template <typename T> void fl(T&); //实参必须是一个左值// 对fl 的调用使用实参所引用的类型作为模板参数类型fl(i); 		// i是一个int; 模板参数类型T是intfl(ci); 	// ci是一个const int; 模板参数T是const intfl(5); 		// 错误: 传递给一个& 参数的实参必须是一个左值
  • 如果一个函数参数的类型是constT& , 正常的绑定规则告诉我们可以传递给它任何类型的实参:一个对象(const或非const)、一个临时对象或是一个字面常量值。当函数参数本身是const时,T的类型推断的结果不会是一个const类型。const已经是函数参数类型的一部分; 因此, 它不会也是模板参数类型的一部分:
template <typename T> void f2(const T&); // 可以接受一个右值// f2中的参数是const &; 实参中的const是无关的// 在每个调用中,f2 的函数参数都被推断为const int&f2(i); 		// i是一个int; 模板参数T是intf2(ci); 	// ci是一个const int, 但模板参数T是intf2(5); 		// 一个const &参数可以绑定到一个右值; T是int

从右值引用函数参数推断类型

  • 当一个函数参数是一个右值引用时,正常绑定规则告诉我们可以传递给它一个右值。当我们这样做时, 推断出的T的类型是该右值实参的类型:
template <typename T> void f3(T&&);f3(42); //实参是一个int 类型的右值;模板参数T是int

引用折叠和右值引用参数

  • 假定i是一个int 对象, 我们可能认为像f3(i)这样的调用是不合法的。毕竟,i是一个左值。但是, C++语言在正常绑定规则之外定义了两个例外规则, 允许这种绑定

这两个例外规则是move这种标准库设施正确工作的基础


  1. 当我们将一个左值传递给函数的右值引用参数, 且此右值引用指向模板类型参数(如T&&)时,编译器推断模板类型参数为实参的左值引用类型

因此, 当我们调用f3(i) 时, 编译器推断T 的类型为int&, 而非intT被推断为int&看起来好像意味着f3的函数参数应该是一个类型int&的右值引用。通常,我们不能(直接)定义一个引用的引用。但是,通过类型别名或通过模板类型参数间接定义是可以的


  1. 如果我们间接创建一个引用的引用, 则这些引用形成了 “折叠” 。对于一个给定类型X:
  • X& &X& &&X&& & 都折叠成类型 X&
  • 类型 X&& && 折叠成 X&&

只在右值引用的右值引用会折叠成右值引用


  • 根据以上两个规则,我们可以将任意类型的实参传递给T&&类型的函数参数
f3(i); 		//实参是一个左值;模板参数T是int&f3(ci); 	//实参是一个左值;模板参数T是一个const int&

当一个模板参数T被推断为引用类型时,折叠规则告诉我们函数参数T&&折叠为一个左值引用类型。例如,f3(i)的实例化结果可能像下面这样:

// 无效代码. 只是用于演示目的void f3<int&>(int& &&); 

int& && 会折叠成 int &。因此, 即使f3的函数参数形式是一个右值引用(即,T&&), 此调用也会用一个左值引用类型实例化f3:

void f3<int&>(int&); 

编写接受右值引用参数的模板函数

  • 模板参数可以推断为一个引用类型,这一特性对模板内的代码可能有令人惊讶的影响:
template <typename T> void f3(T&& val){   	T t = val; 		//拷贝还是绑定一个引用?	t = fcn(t); 	//赋值只改变 t 还是既改变 t 又改变 val?	if (val == t) {    /* ... * / } //若T是引用类型, 则一直为true}
  • 当代码中涉及的类型可能是普通(非引用) 类型, 也可能是引用类型时, 编写正确的代码就变得异常困难(虽然remove_reference这样的类型转换类可能会有帮助)

  • 在实际中, 右值引用通常用于: 模板转发其实参模板被重载
    • 使用右值引用的函数模板通常使用如下方式来进行重载
template <typename T> void f(T&&); 		// 绑定到非const右值template <typename T> void f(const T&); // 左值和const右值

理解std::move

  • 标准库move函数是使用右值引用的模板的一个很好的例子

从一个左值static_cast 到一个右值引用是允许的

通常情况下, static_cast只能用于其他合法的类型转换。但是, 这里又有一条针对右值引用的特许规则

  • 可以用static_cast显式地将一个左值转换为一个右值引用

虽然我们可以直接编写这种类型转换代码, 但使用标准库move函数是容易得多的方式。而且, 统一使用std::move使得我们在程序中查找潜在的截断左值的代码变得很容易 (在之后我们会看到,move函数内部也是用static_cast 来完成左值引用到右值引用的转换的)

std::move 是如何定义的

标准库是这样定义move 的:

// 在返回类型和类型转换中也要用到typenametemplate <typename T>typename remove_reference<T>::type&& move(T&& t){   	return static_cast<typename remove_reference<T>::type&&>(t);}

std::move 是如何工作的

如果传递一个右值:std::move(string("bye!"))

  • 推断出的T 的类型为string。经过引用折叠之后, 这个调用实例化move<string>, 即函数
string&& move(string &&t)
  • remove_referencestring 进行实例化,type 成员是stringmove 的返回类型是string&&move 的函数参数t 的类型为string&&。因此, 此调用的结果就是它所接受的右值引用

现在考虑传递一个左值:std::move(s)

  • 推断出的T 的类型为string&。因此, 这个调用实例化move<string&>, 即
string&& move(string &t)
  • 因此, remove_referencestring&进行实例化,type 成员是stringmove 的返回类型仍是string&&,由此实现了将一个右值引用绑定到一个左值

转发

  • 某些函数需要将其一个或多个实参连同类型不变地转发给其他函数。在此情况下,我们需要保持被转发实参的所有性质,包括实参类型是否是const的以及实参是左值还是右值

作为一个例子, 我们将编写一个函数, 它接受一个可调用表达式和两个额外实参。我们的函数将调用给定的可调用对象, 将两个额外参数逆序传递给它:

// flip1是一个不完整的实现: 顶层const和引用丢失了template <typename F, typename T1, typename T2>void flip1(F f, T1 t1, T2 t2){   	f(t2, t1);}

这个函数一般情况下工作得很好,但当我们希望用它调用一个接受引用参数的函数时就会出现问题:

void f(int v1, int &v2) //注意v2是一个引用{   	cout << v1 << " " << ++v2 << endl; // 改变了绑定到v2的实参的值}

但是, 如果我们通过flip1调用f, f所做的改变就不会影响实参:

f(42, i); // f 改变了实参iflip1(f, j, 42); //通过flip1 调用f 不会改变j

问题在于flip1 调用会实例化为

void flip1(void(*fcn) (int, int&), int t1, int t2); 

定义能保持类型信息的函数参数

通过将一个函数参数定义为一个指向模板类型参数的右值引用,我们可以保持其对应实参的所有类型信息:

  • 使用引用参数(无论是左值还是右值)使得我们可以保持const属性,因为在引用类型中的const是底层的
  • 通过引用折叠就可以保待翻转实参的左值/右值属性:
template <typename F, typename T1, typename T2>void flip2(F f, T1 &&t1, T2 &&t2){   	f(t2, t1);}

这个版本的flip2解决了一半问题。它对于接受一个左值引用的函数工作得很好,但不能用于接受右值引用参数的函数。例如:

void g(int &&i, int& j){   	cout << i << " " << j << endl;}

如果我们试图通过flip2调用g, 则参数t2将被传递给g的右值引用参数。即使我们传递一个右值给flip2:

flip2(g, i, 42); //错误: 不能从一个左值实例化int&&

传递给g 的将是flip2中名为t2的参数。函数参数与其他任何变量一样, 都是左值表达式。因此, flip2中对g的调用将传递给g的右值引用参数一个左值

在调用中使用std::forward 保持类型信息

#include <utility>
  • 我们可以使用新标准库设施forward来传递flip2 的参数,它能保持原始实参的类型
  • forward必须通过显式模板实参来调用,返回该显式实参类型的右值引用。即, forward<T>的返回类型是T&&
  • 我们通常使用forward传递那些定义为模板类型参数的右值引用的函数参数。通过其返回类型上的引用折叠,forward可以保持给定实参的左值/右值属性

使用forward, 我们可以再次重写翻转函数:

template<typename F, typename T1, typename T2>void flip(F f, T1 &&t1, T2 &&t2){   	f(std::forward<T2>(t2), std::forward<T1>(t1));}

std::move相同,对std::forward不使用using声明是一个好主意

重载 与 模板

  • 函数模板可以被另一个模板或一个普通非模板函数重载。与往常一样,名字相同的函数必须具有不同数量或类型的参数

如果涉及函数模板,则函数匹配规则会在以下几方面受到影响:

  • 对于一个调用, 其候选函数包括所有模板实参推断成功的函数模板实例
  • 候选的函数模板总是可行的, 因为模板实参推断会排除任何不可行的模板
  • 与往常一样, 可行函数(模板与非模板)按类型转换(如果对此调用需要的话)来排序。当然, 可以用于函数模板调用的类型转换是非常有限的(只限于顶层const、数组或函数指针转换)
  • 与往常一样,如果恰有一个函数提供比任何其他函数都更好的匹配,则选择此函数。但是, 如果有多个函数提供同样好的匹配, 则:
    • 如果同样好的函数中只有一个是非模板函数, 则选择此函数
    • 如果同样好的函数中没有非模板函数, 而有多个函数模板, 且其中一个模板比其他模板更特例化, 则选择此模板
    • 否则, 此调用有歧义

编写重载模板

作为一个例子, 我们将构造一组调试函数debug_rep, 每个函数都返回一个给定对象的string表示。我们首先编写此函数的最通用版本, 将它定义为一个模板, 接受一个const对象的引用:

// 打印任何我们不能处理的类型template <typename T> string debug_rep(const T &t){   	ostringstream ret; 	ret << t;		return ret.str(); 	}

接下来, 我们将定义打印指针的debug_rep版本:

template <typename T> string debug_rep(T *p){   	ostringstream ret;	ret << "pointer: " << p;	if (p)		// 打印指针本身的值		ret << " " << debug_rep(*p); //打印p 指向的值	else		ret << " null pointer"; 	//或指出p为空	return ret.str(); //返回ret绑定的string的一个副本}

注意此函数不能用于打印字符指针, 因为IO库为char*值定义了一个<<版本,表示一个空字符结尾的字符数组, 并打印数组的内容而非地址值

多个可行模板

如果我们用一个指针调用debug_rep:

cout << debug_rep(&s) << endl;

两个函数都生成可行的实例:

  • debug rep(const string*&), 由第一个版本的debug_rep 实例化而来,T被绑定到string*
  • debug_rep(string*), 由第二个版本的debug_rep 实例化而来, T 被绑定到string

第二个版本的debug_rep 的实例是此调用的精确匹配。第一个版本的实例需要进行普通指针到const 指针的转换。因此应该选择第二个模板


作为另外一个例子,考虑下面的调用:

const string *sp = &s;cout << debug_rep(sp) << endl;

此例中的两个模板都是可行的,而且两个都是精确匹配:

  • debug_rep (const string*&), 由第一个版本的debug_rep 实例化而来, T被绑定到string*
  • debug_rep(const string*), 由第二个版本的debug_rep 实例化而来,T被绑定到const string

在此情况下,正常函数匹配规则无法区分这两个函数。但是,根据重载函数模板的特殊规则,此调用被解析为debug_rep(T*), 即,更特例化的版本


作为下一个例子,我们将定义一个普通非模板版本的debug_rep 来打印双引号包围的string:

string debug_rep(const string &s){   	return '"' + s + '"' ;}

现在, 当我们对一个string 调用debug_rep 时, 编译器会选择非模板版本

重载模板和类型转换

还有一种情况我们到目前为止尚未讨论: C风格字符串指针字符串字面常量。现在有了一个接受stringdebug_rep 版本,我们可能期望一个传递字符串的调用会匹配这个版本。但是, 考虑这个调用:

cout << debug_rep("hi world!") << endl; //调用debug_rep(T*)

本例中所有三个debug_rep 版本都是可行的:

  • debug_rep(const T&), T 被绑定到char[10]
  • debug_rep(T*), T 被绑定到const char
  • debug_rep(const string&), 要求从const char*string 的类型转换

对给定实参来说, 两个模板都提供精确匹配。第二个模板需要进行一次数组到指针的转换, 而对函数匹配来说, 这种转换被认为是精确匹配。非模板版本是可行的, 但需要进行一次用户定义的类型转换, 因此它没有精确匹配, 所以两个模板成为可能调用的函数。与之前一样, T* 版本更加特例化, 编译器会选择它

如果我们希望将字符指针按string 处理, 可以定义另外两个非模板重载版本:

// 将字符指针转换为string, 并调用string 版本的debug_regstring debug_rep(char *p){   	return debug_rep(string(p));}string debug_rep(const char *p){   	return debug_rep(string(p));}

缺少声明可能导致程序行为异常

值得注意的是, 为了使char*版本的debug_rep正确工作, 在定义此版本时,debug_rep(const string&)的声明必须在作用域中。否则, 就可能调用错误的debug_rep版本:

template <typename T> string debug_rep(const T &t);template <typename T> string debug_rep(T *p);// 为了使debug_rep(char*)的定义正确工作, 下面的声明必须在作用域中string debug_rep(const string &);string debug_rep(char *p){   	// 如果接受一个const string&的版本的声明不在作用域中,	// 返回语句将调用debug_rep(const T&)的T实例化为string 的版本	return debug_rep(string(p));}

通常, 如果使用了一个忘记声明的函数, 代码将编译失败。但对重载函数模板的函数 则不是这样。如果编译器可以从模板实例化出与调用匹配的版本, 则缺少的声明就不重要了。在本例中, 如果忘记了声明接受string参数的debug_rep版本, 编译器会默默地实例化接受const T&的模板版本

因此,在定义任何函数之前,记得声明所有重载的函数版本。这样就不必担心编译器由于未遇到你希望调用的函数而实例化一个并非你所需的版本

可变参数模板

  • 一个可变参数模板(variadic template)就是一个接受可变数目参数的模板函数或模板类

我们可以使用一个initializer_list来定义一个可接受可变数目实参的函数。但是,所有实参必须具有相同的类型(或它们的类型可以转换为同一个公共类型)。当我们既不知道想要处理的实参的数目也不知道它们的类型时,可变参数函数是很有用的

  • 可变数目的参数被称为参数包(parameter packet)。存在两种参数包:
    • 模板参数包(template parameter packet), 表示零个或多个模板参数;
    • 函数参数包(function parameter packet), 表示零个或多个函数参数

  • 我们用一个省略号来指出一个模板参数或函数参数表示一个包
    • 在一个模板参数列表中, classtypename后面跟一个省略号指出接下来的参数表示模板参数包
    • 在函数参数列表中,如果一个参数的类型是一个模板参数包, 则此参数也是一个函数参数包

// `foo`是一个可变参数函数模板// Args是一个模板参数包;表示零个或多个模板类型参数 // rest是一个函数参数包;表示零个或多个函数参数template <typename T, typename ... Args>void foo(const T &t, const Args& ... rest); // ... 表示对模板参数包进行包扩展
  • 与往常一样, 编译器从函数的实参推断模板参数类型。对于一个可变参数模板, 编译器还会推断包中参数的数目。例如:
int i = 0; double d = 3.14; string s = "how now brown cow";foo(i, s, 42, d); //包中有三个参数foo(s, 42, "hi"); //包中有两个参数foo(d, s); // 包中有一个参数foo("hi"); // 空包

编译器会为foo实例化出四个不同的版本:

void foo(const int&, const string&, const int&, const double&);void foo(const string&, const int&, const char[3]&);void foo(const double&, const string&);void foo(const char[3]&);

sizeof... 运算符

  • sizeof...运算符返回一个常量表达式, 表示包中有多少元素,而且不会对其实参求值
template<typename ... Args> void g(Args ... args) {   	cout << sizeof...(Args) << endl; //类型参数的数目	cout << sizeof...(args) << endl; //函数参数的数目}

编写可变参数函数模板

  • 可变参数函数通常是递归的第一步调用处理包中的第一个实参, 然后用剩余实参调用自身

作为一个例子, 我们将定义一个print函数在一个给定流上打印给定实参列表的内容:

// 用来终止递归并打印最后一个元素的函数// 此函数必须在可变参数版本的 print 定义之前声明template<typename T>ostream &print(ostream &os, const T &t){   	return os << t; //包中最后一个元素之后不打印分隔符}// 包中除了最后一个元素之外的其他元素都会调用这个版本的printtemplate <typename T, typename ... Args>ostream &print(ostream &os, const T &t, const Args& ... rest){   	os << t << ", ";  // 打印第一个实参	return print(os, rest...); // 递归调用, 打印其他实参}

这段程序的关键部分是可变参数函数中对print的调用:

return print(os, rest ...); //递归调用, 打印其他实参

我们的可变参数版本的print函数接受三个参数: 一个ostream&, 一个const T&和一个参数包。而此调用只传递了两个实参。其结果是rest中的第一个实参被绑定到t, 剩余实参形成下一个print调用的参数包。因此, 递归会执行如下:

在这里插入图片描述

对于最后一次递归调用print(cout, 42), 两个print版本都是可行的。两个函数提供同样好的匹配。但是,非可变参数模板比可变参数模板更特例化,因此编译器选择非可变参数版本

注意,如果非可变参数版本定义在了可变参数版本之后,那么可变参数版本将陷入无限递归,即不断地调用自身进行包扩展。当扩展到print(cout)时,因无法与任何模板匹配从而产生编译错误

包扩展

  • 扩展一个包就是将它分解为构成的元素,对每个元素应用模式(pattern),获得扩展后的列表
  • 通过在模式右边放一个省略号(... )来触发扩展操作

例如, 我们的print函数包含两个扩展:

template <typename T, typename ... Args>ostream & print(ostream &os, const T &t, const Args&... rest) //扩展Args{   	os << t << ", ";	return print(os, rest ...); // 扩展rest}
  • 第一个对Args的扩展操作为扩展模板参数包,编译器将模式const Arg &应用到模板参数包Args中的每个元素。因此,此模式的扩展结果是一个逗号分隔的零个或多个类型的列表,每个类型都形如const type&
  • 第二个扩展发生在对print 的(递归)调用中。在此情况下,模式是函数参数包的名字(即rest)。此模式扩展出一个由包中元素组成的、逗号分隔的列表

C++还允许更复杂的扩展模式。例如,我们可以编写第二个可变参数函数,对其每个实参调用debug_rep,然后调用print打印结果string:

template <typename ... Args>ostream &errorMsg(ostream &os, const Args& ... rest){   	// print(os, debug_rep(al), debug_rep(a2), ... , debug_rep(an)	return print(os, debug_rep(rest) ...);}

与之相对,下面的模式会编译失败

// 将包传递给debug_rep; print (os, debug_rep (al, a2, ... , an))print(os, debug_rep(rest ...)); // 错误:此调用无匹配函数

转发参数包

  • 在新标准下,我们可以组合使用可变参数模板与forward机制来编写函数,实现将其实参不变地传递给其他函数

例如:

// StrVec 为一个自定义的 vector<string>class StrVec {   public:	// `emplace_back`成员用其实参在容器管理的内存空间中直接构造一个元素	// 由于我们希望能使用`string` 的移动构造函数, 因此还需要保持传递给`emplace_back`的实参的所有类型信息	template <typename ... Args> void emplace_back(Args&& ...);	// ...};

如我们所见,保持类型信息是一个两阶段的过程

  • 首先, 为了保待实参中的类型信息,必须将emplace_back函数参数定义为模板类型参数的右值引用
  • 其次,当emplace_back将这些实参传递给construct 时,我们必须使用forward来保持实参的原始类型:
template <typename ... Args>inlinevoid StrVec::emplace_back(Args&& ... args){   	chk_n_alloc(); //如果需要的话重新分配StrVec内存空间	alloc.construct(first_free++, std::forward<Args>(args) ...);}
  • construct调用中的扩展既扩展了模板参数包Args, 也扩展了函数参数包args
// 通过在此调用中使用`forward`, 我们保证如果用一个右值调用`emplace_back`, // 则`construct`也会得到一个右值svec.emplace_back(s1 + s2); //使用移动构造函数

模板特例化

编写单一模板, 使之对任何可能的模板实参都是最适合的, 都能实例化, 这并不总是能办到。在某些情况下, 通用模板的定义对特定类型是不适合的: 通用定义可能编译失败或做得不正确。其他时候, 我们也可以利用某些特定知识来编写更高效的代码, 而不是从通用模板实例化

  • 当我们不能(或不希望)使用模板版本时, 可以定义类或函数模板的一个特例化版本。一个特例化版本(template specialization)就是模板的一个独立的定义,在其中一个或多个模板参数被指定为特定的类型

  • 例如,我们之前定义的通用函数模板compare就不适合字符指针的情况。我们希望compare 通过调用strcmp比较两个字符指针而非比较指针值。为此,我们可以编写一个重载模板来解决这个问题:
    • 但是,只有当我们传递给compare 一个字符串字面常量或者一个数组时,编译器才会调用接受两个非类型模板参数的版本。如果我们传递给它字符指针,就会调用第一个版本
// 第一个版本;可以比较任意两个类型template <typename T> int compare(const T&, const T&);// 第二个版本处理字符串字面常量template <size_t N, size_t M>int compare(const char (&)[N], const char (&)[M]);

定义函数模板特例化

  • 当我们特例化一个函数模板时,必须为原模板中的每个模板参数都提供实参,且函数参数类型必须与一个先前声明的模板中对应的类型匹配
  • 为了指出我们正在实例化一个模板,应使用关键字template后跟一个空尖括号对(<>)。空尖括号指出我们将为原模板的所有模板参数提供实参

  • 我们可以为第一个版本的compare定义一个模板特例化版本来处理字符指针的情况
// compare的特殊版本,处理宇符数组的指针// 编译器推断出特例化的模板参数类型template <>int compare(const char* const &p1, const char* const &p2){   	return strcmp(p1, p2);}// 显式给出特例化的模板参数类型template <>int compare<char * const>(const char* const &p1, const char* const &p2){   	return strcmp(p1, p2);}

  • 为了特例化一个模板,原模板的声明必须在作用域中 (特例化模板必须和原模板声明在同一个命名空间内)。而且,在任何使用模板实例的代码之前,特例化版本的声明也必须在作用域中。如果丢失了一个特例化版本的声明,编译器通常可以用原模板生成代码。这种错误很难查找
  • 因此,模板及其特例化版本应该声明在同一个头文件中。所有同名模板的声明应该放在前面,然后是这些模板的特例化版本

函数重载 与 模板特例化

  • 当定义函数模板的特例化版本时,我们本质上接管了编译器的工作。即,我们为原模板的一个特殊实例提供了定义
  • 一个特例化版本本质上是一个实例,而非函数名的一个重载版本。因此,特例化不影响函数匹配

我们将一个特殊的函数定义为一个特例化版本还是一个独立的非模板函数,会影响到函数匹配。例如,我们已经定义了两个版本的compare函数模板, 一个接受数组引用参数,另一个接受const T&。我们还定义了一个特例化版本来处理字符指针,这对函数匹配没有影响。当我们对字符串字面常量调用compare

compare("hi", "mom")

对此调用,两个函数模板都是可行的,且提供同样好的(即精确的) 匹配。但是,接受字符数组参数的版本更特例化,因此编译器会选择它

如果我们将接受字符指针的compare版本定义为一个普通的非模板函数,此调用的解析就会不同。在此情况下,将会有三个可行的函数:两个模板和非模板的字符指针版本。所有三个函数都提供同样好的匹配。当一个非模板函数提供与函数模板同样好的匹配时,编译器会选择非模板版本

类模板特例化

  • 除了特例化函数模板,我们还可以特例化类模板
  • 与函数模板不同, 类模板的特例化不必为所有模板参数提供实参
  • 一个类模板的部分特例化(partial specialization)本身是一个模板, 使用它时用户还必须为那些在特例化版本中未指定的模板参数提供实参
// 通用模板类template<class T1, class T2> class A {   ...}; // 部分特化的模板类: 第2个类型参数指定为int  template<class T1> class A<T1, int> {   ...};// 若指定所有类型,则<>内将为空template<> class A<int, int> {    ... };

  • 部分特例化版本的模板参数列表是原始模板的参数列表的一个子集或者是一个特例化版本
    • 例如,标准库remove_reference类型。该模板是通过一系列的特例化版本来完成其功能的。在本例中, 特例化版本的模板参数的数目与原始模板相同, 但是类型不同。两个特例化版本分别用于左值引用和右值引用类型:
// 原始的、最通用的版本,可以用任意类型实例化template <class T> struct remove_reference {   	typedef T type;}// 部分特例化版本, 将用于左值引用和右值引用template <class T> struct remove_reference<T&> // 左值引用	{    typedef T type; };	template <class T> struct remove_reference<T&&>  // 右值引用	{    typedef T type; };
int i;// decltype(42)为int, 使用原始模板remove_reference<decltype(42)>::type a// decltype(i)为int&, 使用第一个(T&)部分特例化版本remove_reference<decltype(i)>::type b;// decltype(std::move (i))为int&&, 使用第二个(即T&&)部分特例化版本remove_reference<decltype(std::move(i))>::type c;// 三个变量a、b和c均为int类型

特例化成员而不是类

  • 我们可以只特例化特定成员函数而不是特例化整个模板

template <typename T> struct Foo {   	Foo(const T &t = T()): mem(t) {   }	void Bar() {    /* ... */ }	T mem;	// ...};// 只特例化 Foo<int>::Bar()template<> // 我们正在特例化一个模板void Foo<int>::Bar() // 我们正在特例化 Foo<int> 的成员 Bar{   	...}

学习处理模板化基类内的名称

参考 E f f e c t i v e   C + + Effective\ C++ Effective C++

  • 假设我们需要撰写一个程序,它能够传送信息到若干不同的公司去。信息要不译成密码,要不就是未加工的文字。如果编译期间我们有足够信息来决定哪一个信息传至哪一家公司,就可以采用基于模板的解法:
class CompanyA {   public:	void sendCleartext(const std::string& msg);	void sendEncrypted(const std::string& msg);	...};class CompanyB {   public:	void sendCleartext(const std::string& msg);	void sendEncrypted(const std::string& msg);	...};...		// 针对其他公司设计的 classesclass MsgInfo {    ... };	// 这个 class 用来保存信息,以备将来产生信息template<typename Company>class MsgSender {   public:	void sendClear(const Msginfo& info)	{   		std::string msg;		在这儿,根据 info 产生信息;		Company c;		c.sendCleartext(msg);	}	void sendSecret(const Msginfo& info) // 类似 sendClear; 唯一不同是 调用 c.sendEncrypted(msg);	{    ... }};
  • 现在假设我们有时候想要在每次送出信息时记录某些信息。可以使用派生类解决:
template<typename Company>class LoggingMsgSender: public MsgSender<Company> {   public:	void sendClearMsg(const Msglnfo& info)	{   "传送前" 的信息写至log;		sendClear(info); 	// 想要调用基类成员函数,但无法通过编译"传送后" 的信息写至log;	}	...};
  • 上面的代码无法通过编译,原因在于当编译器遭遇类模板 LoggingMsgSender 定义时,并不知道它继承什么样的类。当然它继承的是 MsgSender<Company>, 但其中的 Company 是个模板参数,不到模板实例化就无法确切知道它是什么。而如果不知道 Company 是什么,就无法知道 class MsgSender<Company> 是否有个 sendClear 函数
    • 例如,类模板 MsgSender 可能有一个特例化版本根本就没有 sendClear 函数。那就是为什么 C++ 拒绝这个调用的原因:它知道基类模板有可能被特例化,而那个特例化版本可能不提供和一般性模板相同的接口。因此它往往拒绝在模板化基类内寻找继承而来的名称(本例的 SendClear)

  • 有三种方法可以解决该问题:
    • (1) 在基类函数调用动作之前加上 this->
    • (2) 使用 using 声明式
    • (3) 明确指出被调用的函数位于基类内. 但这往往是最不让人满意的一个解法,因为如果被调用的是虚函数,上述的明确资格修饰 (explicit qualification) 会关闭动态绑定行为
  • 从名称可视点 (visibility point) 的角度出发,上述每一个解法做的事情都相同:对编译器承诺 "base class template 的任何特化版本都将支持其一般(泛化)版本所提供的接口”
template<typename Company>class LoggingMsgSender: public MsgSender<Company> {   public:	void sendClearMsg(const Msglnfo& info)	{   "传送前" 的信息写至log;		this->sendClear(info); 		// 在基类函数调用动作之前加上 `this->`"传送后" 的信息写至log;	}	...};
template<typename Company>class LoggingMsgSender: public MsgSender<Company> {   public:	using MsgSender<Company>::sendClear;	// 告诉编译器,请它假设 sendClear 位于基类内	void sendClearMsg(const Msglnfo& info)	{   "传送前" 的信息写至log;		sendClear(info);"传送后" 的信息写至log;	}	...};
template<typename Company>class LoggingMsgSender: public MsgSender<Company> {   public:	void sendClearMsg(const Msglnfo& info)	{   "传送前" 的信息写至log;			MsgSender<Company>::sendClear(info); 	// 假设 sendClear 将被继承下来"传送后" 的信息写至log;	}	...};
上一篇:算法初步 (排序, 散列, 贪心, 二分, two pointers)
下一篇:类设计者的工具(五):面向对象程序设计示例

发表评论

最新留言

路过按个爪印,很不错,赞一个!
[***.219.124.196]2025年03月26日 23时24分20秒