【C++ Templates(7)】传值还是传引用
2018-04-19 本文已影响154人
downdemo
传值
- 传值传递实参时,每个实参原则上必须被拷贝,因此每个参数变成一个实参的拷贝,对于类,对象通常会通过拷贝构造函数创建一个拷贝。调用拷贝构造函数的代价可以十分昂贵,然而传值时有许多避免高开销的方法:事实上编译器可能做优化,对复杂的对象使用移动语义
template<typename T>
void printV (T arg) {
...
}
std::string s = "hi";
printV(s);
- 上例中,arg变成一个s的拷贝,然而隐藏的拷贝构造函数不是总会被调用
std::string returnString();
std::string s = "hi";
printV(s); // copy constructor
printV(std::string("hi")); // copying usually optimized away(if not, move constructor)
printV(returnString()); // copying usually optimized away(if not, move constructor)
printV(std::move(s)); // move constructor
- 注意C++17开始,优化是被要求的,C++17前编译器不优化拷贝,但至少必须尝试使用移动语义。在最后一个调用中传递xvalue(一个已存在的使用std::move()得到的非常量对象),调用移动构造函数来说明不再需要s的值
- 因此只有传值调用时开销昂贵,然而这却是大多数常见情况
- 传值还有另一个属性,类型衰变(decay),原始数组会转换为指针,cv限定符会移除
template<typename T>
void printV (T arg) {
...
}
std::string const c = "hi";
printV(c); // c decays so that arg has type std::string
printV("hi"); // decays to pointer so that arg has type char const*
int arr[4];
printV(arr); // decays to pointer so that arg has type char const*
- 因此传递字符串字面值"hi"时,它的类型char const[3]衰变为char const*,以此推断T,模板实例化如下
void printV (char const* arg)
{
...
}
- 这个行为是由C继承而来的,通常能简化传递字符串字面值的处理,但缺点是不能区分传递一个指针指向单元素还是原始数组
传引用
传const引用
- 为了禁止任何不必要的拷贝,传递非模板对象时,可以用const引用
template<typename T>
void printR (T const& arg) {
...
}
std::string returnString();
std::string s = "hi";
printR(s); // no copy
printR(std::string("hi")); // no copy
printR(returnString()); // no copy
printR(std::move(s)); // no copy
- 即使对一个int也不会拷贝
int i = 42;
printR(i); // 传引用而不是拷贝i
// 模板实例化为
void printR(int const& arg) {
...
}
- 传引用时不会衰变,原始数组不会转为指针,cv限定符不会移除,但参数被声明为T const&,T本身不会被推断为const
template<typename T>
void printR (T const& arg) {
...
}
std::string const c = "hi";
printR(c); // T deduced as std::string, arg is std::string const&
printR("hi"); // T deduced as char[3], arg is char const(&)[3]
int arr[4];
printR(arr); // T deduced as int[4], arg is int const(&)[4]
传non-const引用
- 当需要通过传递的实参返回值时,必须使用non-const引用
template<typename T>
void outR (T& arg) {
...
}
// 临时变量(prvalue)或std::move()传递的对象(xvalue)是不允许的
std::string returnString();
std::string s = "hi";
outR(s); //OK: T deduced as std::string, arg is std::string&
outR(std::string("hi")); //ERROR: not allowed to pass a temporary (prvalue)
outR(returnString()); // ERROR: not allowed to pass a temporary (prvalue)
outR(std::move(s)); // ERROR: not allowed to pass an xvalue
// 可以传递non-const原始数组
int arr[4];
outR(arr); // OK: T deduced as int[4], arg is int(&)[4]
// 可以修改元素,处理数组大小
template<typename T>
void outR (T& arg) {
if (std::is_array<T>::value) {
std::cout << "got array of " << std::extent<T>::value << " elems\n";
}
...
}
- 然而模板有一个trick,如果传递const实参,arg可能推断为一个const引用声明,这意味着在期望左值的地方传递右值会被允许
std::string const c = "hi";
outR(c); // OK: T deduced as std::string const
outR(returnConstString()); // OK: same if returnConstString() returns const string
outR(std::move(c)); // OK: T deduced as std::string const
outR("hi"); // OK: T deduced as char const[3]
- 当然在这种情况下,函数被完全实例化时,任何修改实参的尝试都会产生错误
- 如果想禁止传递const对象给non-const引用,可以使用一个static断言触发编译期错误
template<typename T>
void outR (T& arg) {
static_assert(!std::is_const<T>::value, "out parameter of foo<T>(T&) is const");
...
}
- 也可以使用std::enable_if<>在此情况下禁用模板
template<typename T,
typename = std::enable_if_t<!std::is_const<T>::value>
void outR (T& arg) {
...
}
- 或者使用concept(如果支持的话)
template<typename T>
requires !std::is_const_v<T>
void outR (T& arg) {
...
}
传转发引用
*使用引用调用的一个原因是为了完美转发参数,但记住使用转发引用(定义为一个模板参数的右值引用)时,适用特殊的类型推断规则
template<typename T>
void passR (T&& arg) { // arg declared as forwarding reference
...
}
// 可以传所有东西给转发引用,不会创建拷贝
std::string s = "hi";
passR(s); // OK: T deduced as std::string& (also the type of arg)
passR(std::string("hi")); // OK: T deduced as std::string, arg is std::string&&
passR(returnString()); // OK: T deduced as std::string, arg is std::string&&
passR(std::move(s)); // OK: T deduced as std::string, arg is std::string&&
passR(arr); // OK: T deduced as int(&)[4] (also the type of arg)
- 特殊的类型推断可能产生一些意外
std::string const c = "hi";
passR(c); // OK: T deduced as std::string const&
passR("hi"); // OK: T deduced as char const(&)[3] (also the type of arg)
int arr[4];
passR(arr); // OK: T deduced as int (&)[4] (also the type of arg)
- 上例中每种情况都可以知道参数arg类型,无论传的是右值还是const/non-const左值,这是唯一区分三种类型行为的方法,但如果T能隐式转换为引用类型,使用T声明一个未初始化的局部对象可能产生错误
template<typename T>
void passR(T&& arg) { // arg是一个传递的左值的转发引用
T x; // x是一个需要初始化的引用
...
}
foo(42); // OK: T推断为int
int i;
foo(i); // ERROR: T推断为int&,使得模板中的x声明无效
使用std::ref()和std::cref()
- C++11开始,函数模板实参传值还是引用,可以由调用者决定。模板声明为传值时,调用者可以用<functional>中的std::ref()和std::cref()传引用
template<typename T>
void printT (T arg) {
...
}
std::string s = "hello";
printT(s); // pass s by value
printT(std::cref(s)); // pass s “as if by reference”
- 但注意std::cref()不会改变模板中的参数处理,而是用一个trick:用一个看起来像引用的对象包裹传递的实参。事实上它会创建一个std::reference_wrapper<>类型的对象,表示原始的实参,并将此对象传值。这个包裹支持一个操作:一个回到原始类型的隐式类型转换,产生一个原始对象。因此对传递对象如果有一个有效的操作,可以使用引用包裹替代
#include <functional> // for std::cref()
#include <string>
#include <iostream>
void printString(std::string const& s)
{
std::cout << s << '\n';
}
template<typename T>
void printT (T arg)
{
printString(arg); // might convert arg back to std::string
}
int main()
{
std::string s = "hello";
printT(s); // print s passed by value
printT(std::cref(s)); // print s passed ''as if by reference''
}
- 最后一个调用以传值传递一个std::reference_wrapper<string const>类型对象给参数arg,随后会传递和转回它的下层类型std::string
- 注意编译器必须知道到原始类型的隐式转换,因此如果通过泛型代码传递对象通常std::ref()和std::cref()是有效的,比如直接输出T类型传递对象将失败,因为没有为std::reference_wrapper<>定义输出操作
template<typename T>
void printV (T arg) {
std::cout << arg << '\n';
}
...
std::string s = "hello";
printV(s); // OK
printV(std::cref(s)); // ERROR: no operator << for reference wrapper defined
- 同样下面的代码会出错,因为无法比较char const*和std::string的引用包裹
template<typename T1, typename T2>
bool isless(T1 arg1, T2 arg2)
{
return arg1 < arg2;
}
...
std::string s = "hello";
if (isless(std::cref(s) < "world")) ... // ERROR
if (isless(std::cref(s) < std::string("world"))) ... // ERROR
处理字符串字面值和原始数组
- 如果字符串字面值不衰变则会造成一个问题,不同大小的字符串字面值类型不同
template<typename T>
void foo (T const& arg1, T const& arg2)
{
...
}
foo("hi", "guy"); // ERROR:char const[3] and char const[4]
- 声明为传值则可以编译
template<typename T>
void foo (T arg1, T arg2)
{
...
}
foo("hi", "guy"); //compiles, but ...
- 虽然可以编译,但更糟的是,编译期问题可能变成运行期问题
template<typename T>
void foo (T arg1, T arg2)
{
if (arg1 == arg2) { // OOPS: compares addresses of passed arrays
...
}
}
foo("hi", "guy"); // compiles, but ...
字符串字面值和原始数组的特殊实现
- 为了区分传递的是数组还是指针,可以声明只对数组有效的模板参数
template<typename T, std::size_t L1, std::size_t L2>
void foo(T (&arg1)[L1], T (&arg2)[L2])
{
T* pa = arg1; // decay arg1
T* pb = arg2; // decay arg2
if (compareArrays(pa, L1, pb, L2)) {
...
}
}
- 也可以使用type trait检查传递的是否为数组(或指针)
template<typename T,
typename = std::enable_if_t<std::is_array_v<T>>>
void foo (T&& arg1, T&& arg2)
{
...
}
处理返回值
- 对于返回值也能决定传值还是传引用,然而返回引用可能造成潜在的问题。有一些返回引用的常见实践
- 返回容器元素(如通过operator[]或front())
- 允许对类成员授予写访问
- 为链式调用返回对象(流的operator<<和operator>>,类对象的operator)
- 另外,通常返回const引用为成员授予只读权限
- 如果使用不当,所有的这些情况都可能造成问题
std::string* s = new std::string("whatever");
auto& c = (*s)[0];
delete s;
std::cout << c; // run-time ERROR
- 问题还可以变得更难发现
auto s = std::make_shared<std::string>("whatever");
auto& c = (*s)[0];
s.reset();
std::cout << c; //run-time ERROR
- 因此应该确保函数模板传值返回结果,然而用一个模板参数T并不能保证它不是一个引用,因为T有时可能隐式推断为引用
template<typename T>
T retR(T&& p) // p is a forwarding reference
{
return T{...}; // OOPS: returns by reference when called for lvalues
}
- 即使当T由传值调用推断而来,当显式指定模板参数为引用时T也可能变成一个引用
template<typename T>
T retV(T p) //Note: T might become a reference
{
return T{...}; // OOPS: returns a reference if T is a reference
}
int x;
retV<int&>(x); // retT() instantiated for T as int&
- 安全起见有两种选择,一是使用type trait,std::remove_reference<>(其他的trait如std::decay<>也行)把T转为非引用
template<typename T>
typename std::remove_reference<T>::type retV(T p)
{
return T{...}; // always returns by value
}
- 另一种选择是C++14开始提供的使用auto让编译器推断
template<typename T>
auto retV(T p) // by-value return type deduced by compiler
{
return T{...}; // always returns by value
}
推荐的模板参数声明
- 默认声明参数为传值,这很简单且通常对字符串字面值有效,对小的实参和临时或可移动对象的执行很好,传递已存在的巨大对象(左值)调用者有时能用std::ref()和std::cref()避免昂贵的拷贝
- 如果有其他更好的原因才不这样做
- 如果需要一个out或inout参数,它返回一个新对象或允许修改实参,传non-const引用(除非你更喜欢通过指针传递),但需要考虑意外接收const对象的情况
- 如果提供一个模板用来转发实参,声明实参为转发引用,在适当的地方使用std::forward<>(),考虑使用std::decay<>或std::common_type<>协调字符串字面值和原始数组的不同类型
- 如果性能十分关键,拷贝十分昂贵,使用const引用。当然,如果需要局部拷贝这是不适用的
- 如果你有更深刻的理解,不用遵循这些推荐,但不要对性能做出直观的假设,而是去测量
不要过度泛型
- 注意在实践中,函数模板通常不是针对任意类型的实参,而一些限制是适用的。比如知道只传递某种类型的vector,这种情况下最好不要过于笼统地声明函数,如下声明可能意外地产生副作用
template<typename T>
void printVector (std::vector<T> const& v)
{
...
}
- 在这个声明中,可以确定传递的T不能是引用(vector元素不能是引用类型),且显然传值会造成非常大的开销
std::make_pair()的例子
- std::make_pair()是一个很好的揭示决定参数传递机制陷阱的例子,C++98中它声明在std中,使用传引用调用避免不必要的拷贝
template<typename T1, typename T2>
pair<T1,T2> make_pair (T1 const& a, T2 const& b)
{
return pair<T1,T2>(a,b);
}
- 然而使用不同大小的字符串字面值或原始数组时,几乎马上造成严重问题,因此C++03中改为了传值调用
template<typename T1, typename T2>
pair<T1,T2> make_pair (T1 a, T2 b)
{
return pair<T1,T2>(a,b);
}
- 但在C++11中,必须支持移动语义,所以实参必须变为转发引用
template<typename T1, typename T2>
constexpr pair<typename decay<T1>::type, typename
decay<T2>::type>
make_pair (T1&& a, T2&& b)
{
return pair<typename decay<T1>::type,
typename decay<T2>::type>(forward<T1>(a), forward<T2>(b));
}
- 完整的实现更加复杂,需要支持std::ref()和std::cref(),函数还要对std::reference_wrapper的实例解包裹为真正的引用
- 标准库现在有许多地方以相似方式完美转发传递实参,通常结合使用std::decay<>