C++ Primer 16 类模板部分特例化

模板与泛型编程

面向对象编程和泛型编程都能矗立在编写程序时不知道类型的情况。不同之处在于:OPP 能处理类型在程序运行之前都未知的情况;而泛型编程中,在编译时就能知获类型了。

当我们编写一个泛型程序时,是独立于任何特定类型来编写代码的。模板是 C++ 中泛型编程的基础。一个模板就是一个创建类或函数的蓝图或者说公式。


1 定义模板

1.1 函数模板

我们可以定义一个 通用模板,而不是为每个类型都定义一个新函数。

template 
int compare(const T &v1, const T &v2) {
	if (v1 < v2) return -1;
	if (v2 < v1) return 1;
	return 0;
}

模板定义以关键字 template 开始,后跟一个模板参数列表,这是一个逗号分隔的一个或多个模板参数的列表,用小于括号(<)和大于括号(>)包围起来。
注: 在模板定义中,模板参数列表不能为空。

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

实例化函数模板

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

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

编译器用推断出来的模板参数来为我们 实例化 一个特定版本的函数。实例化可以理解为将一个模板函数转换为一个具体的函数。

对于这个调用,编译器会编写并编译一个 compare 版本,其中 T 被替换为 int:

int compare(const int &v1,const int &v2){
    if( v1 < v2) return -1;
    if( v2 < v1) return 1;
    return 0;
}

模板类型参数

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

// 正确 返回类型和参数类型相同
template 
T foo(T* p) {
	T tmp = *p;  // tmp 的类型将是指针 p 指向的类型
	// ...
	return tmp;
}

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

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

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

非类型模板参数

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

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

例如,我们可以编写一个 compare 版本处理字符串常量。第一个模板参数表示第一个数组的长度,第二个参数表示第二个数组的长度:

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

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

compare("hi", "mom");

编译器会在字符串字面常量的末尾插入一个空字符作为终结符,因此编译器会实例出如下版本:

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

注: 非类型模板参数的模板实参必须是常量表达式。

inline 和 constexpr 的函数模板

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

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

编写类型无关的代码

编写泛型代码的两个重要原则:

  • 模板中的函数参数时 const 的引用
  • 函数体中的条件判断仅使用 < 运算符

通过将函数参数设定为 const 的引用,保证了函数可以用于不能拷贝的类型。如果编写代码时只是用 < 运算符,就降低了函数对处理类型的要求。这些类型必须支持 <,但不必同时支持 >。
注: 模板程序应该尽量减少对实参类型的要求。

模板编译

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

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

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

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

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

注: 保证传递给模板的实参支持模板所要求的操作,以及这些操作在模板中能正确工作,是调用者的责任。

1.2 类模板

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

定义类模板

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

