第七章模板

2023-12-18 05:49:06

7.1 函数模板

定义函数模板

一个函数模板就是一个公式,用于生成针对类型的函数版本。模板定义以一个关键字template开始,后跟一个模板参数列表(逗号分隔的一个或多个模板参数的列表)。

// 比较大小的函数模板
template <typename T>
int compare(const T &v1, const T &v2) {
    if (v1 < v2) return -1;
    if (v2 < v1) return 1;
    return 0;
}

实例化函数模板

当我们调用一个函数模板时,编译器通常用函数实参为我们推断模板实参,然后用推断出来的模板参数来为我们实例化一个特定版本的函数。例如:

// T为int, 实例化出int compare(const int&, const int&)
std::cout << compare(1, 0) << std::endl;

模板类型参数

Tips:类型参数必须使用关键字class或者typename,由于typename是在模板已经广泛使用后才引入C++语言的,某些程序员仍然只使用class

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

template <typename T>
T foo(T *p) {
    T tmp = *p;
    // ...
    return tmp;
}

非类型模板参数

1. 例子

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

举个例子,我们可以编写一个compare版本处理字符串字面常量:

// 第一个模板参数表示第一个数组的长度, 第二个模板参数表示第二个数组的长度
// 由于不能拷贝数组, 因此我们将自己的参数定义为数组的引用
template<unsigned N, unsigned M>
int compare(const char(&p1)[N], const char(&p2)[M]) {
    return strcmp(p1, p2);
}

// 实例化出int compare(const char (&p1)[3], const char(&p2)[4])
compare("hi", "cat");
2. 类型与要求

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

一个非类型模板参数可以是一个整型,或者是一个指向对象或函数类型的指针(或左值引用):

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

inline和constexpr的函数模板

函数模板可以声明为inlineconstexpr,这两个说明符要放在模板参数列表之后,返回类型之前:

template <typename T> inline T min(const T&, const T&);

模板实参推断

1. 简介

对于函数模板,编译器利用调用中的函数实参来确定其模板参数。从函数实参来确定模板实参的过程被称为模板实参推断(template argument deduction)。在模板实参推断过程中,编译器使用函数调用中的实参类型来寻找模板实参,用这些模板实参生成的函数版本与给定的函数调用最为匹配。

2. 类型转换与模板类型参数

只有很有限的几种类型转换会自动地应用于这些实参,编译器通常不是对实参进行类型转换,而是生成一个新的模板实例。与往常一样,顶层const无论是在形参中还是在实参中都会被忽略。在其他类型转换中,能在调用中应用于函数模板的包括如下两项:

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

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

3. 函数模板显式实参

当函数返回类型与参数列表中任何类型都不相同时,编译器无法推断出模板实参的类型,我们希望用户控制模板实例化。假设我们定义一个sum函数模板,它接受两个不同类型的参数,我们希望用户指定结果的类型从而控制合适的精度。

// 编译器无法推断T1, 它未出现在函数参数列表中
template <typename T1, typename T2, typename T3>
T1 sum(const T2 &v1, const T3& v2) { return v1 + v2; }

在上面例子中,由于没有任何函数实参的类型可以用来推断T1的类型,因此每次调用sum时调用者必须为T1提供一个显式模板实参。

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

需要注意的是显式模板实参按从左到右的顺序与对应的模板参数匹配,只有尾部(最右)参数的显式模板实参才可以忽略,前提是他们可以从函数参数推断出来。注意下面的写法是糟糕的,用户必须制定所有三个模板实参:

// 糟糕的设计: 用户必须制定所有三个模板参数
template <typename T1, typename T2, typename T3>
T3 alternative_sum(const T2 &v1, const T1& v2) { return v1 + v2; }
// 错误: 不能推断前几个模板参数
auto res = alternative_sum<long long>(i, lng);
// 正确: 显式指定了所有三个参数
auto res = alternative_sum<long, int, long long>(i, lng);
4. 尾置返回类型与类型转换

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

// 尾置返回类型允许我们在参数列表之后声明返回类型
// 该函数模板接收表示序列的一对迭代器并返回序列中一个元素的引用
template <typename It>
auto foo(It beg, It end) -> decltype(*beg) {
    // ... 处理序列
    return *beg;
}

有时候为了获取元素类型,我们可以使用标准库的类型转换模板,这些模板定义在type_traits中。如果我们用一个引用类型实例化remove_reference,则type类型成员将表示被引用的类型。例如我们实例化remove_reference<int&>,则type成员将是int

