再探值与引用:函数模板的类型推导规则辨析
在 初探值与引用:学会正确地使用右值引用 一文中,讲解了右值引用的正确用法。本节继续在前文基础上,讲解值与引用在模板函数中的关系。
在C++11中,有三种引用类型:
const T&:const 左值引用T&:非const的左值引用T&&:右值引用
在非模板函数中,常常使用引用类型来作为参数类型,以避免不必要的赋值。但是在模板函数中,却并不总是建议使用引用类型作为模板参数类型,应优先选择值传递,除非遇到以下情况:
- 对象不允许复制:即拷贝构造函数加上
=delete或设置为private; - 对象用于返回数据(
T&); - 转发引用(
T&&); - 引用传递可以获得明显的性能提升。
值传递
引用传递是为了避免复制,但是值传递也不一定就会发生copy。
基于【初探右值引用】中的Foo类,有如下按值传递的模板函数pass_by_value:
1 | template<typename T> |
也许你认为pass_by_value(Foo{100});会调用一次默认构造函数 + 一次ctor,但实际上在C++11之后,编译器发生了优化只是会调用一次默认构造函数,禁止了优化则会调用一次构造函数 + 一次mtor。输出如下:
1 | g++ reference.cc -o ref && ./ref # 默认情况 |
说明,当模板参数T具有移动构造函数时,即便是按照值传递也会优先调用移动构造函数,那么这种情况下值传递性能也并没有发生损失。
即使对于比较复杂的类型std::string,由于std::string也实现了移动构造函数,因此使用std::string来实例化pass_by_value函数也是一样的情况:
1 | std::string str{"hello Cpp"}; |
对于纯右值
std::string{"pass_by_move"}:传入pass_value_by函数时,无论是否开启优化,都不会产生性能损失。甚至在C++17中,强制实现优化,省去中间的移动构造函数,即不加-fno-elide-constructors编译标志都不会有移动构造的过程,仅一次默认构造函数,就类似上面pass_by_value(Foo{100});输出。对于已经存在的
std::string对象str,如果确认不再使用str,则以std::move(obj)形式传入,告知编译器调用移动构造函数,因此也不会有性能损失。
by the way
std::move(str)之后str的值就处于未定义状态,不可再使用,除非再次初始化。如果
str有const修饰,经过std::move转换也无法触发移动构造函数。为什么?因为
pass_by_value(std::string arg)本质上就是如下一个初始化过程:1
2const std::string str_const {"hello Cpp"};
std::string arg{std::move(str_const)};没有加上
const修饰时,static_cast是可以将左值str转化为右值,触发移动构造函数。当加上
const修饰时,static_cast就不管用了,因为这中间设计到了两个步骤:1)先将const修饰符去掉,这是const_cast的任务,不是static_cast的;2)才是将左值转换为右值。第一步就失败了。因此,加上const修饰时无法调用移动构造函数。进一步,尝试如下类型转换,直接查看编译器错误,会报错:*error: binding reference of type ‘std::string&&’ to ‘const std::string’*。
1
std::string&& rv = std::move(str_const);
但是如果你将鼠标放在
pass_by_value(std::move(str_const));函数上,可以发现IDE显示推断出来的类型是std::string。这又是为什么呢???答案是类型退化,这是接下来类型退化知识。
上述总结,在模板函数以值传递时,只有一种情况会产生复制:若对象obj已经存在,再调用pass_by_value(obj)才会产生复制,那么有没有可以解决的办法,由调用者决定不复制呢?答案是 pass_by_value(std::ref(obj))。
因此,在值传递时,调用者可以自主选择是否复制、移动,具有很大便利性。
类型退化
值传递还有一个重要特性,会导致参数类型T发生退化:
- C-style的裸数组、字符串常量会退化成指针
- 变量的
const和volatile修饰符会自动消失,指针变量除外。 - 引用会退化成普通类型
第二条也解释了为什么类型是const std::string的str_const传入pass_by_value函数后推断为std::string类型。
现有如下的demo:
1 | const int a = 10; |
const除了修饰指针,其余都会退化掉但是可以发现特列:如果
const修饰裸数组是不会退化的,这是为啥?因为裸数组会退化成指针,const修饰指针不会退化,使得const修饰数组时就推导出const T*引用会退化成普通类型。
即使是
const T&也会退化成T
思考下参数退化会带来什么问题?
引用传递
引用传递,与值传递则相反:
- 任何情况下都不会造成copy
- 不会造成模板参数类型退化
下面对三种引用类型进行说明。
const T&
const T& 引用类型:既可以接受左值,也可以接受右值。
假定存在pass_by_const_ref函数,参数arg是const T&引用类型,在函数内部打印传入的arg对象地址:
1 | template<typename T> |
最终的输出打印如下:
1 | $ g++ reference.cc -o ref && ./ref |
一共进行了两次构造函数,没有发生copy,符合预期。而且前两次调用pass_by_const_ref函数打印的地址都一致,是因为arg都是指向了foo,但是第三次调用打印的地址增加了4个字节,是因为临时对象Foo{100}和foo在同一个连续的栈空间中,而Foo类大小只有四个字节(只有一个int类型成员变量num_)。
因此,const T&引用类型,无论传入左值还是右值,都不会发生copy。
类型不退化
同样以值传递中退化,传入pass_bt_const_ref函数:
1 | pass_by_const_ref(str_const); // const std::string& |
分析如下:
pass_by_const_ref函数,传入const std::string类型的对象str_const时,arg推理为const std::string&,没有发生类型退化。其中,T被推理为std::string,const由于出现在pass_by_const_ref函数的模板参数中,因此不会出现在T中;array也是被推断为固定长度类型的const int[&][4],而不是单纯的指针const int*;ch被推断为const char* const&:ch原本类型是const char*,表示无法修改ch指向的字符值,但是可以修改ch值,即可以执行++ch这类操作,而pass_by_const_ref函数给ch加上了一个const修饰,使得ch本身的值也无法修改,即const char* const。
因此,const T&,不会退化模板参数T的类型,反而会加强没有const修饰的输入参数的类型。
by the way
即使pass_by_const_ref函数,可以接受常量,比如pass_by_const_ref(Foo{100}),但是如果pass_by_const_ref内部如下,最终触发的也是复制构造函数。
或者,arg被用于其他函数xxxx_func(arg)的参数,也是触发const Foo&版本,而不是Foo&&函数版本。
1 | template<typename T> |
T&
假定有pass_by_lv_ref函数,以T&为模板参数类型:
1 | template<typename T> |
T& 与 const T&区别有两点:
pass_by_lv_ref函数可以修改arg参数,并且修改后的arg值可以传递给pass_by_lv_ref函数的调用者,避免了return返回。T&不能接受右值,但如果先以const T类型的变量var指向一个T类型的常量,再把var传递给pass_by_ref函数是允许的。如果直接给
pass_by_ref函数传递右值,则报错:error: cannot bind non-const lvalue reference of type ‘std::string&’ to an rvalue of type ‘std::string’。1
2
3
4int main(int argc, char const *argv[]) {
pass_by_lv_ref(std::string{"temp value"});
return 0;
}如果传入
pass_by_lv_ref函数的参数就带有const修饰,那么将会把arg推导为const类型引用,且这个const修饰符是出现在T中。1
2pass_by_lv_ref(str_const); // 推导为:const std::string&
pass_by_lv_ref(std::move(str_const)); // 推导为:const std::string&但是!!!
pass_by_lv_ref函数内部默认arg是可以修改的。当pass_by_lv_ref函数接受了const类型变量,arg被推导为const T&,变成只能读,那么就会导致一个问题:如果在pass_by_lv_ref函数内部修改str_const、ch等const修饰的传入参数,将会编译报错。怎么办?
在C++20之前,使用
std::enable_if_t,直接禁止给pass_by_lv_ref函数传入const类型变量。此时,给pass_by_lv_ref函数传递const类型变量,在IDE中这个函数下方出现红色波浪线,提示不存在这个函数,就不需要等到编译期才发现。1
2
3
4template<typename T, typename = std::enable_if_t< !std::is_const<T>::value>>
void pass_by_lv_ref(T& arg) {
//...
}
T&&
T&&,即右值引用模板化,此时的类型推导原则是:引用折叠。
假定有pass_by_perfect_ref函数,如下:
1 | template<typename T> |
依赖于引用折叠,pass_by_perfect_ref函数能推导出任意类型的输入参数。此时在pass_by_perfect_ref函数内部就能知道传入的参数是什么类型:
1 | pass_by_perfect_ref(str); // 左值推导为:string&, |
一般地,当模板函数使用T&&作为参数类型时,函数内部需使用std::forward<T&&>(arg)来转发输入参数,目的就是为了保持住输入参数的原来类型。
下面先讲解下forward函数:
1 | // 用于接收 const T&、T& |
当
forward函数的输入参数是const T&类型时,std::remove_reference函数会消去T的引用,变成const T,那么forward函数的__t类型即const T&- 再经过
static_cast<const T&&&>(_t),其中目的类型是const T&&&,经引用折叠变成const T&
因此,当输入类型是
const T&时,输出还是这个类型当
forward函数的输入是T&类型时,分析同上,输出还是T&当
forward函数的输入是T或者T&&类型时,经过std::remove_reference函数都会变成T类型,再经过static_cast<T&&>转换,都变成T&&。右值传递到
pass_by_perfect_ref函数时,arg指向的是右值,但arg本身是左值,如果不经过std::forward转换,arg将以左值角色参与后续操作,违背了调用者意图。
因此std::forward函数,搭配模板使用时,比如上面的 pass_by_perfect_ref 函数,arg无论传递给std::forward什么类型,std::forward都能输出相同的类型。因此,std::forward<T&&>(arg)可以触发以arg对象为参数的最合适、正确的的函数,或者构造函数:
- 如果
arg是作为其他函数的参数,那么std::forward<T&&>(arg)则是为了调用该函数的正确模板 - 如果
arg是作为同类型的构造函数的参数,那么std::forward<T&&>(arg)就是为了调用正确的构造函数版本。
因此,对于下面的demo,pass_by_perfect_ref能正确推断出输入参数arg的类型,将arg传给std::forward时,仍然能保持原类型,这就保证了合理的调用后续函数。
1 | pass_by_perfect_ref(str_const); // const string& |
by the way
提一下,std::forward与std::move的区别,再理解下std:forward的完美转发的含义。
有pass_by_perfect_ref和pass_by_move_ref两个函数,内部分别是调用std::forward和std::move进行转发后,再用于构造Foo对象。
1 | template<typename T> |
对于下面的函数调用,都是将左值foo作为传入参数:
1 | int main(int argc, char const *argv[]) { |
输出却不同:
1 | $ g++ reference.cc -o ref && ./ref |
std::forward<T&&>(arg) 输出后arg仍然是左值角色,最后调用的是Foo的复制构造函数,这也符合调用pass_by_perfect_ref函数调用者的预期,因为自己传入的就是foo,就是想要触发ctor。
std::move(arg)输出后arg变成右值角色,最后调用的是Foo的移动构造函数,这会导致pass_by_move_ref函数的调用者传入的foo对象处于未定义状态,这符合调用者的预期吗?明显不是。
对于调用者而言,是否要触发移动构造函数,应该由于调用调用者自己掌控:
- 想触发复制构造函数时,就如下(1)调用
- 想触发移动构造函数时,就如下(2)调用
1 | pass_by_perfect_ref(foo); // (1) |
这也就是经常各个书籍上看到将std::move称为无条件转发,而std::forward是完美转发的理由。
by the way, Again !!!
还记得在本文开篇说,模板函数不选择值传递的四种情况之一是:转发引用(T&&)。现在理解了一点没有?
因为在T&&下,pass_by_perfect_ref可以推导出任意类型的输入参数,调用合适的函数版本。这一过程像值传递一样,完全可以由调用者掌握。
1 | // 调用者自己可以选择 |
T&&也不会退化
自然,T&&引用也不会发生类型退化,传入的裸数组参数arg是什么类型就会变成相应类型的引用,不会加强也不会退化,类似于T&。
1 | pass_by_perfect_ref("hello perfect_ref"); // const char (&rhs)[18] |
T&&的缺点
T&&就当真那么perfect?
如果你在 pass_by_perfect_ref 函数内部使用T定义一个对象,那么当 pass_by_perfect_ref函数传入一个左值时,T此时会被推断为const T&或者T&,由于引用未初始化而报错:error: ‘obj’ declared as reference but not initialized。
1 | template<typename T> |
本文,主要讲解了值传递和引用传递的优缺点,但还有一个遗留问题:当模板函数的传入参数类型是字符串常量和裸数组时,该如何更好地处理?
- 值传递:字符串常量/数组会退化成指针,那如果想判断数组是否相等怎么办?
- 引用传递:字符串/数组会被推导出固定长度类型,比如上面的
const int (&rhs)[4],模板的灵活性不就丧失了?
敬请期待下一次更新,奥里给!!!