C++进阶:模板类型推导

1,541 阅读15分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第5天,点击查看活动详情

所谓 "推导" 指的是编译器在某些情况下,可以根据调用方提供的信息来补全用户未提供的模板参数,是模板实例化 (template instantiation) 的一个步骤,发生的时机是在函数模版的调用时(invoke time of function template)。也就是说,当需要的时候,每次模版函数的调用,均会 (根据调用方提供的信息) 触发一次潜在的模板参数类型推导。 C++11 引入了 auto 和 decltype 关键字实现类型推导,通过这两个关键字不仅能方便地获取复杂的类型,而且还能简化书写,提高编码效率。 在学习auto和decltype之前,我们需要先学习一下模板类型参数的推导,模板类型推断是C++11中关键字auto的基础。当在auto上下文中使用模板类型推断的时候,它不会像应用在模板中那么直观,所以理解模板类型推断是如何在auto中运作的就很重要了。只有当我们熟悉类型推导的规则,才能让我们的代码更加的灵活,通用性好。

模板推导

模板是C++的重要特性,是C++标准模板库的基础。模板可以根据数据类型自动生成代码,大大减少重复代码。模板实例化的时候编译器需要根据具体变量推导数据类型,模板推导出的类型很多时候是显而易见的,有些时候却不太明显,一般,我们声明一个函数模板形式如下:

template<typename T>
void f(ParamType param);

ParamType与T可能不同,因为ParamType可能会包含一些类型的修饰词,比如const&,一般ParamType有以下几种:

T&        // ParamType是个非通用的引用或者是一个指针
const T&   // ParamType是个非通用的const引用或者是一个const指针
T&&       // ParamType是个通用的引用(Universal Reference)
T         // ParamType既不是指针也不是引用

在编译的时候,编译器通过实参来进行推导出两个类型:一个是T的,另一个是ParamType。通常来说这些类型是不同的。举个例子,模板通常采用如下声明:

template<typename T>
void f(const T& param);        // ParamType 是 const T&

如果我们调用如下:

#include <iostream>

template<typename T>
void f(const T& param) {} // ParamType 是 const T&

int main()
{
    int x = 27;
    const int cx = x;
    const int& rx = x;

    f(x);  // x是左值,所以T是int, param的类型是const int&
    f(cx); // cx是左值,所以T是int, param的类型也是const int&
    f(rx); // rx是左值,所以T是int, param的类型也是const int&
    f(27); // 27是右值,所以T是int, 所以param的类型是const int&
}

所以,两个类型并不是完全相同的。还有,一般都认为T的类型与传入的实参类型是一样的,比如上面的例子,事实上两者确实是不一样:T的类型不仅取决于实参类型,也与ParamType紧紧相关。这存在三种不同的情形:

  • ParamType是一个指针或者是一个引用类型,但并不是一个通用的引用类型 (T&或const T&)
  • ParamType是一个通用的引用 (T&&)
  • ParamType既不是指针也不是引用 (T)

引用折叠

在正式介绍上述三种情形前,我们需要简单回顾一下引用折叠这个知识点(更详细可以查看我以前的文章),总的来说规则如下:

& + & -> &
& + && -> &
&& + & -> &
&& + && -> &&

我们来看一个例子:

#include <iostream>

template<typename T>
void f1(T& param) {}

void test_f1() {
    int i = 10;  // 左值
    int& r = i;  // 左值
    int&& rr = 10;  // 左值
    const int& cr = i;  // 左值
    int* p = &i;  // 指针
    const int* const q = p;  // 指针

    f1(i);   //1 & => param是int&
    f1(r);   //2 & + & => param是int&
    f1(rr);  //3 & + && => param是int& =>
    f1(cr);  //4 cv限定符保留,& + & => param是const int&
    f1(p);   //5 T是int*, param是int* &
    f1(q);   //6 cv限定符保留,T是const int* const,param是const int* const &
}

template<typename T>
void f3(T&& param);

void test_f3() {
    int i = 10;
    int& r = i;
    int&& rr = 10;
    const int& cr = i;
    int* p = &i;
    const int* const q = p;

    // 都是左值,采用的规则和T&的相同
    f3(i); //1 && + & => param是int&
    f3(r); //2 && + & => param是int&
    f3(rr); //3 && + & => param是int&
    f3(cr); //4 && + & => param是const int&
    f3(p); //5 && + & => param是int* &
    f3(q); //6 && + & => param是const int* const &

    // 表达式是右值
    f3(42); //7 && + && => param是int&& => T&&是int&& => T是int
}

int main(){
}

通过汇编代码我们很清楚的看到编译器推导的结果(不管是T的类型还是实参类型):

test_f1():
.LFB1705:
        ; ... 此处省略一部分...
        call    void f1<int>(int&) ;//1
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        call    void f1<int>(int&) ;//2
        mov     rax, QWORD PTR [rbp-16]
        mov     rdi, rax
        call    void f1<int>(int&) ;//3
        mov     rax, QWORD PTR [rbp-24]
        mov     rdi, rax
        call    void f1<int const>(int const&) ;//4
        lea     rax, [rbp-40]
        mov     rdi, rax
        call    void f1<int*>(int*&) ;//5
        lea     rax, [rbp-48]
        mov     rdi, rax
        call    void f1<int const* const>(int const* const&)  ;//6
        nop
        leave
        ret
.LFE1705:
test_f3():
.LFB1708:
        ; ... 此处省略一部分...
        call    void f3<int&>(int&) ;//1
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        call    void f3<int&>(int&) ;//2
        mov     rax, QWORD PTR [rbp-16]
        mov     rdi, rax
        call    void f3<int&>(int&) ;//3
        mov     rax, QWORD PTR [rbp-24]
        mov     rdi, rax
        call    void f3<int const&>(int const&) ;//4
        lea     rax, [rbp-48]
        mov     rdi, rax
        call    void f3<int*&>(int*&) ;//5
        lea     rax, [rbp-56]
        mov     rdi, rax
        call    void f3<int const* const&>(int const* const&) ;//6
        mov     DWORD PTR [rbp-28], 42
        lea     rax, [rbp-28]
        mov     rdi, rax
        call    void f3<int>(int&&) ;//7
        nop
        leave
        ret
.LFE1708:

对于上面f3,可能还是有些不明白怎么推导出T的类型,我们再来简单回顾一下左值和右值的概念(更详细可以查看我以前的文章),简单来说,可以取地址的就是左值(已经有内存分配的变量),即将销毁的就是右值,再粗暴直白一点,变量都是左值,字面值都是右值。但是右值和右值引用不是一个概念,右值引用是一个变量,可以是左值。

int &a = 2;       // 左值引用绑定到右值,编译失败
int b = 2;        // 非常量左值
const int &c = b; // 常量左值引用绑定到非常量左值,编译通过
const int d = 2;  // 常量左值
const int &e = c; // 常量左值引用绑定到常量左值,编译通过
const int &b =2;  // 常量左值引用绑定到右值,编程通过
    
int&& r = 42; // r是右值引用,但不是右值而是左值
int&& r2 = r; // 错误:不能将右值引用绑定到左值,r2也是左值

左值引用(指针)

  • 对于T&,不管表达式是什么,param一定是左值引用类型(表达式是指针则param是指针的引用),cv限定符保留;
  • 对于T*,它和T&类似,param一定是指针类型,cv限定符保留;
  • 知道x类型则可以很容易知道T类型;
  • 通过下面的例子可以发现,T一定是非引用类型;
#include <iostream>

template<typename T>
void f1(T& param) {}

void test_f1() {
    int i = 10;
    int& r = i;
    int&& rr = 10;
    const int& cr = i;
    int* p = &i;
    const int* const q = p;

    f1(i);  // param是int& => T&是int& => T是int
    f1(r);  // param是int& => T&是int& => T是int
    f1(rr); // param是int& => T&是int& => T是int
    f1(cr); // param是const int& => T&是const int& => T是const int
    f1(p);  // param是int* & => T&是int* & => T是int*
    f1(q);  // param是const int* const & => T&是const int* const & => T是const int* const
}

template<typename T>
void f2(T* param) {}

void test_f2() {
    int i = 10;
    int& r = i;
    const int& cr = i;
    int* p = &i;
    const int* const q = p;

    f2(&i); // param是int* => T*是int* => T是int
    f2(&cr); // param是const int* => T*是const int* => T是const int
    f2(p); // param是int* => T*是int* => T是int
    f2(q); // param是const int* => T*是const int* => T是const int
}

template<typename T>
void f4(const T& param){}

void test_f4() {
    int x = 27;  //x是左值
    const int cx = x;  //cx是左值
    const int& rx = x;  //rx是左值

    f4(x);     // T是int,param的类型是const int&
    f4(cx);    // T是int,param的类型是const int&
    f4(rx);    // T是int,param的类型是const int&
}

int main(){
}

在上面test_f1和test_f2中,注意crq由于被指定为const类型变量,T被推导成const int/const int* const,这也就导致了参数的类型被推导为const int&const int* const &。这对调用者非常重要。当传递一个const对象给一个引用参数,他们期望对象会保留常量特性,也就是说,参数变成了const的引用。这也就是为什么给一个以T&为参数的模板传递一个const对象是安全的:对象的const特性是T类型推导的一部分,const属性完美保留。

万能引用

这种情形有点复杂,因为通用引用类型参数(即T&&)与右值引用参数的形式是一样的,但是它们是有区别的,前者允许左值传入。类型推断的规则如下:

  • 不管表达式是什么,param都是引用类型:
    • 如果实参是左值,则param是左值引用,cv限定符保留,即采用和T&相同的规则,只不过因为T&&多了个&,为了转为左值引用需要引入引用折叠的概念。通过下面的例子可以发现,T一定是左值引用类型,这是T被推断为引用类型的唯一情况
    • 如果实参是右值,则param是右值引用类型;
#include <iostream>

template<typename T>
void f(T&& param) {}  // param现在是一个通用的引用

int main() {
    int x = 27; 
    const int cx = x; 
    const int& rx = x; 

    f(x);  // x是左值,所以T是int&, param的类型也是int&
    f(cx);  // cx是const左值,所以T是const int&, param的类型也是const int&
    f(rx);  // rx是const左值,所以T是const int&, param的类型也是const int&
    f(27);  // 27是右值,所以T是int, 所以param的类型是int&&
}

汇编代码如下:

; ... 此处省略一部分...
call    void f<int&>(int&) ;//
lea     rax, [rbp-20]
mov     rdi, rax
call    void f<int const&>(int const&) ;//
mov     rax, QWORD PTR [rbp-8]
mov     rdi, rax
call    void f<int const&>(int const&) ;//
mov     DWORD PTR [rbp-12], 27
lea     rax, [rbp-12]
mov     rdi, rax
call    void f<int>(int&&) ;//
mov     eax, 0
leave
ret

对于这种情形,只要区分开左值与右值传入,上面的类型推断就基本清楚了。

既不是指针也不是引用

这种情况也就是所谓的按值传递。这就意味着param就是完全传给他的参数的一份拷贝——一个完全新的对象。基于这个事实可以从实参给出推导的法则:

  • 如果实参不是指针,T和param的类型都是非引用类型,cv限定符去除。比如实参是int、int&、int&&、const int、const int&,只要不是指针,T和param最终都是int;
  • 如果是指针,T和param都是对应的指针类型,cv限定符保留;
#include <iostream>

template<typename T>
void f(T param) {}            // param现在是pass-by-value

int main(){
    int x = 27; 
    const int cx = x;
    const int& rx = x; 

    f(x);                       // T和param的类型都是int
    f(cx);                      // T和param的类型也都是int
    f(rx);                      // T和param的类型还都是int
}

