再探值与引用:函数模板的类型推导规则辨析
在 初探值与引用:学会正确地使用右值引用 一文中,讲解了右值引用的正确用法。本节继续在前文基础上,讲解值与引用在模板函数中的关系。
在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]
,模板的灵活性不就丧失了?
敬请期待下一次更新,奥里给!!!