Item 41: Consider pass by value for copyable parameters that are cheap to move and always copied.

Item 41: Consider pass by value for copyable parameters that are cheap to move and always copied.

C++ 函数参数传递方式有值传递、指针传递、引用传递的方式。一般地,考虑到拷贝开销,建议使用引用传递的方式。例如:

class Widget {
public:
  void addName(const std::string& newName) // take lvalue;
  { names.push_back(newName); }            // copy it
  
  void addName(std::string&& newName)      // take rvalue;
  { names.push_back(std::move(newName)); } // move it; see
  ...                                      // Item 25 for use
                                           // of std::move
private:
  std::vector<std::string> names;
};

对于左值,拷贝进 Widget.names 中。对于右值,移动进 Widget.names。上面代码是有效的,但是实现和维护两个函数有点冗余。

另一种方案是使用万能引用(universal reference)传参。例如:

class Widget {
public:
  template<typename T>                            // take lvalues
  void addName(T&& newName)                       // and rvalues;
  {                                               // copy lvalues,
    names.push_back(std::forward<T>(newName));    // move rvalues;
  }                                               // see Item 25
                                                  // for use of
  ...                                             // std::forward
};

万能引用版本代码量减少了很多,看起来也清爽很多,但也会有其他问题。但模板的实现一般要放到头文件里,也会实例化出多个版本(左值版本、右值版本以及可以转换为 std::string 的类型版本)。于此同时,还存在诸如 Item 30 介绍万能引用和完美转发失效的例子、Item 27 介绍的传参错误时编译报错可读性很差的问题。

那么有没有什么完美的方案可以解决上述两种方案遇到的问题呢?我们来分析下值传递的方案。

class Widget {
public:
  void addName(std::string newName)         // take lvalue or
  { names.push_back(std::move(newName)); }  // rvalue; move it
  ...
};

addName 内对 newName 使用 std::move 可以减少一次拷贝。这里使用 std::move 考虑到两点:首先,newName 独立于传入的参数,不会影响到调用者;再者,这里是最后使用 newName 的地方,对其移动不会影响其他代码。

值传递的方案可以解决引用重载版本的源码冗余问题和万能引用版本的不适用场景、传参错误报错信息可读性等问题,那剩下的问题就是值传递方案的性能了。

在 C++98 中,对于值传递的方案,不管传入的左值还是右值,newName 都会通过拷贝构造函数来构造。而到了 C++11,newName 在传入左值时是拷贝构造,传入右值是移动构造。考虑到下面的代码:

Widget w;
...
std::string name("Bart");

w.addName(name);            // call addName with lvalue
...
w.addName(name + "Jenne");  // call addName with rvalue
                            // (see below)

对于第一个调用,参数 newName 使用左值初始化,是拷贝构造。对于第二个调用,参数 newName 使用右值初始化,是移动构造。

我们把上述三种方案写到一起再对比下性能:

class Widget {                             // Approach 1:overload for             
public:                                    // lvalues and rvalues.
  void addName(const std::string& newName) // take lvalue;
  { names.push_back(newName); }            // copy it
  
  void addName(std::string&& newName)      // take rvalue;
  { names.push_back(std::move(newName)); } // move it; see
  ...                                      // Item 25 for use
                                           // of std::move
private:
  std::vector<std::string> names;
};

class Widget {                             // Approach 2: use universal reference
public:
  void addName(const std::string& newName) // take lvalue;
  { names.push_back(newName); }            // copy it
  
  void addName(std::string&& newName)      // take rvalue;
  { names.push_back(std::move(newName)); } // move it; see
  ...                                      // Item 25 for use
                                           // of std::move

};

class Widget {                              // Approach 3: pass by value
public:
  void addName(std::string newName)         // take lvalue or
  { names.push_back(std::move(newName)); }  // rvalue; move it
  ...
};

同样,考虑上面两种调用方式:

Widget w;
...
std::string name("Bart");

w.addName(name);            // call addName with lvalue
...
w.addName(name + "Jenne");  // call addName with rvalue
                            // (see below)

这里,我们忽略掉编译器根据上下文信息所做的编译优化的干扰,对比下三种方案的性能开销:

  • 引用重载:首先,无论是左值还是右值重载函数, 调用者的实参是被绑定到引用 newName上,没有拷贝或移动开销。再者,对于左值引用重载函数, newName 被拷贝到 Widget::names 内,而对于右值引用重载函数,newName 被移动到 Widget::names 内。总的来说,左值需要一次拷贝,右值需要一次移动。
  • 万能引用:首先,调用者的实参也是被绑定到引用 newName上,也没有拷贝或移动开销。再者,由于使用了 std::forward ,左值实参则被拷贝到 Widget::names 内,而右值实参则被移动到 Widget::names 内。总的来说,左值需要一次拷贝,右值需要一次移动。
    对于调用者传入的参数不是 std::string 类型,而是可以转换为 std::string 的类型,比如 char* 类型,对于引用重载版本,需要先将 char* 构造成 std::string,这会增加其开销,而万能引用版本则直接将 char* 转发给 std::string 构造函数直接构造 std::string 类型,详见 Item 25 。这里不考虑这种特殊情况。
  • 值传递:首先,对于左值,需要调用拷贝构造 newName,而对于右值,需要移动构造 newName。再者, newName 被无条件移动到 Widget::names 内。总的来说,左值需要一次拷贝加一次移动,右值需要两次移动。相较于前两种引用传参的方法,多了一次移动操作。