上面程序的汇编代码如下:

main:
.LFB1705:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        mov     DWORD PTR [rbp-20], 27
        mov     eax, DWORD PTR [rbp-20]
        mov     DWORD PTR [rbp-4], eax
        lea     rax, [rbp-20]
        mov     QWORD PTR [rbp-16], rax
        mov     eax, DWORD PTR [rbp-20]
        mov     edi, eax
        call    void f<int>(int) ;//
        mov     eax, DWORD PTR [rbp-4]
        mov     edi, eax
        call    void f<int>(int) ;//
        mov     rax, QWORD PTR [rbp-16]
        mov     eax, DWORD PTR [rax]
        mov     edi, eax
        call    void f<int>(int) ;//
        mov     eax, 0
        leave
        ret
.LFE1705:

从上面的汇编代码就可以看出,确实三种调用T和param的类型最终都是int。因为param是一个独立的对象——是实参的一个拷贝。实参cxrx不能被修改和param能不能被修改是没有关系的。这就是为什么实参的常量特性(或者是易变性)在推导param的类型的时候被忽略掉了,实参不能被修改并不意味着它的一份拷贝不能被修改。

认识到const(和volatile)在按值传递参数的时候会被忽略掉。正如我们所见,引用的const或者是指针指向const,实参的const特性在类型推导的过程中会被保留。但是考虑一下,如果实参是一个const的指针指向一个const对象,而且实参被通过按值传递给param

#include <iostream>

template<typename T>
void f(T param) {}            // param现在是pass-by-value

int main(){
    // ptr是一个const指针,指向一个const对象
    const char* const ptr = "Fun with pointers";
    f(ptr);  // 给参数传递的是一个const char * const类型
}

汇编代码如下:

push    rbp
mov     rbp, rsp
sub     rsp, 16
mov     QWORD PTR [rbp-8], OFFSET FLAT:.LC0
mov     edi, OFFSET FLAT:.LC0
call    void f<char const*>(char const*) ;//
mov     eax, 0
leave
ret

尽管还是传值方式,但是复制的是指针,当然改变指针本身的值不会影响传入的指针值,所以指针的const属性可以被忽略。但是指针指向常量的属性却不能忽略,因为如果常量属性也忽略,就可以通过指针的副本解引用,然后就修改了指针所指向的值,原来的指针指向的内容也会跟着变化,但是原来的指针指向的是const对象,这样就会产生冲突,所以这个常量的属性无法忽略。因此,param最终的类型是const char*

数组和函数:退化成相应类型的指针

  • 数组类型和指针指向一个数组这是两个不同的类型,数组如果传递给函数会退化为指针,因此在模板参数类型的时候,如果参数类型是T,那么数组会被推导成指针类型,如果参数类型是T&,那么数组就会被推导成数组类型,可以使用sizeof求数组的大小。
#include <iostream>

template<typename T>
void f(T param) {}   // param现在是pass-by-value

int main(){
    // name的类型是const char[13]
    const char name[] = "J. P. Briggs";     
    f(name);  // 给参数传递的是一个const char[13]类型

    const char * ptrToName = name;  // 指向数组的指针
    f(ptrToName);  // 给参数传递的是一个const char *类型
}

汇编代码如下:

push    rbp
mov     rbp, rsp
sub     rsp, 32
movabs  rax, 8233178452071558730
mov     QWORD PTR [rbp-21], rax
mov     DWORD PTR [rbp-13], 1936156521
mov     BYTE PTR [rbp-9], 0
lea     rax, [rbp-21]
mov     rdi, rax
call    void f<char const*>(char const*) ;// 传递的是一个const char[13]类型,数组被退化成指针
lea     rax, [rbp-21]
mov     QWORD PTR [rbp-8], rax
mov     rax, QWORD PTR [rbp-8]
mov     rdi, rax
call    void f<char const*>(char const*) ;//传递的是一个const char *类型
mov     eax, 0
leave
ret

