类设计者的工具(六):模板与泛型编程

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

目录

OOP 和 泛型编程都能处理在编写程序时不知道类型的情况,不同之处在于:OOP能处理类型在程序运行之前都未知的情况,而泛型编程中,在编译时就能获知类型了。之前介绍的容器、迭代器、泛型算法都是泛型编程的例子

模板是C++中泛型编程的基础。一个模板就是一个创建类或函数的蓝图。当用vector这样的泛型类型时, 我们提供足够的信息, 将蓝图转换为特定的类或函数。这种转换发生在编译时

标准库算法都是函数模板, 标准库容鉴都是类模板

定义模板

假定我们希望编写一个compare函数来比较两个值, 我们可能想要定义多个重载函数, 每个函数比较一种给定类型的值。而这些函数几乎是相同的, 唯一的差异是参数的类型, 函数体则完全一样。如果对每种希望比较的类型都不得不重复定义完全一样的函数体, 是非常烦琐且容易出错的。更麻烦的是, 在编写程序的时候, 我们就要确定可能要比较的所有类型。如果希望能在用户提供的类型上使用此函数, 这种策略就失效了

函数模板

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

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的使用情况来确定。

实例化函数模板

当我们调用一个函数模板时,编译器(通常)用函数实参来为我们推断模板实参。例如,在下面的调用中:

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

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

编译器用推断出的模板参数来为我们实例化 (instantiate) 一个特定版本的函数。当编译器实例化一个模板时, 它使用实际的模板实参代替对应的模板参数来创建出模板的一个新“ 实例",这些编译器生成的版本通常被称为模板的实例(instantiation)。例如:

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

模板类型参数

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

类型参数前必须使用关键字classtypename, 这两个关键字的含义相同, 可以互换使用。一个模板参数列表中也可以同时使用这两个关键字:

template <typename T, class U> calc(const T&, const U&);

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

非类型模板参数

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

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

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

template<unsigned N, unsigned M>
int compare(const char (&pl)[N], const char (&p2)[M])
{
    
    
	return strcmp(p1, p2);
}

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

compare("hi", "mom");

编译器会使用字面常量的大小来代替NM, 从而实例化模板

一个非类型参数可以是一个整型, 或者是一个指向对象或函数类型的指针(左值)引用绑定到非类型整型参数的实参必须是一个常量表达式。绑定到指针或引用非类型参数的实参必须具有静态的生存期。指针参数也可以用nullptr或一个值为0 的常量表达式来实例化

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&);

编写类型无关的代码

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

  • 模板中的函数参数是const的引用

通过将函数参数设定为const的引用,我们保证了函数可以用于不能拷贝的类型(如unique_ptr和IO类型)。而且,如果compare用于处理大对象,这种设计策略还能使函数运行得更快

  • 函数体中的条件判断仅使用<比较运算

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

if (v1 < v2) return -1;
if (v1 > v2) return 1;
return 0;

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

实际上,如果我们真的关心类型无关和可移植件,可能需要用less (标准库提供的函数对象模板类,用于比较不同类型的元素)来定义我们的函数:

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

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

模板编译

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

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

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

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

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

  • 第一个阶段是编译模板本身时。在这个阶段,编译器通常不会发现很多错误。编译器可以检查语法错误,例如忘记分号或者变量名拼错等,但也就这么多了
  • 第二个阶段是编译器遇到模板使用时。在此阶段,编译器仍然没有很多可检查的。对于函数模板调用,编译器通常会检查实参数目是否正确、参数类型是否匹配。对于类模板,编译器可以检查用户是否提供了正确数目的模板实参,但也仅限于此了
  • 第三个阶段是模板实例化时,只有这个阶段才能发现类型相关的错误。依赖于编译器如何管理实例化,这类错误可能在链接时才报告

当我们编写模板时,代码不能是针对特定类型的,但模板代码通常对其所使用的类型有一些假设。例如, 我们最初的compare函数中的代码就假定实参类型定义了<运算符。而如果传递给compare 的实参未定义<运算符, 则代码错误。但是, 这样的错误直至编译器在该类型上实例化compare 时才会被发现

类模板

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

定义类模板

作为一个例子, 我们将实现模板类Blob。我们的模板会提供对元素的共享(且核查过的)访问能力

类似函数模板, 类模板以关键字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;
	// 若data[i] 无效, 则抛出msg
	void check(size_type i, const std::string &msg) const;
};

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