template class Blob {
public:
    typedef T value_type;
    typedef typename std::vector::size_type size_type
	// 构造函数
    Blob();
    Blob(std::initializer_list 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> data;
    // 若 data[i] 无效,则抛出 msg
    void check(size_type i,const std::string &msg) const;
};

实例化类模板

当使用一个类模板时,我们必须提供额外信息。这些额外信息就是显示模板实参列表,它们被绑定到模板参数。编译器使用这些模板实参来实例化出特定的类。

例如:

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

当编译器从我们的 Blob 模板实例化出一个类时,它会重写 Blob 模板,将模板参数 T 的每个实例替换为给定的模板实参。不同的定义会实例化处两个不同的类。
注: 一个类模板的每个实例都形成一个独立的类。

类模板的成员函数

类模板的成员函数既可以在类模板内部定义,也可以在类模板外部定义,且定义在类模板内的成员函数被隐式声明为内联函数。

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

template 
ret-type Blob::mumber-name(parm-list)

类模板成员函数的实例化

如果一个成员函数没有被使用,则它不会被实例化。成员函数只有在被用到时才进行实例化,这一特性使得即使某种类型不能完全符合模板操作的要求,我们仍然能用该类型实例化类。
注: 默认情况下,对于一个实例化了的类模板,其成员只有在使用时才被实例化。

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

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

// 试图访问一个不存在的元素, BlobPtr 将抛出一个异常
template class BlobPtr {
public:
    BlobPtr():curr(0) {}
    BlobPtr(Blob &a,sizt_t sz = 0):
        wptr(a.data),curr(sz) {}

    T& operator*() const{
        auto p = check(curr,"dereference past end");
        return (*p)[curr];
    }

    BlobPtr& operator++();
    BlobPtr& operator--();
private:
    std::shared_ptr>
        check(std::size_t ,const std::string &) const;
    
    std::weak_ptr> wptr;
    std::size_t curr;
};

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

类模板和友元

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

一对一友好关系

类模板和另一个(类或函数)模板间友好关系的最常见的形式是建立对应实例及其友元间的友好关系。

// 前置声明,在 Blob 中声明友元所需要的
template class BlobPtr;
template class Blob;  // 运算符 == 中的参数所需要的
template 
	bool opeartor==(const Blob &, const Blob &);

template  class Blob {
    // 每个 Blob 实例将访问权限授予用相同类型实例化的 BlobPtr 和相等运算符
    friend class BlobPtr;
    friend bool operator==
	    (const Blob&, const Blob&);
};

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

Blob ca;  // BlobPtr 和 operator== 都是本对象的友元
Blob ia;  // BlobPtr 和 operator== 都时本对象的友元

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

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

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

// 前置声明,在将模板的一个特定实例声明为友元时要用到
template class Pal;
class C {  // C 是一个普通的非模板类
    friend class Pal;  // 用类 C 实例化 Pal 是 C 的一个友元
    // Pal2 的所有实例都是 C 的友元;这种情况无需前置声明
    template  friend class Pal2;
};

template class C2 {
    friend class Pal;  // Pal 的模板声明必须在作用域之内
    // Pal2 的所有实例都是 C2 的每个实例的友元,不需要前置声明
    template friend class Pal2;
    // Pal3 是一个非模板类,他是 C2 所有实例的友元
    friend class Pal3;
};

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

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

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

template class Bar {
    friend Type;  // 将访问权限授予用于实例化 Bar 的类型
    // ...
};

模板类型别名

我们可以定义一个 typedef 来引用实例化的类:

typedef Blob StrBlob;

但是这条 typedef 语句使用的时用 string 实例化的模板版本的 Blob。由于模板不是一个类型,所以我们不能定义一个 typedef 引用模板。即,无法定义一个 typedef 引用 Blob

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

template using twin = pair;
twin authors;  // authors 是一个 pair

一个模板类型别名是一个族类的别名,就像使用类模板一样,当我们使用 twin 时,需要指出希望使用哪种特定类型的 twin。

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

template using partNo = pair;
partNo books;  // books 是一个 pair

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

类模板的 static 成员

类模板可以声明 static 成员:

template  class Foo {
public:
    static std::size_t count() { return ctr;}
private:
    static std::size_t ctr;
};

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

// 实例化 static 成员
Foo fs;
// 所有三个对象共享同相同的 static 成员和函数
Foo fi, fi2, fi3;

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

Foo fi;
auto ct = Foo::count();
ct = fi.count();  // 正确 使用 Foo::count()
ct = Foo::count();  // 错误 不知道使用哪个版本的 count

一个 static 成员函数只有在使用时才会实例化。

1.3 模板参数

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

模板参数与作用域

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

typedef double A;
template  void f(A a, B b){
    A tmp = a;  // 隐藏外部的 A,此处 tmp 不是 double 类型
    double B;  // 错误 重声明模板参数 B
} 

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

// 错误 非法重用模板参数名 V
template   // ...

使用类的类型成员

通常,我们用作用域运算符(::)来访问 static 成员和类型成员。但编译器遇到类似 T::mem 这样的代码时,它不会知道 mem 是一个类型成员还是一个 static 数据成员,直到实例化时才会知道。但是,为了处理模板,编译器必须知名字是否表示一个类型。例如:

T::size_type * p;

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

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

template 
typename T::value_type top(const T& c) {
    if (!c.empty())
        return c.back();
    else
        return typename T::value_type();
}

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

默认模板实参

在新标准中,我们可以为函数和类模板提供默认实参。例如:重写 compate,默认使用标准库的 less 函数对象模板:

// compare 有一个默认模板实参 less 和一个默认函数实参 F()
template >
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
// 结果依赖于 item1 和 item2 中的 isbn
Sales_data item1(cin), item2(cin);
bool j = compare(item1, item2, compareIsbn);

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

模板默认实参与类模板

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

template  class Number {  // T 默认为 int
public:
    Number(T v = 0) : val(v) { }
private:
    T val;
};

Number lots_of_orecision;  // 未使用默认类型
Number<> average_precision;  // 空 <> 表示希望使用默认类型

1.4 成员模板

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

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

与任何其他模板相同,成员模板也是以模板参数列表开始的:

class DebugDelete {
public:
    DebugDelete(std::ostream &s = std::cerr) : os(s) { }
    // 与任何函数模板相同,T 的类型有编译器推断
    template void operator() (T *p) const {
        os << "deleting unique_ptr" << std::endl;
        delete p;
    }
private:
    std::ostream &os;
};

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

类模板的成员模板

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

template  class Blob {
    template  Blob(It a,It b);
};

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

template   // 类的模板类型参数
template   // 构造函数的类模板类型参数
Blob::Blob(It a,It b) :
	data(std::make_shared>(b, e))  {...}

实例化与成员模板

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

int ia[] = {0, 1, 2};
vector vi = {0, 1, 2};
list w = {"now", "is", "the", "time"};
 
//实例化 Blob 类及接受两个 int* 参数的构造函数
Blob a1(begin(ia), end(ia));
//实例化 Blob 类及接受两个 vector::iterator 的构造函数
Blob a2(vi.begin(), vi.end());
//实例化 Blob 类及接受两个 list::iterator 的构造函数
Blob a2(w.begin(), w.end());

1.5 控制实例化

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

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

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

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

extern template class Blob;  // 声明
template int comapre(const int&,const int&);  // 定义

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

注1: 由于编译器在使用一个模板时自动对其实例化,因此 extern 声明必须出现在任何使用此实例化版本的代码之前。
注2: 对每个实例化声明,在程序中某个位置必须有其显式的实例化定义。

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

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

1.6 效率与灵活

shared_ptr 和 unique_ptr 之间明显不同时它们管理所保存的指针的策略 —— 前者给予我们共享指针所有权的能力;后者则独占指针。这一差异对两个类的功能来说是至关重要的。

这两个类的另一个 差异是它们允许用户重载默认删除器的方式。我们可以很容易地重载一个 shared_ptr 的删除器,只要在创建或 reset 指针时传递给它一个可调用对象即可。与之相反,删除器的类型是一个 unique_ptr 对象的类型的一部分。用户必须在定义 unique_ptr 时以显式模板实参的形式提供删除器的类型。因此,对于 unique_ptr 的用户来说,提供自己的删除器就更为复杂。如何处理删除器的差异实际上就是这两个类功能的差异。

在运行时绑定删除器

shared_ptr 必须能直接访问其删除器,即,删除器必须保存为一个指针或一个封装了指针的类。shared_ptr 不是讲删除器直接保存为一个成员,因为删除器的类型直到运行时才会知道。

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

在编译时绑定删除器

在 unique_ptr 中,删除器的类型是类类型的一部分。即,unique_ptr 有两个模板参数,一个表示它所管理的指针,另一个表示删除器的类型。由于删除器的类型是 unique_ptr 的一部分,因此删除器成员的类型在编译时是知道的,从而删除器可以直接保存在 unique_ptr 对象中。

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


2 模板实参推断

对于函数模板,编译器利用调用中的函数实参来确定其模板参数。从函数实参来确定模板实参的过程被称为模板实参推断

2.1 类型转换与模板类型参数

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

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

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

其他类型转换,如算数转换、派生类向基类的转换以及用户定义的转换,都不能应用于函数模板。
注1: 如果形参时一个应用,则数组不会转换为指针。
注2: 将实参传递给带模板类型的函数形参时,能够自动应用的类型转换只有 const 转换以及数组或函数到指针的转换。

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

一个模板类型参数可以用作多个函数形参的类型。由于只允许有限的几种类型转换,因此传递给这些形参的实参必须具有相同的类型。如果推断出的类型不匹配,则调用就是错误的

long lng;
compare(lng, 1024);  // 错误 不能实例化 compare(long, int)

正常类型转换应用于普通函数实参

函数模板可以有用普通类型定义的参数,即,不涉及模板类型参数的类型。这个函数实参不进行特殊处理:它们正常转换为对应形参的类型。例如:

template  ostream &print(ostream &os, const T &obj) {
	return os << obj;
}
print(cout, 42);  // 实例化 print(ostream&, int)
ofstream f("output");
print(f, 10);  // 使用 print(ostream&, int); 将 f 转换为 ostream&

2.2 函数模板显式实参

在某些情况下,编译器无法推断出模板实参的类型。其他一些情况下,我们希望允许用户控制模板实例化。当函数返回类型与参数列表中任何类型都不相同时,这两种情况最常出现。

指定显式模板实参

我们可以定义返回类型的第三个模板参数,从而允许用户控制返回类型:

// 编译器无法推断 T1,它未出现在函数参数列表中
template 
T1 sum(T2, T3);

每次调用 sum 时调用者都必须为 T1 提供一个 显式模板实参。我们提供显式模板实参的方式于定义类模板实例的方式相同。显式模板实参在尖括号中给出,位于函数名之后,实参列表之前:

// T1 是显式指定的,T2 和 T3 是从函数实参类型推断而来的
auto val3 = sum(i, lng);  // long long sum(int, long)

显式模板实参按由左至右的顺序与对应的模板参数匹配;第一个模板参数与第一个模板参数匹配,第二个实参与第二个参数匹配,依此类推。 只有尾部(最右)参数的显示模板实参才可以忽略,而且前提是它们可以从函数参数推断出来

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

对于用普通类型定义的函数参数,允许进行正常的类型转换,出于同样的原因,对于模板类型参数已经显式指定了的函数实参,也进行正常的类型转换

long lng;
compare(lng, 1024);  // 错误:模板参数不匹配
compare(lng, 1024);  // 正确:实例化 compare(long, long)
compare(lng, 1024);  // 正确:实例化 compare(int, int)

第一个调用是错误的,因为传递给 compare 的实参必须具有相同的类型。如果我们显式指定模板类型参数,就可以进行正常类型转换了。

2.3 尾置返回类型与类型转换

当我们希望用户确定返回类型时,用显式模板实参表示模板函数的返回类型是很有效的。但是在其他情况下,要求显式指定模板实参会给用户添加额外负担,而且不会带来什么好处。

例如,我们可能希望编写一个函数,接受表示序列的一对迭代器,返回序列中的一个元素的引用:

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

我们并不知道返回结果的准确类型,但直到所需类型是所处理的序列的元素类型:

vector vi = {1, 2, 3, 4, 5};
Blob ca = {"hi", "bye"};
auto &i = fcn(vi.begin(), vi.end());  // fcn 应该返回 int&
auto &j = fcn(ca.begin(), ca.end());  // fcn 应该返回 string&

在此例中,我们知道函数应该返回 *beg,而且知道我们可以用 decltype(*beg) 来获取此表达式的类型。但是在编译器遇到函数的参数列表之前,beg 都是不存在的。为了定义此函数,我们必须使用尾置返回类型:

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

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

有时我们无法直接获取所需的类型。比如希望编写一个返回元素值而不是引用的类似 fcn 的函数。但是,对于传递的参数的类型,我们几乎一无所知,唯一可以使用的操作符是迭代器操作,而所有迭代器操作都不会生成元素,只能生成元素的引用。

为了获取元素类型,我们可以使用标准库的类型转换模板。这些模板定义在头文件中 type_traits。这个头文件中的类通常用于所谓的模板元程序设计。

对 Mod,其中 Mod 为 若 T 为 则 Mod::type 为
remove_reference X& 或 X&&
否则
X
T
add_const X&、const X 或函数
否则
T
const T
add_lvalue_reference X&
X&&
否则
T
X&
T&
add_rvalue_reference X& 或 X&&
否则
T
T&&
remove_pointer X*
否则
X
T
add_pointer X& 或 X&&
否则
X*
T*
make_signed unsigned X
否则
X
T
make_unsigned 带符号类型
否则
unsigned X
T
remove_extent X[n]
否则
X
T
remove_all_extent x[n1][n2]
否则
X
T

2.4 函数指针和实参推断

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

template  int compare(const T&, const T&);
// pf1 指向实例 int compare(const int&, const int&)
int (*pf1)(const int&, const int&) = compare;

pf1 中参数的类型决定了 T 的模板实参的类型。在本例中,T 的模板实参类型为 int。指针 pf1 指向 compare 的 int 版本实例。如果不能从函数指针类型确定模板实参,则产生错误

// func 的重载版本:每个版本接受一个不同的函数指针类型
void func(int(*)(const string&, const string&));
void func(int(*)(const int&, const int&));
func(compare);  // 错误:二义性

这段代码的问题在于,通过 func 的参数类型无法确定模板实参的唯一类型。我们可以通过使用显示模板实参来消除 func 调用的歧义:

// 正确:显式指出实例化版本
fun(compare);  // 传递 compare(const int&, const int&)

此表达式调用的 func 版本接受一个函数指针,该指针指向的函数接受两个 const int& 参数。
注: 当参数是一个函数模板实例的地址时,程序上下文必须满足:对每个模板参数,能唯一确定其类型或值。

2.5 模板实参推断和引用

为了理解如何从函数调用进行类型推断,考虑下面例子:

template  void f(T &p);

函数参数 p 是一个模板类型参数 T 的引用,非常重要的是记住两点:编译器会应用正常的引用绑定规则;const 是底层的,不是顶层的。

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

当一个函数参数是模板类型参数的一个普通(左值)引用时(即,形如 T&),绑定规则告诉我们,只能传递给它一个左值。实参可以是 const 类型,也可以不是。实参如果是 const 的,则 T 被推断成 const 类型:

template  void f1(T&);  // 实参必须是一个左值
f1(i);  // i 是一个 int; 模板参数类型 T 是一个 int
f1(ci);  // ci 是一个 const int; 模板参数 T 是一个 const int 
f1(5);  // 错误:传递给一个 & 参数的实参必须是左值

如果一个函数的参数的类型是 const T&,正常的绑定规则告诉我们可以传递给它任何类型的实参 —— 一个对象(const 或非 const)、一个临时对象或是一个字面值常量。

当函数参数本身是 const 时,T 的类型推断结果不会是一个 const 类型。const 已经是函数参数类型的一部分;因此,它不会也是模板参数类型的一部分:

template  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&&)时,正常绑定规则告诉我们可以传递给它一个右值。推断出的 T 的类型是该右值实参的类型:

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