再回头看下本 Item 的标题: Consider pass by value for copyable parameters that are cheap to move and always copied。缘于以下四个原因:

  1. 只考虑值传递的话,只需要写一个函数,目标代码中也会生成一个函数,并且可以避免万能引用方法的问题。但是引入了一点性能开销。
  2. 只对可拷贝的参数使用值传递方法。如果参数是 move-only 的,那值传递的方法肯定会失败。对于 move-only 类型参数,也无须提供左值引用重载函数,只需要一个右值引用的重载函数即可。例如,对于传递 std::unique_ptr 类型参数:
	class Widget {
	public:
	  ...
	  void setPtr(std::unique_ptr<std::string>&& ptr)
	  { p = std::move(ptr); }
	private:
	  std::unique_ptr<std::string> p;
	};
	...
	Widget w;
    ...
    w.setPtr(std::make_unique<std::string>("Modern C++"));

上述代码,std::make_unique<std::string>("Modern C++") 产生一个右值,然后被移动到成员变量 p 上。因此总的开销是一次移动。如果只提供值传递的方法:

	class Widget {
	public:
	  ...
	  void setPtr(std::unique_ptr<std::string> ptr)
	  { p = std::move(ptr); }
	  ...
	};

相同的调用,会隐式移动构造 ptr,接着移动赋值给 p。因而总的开销则是两次移动操作。

  1. 只有当移动开销低时才考虑值传递方法。因为只有当移动开销很低时,额外的一次移动才是可接受的。否则,执行一次不必要的移动操作和执行一次不必要的拷贝操作是类似的,都一样违反了 C++98 中避免值拷贝这一规则。
  2. 只有当参数总是要被拷贝的时才考虑值传递方法。假设在将参数放入 Widget::names 内之前先对参数进行合法性检查,满足条件才放入到 Widget::names 内。例如:
class Widget {
public:
  void addName(std::string newName)
  {
    if ((newName.length() >= minLen) &&
        (newName.length() <= maxLen))
    {
      names.push_back(std::move(newName));
    }
  }
  ...
private:
  std::vector<std::string> names;
};

如果不满足条件则会浪费 newName 的构造和析构的开销,想比较而言,引用传参开销更小。

即使上述条件都满足(移动开销低的可拷贝参数被无条件拷贝)时,值传递也不一定适用。函数参数的拷贝有两种方式:通过构造(拷贝构造或移动构造)和通过赋值(拷贝赋值或移动赋值)。上面例子中的 addName 使用的就是构造的方式,其参数 newName 通过拷贝构造创建了一个新的元素放在 std::vector 的尾部。这种情况比引用传参多一次移动。

当参数通过赋值拷贝,情况要复杂的多。例如,你有一个表示密码的类,由于密码可以被改变,需要同时提供 setterchangeTo 两个方法,值传递方法的实现如下:

class Password {
public:
  explicit Password(std::string pwd)  // pass by value
  : text(std::move(pwd)) {}           // construct text
  
  void changeTo(std::string newPwd)   // pass by value
  { text = std::move(newPwd); }       // assign text

  ...
  
private:
  std::string text;                  // text of password
};

std::string initPwd("Supercalifragilisticexpialidocious");
Password p(initPwd);

这里,p.text 通过构造函数进行了密码的初始化。通过前面的分析可知,相比较引用传递的方法,多了一次额外的移动开销。当通过下面的方式修改密码时:

std::string newPassword = "Beware the Jabberwock";
p.changeTo(newPassword);

changeTo 采用的是赋值构造,值传递的方法会产生性能问题。构造 newPwd 时, std::string 的构造函数会被调用,这个构造函数中会分类内存来保存 newPwd,然后, newPwd 移动赋值给 text,这将导致 text 原来指向的内存会释放掉。也就是说,修改密码的过程发生一次内存的申请和一次内存的释放。其实在这里,旧的密码(“Supercalifragilisticexpialidocious”)比新的密码(“Beware the Jabberwock”)长度更长,没有必要申请或者释放内存。如果采用下面引用重载的方法,很可能申请和释放内存都不会发生:

class Password {
public:
  ...
  void changeTo(const std::string& newPwd) // the overload
  {                                        // for lvalues
    text = newPwd;          // can reuse text's memory if
                            // text.capacity() >= newPwd.size()
  }
  ...
private:
  std::string text;
};

text 的字符串长度大于 newPwd 的时会复用已经分配的内存。因此,开销要比值传递的方式要小。如果旧密码的长度要比新密码短时,那么赋值过程中的申请和释放内存不可避免,则值传递和引用传递二者的开销一致。

上面对函数参数通过赋值来拷贝的分析要考虑多种因素,例如传递的类型、左值还是右值、类型是否使用动态内存等。例如: 对于 std::string,如果它使用了SSO 优化,那么赋值的操作会将要赋值的内容放到 SSO 的缓存中,那么情况又不一样了。SSO 优化详见 Item 29

如果要追求极致的性能,值传递的方式可能不再是一个可行的方法,因为避免一次廉价的移动开销也是很重要的。并且我们并不是总是知道会有多少次这样的移动操作,例如,addName 通过值传递造成了一次额外的移动操作,但是这个函数内部又调用了 validateName,并且也是值传递的方式,这将就又造成了一次额外的移动开销,validateName 内部如果再调用其他的函数,并且这个函数同样是值传递的方式呢?这就造成了累加效应,而采用引用传递的方式就不会有这样的累加效应。

最后,一个与性能无关的话题,但却值得我们关注。那就是值传递的类型切割问题(slicing problem),详见 C++ 按值传递的切割问题(Slicing Problem)

至此,本文结束。

参考:

  • 4
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值