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。缘于以下四个原因:
- 只考虑值传递的话,只需要写一个函数,目标代码中也会生成一个函数,并且可以避免万能引用方法的问题。但是引入了一点性能开销。
- 只对可拷贝的参数使用值传递方法。如果参数是 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
。因而总的开销则是两次移动操作。
- 只有当移动开销低时才考虑值传递方法。因为只有当移动开销很低时,额外的一次移动才是可接受的。否则,执行一次不必要的移动操作和执行一次不必要的拷贝操作是类似的,都一样违反了 C++98 中避免值拷贝这一规则。
- 只有当参数总是要被拷贝的时才考虑值传递方法。假设在将参数放入
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
的尾部。这种情况比引用传参多一次移动。
当参数通过赋值拷贝,情况要复杂的多。例如,你有一个表示密码的类,由于密码可以被改变,需要同时提供 setter
和 changeTo
两个方法,值传递方法的实现如下:
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)。
至此,本文结束。
参考: