c++11-17 模板核心知识(七)—— 模板参数 按值传递 vs 按引用传递

朋友们可以关注下我的公众号,获得最及时的更新:

或者关注我的知乎账号 : https://www.zhihu.com/people/zhangyachen


按值传递

大多数人不喜欢将参数设置为按值传递的原因是怕参数拷贝的过程中带来的性能问题,但是不是所有按值传递都会有参数拷贝,比如:

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

我们逐一看一下上面的4个调用:

  • 第一个 : 我们传递了一个lvalue,这会使用std::string的copy constructor
  • 第二和第三个 : 这里传递的是prvalue(随手创建的临时对象或者函数返回的临时对象),一般情况下编译器会进行参数传递的优化,不会导致copy constructor这个也是C++17的新特性:Mandatory Copy Elision or Passing Unmaterialized Objects
  • 第四个 : 传递的是xvalue(一个使用过std::move后的对象),这会调用move constructor

虽然上面4种情况只有第一种才会调用copy constructor,但是这种情况才是最常见的。

Decay

之前的文章介绍过,当模板参数是值传递时,会造成参数decay:

  • 丢失const和volatile属性。
  • 丢失引用类型。
  • 传递数组时,模板参数会decay成指针。
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*

这种方式有优点也有缺点:

  • 优点:能够统一处理decay后的指针,而不必区分是char const*还是类似const char[13]
  • 缺点:无法区分传递的是一个数组还是一个指向单一元素的指针,因为decay后的类型都是char const*

按引用传递

按引用传递不会拷贝参数,也不会有上面提到的decay。这看起来很美好,但是有时候也会有问题:

传递const reference

template<typename T>
void printR (const T& 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

还是上面的例子,但是当模板参数声明改为const T&后,所有的调用都不会有拷贝。那么哪里会有问题呢?

大家都知道,传递引用时,实际传递的是一个地址,那么编译器在编译时不知道调用者会针对这个地址做什么操作。理论上,调用者可以随意改变这个地址指向的值(这里虽然声明为const,但是仍然有const_cast可以去除const)。因此,编译器会假设所有该地址的缓存(通常为寄存器)在该函数调用后都会失效,如果要使用该地址的值,会重新从内存中载入。

引用不会Decay

之前文章介绍过,按引用传递不会decay。因此如果传递的数组,那么推断参数类型时不会decay成指针,并且const和volatile都会被保留。

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]

因此,在printR函数内通过T声明的变量没有const属性。

传递nonconst reference

如果想改变参数的值并且不希望拷贝,那么会使用这种情况。但是这时我们不能绑定prvalue和xvalue给一个nonconst reference(这是c++的一个规则

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

同样,这种情况不会发生decay:

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

传递universal 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&&
passR(arr);          // OK: T deduced as int(&)[4] (also the type of arg)

但是这里需要额外注意一下,这是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::ref()和std::cref()

主要用来“喂”reference 给函数模板,后者原本以按值传递的方式接受参数,这往往允许函数模板得以操作reference而不需要另写特化版本:

template <typename T>
void foo (T val) ;

...
int x;
foo (std: :ref(x));
foo (std: :cref(x));

这个特性被C++标准库运用于各个地方,例如:

  • make_pair()用此特性于是能够创建一个 pair<> of references.
  • make_tuple()用此特性于是能够创建一个tuple<> of references.
  • Binder用此特性于是能够绑定(bind) reference.
  • Thread用此特性于是能够以by reference形式传递实参。

注意std::ref()不是真的将参数变为引用,只是创建了一个std::reference_wrapper<>对象,该对象引用了原始的变量,然后将std::reference_wrapper<>传给了参数。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"
}

区分指针和数组

前面说过,按值传递的一个缺点是,无法区分调用参数是数组还是指针,因为数组会decay成指针。那如果有需要区分的需求,可以这么写:

template <typename T, typename = std::enable_if_t<std::is_array_v<T>>>
void foo(T &&arg1, T &&arg2) {
  ...
}

std::enable_if后面会介绍,它的意思是,假如不符合enable_if设置的条件,那么该模板会被禁用。

其实现在基本上也不用原始数组和字符串了,都用std::string、std::vector、std::array。但是假如写模板的话,这些因素还是需要考虑进去。

处理返回值

一般在下面情况下,返回值会被声明为引用:

  • 返回容器或者字符串中的元素(eg. operator[]、front())
  • 修改类成员变量
  • 链式调用(operator<<、operator>>、operator=)

但是将返回值声明为引用需要格外小心:

auto s = std::make_shared<std::string>("whatever");
auto& c = (*s)[0];
s.reset();
std::cout << c;     // run-time ERROR

确保返回值为值传递

如果你确实想将返回值声明为值传递,仅仅声明T是不够的:

  • forwarding reference的情况,这个上面讨论过
template<typename T>
T retR(T&& p) {
    return T{...};        // OOPS: returns by reference when called for lvalues
}
  • 显示的指定模板参数类型:
template<typename T>  // Note: T might become a reference
T retV(T p) {
  return T{...}; // OOPS: returns a reference if T is a reference
}

int x;
retV<int&>(x);     // retT() instantiated for T as int&

所以,有两种方法是安全的:

  • std::remove_reference<> :
template<typename T>
typename std::remove_reference<T>::type retV(T p) {
  return T{...};     // always returns by value
}
  • auto :
template<typename T> 
auto retV(T p)  {     // by-value return type deduced by compiler
  return T{...};      // always returns by value
}

之前文章讨论过auto推断类型的规则,会忽略引用。

模板参数声明的推荐

  • 按值传递
    • 数组和字符串会decay。
    • 性能问题(可以使用std::ref和std::cref来避免,但是要小心这么做是有效的)。
  • 按引用传递
    • 性能更好。
    • 需要forwarding references,并且注意此时模板参数为隐式的引用类型。
    • 需要对参数是数组和字符串的情况额外关注。

一般性建议

对应模板参数,一般建议如下:

  • 默认情况下,使用按值传递。理由:
    • 简单,尤其是对于参数是数组和字符串的情况。
    • 对于小对象而言,性能也不错。调用者可以使用std::ref和std::cref.
  • 有如下理由时,使用按引用传递:
    • 需要函数改变参数的值。
    • 需要perfect forwarding。
    • 拷贝参数的性能不好。
  • 如果你对自己的程序足够了解,当然可以不遵守上面的建议,但是不要仅凭直觉就对性能做评估。最好的方法是:测试。

不要将模板参数设计的太通用

比如你的模板函数只想接受vector,那么完全可以定义成:

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

这里就没有必要定义为const T& v.

std::make_pair()模板参数历史演进

std::make_pair()是一个很好演示模板参数机制的例子:

  • 在C++98中,make_pair<>()的参数被设计为按引用传递来避免不必要的拷贝:
template<typename T1, typename T2>
pair<T1,T2> make_pair (T1 const& a, T2 const& b) {
  return pair<T1,T2>(a,b);
}

但是当使用存储不同长度的字符串或者数组时,这样做会导致严重的问题。 这个问题记录在See C++ library issue 181 [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));
}

标准库中perfect forward和std::decay是常见的搭配。

(完)

朋友们可以关注下我的公众号,获得最及时的更新:

posted @ 2020-11-21 00:11  张雅宸  阅读(1369)  评论(0编辑  收藏  举报