引用折叠和右值引用参数

通常不能将一个右值引用绑定到一个左值上。但 C++ 语言在正常绑定规则之外定义了两个例外规则,允许这种绑定。这两个例外规则是 move 这种标准库设施正确工作的基础

  1. 第一个例外规则影响右值引用参数的推断如何进行。当将一个左值传递给函数的右值引用参数,且此右值引用指向模板类型参数(T&&),编译器推断模板类型参数为实参的左值引用类型。因此,当我们调用 f3(i) 时,编译器推断 T 的类型为 int&,而非 int,这意味着 f3 的函数参数应该是一个类型 int& 的右值引用。通常,不能直接定义一个引用的引用,但通过类型别名或通过模板类型参数间接定义是可以的。
  2. 第二个例外绑定规则是:如果间接创建了一个引用的引用,则这些引用形成了“折叠”。在所有情况下(除了一个例外),引用会折叠成一个普通的左值引用类型。只在一种特殊情况下引用会折叠成右值引用:右值引用的右值引用。即,给定一个类型 X:
    • X& &,X& && 和 X&& & 都折叠成类型 X&
    • X&& && 折叠成 X&&
      注: 引用折叠只能应用于简介创建的引用的引用,如类型别名或模板参数。

如果将引用折叠规则和右值引用的特殊类型推断规则组合在一起,会有:

f3(i);  // 实参是要给左值;模板参数 T 是一个 int&
f3(ci);  // 实参是一个左值;模板参数 T 是一个 cosnt int&

当一个模板参数 T 被推断为引用类型时,折叠规则告诉我们函数参数 T&& 被折叠成左值引用:

void f3(int& &&);  // 当 T 是 int& 时,函数参数为 int& &&
// int& && 会折叠成 int&
void f3(int&);  // 当 T 是 int& 时,函数参数折叠为 int&

如果一个函数参数是指向模板参数类型的右值引用(T&&),则可以传递给它任一类型(左值或右值)的实参。如果将一个左值传递给这样的参数,则函数参数被实例化为一个普通的左值引用。

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

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

template void f3(T&& val) {
    T t = val;  // 拷贝还是绑定一个引用?
    t = fcn(t);  // 赋值只改变 t 还是即改变 t 又改变 val?
    if(val == t) {  // 若 T 是引用类型,则一直为 true
        /*....*/
    }
}

在实际中,右值引用通常用于两种情况:模板转发其实参或模板被重载。

2.6 理解 std::move

标准库 move 函数时使用右值引用的模板的一个很好的例子。虽然不能直接将一个右值引用绑定到一个左值上,但可以用 move 获得一个绑定到左值上的右值引用。由于 move 本质上可以接受任何类型的实参,因此我们不会惊讶于它是一个函数模板。

