7-按值传递与按引用传递

按值传递

  • 按值传递实参时,原则上会拷贝每个实参,对于类通常还需要调用拷贝构造函数。调用拷贝构造函数可能开销很大,但事实上编译器可能对按值传递做优化,通过对复杂的对象使用移动语义避免高开销
template<typename T>
void printV(T arg) {}

std::string s = "hi";
printV(s);
  • 上例中,arg变成一个s的拷贝,然而拷贝构造函数不是总会被调用
std::string returnString();
std::string s = "hi";
printV(s); // 拷贝构造
printV(std::string("hi")); // 通常会优化拷贝,否则使用移动构造
printV(returnString()); // 通常会优化拷贝,否则使用移动构造
printV(std::move(s)); // 移动构造
  • C++17开始,优化是被要求的。C++17前编译器不优化拷贝,但至少必须尝试使用移动语义。在最后一个调用中传递xvalue(一个已存在的使用std::move得到的non-const对象),将强制调用移动构造函数来说明不再需要s的值
  • 因此只有传递左值时,才会对按值传递的函数造成大的开销,然而这却是大多数常见情况

按值传递与类型退化(Decay)

  • 按值传递时,字符串字面值和原始数组会转换为指针,cv限定符会被移除
template<typename T>
void printV(T arg) {}

const std::string c = "hi";
printV(c); // 移除const限定符,T为std::string
printV("hi"); // 退化为指针,T为const char*
int arr[4];
printV(arr); // 退化为指针,T为int*
  • 这个行为是由C继承而来的,优点是能简化传递的字符串字面值的处理,缺点是不能区分指针指向的是单元素还是原始数组,之后会讨论如何处理字符串字面值和原始数组

传const引用

  • 传const引用可以避免不必要的拷贝
template<typename T>
void printR(const T&) {}

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(const int&) {}
  • 按引用传递时,原始数组不会转为指针,cv限定符不会移除。调用参数被声明为const T&,T本身不会被推断为const
template<typename T>
void printR(const T& x) {}

const std::string c = "hi";
printR(c); // T推断为std::string,x为const std::string&
printR("hi"); // T推断为char[3],x为char const(&)[3]
int arr[4];
printR(arr); // T推断为int[4],x为inconst T(&)[4]

传non-const引用

  • 想修改传递的实参时,必须传non-const引用(也可以用指针)
template<typename T>
void outR(T& x) {}

// 不能传递临时变量(prvalue)或std::move()传递的对象(xvalue)
std::string returnString();
std::string s = "hi";
outR(s); // OK:T推断std::string,x为std::string&
outR(std::string("hi")); // 错误:不允许传递临时量(prvalue)
outR(returnString()); // 错误:不允许传递临时量(prvalue)
outR(std::move(s)); // 错误:不允许传递xvalue

// 可以传递non-const原始数组
int arr[4];
outR(arr); // OK: T推断为int[4],arg为int(&)[4]
// 因此可以修改元素,比如获取数组大小
template<typename T>
void outR(T& arg)
{
  if (std::is_array<T>::value)
  {
    std::cout << std::extent<T>::value;
  }
}
  • 但这有一个潜在的问题是,如果传递const实参,arg会被推断为一个const引用,这意味着在期望左值的地方传递右值会被允许
const std::string c = "hi";
outR(c); // OK:T推断const std::string
outR(returnConstString()); // OK: 同上(returnConstString返回const string)
outR(std::move(c)); // OK:T推断const std::string
outR("hi"); // OK:T推断为const char[3]
  • 在这种情况下,函数被完全实例化时,任何修改实参的尝试都会产生错误。如果想禁止传递const对象给non-const引用,可以使用一个static断言,这样在传递const实参时将触发编译期错误
template<typename T>
void outR(T& arg)
{
  static_assert(!std::is_const<T>::value, "out parameter of f<T>(T&) is const");
}
template<typename T, typename = std::enable_if_t<!std::is_const_v<T>>
void outR(T& arg) {}
  • 或者使用concepts(如果支持的话)
