C++ 模板参数:按值传递 vs 按引用传递

  • 按值传递
    • Decay
  • 按引用传递
    • 引用不会 Decay
    • 传递 const reference
    • 传递 nonconst reference
    • 传递 universal reference
  • 使用 std::ref()和 std::cref()
  • 区分指针和数组
  • 处理返回值
    • 确保返回值为值传递
  • 模板参数声明的推荐
    • 一般性建议
    • 不要将模板参数设计的太通用
  • std::make_pair()模板参数历史演进

按值传递

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

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
    [1]
  • 第四个 : 传递的是 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++的一个 规则
[2]

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  // for std::cref()
#include
#include

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>>
void foo(T &&arg1, T &&arg2) {
...
}

std::enable_if
后面会介绍,它的意思是,假如不符合 enable_if 设置的条件,那么该模板会被禁用。
其实现在基本上也不用原始数组和字符串了,都用 std::string、std::vector、std::array。但是假如写模板的话,这些因素还是需要考虑进去。

处理返回值

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

  • 返回容器或者字符串中的元素(eg. operator[]、front())
  • 修改类成员变量
  • 链式调用(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::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& v)
{
...
}

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

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

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

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

但是当使用存储不同长度的字符串或者数组时,这样做会导致严重的问题。这个问题记录在 See C++ library issue 181 [LibIssue181]
[3]

  • 于是在 C++03 中,模板参数改为了按值传递:
template<typename T1, typename T2>
pair make_pair (T1 a, T2 b)
{
return pair(a,b);
}
  • C++11 引入了移动语义,于是定义又改为(真实定义要比这个复杂一些):
template <typename T1, typename T2>
constexpr pair<typename decay::type, typename decay::type>
make_pair(T1 &&a, T2 &&b)
{
return pair<typename decay::type, typename decay::type>(
forward(a), forward(b));
}

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

参考资料

[1]

Mandatory Copy Elision or Passing Unmaterialized Objects: http://www.cplusplus2017.info/c17-guaranted-copy-elision/

[2]

规则: https://stackoverflow.com/questions/1565600/how-come-a-non-const-reference-cannot-bind-to-a-temporary-object

[3]

See C++ library issue 181 [LibIssue181]: https://cplusplus.github.io/LWG/issue181