EM 17:考虑对move 代价低的参数采用值传递
某些函数在通常情况下会对参数进行复制操作。set 函数就是很好的例子。set 函数通常对保存在对象内的变量
进行赋值。这类函数要想得到最高的效率,如果传入参数为左值类型,则调用复制;如果传入参数为右值类型,则调用移动 :
class Widget{
public:
void setName(const std::string& newName) // take lvalue;
{ name = newName; } // copy it
void setName(std::string&& newName) // take rvalue;
{ name = std::move(newName); } // move it
…
private:
std::string name;
};
这样的方式需要写两个函数,而它们的基本功能完全相同。这有点恼人:要声明两个函数,实现两个函数,文档记录两个函数,维护两个函数,哎!
更进一步,在目标文件中会有两个函数体——如果你比较在意程序的脚注的话。在之前的例子中,两个函数很可能都是内联(inline)的,这样容易消除两个函数带来的代码膨胀。然而如果不是内联的,在目标文件中就会出现两个函数体。(当在函数体内创建指针、在复杂上下文中调用这些函数,或者关闭内联选项,函数就无法内联。)
另一个方法是让函数setName 成为一个模板函数,并接受通用引用传递(见EM 26):
class Widget {
public:
template<typename T>
void setName(T&& newName) // take lvalues and
{ name = std::forward<T>(newName); } // rvalues; copy
… // lvalues, move rvalues
};
这样的话减少了处理的代码,尽管如此,假设这个函数既被传入左值参数调用又被传入右值参数调用,在目标文件中仍然是两个函数。(模板会对左值和右值参数分别实例化。)更进一步,采用通用引用有缺陷,在EM 32 里有解释, 并不是所有参数可以用通用引用传递,在EM 29中解释,如果客户端传入不恰当的参数给通用引用, 编译器的错误信息就会,呃,让你抓狂。
如果存在一种方法,写出类似setName 的函数,当传入左值时调用复制,传入右值时调用移动,并且在源代码和目标文件中都只有一份函数需要处理,还要避免通用引用的怪异表现,那岂不是更好?这样的方法确实存在。我们所需要做的是抛弃一条成为C++程序员最初的原则,即避免通过值传递参数。对于像setName 中的newName 那样的参数,通过值传递参数恰恰是我们需要的方法。
在我们讨论为什么值传递是一个恰当方法前,我们先观察newName 和 setName 的值传递版本如何实现的。
class Widget {
public:
void setName(std::string newName) // take lvalue or
{ name = std::move(newName)} // rvalue; move it
…
};
这部分代码中唯一不太显然的部分是对参数newName用std::move 。通常std::move 是用于右值引用(参考EM 27),但是在当前情况下,我们知道
1. newName 是一个与调用者所传入的参数完全无关的对象,所以改变newName 的值并不会影响调用者;
2. 这里是最终使用newName 的位置,移动它的值对函数其余部分没有影响。
这里只有一个setName 函数,也就避免了冗余函数——不仅仅是源代码,也包括相应的目标文件。我们没有采用通用引用, 也就是说这种方法不会有对通用引用传入不恰当参数所造成的奇怪错误。剩下的问题是这样设计的效率如何。我们采用值传递,开销岂不是很大?
在C++98中,毫无疑问开销会很大,因为无论调用者传入什么,参数newName 会拷贝构造出一个。在C++11中,newName 只对左值采用拷贝构造,对右值则采用移动构造。
Widget w;
…
std::string widgetID("Bart");
w.setName(widgetID);
…
w.setName(widgetID + "Jenne");
第一次调用setName (传入widgetID),参数newName 通过左值初始化。因此newName 是拷贝构造的,和C++98时一致;在第二次调用时,newName 通过std::string 对象的operator+ 调用得到一个新对象(即append 操作)。这个对象是右值, 因此newName 是移动构造的。
总结一下,当调用者传入左值,newName调用拷贝构造;当调用者传入右值,newName调用移动构造,这正是我们需要的,简洁明了,不是么?
“不可能这么简单吧?”你可能会这么认为。“其中必有蹊跷。”然而这里并没有。但是我们还是应该牢记一些条件。我们回忆一下setName 的三个实现版本:
class Widget { // Approach 1:
public: // overload for
void setName(const std::string& newName) // lvalues and
{ name = newName; } // rvalues
void setName(std::string&& newName)
{ name = std::move(newName); }
…
private:
std::string name;
};
class Widget { // Approach 2:
public: // use universal
template<typename T> // reference
void setName(T&& newName)
{ name = std::forward<T>(newName); }
…
};
class Widget { // Approach 3:
public: // pass by value
void setName(std::string newName)
{ name = std::move(newName); }
…
};
这是两个不同场景的调用方式:
Widget w;
…
std::string widgetID("Bart"); // pass lvalue
w.setName(widgetID);
…
w.setName(widgetID + "Jenne"); // pass rvalue
现在来考察这三种setName 实现方式分别对两种不同调用场景的开销。这里仅针对拷贝和移动操作的开销。
- 重载: 无论左值还是右值传入, 调用者通过引用将参数绑定到newName 。这里针对对拷贝和移动而言没有开销。 在左值重载中,newName 拷贝到Widget::name 中; 而在右值重载中则是移动赋值。总体开销:左值参数有一次拷贝,右值参数有一次移动。
-使用通用引用: 和重载类似,调用者参数仅仅通过引用绑定到newName ,这没有开销。因为调用了std::forward 函数(参见 EM 25),左值拷贝,或者右值移动到Widget::name 中。总体开销和重载方法一样:左值参数一次拷贝,右值参数一次移动。这和预期的一样。通过传入左值或者右值给通用引用参数,模板函数会实例化两份函数实现。
-参数值传递: 无论左值函数右值参数传入,参数newName 都将会构造出来。如果是左值传入, 这需要调用拷贝构造;如果是右值传入,通常是需要移动构造。在函数体内,newName 无条件地移动赋值给Widget::name 。总体开销为:左值参数一次拷贝一次移动,右值参数两次移动。
回头在看看这个条款的标题:
考虑对move 代价低的参数采用值传递
其中包含了这么做的原因。事实上共有三个原因:
第一,你只需要考虑参数值传递。它只需要写一个函数即可,并且仅仅产生一份目标代码,而且避免了用通用引用所带来的不爽的编译错误信息。但是它比其余两个方法有更大的开销,具体而言,无论是左值还是右值参数,都比另外两个方法多一次移动赋值。
第二个需要注意之处。考虑参数值传递,仅仅在参数move代价低的条件下。当move 的开销低时,额外的移动赋值可以忽略。然而当move 开销并非很低时,额外的移动赋值可能产生难以承受的代价。EM31条款解释了并不是所有的类型都是move 代价很低的——甚至标准库中的一些类型也是move代价不可忽略的。此时,一个额外的移动赋值就如同一个不必要的拷贝,避免不必要的拷贝操作正是C++98准则下避免值传递的重要原因。
最后一个, 值传递参数仅仅适用于函数体内总是需要拷贝。对于赋值类型函数(比如构造函数中对成员数据赋初始值这类),这个条件总是满足。然而当一个函数中,只有在某些条件满足时才将一个值添加到数据成员中时,使用值传递就不合适了。
class Widget {
public:
bool insert(std::string s)
{
if((s.length() >= MinLen) && (s.length() <= MaxLen)) {
values.insert(s);
return true;
}
else {
return false;
}
}
…
private:
std::unordered_set<std::string> values;
};
在这个函数中,我们需要构造s,即使它不需要拷贝。如果这个例子中传入的值太短或者太长,比如:
Widget w;
…
std::string finalGWTWWords("Tomorrow is another day");
…
auto status = w.insert(finalGWTWWords);
我们需要承担拷贝finalGWTWWords 的开销,即使它小于MinLen 或者大于MaxLen 。(注意这里确实是拷贝构造,而不是移动构造。因为finalGWTWWords 是一个左值。)这当然不是一个高效的做法。
即使在一些函数中,某些move开销低的类型需要无条件拷贝,值传递有时也不是一个恰当的设计。对于一些要求执行速度越快越好的代码,避免一些看似低开销的移动赋值也很重要。另外,到底执行了多少移动赋值并不总是很清楚。在我们的例子Widget::setName 中,值传递版本仅仅引入了一个额外的移动赋值,但是若Widget::setName 调用了Widget::validateName,并且这个函数也通过值传递参数。(有这个可能需要拷贝参数,比如存储所有发生变化的值。)再假设validateName 调用了另一个函数,那个函数也通过值传递……
现在能够明白这个问题的源头在哪儿。当有函数调用链时,每一个函数都按照值传递,因为“仅仅多一次廉价的移动赋值”。整个函数调用的开销有可能难以忍受了。使用引用传递(如左值和右值函数重载以及通用引用的方法),函数调用链不会产生这种累积开销。
如果仔细审视过这个条款,你可能注意到我的注释:如果我们所讨论的限制条件满足的话,即移动赋值开销低,并且需要无条件的拷贝时,使用值传递“通常”没有蹊跷。“通常”到底是什么呢?
“通常”针对的是切片问题。值传递对切片敏感,而引用传递并没有这个问题。这是C++98的常见问题,这里无需详述。但是如果一个函数设计时允许接收基类或者其派生类,你就不能对参数值传递。因为如果采用值传递,派生类信息就会被抹去。
class Widget{...}; // base class
class SpecialWidget: pulbic Widget {...}; // derived class
void processWidget(Widget w); // func for any kind of Widget,
// including derived types;
... // suffers from slicing problem
SpecialWidget sw;
...
processWidget(sw); // processWidget sees a
// Widget, not a SpecialWidget!
如果你对切片问题不熟悉,可以上网搜索或者向你的朋友询问,有很多途径可以获取相应的信息。你会发现除了效率因素,切片问题是也是值传递在C++98中名声不佳的原因之一。这也是我们从开始学习C++编程以来就知道的避免用值传递的原因。
C++11 没有根本上改变C++98对待值传递的哲学。即值传递仍然有大家都希望避免的性能问题和切片问题。C++11中的新特性是左值和右值的区分。一个对右值用move 语义的函数,来提高性能的函数,要么需要写多个函数(左值和右值的重载),要么采用通用引用。这两个方案都有缺陷。对一些特殊的情形,比如移动赋值代价低的类型,函数体内无条件需要拷贝,并且不考虑切片问题的话,值传递提供了一个容易实现的选项,并且效率可以非常接近引用传递,还避免了引用传递的一些缺陷。
需要记住:
对于移动赋值代价低的参数,并且需要无条件拷贝,采用值传递和采用引用传递的效率非常接近,并且容易实现,也减少目标文件中的代码。
值传递会受到切片问题影响,所以这不适用于作为基类的类型的参数传递的方式。