值传递还是引用传递(By Value or By Reference)

对于模板实参,选择值传递还是引用传递时一个复杂的问题。虽然通常情况下引用传递的代价更低,但是我们还是建议:在没有更好的原因的话,建议使用值传递。而更好的原因通常包括以下几点:

  • 无法拷贝。
  • 参数用于返回数据。
  • 模板转发参数,并保持原有特性。
  • 有重大的性能提升。

下面就值传递和引用传递做一些探讨。

值传递

值传递原则上其是可以拷贝的,因而模板函数的实参实际上是传递参数的拷贝。对于类类型,则需要其有拷贝构造函数。拷贝通常是昂贵的,但是也有很多方法避免昂贵的拷贝。看个例子:

template<typename T>
void printV (T arg) {
  ...
}

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

对于 std::string 类型,拷贝是比较昂贵的。 std::string 内部需要申请一块内存用于存储数据,对 std::string 的拷贝是深拷贝(deep copy)。

对于第一个调用,传递是一个左值(lvalue),需要调用拷贝构造函数。

对于第二、第三个调用,传递的是一个纯右值(prvalue),编译器通常会优化,使得拷贝不会发生。在 C++17 之前,编译器不优化拷贝,但至少会尝试使用移动构造。C++17 开始,拷贝优化是必须的。

对于第四个调用,传递的是一个将亡值,会强制使用移动构造。

对于 std::string 类型,对于短字符串,也可能会采用SSO(Small String Optimization):在类内部存储数据,而不额外申请内存,这也使得拷贝不再那么昂贵。

此外,还有诸如返回值优化(RVO)也会减少拷贝构造函数的调用。RVO 详情可以参见 C++ 返回值优化 RVO

值传递的类型退化

对于值传递,就不得不提值传递的类型退化(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* ,函数模板会被推断为:

void printV (char const* arg)
{
  ...
}

这种特性是继承自 C 语言,既有好处也有坏处。它简化了字符串字面值的处理,但是在函数内部我们无法分辨参数是指向一个元素的指针还是原始数组。下面的章节也会单独讨论字符串字面值和原始数组的处理。

引用传递

接下来,我们讨论下引用传递的特点。引用传递不会发生拷贝,参数类型也不会退化,但也会有一些问题。

常量引用传递

为了避免不必要的拷贝,并且在函数体内不修改实参值,可以将模板行参申明为常量引用(constant reference)。例如:

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

引用的底层实现其实是指针,本质上传引用和传其指针是一样的。

引用传递时,不会发生类型退化,也即原始数组不会转为指针,cv 限定符不会移除。例如:

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]

行参被声明为 const T&T 本身不会被推断为 const。因此,在函数内部申明为 T 类型的局部变量不是 constant 的。

非常量引用传递

如果想通过传递参数返回数据,可以将模板函数行参申明为非常量引用(nonconstant reference)。例如:

template<typename T>
void outR (T& arg) {
  ...
}

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

注意,此时传递 prvalue 或者 xvalue 不被允许。同样的非常量引用传递值的类型也不会发生类型退化。例如:

int arr[4];
outR(arr); // OK: T deduced as int[4], arg is int(&)[4]

outR() 内部可以判断传入值是不是数组类型。例如:

template<typename T>
void outR (T& arg) {
  if (std::is_array<T>::value) {
    std::cout << "got array of " << std::extent<T>::value << " elems\n";
  }
  ...
}

如果你传入的实参是 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 6
outR("hi");                // OK: T deduced as char const[3]

如果想避免传递 const 实参给 nonconst 行参,可以有如下方法:

  • 使用静态断言,可以在编译期拦截。
template<typename T>
void outR (T& arg) {
  static_assert(!std::is_const<T>::value,
                "out parameter of foo<T>(T&) is const");
  ...
}
  • 使用 enable_if 禁止模板实例化。
template<typename T,
         typename = std::enable_if_t<!std::is_const<T>::value>
void outR (T& arg) {
  ...
}

或者使用 concepts:

template<typename T>
requires !std::is_const_v<T>
void outR (T& arg) {
  ...
}

转发引用传递

如果希望模板具有转发实参的特性,则需要将模板行参申明为转发引用(forwarding reference)。例如:

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&&
int arr[4];
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)

看似转发引用是完美的。对于实参是一个左值,T 会被推断为一个引用类型,如果用 T 申明一个没有初始化的局部变量将导致错误。例如:

template<typename T>
void passR(T&& arg) { // arg is a forwarding reference
  T x;   // for passed lvalues, x is a reference, which requires an initializer
  ...
}