std::move 是如何定义的

标准库是这样定义 move 的:

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

move 的函数参数是 T&& 是一个指向模板类型参数的右值引用。通过引用折叠,此参数可以与任何类型的实参匹配。

string s1("hi!"), s2;
s2 = std::move(string("bye!"));  // 正确:从一个右值移动数据
s2 = std::move(s1);  // 正确:但在赋值之后,s1的值是不确定的

在第一个赋值中:传递给 move 的是 string 的构造函数的右值结果。当像一个右值引用函数参数传递一个右值时,由实参推断出的类型被引用的类型。因此,在 std::move(string(“bye!”)) 中:

  • 推断出 T 为 string。
  • 因此,remove_reference 用 string 进行实例化。
  • remove_reference 的 type 为 string。
  • move 的返回类型为 string&&。
  • move 的函数参数 t 的类型为 string&&。

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

string&& move(string&& t)

函数返回 static_cast(t)。t 的类型以及时 string&&,于是类型转换什么都不做。

在第二个赋值中:它调用了 std::move()。在此调用中,传递给 move 的实参是一个左值。这样:

  • 推断出 T 的类型为 string&。
  • 因此,remove_reference 用 string& 进行实例化。
  • remove_reference 的 type 成员是 string。
  • move 的返回类型为 string&&。
  • move 的函数参数 t 的实例化为 string& &&,会折叠为 string&。