template <typename It>
// 为了使用模板参数的成员, 必须用typename
auto foo(It beg, It end) -> typename remove_reference<decltype(*beg)>::type {
    // ...处理序列
    return *beg;  // 返回序列中一个元素的拷贝
}
5. 模板实参推断和引用
5.1 从左值引用函数参数推断类型

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

template <typename T> void f1(T&);  // 实参必须是一个左值

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

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

template <typename T> void f2(const T&);  // 实参可以是任何类型(包括右值)
f2(i);   // i是一个int, 模板参数T是int
f2(ci);  // ci是一个const int, 模板参数T是int
f2(5);   // 字面常量值, 模板参数T是int
5.2 从右值引用函数参数推断类型

当一个函数参数是一个右值引用时(即形如T&&),绑定规则告诉我们可以传递给它一个右值,推断出的T类型是该右值实参的类型:

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

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

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

基于第一个例外规则,当我们调用f3(i)时,编译器推断T类型为int &而非intT被推断为int &看起来好像意味着f3的函数参数应该是一个类型为int &的右值引用,但是基于第二个例外规则它会被折叠成左值引用。

第二个例外规则:如果我们间接创建一个引用的引用(通过类型别名或者模板类型参数间接定义),则这些引用形成了“折叠”。在除右值引用的右值引用之外所有的情况下,引用会折叠成一个普通的左值引用类型。

对于一个给定类型X

  • X& &X& &&X&& &都折叠成X&
  • X&& &&折叠成X&&

将两个例外规则组合起来,意味着我们可以对一个左值调用f3

f3(i);   // i是一个int, 模板参数T是int&
f3(ci);  // ci是一个const int, 模板参数T是一个const int&

Tips:上面两个例外规则暗示我们可以将任意类型的实参传递给T&&类型的函数模板参数。

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

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

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

3.1 传递右值

当我们对一个右值(例如字面值42)调用f3时,T被推断为int。在此情况下局部变量t的类型为int,且通过拷贝参数val的值被初始化。当我们对t赋值时,参数val保持不变。

3.2 传递左值

当我们对一个左值i调用f3时,则T被推断为int&。当我们定义并初始化局部变量t时,赋予它类型int&。因此对t的初始化将其绑定到val。当我们对t赋值时,也同时改变了val的值。在f3的这个实例化版本中,if判断将永远为true

3.3 实际开发中的处理

在实际开发中,右值引用通常用于模板转发其实参或模板被重载两种情况。使用右值引用的函数模板通常以如下方式进行重载:

template <typename T> void f(T&&);       // 绑定到非const右值
template <typename T> void f(const T&);  // 左值和const右值

转发函数参数

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

1. 参数转化时可能丢失顶层const和引用

举个例子,我们编写接受一个可调用表达式和两个额外实参的函数。我们的函数将调用给定的可调用对象并将两个额外参数逆序传递给它。下面是我们翻转函数的初步模样:

// 接受一个可调用对象与另外两个参数的模板, 对"翻转"的参数调用给定的可调用对象
// flip1是一个不完整的实现: 顶层const和引用丢失了
template <typename F, typename T1, typename T2> void flip1(F f, T1 t1, T2 t2) {
    f(t2, t1);
}

上面的函数一般情况下都能正常工作,但是当我们希望它调用一个接受引用参数的函数时就会出现问题:

#include <iostream>

// 接受一个可调用对象与另外两个参数的模板, 对"翻转"的参数调用给定的可调用对象
// 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) {
    std::cout << v1 << " " << ++v2 << std::endl;
}


int main() {
    int i = 10;
    f(100, i);                     // 输出100, 11
    std::cout << i << std::endl;  // i被修改了, 输出11

    int j = 10;
    flip1(f, j, 100);              // 输出100, 10
    std::cout << j << std::endl;  // j不会被修改, 输出10(f函数丢失了j的左值引用)
    return 0;
}
2. 定义能保持类型信息的函数参数

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

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

#include <iostream>

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

void f(int v1, int &v2) {
    std::cout << v1 << " " << ++v2 << std::endl;
}


int main() {
    int i = 10;
    f(100, i);                     // 输出100, 11
    std::cout << i << std::endl;  // i被修改了, 输出11

    int j = 10;
    flip2(f, j, 100);              // j递增: 输出100, 11
    std::cout << j << std::endl;  // j被修改了, 输出11
    return 0;
}

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

#include <iostream>

template <typename F, typename T1, typename T2> void flip1(F f, T1 &&t1, T2 &&t2) {
    f(t2, t1);
}

void g(int &&v1, int &v2) {
    std::cout << v1 << " " << v2 << std::endl;
}


