有些函数的形参本来就是打算拿来复制的。例如,成员函数addName
可能会将其形参复制入其私有容器。为效率考虑,这样的函数应该针对左值实参实施复制,而针对右值实参实施移动。为了满足这个特性,可以使用以下三个方法完成:
1、重载
class Widget{
public:
void addName(const std::string& newName) //接受左值
{names.push_back(newName);} //对其实施复制
void addName(std::string&& newName) //接受右值
{names.push_back(std::move(newName));} //对其实施移动
private:
std::vector<std::string> names;
};
这样写也没有错,但要求撰写本质上在做同一件事的两个函数。这么一来可就有活干了:需要撰写两份函数声明,两份函数实现,两份函数文档,两份函数维护工作量,要命。
2、万能引用
另一种方法是把addName
写成接受万能引用的函数模板:
class Widget {
public:
template<typename T> //接受左值
void addName(T&& newName) //也接受右值
{ //对左值实施复制
names.push_back(std::forward<T>(newName)); //对右值实施移动
}
...
};
这确实减少了你需要着手处理的源代码数量,但是万能引用的使用会导致其他方面的复杂性。例如,作为模板,addName的实现通常必须置于头文件中。它还可能在对象代码中产生好几个函数,因为它不仅针对左值和右值会产生不同的实例化结果,针对std::string和可以转型为std::string的型别,也会产生不同的实例化结果,同时,有些型别不能按通用引用方式传递,参见Item 30,如果客户传入了不正确的实参型别,编译器错误信息可能会吓人一跳,参见Item 25。
3、按值传递
在我们讨论为什么按值传递可能非常适用于newName和addName之前,先看看实现长成什么样子:
class Widget{
public:
void addName(std::string newName) //既接受左值
{names.push_back(std::move(newName));} //也接受幼稚,对后者实施移动
....
};
这段代码中唯一无法一看就懂的部分,是针对形参newName实施std::move。通常情况下,std::move仅会针对右值引用实施。但在这种情况下,我们确知:
- 无论调用方传入什么,newName都对它没有任何依赖,所以更改newName不会对调用方产生任何影响;
- 这次使用newName是对它的最后一次使用,所以移动它也不会对函数的其余部分产生任何影响。
只有单个addName函数的事实,就已经说明我们做到了避免源代码以及目标代码中的代码重复。我们没有使用万能引用,所以采用这种他丼也不会导致头文件膨胀、怪异的失败情形或令人费解的错误消息。但是,这样设计会导致效率问题嘛?毕竟我们可是按值传递的,会不会发生高昂的成本呢?
在C++98中,可以打包票的说,肯定会发生的。无论调用方传入的是什么,形参newName都会经由复制构造函数创建。不过,在C++11中,newName仅在传入左值时才会被复制构造。而如果传入的是个右值,它会被移动构造。就像这样:
Widget w;
...
std::string name("Adam");
w.addName(name); //在调用addName时传入左值
...
w.addName(name + "Xiao"); //在调用addName时传入右值
在addName的第一个调用中(即传入的是name时),用以初始化形参newName的是个左值。所以,对newName实施的是复制构造,一如在C++98中那样。在第二个调用中,用以初始化newName的,则是std::string的operator+的调用产生的结果std::string型别对象(即字符串附加操作)。那样的对象是个右值,所以对newName实施的是移动构造。
三种方法对比分析
我们把前两个版本称为按引用的途径
,因为他们都是按引用传递形参的。
而下面则是我们考察过的两种调用场景:
Widget w;
...
std::string name("Adam");
w.addName(name); //传入左值
...
w.addName(name + "Xiao"); //传入右值
现在考虑下成本问题,考察对象是复制和移动操作,考察两个调用场景在我们讨论过的三个addName
实现中的每一个中添加一个名字会带来的成本是多少。
- 重载:无论传入左值还是右值,调用方的实参都会绑定到名字为newName的引用上。而这么做不会在复制或移动时带来任何成本。在接受左值的重载版本中,newName被复制入Widget::names;在接受右值的重载版本中,newName被移入Widget::names。成本合计:对于左值是一次复制,对于右值是一次移动。
- 使用万能引用:与重载一样,调用方的实参会绑定到引用newName上。这是无成本操作。由于使用了std::forward,左值std::string实参被复制入Widget::names中,而右值std::string实参则被移入。传入std::string实参的成本合计结果与重载相同。成本合计:对于左值是一次复制,对于右值是一次移动。
- 按值传递:无论传入的是左值还是右值,针对形参newName都必须实施一次构造。如果传入的是个左值,成本是一次复制构造。如果传入的是个右值,成本是一次移动构造。在函数体内,newName需要无条件地移入Widget::names。这么一来,就得到了成本合计的结果:对左值而言,是一次复制加一次移动;对右值而言,是两次移动。与按引用途径相比,无论是左值和右值,都存在一次额外的移动操作。
回顾一下本条件的标题:
针对可复制的形参,在移动成本低并且一定会被复制的前提下,考虑将其按值传递
你只是考虑按值传递。没错,它只要求撰写单个函数。没错,它在目标代码中只生成单个函数。没错,它能够避免万能引用带来的一系列毛病。但不要忘了,它的成本更高一些,并且,我们还会展示,在有些情况下,还会产生更多在此尚未讨论的成本。但上面仅仅针对的是可复制的形参,才考虑按值传递。不符合这个要求的形参必然具备只移型别,因为如果它们本来不可复制,但函数却总会创建副本的话,那就必须经由移动构造函数来创建该副本。回忆一下,按值传递相对于重载而言的优势,就在于若采用按值传递则只需撰写单个函数。但是只移型别却并不需要针对左值型别提供重载版本,因为复制左值需要调用复制构造函数,而只移型别的复制构造函数根本就已经被禁用了。这意味着,只需为右值型别的实参提供支持即可。所以在此情况下,所谓重载解决方案只要求一个重载版本:那个接受右值引用型别的重载版本。
考虑一个类,它含有一个std::unique_ptr型别的数据成员和一个针对它的设置器。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_unqiue<std::string>("Modern C++"));
在这里,从std::make_unique
返回右值std::unique_ptr<std:string>
会以右值引用方式传递给setPtr
,在那里它被移入数据成员p,
总成本是一次移动。
如果setPtr
以按值传递方式来接受形参:
class Widget {
public:
...
void setPtr(std::unique_ptr<std::string> ptr)
{p = std::move(ptr);}
};
同一调用会造成针对形参ptr
实施移动构造后,再将ptr
移入数据成员p
,这么一来,总成本成了两次移动,比重载
途径翻了一倍。
所以按值传递仅在形参移动成本低廉的情况下,才值得考虑。只有当移动成本低廉时,一次移动带来的额外成本才可能是可以接受的,但如果这个前提都不成立,那么执行不必要的移动就和执行不必要的复制没有区别了。而避免不必要的复制操作的重要性,也正是C++98中要尽量避免按值传递这条金科玉律的出发点。
你应该只针对一定会被复制的形参才考虑按值传递。欲理解为何这一点很重要,假设在将其形参复制到容器names之前,addName会先检查该新名字是否太短或太长。如果太短或太长,则忽略添加该名字的请求。按值传递的实现可能会写成这样:
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;
};
即使没有向names
添加任何内容,该函数也会招致构造和析构newName
的成本。而如果采用了按引用途径,就不必为此买单。
例外情况: 基于赋值的形参复制成本有可能取决于参与赋值的对象的取值
即使你面对的函数的确是在针对可复制型别实施无条件复制,并且移动成本也低廉,还是存在不合适的采用按值传递的一些情况。
原因在于,函数可以经由两种方式来实施复制:
- 经由构造(即复制构造或移动构造)
- 经由赋值(即复制赋值或移动赋值)
addName采用的是构造方式:其参数newName被传递给vector::push_back,并且在该函数内,newName被复制构造入std::vector末尾所创建的新元素中。
对于使用构造来实施形参复制的函数,我们前面的分析已经是完整的了:使用值传递的话,无论传入的左值还是右值,都会带来一次额外移动的成本。
如果采用赋值来实施形参复制的话,情况就更复杂了。例如,假定我们有个表示密码的类。由于密码可以更改,我们提供一个设置器函数changeTo。在采用按值传递策略的前提下,我们可能会像这样实现Password:
class Password{
public:
explicit Password(std::string pwd) //按值传递
: text(std::move(pwd)) {} //对text实施构造
void changeTo(std::string newPwd) //按值传递
{ text = std::move(newPwd);} //对text实施赋值
....
private:
std::string text; //表示密文
};
以明文的形式存储密码会让软件安全特勤组狂暴,但先不说这个,考虑下面的代码:
std::string intPwd("Supercalifragilisticexpialidocious");
Passwork p(initPwd);
这段代码并无意外:p.text采用给定密码构造,而在构造函数中采用按值传递会产生std::string的移动构造成本,该成本在采用重载或完美转发时不会发生。一切运行正常。
不过,该程序的某个用户可能会感觉初始密码不尽如人意。因为Supercalifragilisticexpialidocious是可以在许多字典中直接找到的。因此,他可能会采取行动,造成等价于以下代码加以执行的结果:
std::string newPassword = "Beware the Jabberwock";
p.changeTo(newPassword);
新密码与旧密码谁好谁坏这里不做定论。我们所面临的问题则是changeTo采用了赋值来对形参newPwd实施复制,这有可能导致该函数的按值传递策略带来爆发式的成本。
但在本例情况下,旧密码("Supercalifragilisticexpialidocious")比新密码("Beware the Jabberwock")更长,所以不需要实施任何内存分配和回收。如果采用重载途径,则很可能不会发生任何动态内存管理行为:
class Password{
public:
....
void changeTo(const std::string& newPwd) //为左值而准备的重载
{
text = newPwd; //在下式成立的前提下,可以复用text的内存
//text.capacity() >= newPwd.size()
}
...
private:
std::string text; //表示密文
};
在此场景下,按值传递的代价会包括额外的内存分配和回收成本,该成本可能会比std::string移动操作的成本高出几个数量级。
有意思的是,如果旧密码比新密码短,在赋值过程中一般而言不可能避免分配—回收这对动作,在此情况下,按值传递可能与按引用传递有着大致相同的运行速度。也就是说,基于赋值的形参复制成本有可能取决于参与赋值的对象的取值!这一分析结果适用于可能在动态分配的内存中持有值的任何形参型别。不是所有的型别都符合这一特征,但很多确实符合,这其中就包括std::string和std::vector。
这样的潜在成本增加通常只在传入左值实参时才会发生,因为实施内存分配和回收的需求通常仅在实施真正的复制操作(即,非移动操作)时才发生。对于右值实参而言,几乎总是只需要移动就足够了。
总而言之,采用赋值方式复制形参的函数,其按值传递带来的额外成本取决于传入的型别、左值和右值实参的占比、型别是否使用动态分配内存,还有,在确实使用了动态内存的前提下,该型别的赋值运算符如何实现,以及与赋值目标相关联的内存是否至少与赋值源相关联的内存尺寸相当的可能性高低。对于std::string而言,还要取决于实现是否使用了小型字符串优化(small string optimization, SSO,参见Item 29),还有,如果确实使用了SSO,所赋的值是否放的进SSO缓冲区。
所以,和我之前说的那样,当通过赋值实施形参复制时,进行按值传递的成本分析会是复杂的。通常情况下,最实用的途径是采取无罪推定,除非证明有罪的策略。也就是说,总是采用重载或万能引用而非按值传递,除非已确凿地证明按值传递能够为所需的形参型别生成可接受效率的代码。
既然如此,对于那些必须运行的尽可能快的软件来说,按值传递可能并非可行的策略,因为也许即使移动成本低廉,可能避免它们仍然重要。更何况,其实会发生多少移动操作并不非常清晰。在Widget::addName例子中,按值传递只会招致一次额外的移动操作,但是假设Widget::add会先调用Widget::validateName,并且调用后者时也按值传递(它可能会有个总是复制其形参的理由,例如,将其存储在某个所有待验证的值构成的数据结构中)。并假设validateName会调用第三个也要求按值传递的函数…你应该可以看出来,这样下去会往什么方向发展。当存在函数调用链时,如果每个函数都出于“只不过耗费一次低成本的移动”而选用了按值传递的话,则整个调用的成本可能会超过你能容忍的范围。而如果使用的是按引用的形参传递,则调用链不会产生这种累积性的开销。另一个古老的但需要牢记的切片问题
还有一个与效率无关,但仍需要牢记的议题是,不同于按引用传递,按值传递较容易遭遇切片问题。这个问题在C++98中已是老生常谈,所以我不会展开。但是如果你有个函数被设计用以接受一个积累型别或从它派生的任何型别的形参,你肯定不会想要声明该型别的按值传递形参,因为传入的任何可能的派生型别对象的派生类特征都将会被“截断”掉:
class Widget {....}; //基类
class SpecialWidget: public Widget {....}; //派生类
void processWidget(Widget w); //为任何Widget而设计的函数
//包含派生型别
//会受到切片问题侵害
....
SpecialWidget sw;
....
processWidget(sw); //processWidget看到的只是
//一个Widget而非SpeicalWidget型别的对象!
如果你对切片问题尚不熟悉,搜索引擎和互联网是你的朋友,那上面的相关信息汗牛充栋。你会了解到,切片问题的存在是按值传递在C++98中如此声名狼藉的另一个原因(比影响性能更甚)。为何你在初学C++程序设计时就会被告知的事项之一,就是避免按值传递用户自定义型别对象,这也不无理由。
C++11并没有从根本上颠覆C++98关于按值传递的智慧。一般地,按值传递仍会导致一些你本想避免的性能损失,按值传递还会导致切片问题。C++11引入的新特性是左值和右值的区别对待。欲实现函数以利用可复制右值型别的移动语义,就需要重载或使用万能引用之一,但这两者都有一定的缺点。对于可复制的、移动成本低廉的型别,并且传入的函数总是对其实施复制这个特殊情况,在切片问题也无需担心的前提下,按值传递可以提供一个易于实现的替代方法,它和按引用传递的竞争对手效率相近,但是避免了它们的不足。