template<typename T>
requires !std::is_const_v<T>
void outR(T& arg) {}

传转发引用

  • 可以传任何类型给转发引用,这是唯一区分右值、const左值、non-const左值三种类型行为的方法
template<typename T>
void passR(T&& arg) {}

std::string s = "hi";
passR(s); // OK:T和arg都是std::string&
passR(std::string("hi")); // OK:T推断为std::string,arg为std::string&&
passR(returnString()); // OK: T推断为std::string,arg为std::string&&
passR(std::move(s)); // OK: T推断为std::string,arg为std::string&&
passR(arr); // OK:T和arg都是int(&)[4]
const std::string c = "hi";
passR(c); // OK: T和arg都是const std::string&
passR("hi"); // OK:T和arg都是char const(&)[3]
int arr[4];
passR(arr); // OK:T和arg都是int(&)[4]
  • 传转发引用时,如果实参是左值,T一定会被推断为引用类型。这导致了一个潜在的问题,如果声明T类型局部对象可能产生错误
template<typename T>
void passR(T&& arg)
{
  T x; // 如果T是引用类型,x就会因没有初始化而出错
}

passR(42); // OK: T推断为int
int i;
passR(i); // 错误:T推断为int&,使得模板中的x声明无效
template<typename T>
void f(T&&)
{
  std::remove_reference_t<T> x; // x一定不会是一个引用
}

使用std::ref()和std::cref()

  • C++11开始,模板声明为按值传递时,可以用std::ref()/std::cref()实现按引用传递的效果
#include <functional>

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()不会改变模板中的参数处理,只是用一个看起来像引用的std::reference_wrapper对象包裹传递的实参,再按值传递这个包裹。这个包裹支持一个回到原始类型的隐式类型转换,这个转换将生成一个原始对象
#include <functional>
#include <string>
#include <iostream>

void printString(const std::string& 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<const std::string>类型对象给参数arg,随后会传递和转回它的底层类型std::string
  • 编译器必须知道到原始类型的隐式转换,因此std::ref()和std::cref()通常只在通过泛型代码传递对象时正常工作。比如传递的是std::reference_wrapper时,直接打印对象将失败,因为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)); // 错误:没有定义用于reference wrapper的operator<<
  • 同理下面的代码会出错
template<typename T1, typename T2>
bool isless(T1 arg1, T2 arg2)
{
  return arg1 < arg2;
}

std::string s = "hello";
if (isless(std::cref(s), "world")) ... // 错误
if (isless(std::cref(s), std::string("world"))) ... // 错误

处理字符串字面值和原始数组

  • 如果字符串字面值不退化则会造成不同大小的字符串字面值类型不同的问题
template<typename T>
void f(const T& arg1, const T& arg2)
{}

f("hi", "guy"); // 错误:const char[3]和const char[4]
  • 声明为按值传递则可以编译
template<typename T>
void f(T arg1, T arg2)
{}

f("hi", "guy");
  • 虽然可以编译,但更糟的是,编译期问题可能变成运行期问题,比如比较两个参数时,实际比较的是指针地址,而非指向的内容
template<typename T>
void f(T arg1, T arg2)
{
  if (arg1 == arg2) ...
}

f("hi", "guy"); // 比较的是两者的指针地址

用于字符串字面值和原始数组的特殊实现

  • 为了区分传递的是数组还是指针,可以声明只对数组有效的模板参数
template<typename T, std::size_t L1, std::size_t L2>
void f(T (&arg1)[L1], T (&arg2)[L2])
{
  T* pa = arg1; // decay arg1
  T* pb = arg2; // decay arg2
  if (compareArrays(pa, L1, pb, L2))
  {
    ...
  }
}
  • 也可以使用type traits检查传递的是否为数组(或指针)