int main() {
    // 错误: 不能从一个左值实例化int&&
    // error: cannot bind ‘int’ lvalue to ‘int&&’
    int j = 10;
    flip1(g, j, 100);
    return 0;
}
3. 在调用中使用std::forword保持类型信息

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

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

#include <iostream>

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

void g(int &&v1, int &v2) {
    std::cout << v1 << " " << ++v2 << std::endl;
}


int main() {
    int j = 10;
    flip3(g, j, 100);             // 输出100, 11
    std::cout << j << std::endl;  // j被修改了, 输出11
    return 0;
}

函数模板与重载

1. 简介

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

2. 函数匹配规则

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

  • 对于一个调用,其候选函数包括所有模板实参推断成功的函数模板实例
  • 候选的函数模板总是可行的,因为模板实参推断会排除任何不可行的模板
  • 与往常一样,可行函数(模板与非模板)按类型转换(如果对此调用需要的话)来排序
  • 与往常一样,如果恰有一个函数提供比任何其他函数都更好的匹配,则选择此函数
  • 如果有多个函数提供同样好的匹配,那么:
    • 如果同样好的函数只有一个是非模板函数,则选择此函数
    • 如果同样好的函数中没有非模板函数,而有多个函数模板,且其中一个模板比其他模板更加特例化,则选择此模板
    • 否则,此调用有歧义
3. 编写重载模板

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

// 打印任何我们不能处理的类型: 该对象可以是任意具有输出运算符的类型
template <typename T> string debug_rep(const T &t) {
    ostringstream ret;
    ret << t;
    return ret.str();
}

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

// 注意此函数不能用于char*对象, 因为IO库为char*值定义了一个<<版本, 此版本假定指针表示一个空字符结尾的字符数组, 并打印数组的内容而非地址值
template <typename T> string debug_rep(T *p) {
    ostringstream ret;
    ret << "pointer: " << p;      // 打印指针本身的值
    if (p) {
        ret << " " << debug(*p);  // 打印p指向的值
    } else {
        ret << " null pointer";   // 或者指出指针p为空
    }
    return ret.str();
}

我们可以这样使用这些函数:

// 传递的是一个非指针对象, 因此只有第一个版本的debug_rep是可行的
std::string s("hi");
std::cout << debug_rep(s) << std::endl;

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

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

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

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

第二个版本的实例是此调用的精确匹配,第一个版本需要进行普通指针到const指针的转换,编译器会选择第二个debug_rep版本。

考虑下面这个例子,它提供了多个可行模板:

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

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

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

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

Tips:当有多个重载模板对一个调用提供同样好的匹配时,应该选择最特例化的版本。

设计这条规则的原因在于,如果没有它将无法对一个const的指针调用指针版本的debug_rep。问题在于模板debug_rep(const T&)本质上可以用于任何类型,包括指针类型。此模板比debug_rep(T*)更通用,后者只能用于指针类型。没有这条规则的话,传递const指针的调用永远是有歧义的。

4. 非模板函数与模板重载

Tips:对于一个调用,如果一个非函数模板与一个函数模板提供同样好的匹配,则选择非模板版本。

我们再定义一个普通非模板版本的debug_rep

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

此时我们对一个string调用debug_rep时:

std::string s("hi");
std::cout << debug_rep(s) << std::endl;

有两个同样好的可行函数:

  • debug_rep<string>(const string&),由第一个版本的debug_rep实例化而来,T被绑定到string
  • debug_rep<const string&>,普通非模板函数

在本例中两个函数具有相同的参数列表,因此显然两者提供同样好的匹配,但是编译器会选择非模板版本。当存在多个同样好的函数模板时,编译器选择最特例化的版本,处于同样的原因,一个非模板函数比一个函数模板好。

5. 类型转换与模板重载

对于C风格字符串和字符串字面常量而言,考虑如下调用:

// 调用debug_rep(T*)
std::cout << debug_rep("tomocat") << std::endl;

下面三个debug_rep版本都是可行的:

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

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

由于接受T*指针的函数模板不能正确处理char*,因此我们定义另外两个非模板重载版本:

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处理, 并调用string版本的debug_rep
string debug_rep(char *p) {
    return debug_rep(string(p));
}
string debug_rep(const char *p) {
    return debug_rep(string(p));
}

Tips:一般情况下如果使用了一个忘记声明的函数,代码将编译失败。但对于重载函数模板的函数而言,如果编译器可以从模板实例化出与调用匹配的版本,则缺少的声明就不重要的。在上面的例子中,如果忘记声明接受string参数的debug_rep版本,编译器会默认地接收const T&的模板版本。

文章来源:https://blog.csdn.net/m0_73051397/article/details/135050460
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。