我们的Blob模板有一个名为T的模板类型参数, 用来表示Blob保存的元素的类型
除了模板参数列表和使用T代替string之外, 此类模板的定义与之前定义的类版本是一样的

实例化类模板

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

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() const
for (size_t i = 0; i != squares.size(); ++i)
{
    
    
	squares[i] = i * i; // 实例化Blob<int>::operator[] (size_t)
}

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

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

// 若试图访问一个不存在的元素,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>&);
	// 其他成员定义, 与之前相同
};

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

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

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

// 前置声明, 在将模板的一个特定实例声明为友元时要用到
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这样的类

模板类型别名

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

typedef Blob<string> StrBlob;

由于模板不是一个类型, 我们不能定义一个typedef引用一个模板。即, 无法定义一个typedef引用Blob<T>

但是, 新标准允许我们用using为类模板定义一个类型别名

template<typename T> using twin = pair<T, T>;

// 就像使用类模板一样, 当我们使用twin时, 需要指出希望使用哪种特定类型的twin
twin<string> authors; // authors是一个pair<string, string>

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

template <typename T> using partNo = pair<T, unsigned>;
partNo<string> books; // books是一个pair<string, unsigned>

类模板的static 成员

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

template <typename T> class Foo {
    
    
public:
	static std::size_t count() {
    
     return ctr; }
	// 其他接口成员
private:
	static std::size_t ctr;
	// 其他实现成员
};

在这段代码中, Foo是一个类模板, 它有一个名为countpublic static成员函数和一个名为ctrprivate static数据成员。每个Foo的实例都有其自己的static成员实例。 即, 对任意给定类型X, 都有一个Foo<X>::ctr和一个Foo<X>::count成员

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

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

与非模板类的静态成员相同, 我们可以通过类类型对象来访问一个类模板的static成员, 也可以使用作用域运算符直接访问成员

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成员函数只有在使用时.才会实例化

模板参数

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

模板参数与作用域

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

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

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

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

模板声明

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

template <typename T> int compare(const T&, const T&);
template <typename T> class Blob;

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

一个特定文件所需要的所有模板的声明通常一起放置在文件开始位置,出现于任何使用这些模板的代码之前, 原因我们将在16.3节(第617页)中解释

使用类的类型成员

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

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

T::size_type * p;

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

默认情况下,C++语言假定通过作用域运算符访问的名字不是类型。因此,如果我们希望使用一个模板类型参数的类型成员,就必须显式告诉编译器该名字是一个类型。我们通过使用关键字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函数对象模板:

// 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时, 可以提供自己的比较操作.但这并不是必需的:

bool i = compare(0, 42); //使用less; i为-1
// 结果依赖于iteml和item2中的isbn
Sales_data iteml(cin), item2(cin);				// 使用默认函数实参
bool j = compare(iteml, item2, compareisbn);

与往常一样, 模板参数的类型从它们对应的函数实参推断而来。在第二个调用中, T的类型被推断为Salesdata,F被推断为compareisbn的类型

模板默认实参与类模板

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

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; // 使用默认类型

成员模板

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

成员模板不能是虚函数

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

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

// 函数对象类, 对给定指针执行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;
};

我们可以用这个类代替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 T> //类的类型参数
template <typename It> //构造函数的类型参数
	Blob<T>::Blob(It b, It e): 
		data(std::make_shared<std::vector<T>>(b, e)) {
    
    }

控制实例化

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