template<typename T, typename = std::enable_if_t<std::is_array_v<T>>>
void f(T&& arg1, T&& arg2) {}

处理返回值

  • 对于返回值也能决定按值传递还是按引用传递,然而返回引用可能造成潜在的问题。有一些返回引用的常见实践
    • 返回容器元素(如通过operator[]front()
    • 允许对类成员进行写访问
    • 为链式调用返回对象(stream的operator<<operator>>,类对象的operator)
  • 另外,通常返回const引用为成员授予只读权限
  • 如果使用不当,所有的这些情况都可能造成问题
std::string* s = new std::string("whatever");
auto& c = (*s)[0];
delete s;
std::cout << c; // run-time ERROR
  • 上面使用了delete,也许可以很快定位到错误,但问题还可以变得更难发现
auto s = std::make_shared<std::string>("whatever");
auto& c = (*s)[0];
s.reset();
std::cout << c; // run-time ERROR
  • 因此应该确保函数模板按值传递返回结果,然而模板参数T有时可能隐式推断为引用
template<typename T>
T retR(T&& p)
{
  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&
template<typename T>
std::remove_reference_t<T> retV(T p)
{
  return T{...}; // always returns by value
}
  • 另一种方法是,使用auto作为返回类型,这个特性在C++14中引入
template<typename T>
auto retV(T p) // by-value return type deduced by compiler
{
  return T{...}; // always returns by value
}

如果没有特殊需求,推荐按值传递

  • 按值传递很简单且通常对字符串字面值有效,对小的实参和临时或可移动对象的执行很好,对于大的左值对象也能用std::ref()和std::cref()实现按引用传递的效果
  • 如果需要一个输入输出参数,它返回一个新对象或允许修改实参,传non-const引用(也可以按指针传递),但注意要考虑意外接收const对象的情况
  • 如果提供一个模板用来转发实参,使用转发引用,并考虑使用std::decaystd::common_type协调字符串字面值和原始数组的不同类型
  • 如果性能十分关键,拷贝开销巨大,传const引用。当然,如果需要局部拷贝这是不适用的
  • 如果你有更深刻的理解,不用遵循这些推荐,但不要对性能做出直观的假设,而要实测

不要过度泛型

  • 注意在实践中,函数模板一般不是用于任意类型的实参,比如只传递某种类型的std::vector,这种情况下最好不要过于笼统地声明函数,而是针对特定情况声明如下
template<typename T>
void printVector(const std::vector<T>& v);
  • 在这个声明中,可以确定传递的T不能是引用(vector元素不能是引用类型),且显然按值传递会造成非常大的开销,所以选择传const引用

std::make_pair的例子

  • std::make_pair是一个很好的揭示决定参数传递机制陷阱的例子,它在C++98中使用按引用传递以避免不必要的拷贝
template<typename T1, typename T2>
pair<T1, T2> make_pair(const T1& a, const T2& 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<decay_t<T1>, decay_t<T2>>
make_pair(T1&& a, T2&& b)
{
  return pair<decay_t<T1>, decay_t<T2>>(forward<T1>(a), forward<T2>(b));
}

文章作者: 张小飞
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-ND 4.0 许可协议。转载请注明来源 张小飞 !
 上一篇
08-编译期编程 08-编译期编程
C++有许多支持编译期编程的特性 C++98前,模板提供了编译期计算的能力,包括使用循环和执行路径选择 使用偏特化,可以在编译期根据特定的约束或要求,在不同的类模板实现之间进行选择 使用SFINAE,允许在不同的函数模板实现之间对不同的类
下一篇 
6-移动语义与enable_if 6-移动语义与enable_if
完美转发(Perfect Forwarding) 要用一个函数将实参的如下基本属性转发给另一个函数 可修改的对象转发后应该仍可以被修改 常量对象应该作为只读对象转发 可移动的对象应该作为可移动对象转发 如果不使用模板实现这些功能,必须编
  目录