因此,这个调用实例化 move,即

string&& move(string &t);

这就是,将一个右值引用绑定到左值。这个实例的函数体返回 static_cast(t)。在此情况下,t 的类型为 string&,cast 将其转换为 string&&。

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

通常情况下,static_cast 只能用于其他合法的类型转换。有一条针对右值引用的特许规则:虽然不能隐式地将一个左值转换为右值引用,但是可以使用 static_cast 显式地将一个左值转换为一个右值引用。

2.7 转发

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

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

// 对“翻转”的参数调用给定的可调用对象
// filip1 是一个不完整的实现:顶层 const 和引用丢失了
template
void flip1(F f, T1 t1, T2 t2){
    f(t2, t1);
}

对于下面这个可调用对象:

void f(int v1, int &v2) {  // 注意 v2 是一个引用
    cout << v1 << " " << ++v2 << endl;
}

这段代码中,f 改变了绑定到 v2 的实参的值。但是,如果我们通过 flip1 调用,f 就不会影响实参:

f(42,i);  // f 会改变实参 i 的值
flip1(f,j,42);  // j 的值不会改变

问题在于 j 被传递给 flip1 的参数 t1。此参数是一个普通的、非引用的类型 int,而非 int&。因此这个 flip1 调用会实例化为:

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

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

