何时考虑值传递——当参数可拷贝,移动开销小并且始终被拷贝
本文最后更新于:April 20, 2022 am
本篇博客主要讨论在现代C++中,何时应当考虑值传递
系列导航:锦上添花的微调
值传递真的一无是处吗
函数的功能影响了它的表现,有的函数的形参是一定要被拷贝一次的[1]:
1 |
|
如上述代码所示,当Widget::addName
的参数是常量左值引用时,必须将参数拷贝一份放到容器中;而参数是右值引用时,应该使用移动语义以避免拷贝构造。
这样写代码能跑,但是要写两个功能一模一样的函数,而它们的区别仅仅是是否真正拷贝了参数,我们需要为此多一次声明,多一次实现,多一次维护……
更进一步,在目标代码中也会有两份函数——如果我们需要关心程序足迹[2],就需要关心这一点。当然在上述代码中,两个函数大概率会被做成内联,因此在目标代码上不会有两个函数。
另一种可行的方案是让addName成为一个函数模板,并且使用万能引用(参见Item24):
1 |
|
这样写能够减少源代码,但是使用万能引用导致了其它的复杂度。作为函数模板,addName
肯定必须被实现在头文件中。与之相对应的,在目标代码中就完全有可能出现多个函数,因为它不仅在面对左值和右值时被实例化成不同版本,当外部传入std::string
和能够被隐式转换成前者的参数时(比如const char*
,参见Item25),也会产生不同的实例化版本。此外,万能引用的函数模板并不能接收所有类型的参数(参见Item30),同时,当函数调用者传入了不正确的参数类型时,编译器会抛出冗长的迷惑报错(参见Item27)。
那么,有一种方法,能够让我们只写一个函数的代码,并且保证目标代码中也只有一份函数,同时妥善处理左值和右值对应的拷贝不拷贝的问题,还能不像万能引用那样太有个性吗?答案是有的,并且它非常朴实无华——值传递。在成为C++开发者的第一堂课上,或许就有人教我们要避免对用户自定义类的值传递,但是在这个场景下,值传递就是较优解。
1 |
|
唯一不太明显的地方可能就是第四行的std::move
,因为通常情况下,我们只在右值引用上使用移动语义。在这个情境下,我们知道:
newName
是一个和调用者传入的对象完全独立的对象,改变newName
并不会改变调用者一侧- 在这一行以后,我们在
addName
函数内不会再次使用newName
这一个变量了,改变newName
的状态也不会影响接下来代码的执行结果
考虑到仅仅只有一份addName
函数,因此在源码和目标代码层面上也不会有多的函数版本。我们没有使用万能引用,因此不会导致头文件膨胀,也不会有奇怪的完美转发失败案例,或者编译器的迷惑报错。但是性能和效率呢,这里可是值传递,那岂不是在任何情况下都会调用拷贝构造函数?
在C++98里,确实是这样。但是自从C++11以后,当传入的是左值时,才会调用拷贝构造函数,而如果传入的是右值,则会调用移动构造函数。
1 |
|
在第三行,我们传入了左值,这一次newName
被左值初始化,所以newName
是被拷贝构造的;但是在第四行中,我们传入的是std::string::operator+(const char*)
的返回值,是个右值,因此newName
是被右值初始化的,理所当然调用移动构造函数。
用于拷贝构造的情景
如此便简洁地实现了我们的目的,不是吗?但是这里有一些警告,需要我们铭记于心。
我们重新对比三种实现方式,一一分析它们的开销(我们并不考虑编译器对于拷贝和移动操作的优化):
- 重载实现的版本:不管传左值还是右值,调用者的参数都被绑定到一个叫做
newName
的引用上,这和拷贝和移动比起来,开销可以忽略不计。 - 万能引用实现的版本:在万能引用的情况下,对左值实行拷贝,对右值实行移动,因此开销总结和重载版本是一样的:左值一次拷贝操作,右值一次移动操作
- 根据Item25,当调用者传入了
std::string
以外的参数类型时,比如字符串字面量(const char*
),这个参数将会被原封不动地转发给std::string
的构造函数,因此在这一情况下可以做到0拷贝,这也是万能引用版本独有的优势,但这不影响我们讨论的结果。
- 根据Item25,当调用者传入了
- 值传递实现的版本:当传入左值时,调用一次拷贝构造函数构造
newName
;当传入右值时,调用一次移动构造函数构造newName
。随后调用push_back
时,都传入了右值。因此在性能上,不管传入左值还是右值,值传递的版本始终会比上述两个版本多一次移动构造的开销。
此时我们再看看这一节的标题:当参数可拷贝,移动开销小并且始终被拷贝时,考虑使用值传递
这么遣词是有理由的,事实上,有四个原因:
应该考虑使用值传递,而不是必须。虽然有诸多优势,但值传递总会多一次移动构造,并且在某些情况下,还有一些下文会讨论的额外开销
只针对可拷贝的对象。一旦对象是不可拷贝的,首先,值传递的函数就不能接收左值,因为需要调用类的拷贝构造函数来初始化参数;其次,回想我们在重载版本的实现中为什么要为常量左值引用和右值引用写两个函数?这正是因为我们想尽量避免调用
push_back
时产生的拷贝构造(至少传入右值时,不应该执行拷贝构造),现在对象都不可拷贝了,那我们只需要写一份右值引用作为参数版本的函数不就好了吗?比如,当自定义的类中有
std::unique_ptr
作为成员时,这个成员的setter
就只有一个右值引用的版本了1
2
3
4
5
6
7
8class Widget{
public:
void setPtr(std::unique_ptr<std::string>&& ptr){
p=std::move(ptr);
}
private:
std::unique_ptr<std::string> p;
}而如果我们仍头铁用值传递,那么调用时传入右值会额外引起一次移动构造,开销几乎是上述代码的两倍
仅当移动操作开销不高时才应该考虑值传递。如果移动操作开销很高,那不必要的移动就和不必要的拷贝很类似了。
仅当参数总是被拷贝时,才应该考虑值传递。假设在调用
push_back
之前,我们还需要做一些额外的逻辑,比如:1
2
3
4
5
6
7
8
9
10class Widget{
public:
void addName(std::string newName){
if(newName.length() >= minLen){
names.push_back(std::move(newName));
}
}
private:
std::vector<std::string>names;
};只有当满足条件时,才会将
newName
添加到names
中,也就是说,如果这里函数的参数是常量左值引用,参数也不总是被拷贝到names
中去。那么当没有满足条件时,值传递版本在函数退出时需要执行newName
的析构函数,而如果是常量左值引用版本,则完全不需要。
即使我们面临的情况满足这四点要求,也会有值传递并不合适的地方:因为函数可能通过拷贝构造来进行拷贝,也可能通过拷贝赋值进行拷贝。我们目前为止只分析了通过拷贝构造的方式,而通过拷贝赋值的情景则更为复杂[3]。
用于拷贝赋值的情景
假设我们有一个类Password
用来盛放客户的密码,并且为之配备了一些函数:
1 |
|
看两种调用方式
1 |
|
在情景1中,通过调用Password
类的构造函数,拷贝initPwd
对text
进行初始化,也能通过常量左值引用和万能引用的方式进行优化。
在情景2中,函数通过值传递来接参数的方式会引起爆炸式的开销,为什么呢?
首先,函数changeTo
采用值传递,这使得我们需要调用拷贝构造来为函数参数newPwd
进行初始化,这会引起一次动态内存分配,这也是情景1中跑不掉的开销。然后,我们将newPwd
移动到text
中,那么text
先对自身原本的动态内存进行释放(因为之前text
存着“Supercalifragilisticexpialidocious”),然后再接管newPwd
的动态内存——释放原先的动态内存这又是一次动态内存操作。
如果我们使用常量左值引用的方式:
1 |
|
代入到情境中,如果text
已经被“Supercalifragilisticexpialidocious”初始化过了,那么再改为”Beware the Jabberwock”时,就不需要重新分配内存了,因为前一个密码更长。结果就是,使用常量左值引用的函数一次动态内存操作都不会引起。
有趣的是,如果前一份密码更短,那么一次内存分配和一次内存释放就几乎是板上钉钉的事情了。这种情况下,值传递函数参数的构造开销就由参与构造的对象决定了。这种分析并不对于所有类型都管用,而是只针对于将很多内容存放在动态内存中的类,比如std::string
, std::vector
另外,这种额外的内存分配释放开销只在传入了左值时才会出现,因为只有在执行拷贝时才会有内存重新分配和释放的操作,而执行移动时则几乎没有。
稍微总结一下,在形参被用作另一个对象的拷贝赋值运算符的参数时,值传递的函数的额外开销取决于以下几点:
被拷贝对象的类型
调用函数的场景中,向函数传递左值和向函数传递右值的比率
被拷贝的类型是否使用动态分配的内存,以及
如果是,还和拷贝赋值运算符函数的实现方式,以及作为被赋值对象的内存空间是否至少和被拷贝对象的内存空间一样大的概率
在这个场景下,这还取决于函数的实现是否使用了 小型字符串优化(Small String Optimization, SSO),如果是的话,还和被赋值的字符串是否放得进SSO缓冲有关
因此,就像之前说的,当函数形参被用于拷贝赋值时,情况会复杂得多。通常情况下,面对如此多的影响因素,行之有效的策略是——总是将值传递的方式当成有害,平时使用重载版本或者万能引用版本,直到对于我们所传递的类型,值传递的方式被证实确实没有很大的性能劣势。
如今,软件必须被实现得尽可能快,导致避免一次并不昂贵的移动操作也显得十分重要。并且,我们也不清楚,究竟需要在函数内部移动多少次。在Widget::addName
中,我们只移动了一次,假设有一个方法Widget::validateName
,它也是值传递的,如果在addName
中需要调用另一个方法validateName
,而validateName
又会调用另一个值传递的参数……这种调用链越长,需要用到的移动操作次数也就越多,而使用引用则不会。
切片问题
最后,还有另一个和性能没有关系的问题,但仍需要我们牢记——值传递的函数,不像引用传递那样,容易引起切片问题,这在C++98中已经是老生常谈了:
1 |
|
假如有一个函数processWidget
,被设计成用于处理所有Widget
类的对象和所有其派生类的对象,如果它被意外地写成了按照值传递的方式,那么在这个函数内部,所有的派生类都会被切片成基类对象。
这也是除性能因素外,另一个使得值传递在C++98中如此臭名昭著的原因。
C++11从根本上改变C++98对于值传递的立场。总的来说,值传递会引起我们希望避免的性能损失,并且总是导致切片问题。C++11的新内容区别对待了左值参数和右值参数。希望借助移动语义来实现函数总是会要求我们利用重载特性或者使用万能引用,然而这两种方式都有缺点。对于可拷贝、移动操作性能开销小的类型,并且其在函数内部总会被拷贝一次,且不用担心切片问题的情景,值传递是一种简单的可替代方案,它几乎和引用传递一样高效,并且避免了后者的缺点。
总结
对于可拷贝、移动开销小并且一定会被拷贝的形参而言,使用值传递会提供接近引用传递的性能,以及生成更少的目标代码
通过构造拷贝形参的成本可能比通过赋值拷贝形参的高得多
值传递会引起切片问题,对于基类参数来说一般是不合适的
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!