所以,对于函数模板类型推断来说,数组参数推断的也是指针类型。上面例子中name是个数组,ptrToName是指向数组的指针,但这两种情形都是T和param被推导成const char*

但是有一个特例。尽管模板函数不能被真正的定义成参数为数组,但是可以声明参数是数组的引用!所以如果我们修改模板f的参数成引用:

#include <iostream>

template<typename T>
void f(T& param) {} 

int main(){
    // name的类型是const char[13]
    const char name[] = "J. P. Briggs";     
    f(name);  // 给参数传递的是一个const char *类型

    const char * ptrToName = name;          // 数组被退化成指针
    f(ptrToName);  // 给参数传递的是一个const char *类型
}

生成的汇编代码如下:

push    rbp
mov     rbp, rsp
sub     rsp, 32
movabs  rax, 8233178452071558730
mov     QWORD PTR [rbp-13], rax
mov     DWORD PTR [rbp-5], 1936156521
mov     BYTE PTR [rbp-1], 0
lea     rax, [rbp-13]
mov     rdi, rax
 ;// 传递的是一个const char[13]类型, T类型还是数组,param是数组的引用
call    void f<char const [13]>(char const (&) [13])
lea     rax, [rbp-13]
mov     QWORD PTR [rbp-24], rax
lea     rax, [rbp-24]
mov     rdi, rax
call    void f<char const*>(char const*&)  ;//传递的是一个const char *类型
mov     eax, 0
leave
ret

T最后推导出来的实际的类型就是数组!类型推导包括了数组的长度,所以在这个例子里面,T被推导成了const char [13],函数f的参数(数组的引用)被推导成了const char (&)[13]。语法看起来怪怪的,但是理解了这些可以升华你的精神。

有趣的是,数组的引用利用函数模板可以推导出数组的大小,声明数组的引用可以使的创造出一个推导出一个数组包含的元素长度的模板:

#include <iostream>

template <typename T, int N> 
int ArraySize (T (&arr)[N]) { //此处是数组的引用
    return N;
} 

int main(){
    int a[10];
	std::cout << ArraySize(a) << std::endl;	//输出结果为10
}

当然,作为一个现代的C++开发者,应该优先选择标准库的std::array

数组并不是C++唯一可以退化成指针的东西。函数类型可以被退化成函数指针,和我们上面讨论的数组的推导类似,函数可以被退化成函数指针,下面我们来说一下函数作为参数的情形,

  • 函数作为参数会被退化成函数指针,如果参数类型是T,函数会被推导成一个函数指针,指向这个函数,如果参数类型是T&,那么函数会被推导成一个引用指向函数的引用。
#include <iostream>

// someFunc是一个函数,类型是void(int, double)
void someFunc(int, double) {}    

template<typename T>
void f1(T param) {}  // 在f1中 参数直接按值传递

template<typename T>
void f2(T& param) {}  // 在f2中 参数是按照引用传递


int main(){
    f1(someFunc);   // param被推导成函数指针, 类型是void(*)(int, double)
    f2(someFunc);   // param被推导成指向函数的引用, 类型时void(&)(int, double)
}

汇编代码如下:

push    rbp
mov     rbp, rsp
mov     edi, OFFSET FLAT:someFunc(int, double)
call    void f1<void (*)(int, double)>(void (*)(int, double)) ;//推导成函数指针
mov     edi, OFFSET FLAT:someFunc(int, double)
call    void f2<void (int, double)>(void (&)(int, double)) ;//指向函数的引用
mov     eax, 0
pop     rbp
ret

总结:

  • ParamType 既不是指针也不是引用时,采用值传递模式,忽略表达式的引用部分、const、volatile。
  • ParamType 是指针或引用,不是通用引用时,忽略引用部分,进行模式匹配,先确定ParamType,再推导T。
  • ParamType 是通用引用时,左值特殊对待(T和ParamType都是左值引用)。
  • 数组参数、函数参数非引用传递时当作指针,引用类型不会。

参考

《Effective Modern C++》