在大系统中, 在多个文件中实例化相同模板的额外开销可能非常严重。在新标准中,我们可以通过显式实例化(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> sal, 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.o将会包含compareint实例化版本的定义和Blob<string>类的定义。当我们编译此应用程序时,必须将templateBuild.oApplication.o链接到一起


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

例如,我们不能显式实例化vector<NoDefault> (NoDefault 为一个没有默认构造函数的类),原因是显式实例化vector<NoDefault> 时,编译器会实例化接受容器大小参数的构造函数,该构造函数会使用元素类型的默认构造函数来对元素进行值初始化,进而导致编译错误

效率与灵活性

对模板设计者所面对的设计选择,标准库智能指针类型给出了一个很好的展示:

  • shared_ptrunique_ptr之间的一个差异是它们允许用户重载默认删除器的方式。我们可以很容易地重载一个shared_ptr的删除器,只要在创建或reset指针时传递给它一个可调用对象即可。与之相反,删除器的类型是一个unique_ptr 对象的类型的一部分。用户必须在定义unique_ptr时以显式模板实参的形式提供删除器的类型

这一实现策略上的差异可能对性能有重要影响

在运行时绑定删除器

  • 可以推断出,shared_ptr中删除器必须保存为一个指针或一个封装了指针的类(如function
  • 同时,我们可以确定shared_ptr不是将删除器直接保存为一个成员,因为删除器的类型直到运行时才会知道。实际上,在一个shared_ptr 的生存期中, 我们可以随时改变其删除器的类型。我们可以使用一种类型的删除器构造一个shared_ptr, 随后使用reset赋予此shared_ptr另一种类型的删除器。通常,类成员的类型在运行时是不能改变的。因此,不能直接保存删除器

为了考察删除器是如何正确工作的, 让我们假定shared_ptr将它管理的指针保存在一个成员p中, 且删除器是通过一个名为del 的成员来访问的。则shared_ptr 的析构函数必须包含类似下面这样的语句:

// del的值只有在运行时才知道;通过一个指针来调用它
del ? del(p) : delete p; // del(p)需要运行时跳转到del的地址

由于删除器是间接保存的,调用del(p)需要一次运行时的跳转操作,转到del中保存的地址来执行对应的代码

在编译时绑定删除器

现在, 让我们来考察unique_ptr可能的工作方式。在这个类中,删除器的类型是类类型的一部分。即, unique_ptr有两个模板参数,一个表示它所管理的指针,另一个表示删除器的类型。删除器可以直接保存在unique_ptr 对象中

unique_ptr的析构函数与shared_ptr的析构函数类似, 也是对其保存的指针调用用户提供的删除器或执行delete:

// del在编译时绑定;直接调用实例化的删除器
del(p); //无运行时额外开销

通过在编译时绑定删除器,unique_ptr避免了间接调用删除器的运行时开销。通过在运行时绑定删除器,shared_ptr使用户重载删除器更为方便

练习 16.28

模板实参推断

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

类型转换与模板类型参数

我们在一次调用中传递给函数模板的实参被用来初始化函数的形参。如果一个函数形参的类型使用了模板类型参数, 那么它采用特殊的初始化规则。只有很有限的几种类型转换会自动地应用于这些实参。编译器通常不是对实参进行类型转换,而是生成一个新的模板实例

与往常一样, 顶层const 无论是在形参中还是在实参中,都会被忽略。在其他类型转换中, 能在调用中应用于函数模板的包括如下两项。

  • const转换: 可以将一个非const对象的引用(或指针)传递给一个const的引用(或指针) 形参
  • 数组或函数指针转换:如果函数形参不是引用类型, 则可以对数组或函数类型的实参应用正常的指针转换。一个数组实参可以转换为一个指向其首元素的指针。一个函数实参可以转换为一个该函数类型的指针

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

template <typename T> T fobj(T, T); //实参被拷贝
template <typename T> T fref(const T&, const T&); //引用
string sl("a value");
const string s2("another value");

fobj(sl, s2); 		// 调用fobj(string, string); const被忽略
fref(sl, s2);		// 调用fref(const string&, const string&)
					// 将sl转换为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, canst 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)。显式模板实参在尖括号中给出,位于函数名之后,实参列表之前:

// Tl是显式指定的,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)

因此,可以利用显式指定的实参来使用标准库函数max比较一个int和一个double

auto m = max<double>(1, 2.0);

尾置返回类型与类型转换

当我们希望用户确定返回类型时,用显式模板实参表示模板函数的返回类型是很有效的。但在其他情况下, 要求显式指定模板实参会给用户增添额外负担。例如, 我们可能希望编写一个函数, 接受表示序列的一对迭代器和返回序列中一个元素的引用:

template <typename it>
??? &fcn(It beg, It end)
{
    
    
	// 处理序列
	return *beg; //返回序列中一个元素的引用
}

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

template <typename It>
auto fcn(It beg, It end) -> decltype(*beg)
{
    
    
	// 处理序列
	return *beg; //返回序列中一个元素的引用
}

解引用运算符返回一个左值, 因此通过decltype 推断的类型为beg 表示的元素的类型的引用

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

有时我们无法直接获得所需要的类型。例如, 我们可能希望编写一个类似fcn 的函数, 但返回一个元素的值而非引用。在此函数中, 我们知道唯一可以使用的操作是迭代器操作, 而所有迭代器操作都不会生成元素, 只能生成元素的引用

为了获得元素类型, 我们可以使用标准库的类型转换(type transformation) 模板。这些模板定义在头文件type_traits

在这里插入图片描述

在本例中,我们可以使用remove_reference 来获得元素类型。remove_reference模板有一个模板类型参数和一个名为type 的(public) 类型成员。如果我们用一个引用类型实例化remove_reference, 则type 将表示被引用的类型。因此,给定一个迭代器beg:

remove_reference<decltype(*beg)>::type

将获得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是int
fl(ci); 	// ci是一个const int; 模板参数T是const int
fl(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是int
f2(ci); 	// ci是一个const int, 但模板参数T是int
f2(5); 		// 一个const &参数可以绑定到一个右值; T是int

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

当一个函数参数是一个右值引用时,正常绑定规则告诉我们可以传递给它一个右值。当我们这样做时, 类型推断过程类似普通左值引用函数参数的推断过程。推断出的T的类型是该右值实参的类型:

template <typename T> void f3(T&&);
f3(42); //实参是一个int 类型的右值;模板参数T是int

引用折叠和右值引用参数

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


第一个例外规则影响右值引用参数的推断如何进行。当我们将一个左值传递给函数的右值引用参数, 且此右值引用指向模板类型参数(如T&&)时,编译器推断模板类型参数为实参的左值引用类型。因此, 当我们调用f3(i) 时, 编译器推断T 的类型为int&, 而非int

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


在这种情况下,我们可以使用第二个例外绑定规则:如果我们间接创建一个引用的引用, 则这些引用形成了 “折叠” 。在所有情况下(除了一个例外),引用会折叠成一个普通的左值引用类型。在新标准中,折叠规则扩展到右值引用。只在一种特殊情况下引用会折叠成右值引用:右值引用的右值引用。即,对于一个给定类型X:

  • X& &X& &&X&& & 都折叠成类型 X&
  • 类型 X&& && 折叠成 X&&

如果将引用折叠规则和右值引用的特殊类型推断规则组合在一起,则意味着我们可以对一个左值调用f3。当我们将一个左值传递给f3的(右值引用) 函数参数时,编译器推断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&); 

这两个规则导致了两个重要结果:

  • 如果一个函数参数是一个指向模板类型参数的右值引用(如,T&&), 则它可以被绑定到一个左值;且
  • 如果实参是一个左值,则推断出的模板实参类型将是一个左值引用,且函数参数将被实例化为一个(普通) 左值引用参数(T&)

另外值得注意的是,这两个规则暗示,我们可以将任意类型的实参传递给T&&类型的函数参数

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

模板参数可以推断为一个引用类型,这一特性对模板内的代码可能有令人惊讶的影响:

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函数是使用右值引用的模板的一个很好的例子

std::move 是如何定义的

标准库是这样定义move 的:

// 在返回类型和类型转换中也要用到typename
template <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
  • 因此, remove_referencestring 进行实例化。remove_reference<string>type 成员是stringmove 的返回类型是string&&
  • move 的函数参数t 的类型为string&&

因此, 这个调用实例化move<string>, 即函数

string&& move(string &&t)

因此, 此调用的结果就是它所接受的右值引用


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

  • 推断出的T 的类型为string&
  • 因此, remove_referencestring&进行实例化,type 成员是stringmove 的返回类型仍是string&&

因此, 这个调用实例化move<string&>, 即

string&& move(string &t)

这正是我们所寻求的一一我们希望将一个右值引用绑定到一个左值。这个实例的函数体返回static_cast<string&&>(t)。在此情况下, t的类型为string&,static_cast将其转换为string&&

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

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

  • 虽然不能隐式地将一个左值转换为右值引用, 但我们可以用static_cast显式地将一个左值转换为一个右值引用

一方面, 通过允许进行这样的转换, C++语言认可了将一个右值引用绑定到一个左值来允许它们截断左值。但另一方面, 通过强制使用static_cast, C++语言试图阻止我们意外地进行这种转换

最后, 虽然我们可以直接编写这种类型转换代码, 但使用标准库move函数是容易得多的方式。而且, 统一使用std::move使得我们在程序中查找潜在的截断左值的代码变得很容易

转发

某些函数需要将其一个或多个实参连同类型不变地转发给其他函数。在此情况下,我们需要保持被转发实参的所有性质,包括实参类型是否是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 改变了实参i
flip1(f, j, 42); //通过flip1 调用f 不会改变j

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

void flipl(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 保持类型信息

我们可以使用forward新标准库设施来传递flip2 的参数,它能保持原始实参的类型。forward定义在头文件utility中。forward必须通过显式模板实参来调用forward返回该显式实参类型的右值引用。即, forward<T>的返回类型是T&&

通常情况下,我们使用forward传递那些定义为模板类型参数的右值引用的函数参数。通过其返回类型上的引用折叠,forward可以保持给定实参的左值/右值属性

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

template<typename F, typename Tl, typename T2>
void flip(F f, Tl &&tl, 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版本:

// 注意: 此函数不能用于char*
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_reg
string 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)就是一个接受可变数目参数的模板函数或模板类。可变数目的参数被称为参数包(parameter packet)。存在两种参数包:

  • 模板参数包(template parameter packet), 表示零个或多个模板参数;
  • 函数参数包(function parameter packet), 表示零个或多个函数参数

我们用一个省略号来指出一个模板参数或函数参数表示—个包。在一个模板参数列表中, classtypename指出接下来的参数表示零个或多个类型的列表; 一个类型名后面跟一个省略号表示零个或多个给定类型的非类型参数的列表。在函数参数列表中,如果一个参数的类型是一个模板参数包, 则此参数也是一个函数参数包。例如:

// Args是一个模板参数包; rest是一个函数参数包
// Args表示零个或多个模板类型参数
// rest表示零个或多个函数参数
template <typename T, typename ... Args>
void foo(const T &t, const Args& ... rest);

声明了foo是一个可变参数函数模板, 它有一个名为T的类型参数, 和一个名为Args的模板参数包。这个包表示零个或多个额外的类型参数。foo的函数参数列表包含一个名为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]&);

在每个实例中,T的类型都是从第一个实参的类型推断出来的。剩下的实参(如果有的话)提供函数额外实参的数目和类型

sizeof... 运算符

当我们需要知道包中有多少元素时, 可以使用sizeof...运算符。类似sizeofsizeof...也返回一个常量表达式, 而且不会对其实参求值:

template<typename ... Args> void g(Args ... args) {
    
    
	cout << sizeof...(Args) << endl; //类型参数的数目
	cout << sizeof...(args) << endl; //函数参数的数目
}

编写可变参数函数模板

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

作为一个例子, 我们将定义一个函数来打印错误信息。我们首先定义一个名为print的函数, 它在一个给定流上打印给定实参列表的内容

可变参数函数通常是递归的第一步调用处理包中的第一个实参, 然后用剩余实参调用自身。我们的print 函数也是这样的模式, 每次递归调用将第二个实参打印到第一个实参表示的流中。为了终止递归, 我们还需要定义一个非可变参数的print函数, 它接受一个流和一个对象:

// 用来终止递归并打印最后一个元素的函数
// 此函数必须在可变参数版本的print 定义之前声明
template<typename T>
ostream &print(ostream &os, const T &t)
{
    
    
	return os << t; //包中最后一个元素之后不打印分隔符
}
// 包中除了最后一个元素之外的其他元素都会调用这个版本的print
template <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)时,因无法与任何模板匹配从而产生编译错误