为了通过翻转函数传递一个引用,我们需要重写函数,使其能保持给定实参的“左值性”。更进一步,可以想到我们也希望保持参数的 const 属性。

通过将一个函数定义为一个指向模板类型参数的右值引用,我们可以保持其对应的实参的所有类型信息。使用引用参数(无论是左值还有右值)使得我们可以保持 const 属性,因为在引用类型中的 const 是底层的。而将函数参数定义为 T&& 通过引用折叠就可以保持翻转实参的左值/右值属性:

template
void flip2(F f, T1&& t1, T2&& t2) {
    f(t2, t1);
}

注: 如果一个函数参数是指向模板类型的右值引用(如 T&&),它对应的实参的 const 属性和左值/右值属性将得到保持。

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

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

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

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

此时 flip2 中对 g 的调用将传递给 g 的右值引用参数一个左值。

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

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

使用 forward,再次重写反转函数:

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

如果我们调用 flip(g, i, 42),i 将以 int& 类型传递给 g,42 将以 int&& 类型传递给 g。


3 重载与模板

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

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

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

注1: 当有多个重载模板对一个调用提供同样好的匹配时,应该选择最特例化的版本。
注2: 对于一个调用,如果一个非函数模板与一个函数模板提供同样好的匹配,则选择非模板版本。
注3: 在定义任何函数事前,记得声明所有重载的函数把呢不能。这样就不必担心编译器由于未遇到你希望调用的函数而实例化一个并非你所需的版本。


4 可变参数模板

一个可变参数模板就是一个接受可变数目参数的模板函数或模板类。可变数目的参数被称为参数包。存在两种参数包:

  • 模板参数包,表示零个或多个模板参数。
  • 函数参数包,表示零个或多个函数参数。

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

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

与往常一样,编译器从函数的实参推断模板参数类型。对于一个可变参数模板,编译器还会推断包中参数的数目。例如:

int i = 0; double d = 3.14; string s = "how";
foo(i, s, 42, d);  // 包中有三个参数
foo(s, 42, "hi");  // 包中有两个参数
foo(d, s);  // 包中有一个参数
foo("hi");  // 空包

sizeof … 运算符

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

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

4.1 编写可变参数函数模板

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

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

我们的 print 函数也是这样的模式,每次递归调用将第二个参数打印到第一个实参表示的流中。为了终止递归,还定义一个非可变参数的 print 函数,它接受一个六和一个对象:

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

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

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

在每个调用中,包中的第一个实参被移除,称为绑定到 t 的实参。
注: 当定义可变参数版本的 print 时,非可变参数版本的声明必须在作用域中。否则,可变参数版本会无限递归。

4.2 包扩展

对于一个参数包,除了获取其大小外,我们能做的唯一的事情就是扩展它。当扩展一个包时,我们还要提供用于每个扩展元素的模式

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

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

template
ostream &
print( ostream &os, const T &t, const Args&... rest) {  // 扩展 Args
    os << t << ", ";
    returen print(os, rest...);  // 扩展 rest
}

对 Args 的扩展中,编译器将模式 const Args& 应用到模板参数包 Args 中的每个元素。因此,此模式的扩展结果时一个逗号分隔的零个或多个类型的列表,每个类型都形如 const type&,

print(cout, i, s, 42);  // 包中有两个参数
// 最后两个实参的类型和模式一起确定了尾置参数的类型,此调用被实例化为
ostream& print(ostream&, const int&, const string&, const int&);

对 rest 的扩展,此模式扩展出一个由包中元素组成的、逗号分隔的列表。因此,这个调用等价于:

print(os, s, 42);

理解包扩展

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

// 在 print 调用中对每个实参调用 debug_rep
template 
ostream &errorMsg(ostream &os, const Args&... rest) {
	// print(os, debug_rep(a1), debug_rep(a2), ...)
    return print(os, debug_rep(rest)...);
}

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

// print(cerr, debug_erp(a1, a2, a3))
print(os, debug_rep(rest...));

注: 扩展中的模式会独立地应用于包中的每个元素。

4.3 转发参数包

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

为了保持实参中的类型信息,必须将 emplace_back 的函数参数定义为模板类型参数的右值引用。

class StrVec {
public:
    template  void emplace_back(Args&&...);
};

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

template
inline
void StrVec::emplace_back(Args&&... args) {
    check_n_alloc();  // 如果需要的话重新分配 StrVec 内存空间
    alloc.construct(first_free++, std::forward(args)...);
}

construct 调用中的扩展为 std::forward(args)。它及扩展了模板参数包 Args,也扩展了函数参数包 args。


5 模板特例化

当我们不能(或不希望)使用模板版本时,可以定义类或函数模板的一个特例化版本。

一个特例化版本就是模板的一个独立定义,在其中一个或多个模板参数被指定为特定的类型。

定义函数模板特例化

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

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

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

函数重载与模板特例化

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

我们将一个特殊的函数定义为一个特例化版本还是一个独立的非模板函数,会影响到函数匹配。
  注: 编译器会优先选择非模板版本。

关键概念: 普通作用域规则应用于特例化,为了特例化一个模板,原模板的声明必须在作用域中。而且,在任何使用模板实例的代码之前,特例化版本的什么也必须在作用域中。模板及其特例化版本应该声明在同一个头文件中。所有同名模板的声明应该放在前面,然后是这些模板的特例化版本

类模板特例化

我们将为标准库 hash 模板定义一个特例化版本,可以用它来将 Sales_data 对象保存到无序容器中。一个特例化hash类必须定义:

  • 一个重载的调用运算符,接受一个容器关键字类型的对象,返回一个size_t。
  • 两个类型成员,result_type 和 argument_type,分别调用运算符的返回类型和参数类型。
  • 默认构造函数和拷贝赋值运算符。

在定义此特例化版本 hash 时,唯一复杂的地方是:必须在原模板定义所在的命名空间中特例化它

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

// 打开 std 命名空间,以便特例化 std::hash
namespace std {
	template <>  // 我们正在定义一个特例化版本,模板参数为 Sales_data
	struct hash {
		// 用来散列一个无序容器的类型必须要定义下列类型
	    typedef size_t result_type;
	    typedef Sales_data argument_type;  // 默认情况下,此类型需要 ==
	    size_t operator()(const Sales_data &s) const;
	    // 我们的类使用和成的拷贝控制函数和默认构造函数
	};
	size_t
	hash::operator() (const Sales_data &s) const {
	    return hash()(s.bookNo) ^
	            hash()(s.units_sold) ^
	            hash()(s.revenue);
	}
}  // 关闭 std 命名空间;注意:右花括号之后没有分号

由于 hash 使用 Sales_data 的私有成员,所以将它必须声明为 Sales_data 的友元:

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

类模板部分特例化

与函数模板不同,类模板的特例化不必为所有模板参数提供实参。我们可以只指定一部分而非所有模板参数,或是参数的一部分而非全部特性。一个类模板的部分特例化本身是一个模板,使用它时用户还必须为那些在特例化版本中未指定的模板参数提供实参。
  注: 我们只能部分特例化类模板,而不能部分特例化函数模板。

特例化成员而不是类

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

例如,如果 Foo 是一个模板类,包含一个成员 Bar,我们可以只特例化该成员:

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

在本例中我们只特例化 Foo 类的一个成员,其他成员将由 Foo 模板提供:

Foo fs;  // 实例化 Foo::::Foo()
fs.Bar();  // 实例化 Foo::::Bar
Foo fi;  // 实例化 Foo::::Foo()
fi.Bar();  // 使用我们特例化版本的 Foo::::Bar

你可能感兴趣的:(C++,Primer,c++)