foo(42);  // OK: T deduced as int
int i;
foo(i);   // ERROR: T deduced as int&, which makes the declaration of x in passR() invalid

一种解决方案是使用 std::remove_reference:

template<typename T>
void passR(T&& arg)
{
  std::remove_reference_t<T> x;
}

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

从 C++11 开始,对于模板实参是值传递,调用者可以决定是值传递还是引用传递。使用 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::ref()std::cref() 创建了一个 std::reference_wrapper<> 对象来包裹传递的参数 s,使之表现的像一个引用,然后通过值传递传递这个对象,并且这个包裹可以隐式转回原始类型。例如:

#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

要注意的是:编译器需要知道这种包裹隐式转回原始类型,例如上面例子的 printString() 的调用,才会将 std::reference_wrapper<const std::string> 再隐式转回 const std::string 。因此,std::ref()std::cref() 通常只在通过泛型代码传递对象时正常工作。例如,下面代码将无法工作:

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

因为 operator<< 没有支持 std::reference_wrapper<const std::string> 。但是下面的代码却可以正常工作:

template<typename T>
void printV (T arg) {
  std::cout << arg << ’\n’;
}
...
int x = 2;
printV(std::cref(x));  // ok
float y = 2.2;
printV(std::cref(y));  // ok

猜测 operator<< 支持了 std::reference_wrapper<const int>std::reference_wrapper<const float>

此外,需要注意的是:std::ref()std::cref() 只是尝试模拟引用传递,并不能真正变成引用,在非模板情况下,std::ref()std::cref() 没法实现引用传递,只有模板自动推导类型时,std::ref()std::cref() 能用包装类型 reference_wrapper<> 来代替原本会被识别的值类型,而 reference_wrapper <> 能隐式转换为被引用的值的引用类型。

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

对于模板行参为字符串字面值和原始数组:

  • 值传递会使参数类型退化。
  • 引用传递不会使参数类型退化。

这两种方式都有好处和坏处。当数组退化成指针时,失去了原始数组的信息。但是不退化也会有问题,比如两个长度不同的字符串字面值是两种不同的类型。例如:

template<typename T>
void foo (T const& arg1, T const& arg2)
{
  ...
}
foo("hi", "guy"); // ERROR

这里 "hi" 的类型是 const char[3],而 “guy” 的类型是 const char[4],二者类型不同,编译失败。如果将 foo 参数申明为值传递:

template<typename T>
void foo (T arg1, T arg2)
{
  if (arg1 == arg2) { // OOPS: compares addresses of passed arrays
    ...
  }
}
foo("hi", "guy");  // compiles, but ...

编译没有问题,但是 operator== 是比较的是二者的地址,而显然期望的是比较两个字符串值。

对于字符串字面值和原始数组的模板函数,这里提供两种方案。

  • 申明只对数组有效的模板参数
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 traits 检查传参是否为数组
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 可能被隐式推导成引用。例如:

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<>T 转换为非引用。
template<typename T>
typename std::remove_reference<T>::type retV(T p)
{
  return T{...}; // always returns by value
}
  • 申明返回值为 auto, 让编译器自动推导返回值类型,因为 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() 实现按引用传递的效果。

如果有更好的理由,可以选择引用传递:

  • 你需要使用行参返回结果,可以传 nonconst 引用。
  • 如果模板用来转发实参,使用转发引用。并考虑使用 std::decaystd::common_type 协调字符串字面值和原始数组的不同类型。
  • 如果性能是关键因素,并且拷贝开销很大,使用 const 引用。

当然,也不要局限于这些建议,不要对性能做直观假设,而是要实测。

实践当中,模板类型最好不要适配任意类型实参。例如,你只传递的某种类型的 std::vector,则不要过于泛化地申明行参,应该只将 std::vector 的类型模板化:

template<typename T>
void printVector (std::vector<T> const& v)
{
  ...
}

再看一个例子, std::make_pair 是一个很好的揭示参数传递机制陷阱的例子。

在 C++98 中,为了避免不必要的拷贝,使用引用传递:

template<typename T1, typename T2>
pair<T1,T2> make_pair (T1 const& a, T2 const& b)
{
  return pair<T1,T2>(a,b);
}

但这在原始数组和字符串字面值的大小不同时会产生严重问题,可以参见 LibIssue181

因此,在 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));
}

这是个示例,完整实现要比这个更复杂。

至此,本文结束。

参考:

  • http://www.tmplbook.com
  • 5
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值