包扩展

对于一个参数包,除了获取其大小外,我们能对它做的唯一的事情就是扩展(expand)它。当扩展一个包时,我们还要提供用于每个扩展元素的模式(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 类添加一个emplace_back成员。emplace_back成员应是一个可变参数成员模板, 它用其实参在容器管理的内存空间中直接构造一个元素。由于我们希望能使用string 的移动构造函数, 因此还需要保持传递给emplace_back的实参的所有类型信息

如我们所见,保持类型信息是一个两阶段的过程。首先, 为了保待实参中的类型信息,必须将emplace_back的函数参数定义为模板类型参数的右值引用:

class StrVec {
    
    
public:
	template <typename ... Args> void emplace_back(Args&& ...);
	// ...
};

其次,当emplace_back将这些实参传递给construct 时,我们必须使用forward来保持实参的原始类型:

template <typename ... Args>
inline
void 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); //使用移动构造函数

传递给emplace_back的实参是一个右值, 它将以如下形式传递给construct

std::forward<string>(string("the end"))

forward<string>的结果类型是string&&,因此construct将得到一个右值引用实参。construct会继续将此实参传递给string的移动构造函数来创建新元素

模板特例化

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

我们的compare函数是一个很好的例子, 它展示了函数模板的通用定义不适合一个特定类型(即字符指针)的情况。我们希望compare 通过调用strcmp比较两个字符指针而非比较指针值

// 第一个版本;可以比较任意两个类型
template <typename T> int compare(const T&, const T&);
// 第二个版本处理字符串字面常量
template<size_t N, size_t M>
int compare(const char (&)[N], const char (&)[M]);

但是,只有当我们传递给compare 一个字符串字面常量或者一个数组时,编译器才会调用接受两个非类型模板参数的版本。如果我们传递给它字符指针,就会调用第一个版本

为了处理字符指针(而不是数组),可以为第一个版本的compare定义一个模板特例化(template specialization)版本。一个特例化版本就是模板的一个独立的定义,在其中一个或多个模板参数被指定为特定的类型

定义函数模板特例化

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

// compare的特殊版本,处理宇符数组的指针
template <>
int compare(const char* const &p1, const char* const &p2)
{
    
    
	return strcmp(p1, p2);
}

当我们定义一个特例化版本时,函数参数类型必须与一个先前声明的模板中对应的类型匹配

为了特例化一个模板,原模板的声明必须在作用域中。而且,在任何使用模板实例的代码之前,特例化版本的声明也必须在作用域中。如果丢失了一个特例化版本的声明,编译器通常可以用原模板生成代码。这种错误又很难查找。

模板及其特例化版本应该声明在同一个头文件中。所有同名模板的声明应该放在前面,然后是这些模板的特例化版本

函数重载与模板特例化

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


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

compare("hi", "mom")

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

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

类模板特例化

除了特例化函数模板,我们还可以特例化类模板。默认情况下,无序容器使用hash<key_type>来组织其元素。作为一个例子,我们将为标准库hash模板定义一个特例化版本,可以用它来将Sales_data对象保存在无序容器中。一个特例化hash类必须定义:

  • 一个重载的调用运算符,它接受一个容器关键字类型的对象,返回一个size_t
  • 两个类型成员,result_typeargument_type, 分别调用运算符的返回类型和参数类型
  • 默认构造函数和拷贝赋值运算符(可以隐式定义)。

在定义此特例化版本的hash 时,唯一复杂的地方是:必须在原模板定义所在的命名空间中特例化它。为了达到这一目的,首先必须打开命名空间:

// 打开std命名空间,以便特例化std::hash
namespace std {
    
    
}// 关闭std命名空间;注意: 右花括号之后没有分号

花括号对之间的任何定义都将成为命名空间std的一部分

下面的代码定义了一个能处理Sales_data的特例化hash 版本:

namespace std {
    
    
	template <>
	struct hash<Sales_data>
	{
    
    
		// 用来散列一个无序容器的类型必须要定义下列类型
		typedef size_t result_type;
		typedef Sales_data argument_type; //默认情况下, 此类型需要 ==
		size_t operator()(const Sales_data& s) const;
		// 我们的类使用合成的拷贝控制成员和默认构造函数
	};
	
	// 重载的调用运符符必须为给定类型的值定义一个哈希函数
	size_t
	hash<Sales_data>::operator()(const Sales_data& s) const
	{
    
    
		return hash<string>()(s.bookNo) ^
				hash<unsigned>()(s.units_sold) ^
				hash<double>()(s.revenue);
	}
}

在本例中, 我们将定义一个好的哈希函数的复杂任务交给了标准库。值得注意的是, 我们的hash函数计算所有三个数据成员的哈希值, 从而与我们为Sales_data定义的operator==是兼容的。默认情况下,为了处理特定关键字类型, 无序容器会组合使用key_type 对应的特例化hash 版本和key_type上的相等运算符

假定我们的特例化版本在作用域中, 当将Sales_data作为容器的关键字类型时,编译器就会自动使用此特例化版本:

// 使用hash<Sales_data>和Sales_data的operator=
unordered_multiset<Sales_data> SDset;

由于hash<Sales_data>使用Sales_data 的私有成员, 我们必须将它声明为Sales_data的友元:

template <class T> class std::hash; // 友元声明所需要的
class Sales_data
{
    
    
	friend class std::hash<Sales_data>;
	// ...
};

为了让Sales_data 的用户能使用hash 的特例化版本, 我们应该在Sales_data的头文件中定义该特例化版本

类模板部分特例化

与函数模板不同, 类模板的特例化不必为所有模板参数提供实参。一个类模板的部分特例化(partial specialization)本身是一个模板, 使用它时用户还必须为那些在特例化版本中未指定的模板参数提供实参

例如,标准库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类型

特例化成员而不是类

我们可以只特例化特定成员函数而不是特例化整个模板。例如, 如果Foo是一个模板类, 包含一个成员Bar, 我们可以只特例化该成员:

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

如果我们使用Foo<int>的成员Bar, 则会使用我们定义的特例化版本

猜你喜欢

转载自blog.csdn.net/weixin_42437114/article/details/109094698
今日推荐