移动语义使得编译器得以使用成本较低的移动操作,来代替成本较高的复制操作;完美转发使得人们可以撰写接收任意实参的函数模板,并将其转发到目标函数,目标函数会接收到与转发函数所接收到的完全相同的实参。右值引用是将这两个不相关的语言特性连接起来的底层语言机制,正是它使得移动语义和完美转发成了可能。
23:理解std::move和std::forward
std::move并不进行任何移动,std::forward也不进行任何转发。这两者在运行期都无所作为,它们不会生成任何可执行代码。实际上,std::move和std::forward都是仅仅执行强制类型转换的函数(模板)。std::move无条件的将实参强制转换成右值,而std::forward则仅在某个特定条件下才执行这样的强制转换。
In particular, std::move produces an xvalue expression that identifies its argument t. It is exactly equivalent to a static_cast to an rvalue reference type.
C++11中std::move的示例实现如下,它并不符合标准的所有细节,但是已经非常接近了:
template<typename T> typename remove_reference<T>::type&& move(T&& param) { using ReturnType = typename remove_reference<T>::type&&; return static_cast<ReturnType>(param); }
std::move的形参是一个万能引用,它返回的是指向同一对象的右值引用。std::move返回值的&&表示它返回右值引用,但是如条款28所说,如果T是个左值引用的话,那么T&&就成了左值引用。为了避免这种情况,使用std::remove_reference将T的引用去掉,从而保证&&作用于非引用之上,从而可以确保std::move返回的是右值引用。而从函数返回的右值引用实际上是个右值,这就是std::move全部的所作所为。在C++14中,上面的实现可以有更简单的方式:
template<typename T> decltype(auto) move(T&& param) { using ReturnType = remove_reference_t<T>&&; return static_cast<ReturnType>(param); }
std::move只做一件事,就是把实参强制转换成右值,而右值是可以移动的,所以在一个对象上实施了std::move,就是告诉编译器该对象可移动。比如下面的例子:
std::string s1("hello,world"); std::string s2(static_cast<std::string &&>(s1)); std::cout << "s1 is " << s1 << std::endl; //"s1 is " std::cout << "s2 is " << s2 << std::endl; //"s2 is hello,world" std::string s3("hello, world"); std::string s4(std::move(s3)); std::cout << "s3 is " << s3 << std::endl; //"s3 is " std::cout << "s4 is " << s4 << std::endl; //"s4 is hello, world"
但是,右值也仅仅是移动的必要条件,而非充分条件。比如下面的代码:
class Annotation { public: explicit Annotation(const std::string text) : value(std::move(text)) { … } … private: std::string value; };
这段代码能够编译,也能运行成功。然而,其中的text并没有被移动,而是被复制到value中的。尽管text已经被std::move强制转换成右值了,但是text的类型是const string,因此强制转换之后,类型变成了const string右值。而std::string的复制构造函数和移动构造函数是:
string(const string& rhs); // copy ctor string(string&& rhs); // move ctor
const std::string类型的右值无法传递给std::string移动构造函数,因为移动构造函数只接受non-const的std::string类型的右值。所以,这里只能调用复制构造函数,因为const std::string的左值引用可以绑定到const右值上。这种行为对于维持常量正确性至关重要,因为将值移出对象通常会改动对象,所以语言不应该允许常量对象传递到有可能改动它们的函数。
总结而言,如果想将某个对象移动到某处,则不要将其声明为常量,因为针对常量对象执行的移动操作将默默的变换为复制操作.
The functions that accept rvalue reference parameters (including move constructors, move assignment operators, and regular member functions such as std::vector::push_back) are selected, by overload resolution, when called with rvalue arguments (either prvalues such as a temporary objects or xvalues such as the one produced by std::move). If the argument identifies a resource-owning object, these overloads have the option, but aren‘t required, to move any resources held by the argument. For example, a move constructor of a linked list might copy the pointer to the head of the list and store nullptr in the argument instead of allocating and copying individual nodes.
std::forward与std::move类似,只是std::forward仅在特定条件下才实施同样的强制类型转换。因此,std::forward是个有条件的强制类型转换。考虑std::forward典型应用场景,也就是某个函数模板使用万能引用作为形参,随后将其传递给另一个函数的例子:
void process(const Widget& lvalArg); // process lvalues void process(Widget&& rvalArg); // process rvalues template<typename T> void logAndProcess(T&& param) { auto now = std::chrono::system_clock::now(); makeLogEntry("Calling ‘process‘", now); process(std::forward<T>(param)); } Widget w; logAndProcess(w); logAndProcess(std::move(w));
在logAndProcess中,将param传递给process,而process根据形参是左值还是右值进行了重载,我们自然期望当调用logAndProcess时若传入的若是个左值,则该左值可以在传递给process时仍被当做一个左值,而当传入logAndProcess的是个右值时,调用的是process使用右值类型形参的那个重载版本。
然而,所有形参都是左值,因此logAndProcess内对process的调用都会是左值引用的那个版本,为了避免这种结果,需要一种机制,当且仅当初始化param的实参是个右值时,把param强制转换成右值类型,这就是forward所做的一切。这就说std::forward是有条件的强制转换的原因。
std::forward如何得知实参是右值呢?上面的例子,std::forward如何分辨param是通过左值还是右值完成了初始化呢?实际上该信息是被编码到logAndProcess的模板形参T中的,该形参被传递给std::forward后,随即由后者将编码了的信息恢复出来,具体原理键条款28。
24:区分万能引用和右值引用
T&&有两种不同的含义,一种是右值引用,另一种是万能引用:
void f(Widget&& param); // rvalue reference Widget&& var1 = Widget(); // rvalue reference auto&& var2 = var1; // not rvalue reference template<typename T> void f(std::vector<T>&& param); // rvalue reference template<typename T> void f(T&& param); // not rvalue reference
万能引用既可以绑定到右值(像右值引用那样),也可以绑定到左值(像左值引用那样),它也可以绑定到const对象或non-const对象,以及volatile对象或non-volatile对象。它们几乎可以绑定到任何对象,因此称之为万能引用(标准中称为转发引用forwarding reference)。
万能引用存在于两种场景中,一种是函数模板的形参,另一种是auto声明:
template<typename T> void f(T&& param); // param is a universal reference auto&& var2 = var1; // var2 is a universal reference
它们都涉及类型推导。如果你看到了T&&,但没有涉及类型推导,则它是个右值引用:
void f(Widget&& param); // no type deduction, param is an rvalue reference Widget&& var1 = Widget(); // no type deduction, var1 is an rvalue reference
万能引用的初始化物决定了它代表的是左值还是右值引用。如果初始化物为右值,则万能引用成为右值引用,如果初始化物为左值,则万能引用成为左值引用:
template<typename T> void f(T&& param); // param is a universal reference Widget w; f(w); // lvalue passed to f; param‘s type is Widget& (an lvalue reference) f(std::move(w)); // rvalue passed to f; param‘s type is Widget&& (an rvalue reference)
万能引用除了必须涉及类型推导,而且它的形式必须正好是”T&&”。比如下面的代码:
template<typename T> void f(std::vector<T>&& param); // param is an rvalue reference std::vector<int> v; f(v); // error! can‘t bind lvalue to rvalue reference
虽然涉及类型推导,但是模板f的形参形式是std::vector<T>&&,而不是T&&。因此param实际上是个右值引用,所以使用左值v调用f时出错。
即使多了一个const,也不是万能引用:
template<typename T> void f(const T&& param); // param is an rvalue reference
即使在一个模板内,看到一个函数形参的类型是T&&,也不一定是个万能引用。因为在模板内并不能保证一定涉及类型推导:
template<class T, class Allocator = allocator<T>> class vector { public: void push_back(T&& x); … };
push_back并不涉及类型推导,因为push_back作为vector的一部分,如果不存在特定的vector实例,push_back也无从存在。实例的类型完全决定了push_back的声明类型。比如给定std::vector<Widget> w,就会导致push_back的原型为:void push_back(Widget&& x);
这里push_back就不涉及任何类型推导。作为对比,std::vector内的emplace_back确确实实的涉及类型推导:
template<class T, class Allocator = allocator<T>> class vector { public: template <class... Args> void emplace_back(Args&&... args); … };
其中的类型形参Args独立于vector的类型形参T,所以Args必须在每次emplace_back调用时进行类型推导,因此他就是个万能引用。
auto变量也可以作为万能引用。声明为auto&&类型的变量都是万能引用,因为它肯定涉及类型推导,也有正确的形式。比如在C++14中:
auto timeFuncInvocation = [](auto&& func, auto&&... params) { //start timer; std::forward<decltype(func)>(func)( std::forward<decltype(params)>(params)... ); //stop timer and record elapsed time; };
其中,func是个万能引用,params是0个或多个万能引用(一个万能引用形参包)。多亏有了auto万能引用,才能使得timeFuncInvocation可以计算出大多数任何函数的执行时间(并非全部,参考条款30)。
万能引用的底层支撑,实际上是“引用折叠”,在条款28中会介绍。
25:在右值引用上使用std::move,在万能引用上使用std::forward
右值引用仅能绑定到右值上,如果函数形参是右值引用,意味着它绑定的对象是可以移动的。当需要把右值引用形参绑定的对象传递给其他函数时,应该允许其他函数也能利用该对象的移动特性。这种方法就是将形参转换为右值:
class Widget { public: Widget(Widget&& rhs) : name(std::move(rhs.name)), p(std::move(rhs.p)) { … } … private: std::string name; std::shared_ptr<SomeDataStructure> p; };
万能引用则不同,它只是可能会绑定到可移动对象上,万能引用只有在使用右值进行初始化时,才会强制转换到右值类型。而这正好是std::forward的行为:
class Widget { public: template<typename T> void setName(T&& newName) { name = std::forward<T>(newName); } … };
简而言之,当转发右值引用给其他函数时,应当对其使用std::move(无条件的将其转换为右值),而当转发万能引用时,应当对其使用std::forward(有条件的向右值进行强制类型转换)。
如果反过来,比如针对右值引用实施std::forward,则需要的代码啰嗦,易错,且不符合习惯用法,比如下面的代码:
class Widget { public: Widget(Widget&& rhs) : s(std::move(rhs.s)) { ++moveCtorCalls; } private: static std::size_t moveCtorCalls; std::string s; };
如果要使用std::forward实现同样的效果,则必须这么写:
class Widget { public: Widget(Widget&& rhs) : s(std::forward<std::string>(rhs.s)) { ++moveCtorCalls; } … };
调用std::forward时,必须指明模板类型实参。在需要右值时,传递给std::forward的不能是个引用类型,这是将传递给形参的是右值这一信息进行编码的惯常行为。因此,针对右值使用std::move而非std::forward,可以有更少的码,省去了传递模板类型形参以指明参数为右值的编码信息的麻烦。
同样的,如果针对万能引用实施std::move,可能会导致左值被意外改动:
class Widget { public: template<typename T> void setName(T&& newName) { name = std::move(newName); } // compiles, but is bad, bad, bad! private: std::string name; }; std::string getWidgetName(); // factory function Widget w; auto n = getWidgetName(); // n is local variable w.setName(n); // moves n into w! … // n‘s value now unknown
局部变量n传递给w.setName,调用者合情合理的认为该函数只是对n进行只读操作,然而setName内部却使用了std::move将其转换为右值,导致n的值会被移动到w.name中,因此,setName调用结束后,n将变成一个不确定的值。
你或许认为setName不应该将其参数声明为万能引用,当setName确定不会修改其参数时,这样的引用不能是const的。因此,你认为使用下面的重载可以解决之前的问题:
class Widget { public: void setName(const std::string& newName) { name = newName; } void setName(std::string&& newName) { name = std::move(newName); } … };
这种重载需要编写和维护更多的代码,更重要的是其效率更低。比如下面的用法:w.setName(“Adela Novak”),“Adela Novak”是个左值,在使用万能引用的版本中,T被推导为const char (&)[13],也就是个字符数组的左值引用,然后将其通过std::forward转发给std::string的赋值操作符,这种情况下,没有std::string的临时对象产生。而在重载版本中,会使用“Adela Novak”产生一个临时std::string对象,然后调用右值引用版本的setName,将临时对象的内容移动到name中,这就产生了临时对象的构造和析构成本,因而比只调用std::string的赋值运算符的成本要高。
实际上,重载版本的最严重问题并不是代码膨胀,习惯用法的背离,甚至也不是运行期的效率问题。而是这种设计的可扩展性较差。Widget::setName目前仅有一个参数,就需要2个重载版本,如果扩展到n个参数,如果每个参数都需要一个左值和一个右值版本,则重载数量将达到2的n次方。更糟糕的是,有些函数的参数个数是不定的,而每个形参可能是左值也可能是右值,std::make_unique和std::make_shared就是这样的函数,针对这样的函数,重载是不可能的,使用万能引用才是唯一的办法:
template<class T, class... Args> shared_ptr<T> make_shared(Args&&... args); template<class T, class... Args> unique_ptr<T> make_unique(Args&&... args);
在按值返回的函数中,如果返回的是绑定到对象的右值引用或万能引用,则当返回该引用时,需要使用std::move或std::forward。比如下面的代码:
Matrix operator+(Matrix&& lhs, const Matrix& rhs) { lhs += rhs; return std::move(lhs); }
通过将lhs强制转换为右值,因此lhs会被移动到函数返回值的位置(一个临时对象)。如果不使用std::move,lhs是个左值,编译器会将其复制到返回值的存储位置。如果Matrix支持移动构造,移动构造比复制构造成本更低,因此使用std::move具有更高的效率;如果Matrix不支持移动构造,使用std::move也不会有害处,因为会使用右值调用复制构造函数,被复制到函数返回值的位置,将来Matrix支持移动操作了,则operator+将会自动受益于使用了使用std::move。
对于万能引用和std::forward而言,情况类似:
template<typename T> Fraction reduceAndCopy(T&& frac) { frac.reduce(); return std::forward<T>(frac); }
如果传递一个右值,则它的值会被移动到返回值上,如果传递一个左值,则会被复制到返回值上。但是如果省略了std::forward,则frac会无条件的复制到返回值上。
注意,上面的结论并不适用按值返回局部变量的函数:
Widget makeWidget() { Widget w; … return w; }
如果在return语句中针对w实施了std::move,则是错误的行为。这是因为,编译器RVO(返回值优化)的存在,使得Widget outw = makeWidget()这样的语句,makeWidget可以在函数返回值位置outw上直接创建局部变量w,从而避免将w复制到outw。
返回值优化只有在其不会影响软件的可观测行为时,才会允许发生,具体而言,编译器若要在一个按值返回的函数里省略对局部对象(这里的局部对象包括:局部变量,return语句中创建的临时对象。不包括函数的参数)的复制(或移动),需要满足两个条件:局部对象的类型和返回值类型相同;返回的就是局部对象本身。return w;语句这两个前提条件都满足了,因而任何合格的C++编译器都会使用RVO来避免复制w。
而移动版本的makeWidget却做了名副其实的事情(假设Widget支持移动构造),它将w的内容移动到makeWidget的返回值空间上,而不能使用RVO,这是因为它违反了第二个条件,使用std::move返回的不是局部对象w,而是w的引用。因此编译器必须把w移动到函数的返回值存储位置。开发者本来想用std::move来帮助编译器进行优化,结果却适得其反的限制了本来可用的优化选项。
ROV是一种优化措施,编译器并不是必须这样做。当编译器不会进行RVO时,你认为使用std::move是正确的行为。实际上标准中提到了:即使实施RVO的前提条件满足,但编译器选择不执行复制省略的时候,返回对象必须作为右值处理。这样一来,就相当于当RVO条件满足时,要么实施复制省略,要么std::move隐式的实施于返回的局部对象上。因此,在makeWidget的复制版本中,编译器必须要么省略w的复制操作,要么让函数特殊处理,与下面的函数等价:return std::move(w);
这种情况也适用于按值传递函数形参,当他们作为函数返回值时,不适合实施复制省略,但是编译器必须在其返回时作为右值处理。因此,如果代码是这样的:
Widget makeWidget(Widget w) { … return w; }
编译器必须处理这样的代码,以使他们与以下代码等价:
Widget makeWidget(Widget w) { … return std::move(w); // treat w as rvalue }
因此,针对函数中按值返回的局部对象实施std::move,不能给编译器帮上忙(如果不执行复制省略,则必须将局部对象作为右值处理,效果一样),却可能帮倒忙(阻止RVO的实施)。
上面的结论可以通过下面的代码来验证:
class Widget { public: Widget(Widget &&w) { std::cout << "this is Widget(Widget &&w)\n"; } Widget() { std::cout << "this is Widget()\n"; } Widget(const Widget &w) { std::cout << "this is Widget(const Widget &w)\n"; } }; Widget funw(Widget &&rw) { return rw; } Widget funw2(Widget &&rw) { return std::move(rw); } Widget funw3() { Widget w; return w; } Widget w = funw(Widget()); std::cout << "------------------\n"; Widget w2 = funw2(Widget()); std::cout << "------------------\n"; Widget w3 = funw3();
如果让编译器放弃RVO,使用编译命令:g++ -o move move.cpp -std=c++11 -fno-elide-constructors,结果如下:
this is Widget() this is Widget(const Widget &w) this is Widget(Widget &&w) ------------------ this is Widget() this is Widget(Widget &&w) this is Widget(Widget &&w) ------------------ this is Widget() this is Widget(Widget &&w) this is Widget(Widget &&w)
w的构造,没有在参数rw上实施std::move,导致rw复制构造返回值对象(临时对象),然后再由返回值对象移动构造w,因此,这里调用了一次复制构造函数和移动构造函数;w2的构造中,针对rw实施了std::move,因此rw移动构造返回值对象,而后返回值对象又移动构造w2,因此这里调用了两次移动构造函数;w3的构造,使用w构造返回值对象,编译器隐式的对其实施了std::move,而后返回值对象又移动构造w3,因此,这里调用了两次移动构造函数。
如果开启RVO:g++ -o move move.cpp -std=c++11,结果如下:
this is Widget() this is Widget(const Widget &w) ------------------ this is Widget() this is Widget(Widget &&w) ------------------ this is Widget()
开启RVO的情况下,直接在接收返回值对象的地方进行构造,特别是构造w3时,直接在w3上构造funw3中的w,因此只有一次默认构造函数的调用。
26:避免万能引用上的重载
std::multiset<std::string> names; // global data structure void logAndAdd(const std::string& name) { auto now = std::chrono::system_clock::now(); log(now, "logAndAdd"); names.emplace(name); } std::string petName("Darla"); logAndAdd(petName); // pass lvalue std::string logAndAdd(std::string("Persephone")); // pass rvalue std::string logAndAdd("Patty Dog"); // pass string literal
第一个logAndAdd调用,形参name绑定到了变量petName,在logAndAdd内部,name最终传递给了names.emplace。由于name是个左值,因此他是被复制到names中的;第二个logAndAdd调用,形参name绑定到了一个右值,name自身是个左值,所以它也是被复制到names中的,然而,原则上该值是可以被移动到names中;第三次logAndAdd调用,形参name还是绑定到了一个右值,但这次的std::string类型的临时变量是从”Patty Dog”隐式构造的,和之前一样,name还是被复制到names中的,但是本次调用传递给logAndAdd的是个字符串字面量,如果该字符串字面量直接传递给emplace,则完全没有必要构造一个std::string类型的临时对象,emplace完全可以利用这个字符串字面量在names内部直接构造一个std::string对象。
为了解决上面的效率低下问题,使用万能引用重写logAndAdd即可:
template<typename T> void logAndAdd(T&& name) { auto now = std::chrono::system_clock::now(); log(now, "logAndAdd"); names.emplace(std::forward<T>(name)); } std::string petName("Darla"); logAndAdd(petName); // copy lvalue into multiset logAndAdd(std::string("Persephone")); // move rvalue instead of copying it logAndAdd("Patty Dog"); // create std::string in multiset instead of copying a temporary std::string
假设有了新需求,导致logAndAdd需要一个重载版本:
std::string nameFromIdx(int idx); // return name corresponding to idx void logAndAdd(int idx) { auto now = std::chrono::system_clock::now(); log(now, "logAndAdd"); names.emplace(nameFromIdx(idx)); }
有了这个重载版本之后,下面的调用都能成功:
std::string petName("Darla"); // as before logAndAdd(petName); // as before logAndAdd(std::string("Persephone")); // as before logAndAdd("Patty Dog"); // as before logAndAdd(22); // calls int overload
然而,下面的语句却会失败:
short nameIdx; logAndAdd(nameIdx); // error!
原因在于,形参类型为T&&的版本可以将T推导为short,从而产生一个精确匹配;而形参为int的重载版本只能在类型提升之后才能匹配到short类型的实参。因此,这里调用的是万能引用版本,导致一个short类型被std::forward传递给names的emplace成员函数,再然后又被转发给std::string构造函数,但是std::string的构造函数中没有形参为short的版本,所以到这里就失败了,这一切的原因在于,对于short类型的实参而言,万能引用产生了比int更好的匹配。
参数为万能引用的函数,是C++中最贪婪的,它们在具现过程中,和几乎任何实参类型都产生精确匹配,这就是为什么把重载和万能引用这两者结合起来几乎总是馊主意:一旦万能引用成为重载候选,他就会吸引走大批的实参类型,远比你期望的要多。再考虑下面的代码:
class Person { public: template<typename T> explicit Person(T&& n) : name(std::forward<T>(n)) {} explicit Person(int idx) : name(nameFromIdx(idx)) {} … private: std::string name; };
实际上,因为编译器会自动生成特种成员函数,因此Person中有比肉眼可见更多的重载版本,也就是复制和移动构造函数,Person类中实际上是长这样的:
class Person { public: template<typename T> explicit Person(T&& n) : name(std::forward<T>(n)) {} explicit Person(int idx); // int ctor Person(const Person& rhs); // copy ctor (compiler-generated) Person(Person&& rhs); // move ctor (compiler-generated) … };
考虑下面的调用:
Person p("Nancy"); auto cloneOfP(p); // create new Person from p; this won‘t compile!
尝试从一个Person构造另一个Person,尽管看起来应该是调用复制构造函数,但是实际上它却是调用了完美转发构造函数,从而引起编译错误。这是因为,cloneOfP的实参是个非常量左值p,这意味着模板构造函数可以实例化接收Person类型的非常亮左值形参:
explicit Person(Person& n) : name(std::forward<Person&>(n)) {}
在调用cloneOfP时,p既可以传递给复制构造函数,也可以传递给实例化了的模板。但如果调用复制构造函数还需要添加const饰词,而实例化了的模板却是精确匹配,所以编译器会选择万能引用版本。如果稍微改一下代码,就可以调用真正的复制构造函数了:
const Person cp("Nancy"); // object is now const auto cloneOfP(cp); // calls copy constructor!
这里可以精确匹配到复制构造函数了。尽管模板化构造函数也可以实例化同样的签名:
explicit Person(const Person& n);
然而C++重载决议规则中有这么一条:在函数调用时,一个模板实例化函数和非函数模板具备相同的匹配程度,优先选用常规函数。
如果Person类被派生,则派生类的复制和移动操作的平凡实现也会表现出让人大跌眼镜的行为:
class SpecialPerson: public Person { public: SpecialPerson(const SpecialPerson& rhs) : Person(rhs) { … } SpecialPerson(SpecialPerson&& rhs) : Person(std::move(rhs)) { … } };
派生类函数把类型为SpecilPerson的实参传递给了基类,然后在Person类的构造函数中完成模板实例化和重载决议,最终导致编译错误。
在万能引用上实施重载是个糟糕的思路,然而你有需要针对绝大多数的实参类型实施转发,也需要针对特殊实参类型进行特殊处理,这时该怎么办,请看下一条款。
27:熟悉重载万能引用的替代方案
针对重载万能引用带来的问题,下面是几种解决方法:
1:舍弃重载
条款26的第一个例子,logAndAdd,可以作为很多函数的代表,这样的函数只需要把本来打算进行重载的版本重命名为不同的名字就可以避免重载万能引用带来的问题,比如logAndAdd就可以分别改成logAndAddName和logAndAddNameIdx。但是这种方法不适用于第二个例子,Person类的构造函数。
2:传递const T&类型的形参
这种方法是回归C++98,使用左值常量引用类型来代替传递万能引用类型。实际上这就是条款26第一个例子的首次实现。这种方法的缺点就是达不到我们想要的高效率,然而放弃部分效率来保持简洁性不失为有一定吸引力的权衡结果。
3:传值
一种能够提升性能,却不用增加一点复杂性的方法,就是把传递的形参从引用类型换成传值,这种设计遵守了条款41的建议,其运作原理和效率提升的细节等到条款41中讨论。这里仅展示一下该技术如何在Person例子中的运用:
class Person { public: explicit Person(std::string n) : name(std::move(n)) {} explicit Person(int idx) : name(nameFromIdx(idx)) {} private: std::string name; };
4:标签分派
无论是传递左值常量还是传值,都不支持完美转发。如果使用万能引用的动机就是为了实施完美转发,那就只能采用万能引用。这种情况下,如果也不想舍弃重载,则需要采用其他的办法。
重载决议会考察所有重载版本的形参,然后选择全局最佳匹配的函数。尽管万能引用会匹配任何形式的实参,但是如果万能引用仅是形参列表的一部分,则可以利用该非万能引用形参达到解决问题的目的。这就是标签分派的基础。
针对logAndAdd例子而言,重新实现logAndAdd函数,把它委托给另外两个函数,一个接收整型值,另一个接收其他所有类型,而logAndAdd本身也接收所有类型的实参。两个完成实际工作的函数可以命名为logAndAddImpl,这两个函数都有第二个形参,用于判断传入的实参是否为整型,正是这第二个形参,阻止了万能引用重载带来的问题,具体的代码如下:
template<typename T> void logAndAdd(T&& name) { logAndAddImpl( std::forward<T>(name), std::is_integral<typename std::remove_reference<T>::type>() ); } template<typename T> void logAndAddImpl(T&& name, std::false_type) { auto now = std::chrono::system_clock::now(); log(now, "logAndAdd"); names.emplace(std::forward<T>(name)); } std::string nameFromIdx(int idx); void logAndAddImpl(int idx, std::true_type) { logAndAdd(nameFromIdx(idx)); }
概念上,logAndAdd会向logAndAddImpl传递一个布尔值,用以表示传递给logAndAdd的实参是否为整型。不过true和false都是运行期值,但是我们需要利用的是重载决议(一种编译期现象)来选择正确的logAndAddImpl重载版本。这意味着我们需要一个对应于true的类型以及一个对应于false的类型,C++标准库提供了名为std::true_type和std::false_type这一对类型来满足这样的需求。若T是整型,则经由logAndAdd传递给logAndAddImpl重载版本,就会是个继承自std::true_type类型的对象,反之若T不是整型,则实参会是个继承自std::false_type类型的对象。
注意,整数版本的logAndAddImpl按索引查找对应的名字,然后再将名字传递给logAndAdd,然后经由std::forward转发给另一个logAndAddImpl重载版本,这样可以避免在两个重载版本中都放入记录日志的代码。
std::is_integral和std::false_type、std::true_type都是定义在<type_traits>中的模板类。
template< class T, T v > struct integral_constant; typedef integral_constant<bool, true> true_type; typedef integral_constant<bool, false> false_type; template<class _Ty> struct _Is_integral : false_type {}; template<> struct _Is_integral<bool> : true_type {}; template<> struct _Is_integral<char> : true_type {}; ... template<class _Ty> struct is_integral : _Is_integral<typename remove_cv<_Ty>::type> {};
integral_constant封装了一个类型为T,值为v的静态constexpr成员;std::false_type和std::true_type是integral_constant模板类的具现:
is_integral判断T是否为整型。如果T的类型是bool, char, char16_t, char32_t, wchar_t, short, int, long, long long,or any implementation-defined extended integer types, including any signed, unsigned, and cv-qualified variants,则is_integral内部的静态成员value初始化为true,否则为false。实际上,is_integral间接派生自true_type或false_type。
std::false_type、std::true_type就是所谓的“标签”,它决定了调用的是哪个重载版本。运用它们的唯一目的就在于强制重载决议按我们想要的方向推进。这些形参不需要名字,因为他们在运行期不起任何作用。有的编译器能够识别出这些标签形参并未使用过,从而可以将优化掉。
5:对接收万能引用的模板施加限制
标签分派的关键在于,存在一个无重载的函数作为客户端API,该函数会把待完成的工作分派到实现函数。这种方法对于条款26的第二个例子,即Person类的完美转发构造函数束手无策。这是因为编译器可能会自行生成复制构造函数和移动构造函数,因此如果仅撰写一个完美转发构造函数,然后在其中使用标签分派,那么有些针对构造函数的调用就可能会有编译器生成的构造函数进行处理,从而绕过了标签分派机制
真正的问题在于编译器生成的函数并不保证一定会绕过标签分派的设计,当收到使用左值对象的复制请求时,期望调用的是复制构造函数,但是只要提供了一个接受万能引用的构造函数,则使用非常量左值初始化时,总是调用到万能引用构造函数;而且,派生类以传统方式实现其复制和移动构造函数时,总是会调用到万能引用构造函数,尽管正确的行为应该是调用基类的复制构造和移动构造函数。
这种情况下,标签分派方法无计可施。需要使用另一种技巧:SFINAE和std::enable_if。std::enable_if可以强制编译器表现出来的行为如同特定的模板不存在一样。实施了std::enable_if的模板,只有在满足了std::enable_if指定的条件的前提下才会启用。std::enable_if的形式如下:
class Person { public: template<typename T, typename = typename std::enable_if<condition>::type> explicit Person(T&& n); … };
针对我们讨论的例子,如果传递的类型是Person,则需要禁用完美转发构造函数。使用std::is_same可以判断两种类型是否为同一类型。而且还需要注意的是,T有可能被推导为Person&,因此,说T不是Person类型,实际上还要注意它是否是个引用,Person&和Person&&都应该与Person做相同处理;而且,const Person、volatile Person和const volatile Person都应该与Person做相同处理。所以,需要一种方法剔除所有的引用、const和volatile饰词,这种方法就是使用std::decay,std::decay<T>::type就是剔除了T中的引用、const和volatile之后的类型,所以代码如下:
class Person { public: template<typename T, typename = typename std::enable_if< !std::is_same<Person, typename std::decay<T>::type>::value>::type > explicit Person(T&& n); … };
实际上,我们需要的条件不仅是保证T不是Person类型,而且T还不能是Person类的派生类型。标准库中的std::is_base_of可以用来判定一个类型是否由另一个类型派生而来,而且,所有类型都可以认为是从它自身派生而来的,所以,正确的代码是:
class Person { public: template<typename T, typename = typename std::enable_if< !std::is_base_of<Person, typename std::decay<T>::type >::value >::type > explicit Person(T&& n); … };
然而,在条款26中,还有一个接收整型实参的重载构造函数,因此接收万能引用的版本还需要把该构造函数剔除出去。所以,最终的代码是:
class Person { public: template<typename T, typename = std::enable_if_t< !std::is_base_of<Person, std::decay_t<T>>::value && !std::is_integral<std::remove_reference_t<T>>::value > > explicit Person(T&& n) : name(std::forward<T>(n)) { … } explicit Person(int idx) : name(nameFromIdx(idx)) { … } … // copy and move ctors, etc. private: };
这里使用了C++14的写法,依然显得很复杂。如果你有可能使用其他技术来避免万能引用和重载的混合,你就应该使用它,如果你习惯了函数式语法,就会发现上面的代码也没有那么糟糕。
6:总结
本条款的前三种技术(放弃重载,传递const T&和传值)都需要对函数形参逐一指定类型,而后两种技术(标签分派和对模板启用限制)则利用了完美转发,因此无需指定形参类型。
按理说,使用完美转发效率更高,但是完美转发也有缺点,首先某些类型无法实施完美转发(条款30讨论这种情况),再者当客户传递了非法形参时,编译器给出的错误信息可能难以理解。比如:Person p(u”onrad Zuse”),这里使用char16_t类型的字符串初始化std::string是非法的。如果使用前三种技术,编译器会给出直截了当的错误信息;如果使用后两种技术,则const char16_t类型的数组绑定到构造函数的形参,然后转发到Person的std::string类型的成员变量的构造函数时,编译器才会发现无法用char16_t构造std::string,某些编译器,给出的错误信息有160行之多。
这还只是万能引用仅转发了一次的情况,如果系统复杂,有可能万能引用要经过数层转发才会抵达判断实参类型是否可接受的场所,转发的次数越多,错误信息就越让人摸不着头脑。
在Person中,我们了解到转发函数的万能引用形参应该用于std::string对象的初始化物,所以,可以使用static_assert来判断这一条件,static_assert可以再编译期进行assert检查。使用std::is_constructible可以在编译期判断某个类型的对象是否能从另一类型的对象出发完成构造,所以:
class Person { public: template<typename T, typename = std::enable_if_t< !std::is_base_of<Person, std::decay_t<T>>::value && !std::is_integral<std::remove_reference_t<T>>::value > > explicit Person(T&& n) : name(std::forward<T>(n)) { // assert that a std::string can be created from a T object static_assert( std::is_constructible<std::string, T>::value, "Parameter n can‘t be used to construct a std::string" ); … // the usual ctor work goes here } … };
这种情况下,当客户端尝试从一个无法构造出std::string类型的对象创建一个Person类型的对象时,会产生出指定的错误信息。
28:理解引用折叠
条款23提到过,模板形参为万能引用,当传递实参到函数模板时,推导出来的模板形参会将实参是左值还是右值的信息编码到结果类型中:
template<typename T>
void func(T&& param);
模板形参T的推导结果中,会把传递的实参是左值还是右值的信息给编码进去。所谓的编码机制其实很多简单:传递左值时T被推导为左值引用;传递右值时T被推导为非引用类型:
Widget widgetFactory(); Widget w; func(w); // call func with lvalue; T deduced to be Widget& func(widgetFactory()); // call func with rvalue; T deduced to be Widget
这个机制就是决定了万能引用是成为左值引用还是右值引用的机制,也是std::forward得以运作的机制。
在C++中,“引用的引用”是非法的,因此不允许你写成下面的形式:
int x; auto& & rx = x; // error! can‘t declare reference to reference
然而,某些情况下,却有可能发生引用的引用:
template<typename T> void func(T&& param); func(w);
如果把T的推导结果带入函数模板,就得到了下面的结果:
void func(Widget& && param);
但是编译器却没有报错,并最终生成下面的函数签名:
void func(Widget& param);
这就是引用折叠发生了作用。尽管用户被禁止声明引用的引用,但编译器却可以在特殊的语境中产生引用的引用,这种情况下,就会使用引用折叠规则,使双重引用折叠为单个引用:如果任一引用为左值引用,则结果为左值引用;否则,为右值引用。也就是:
A& & --> A& A& && --> A& A&& & --> A& A&& && --> A&&
引用折叠是使std::forward得意运作的关键:
template<typename T> void f(T&& fParam) { … someFunc(std::forward<T>(fParam)); }
由于fParam是个万能引用,所以传递给f的实参是左值还是右值会被编码到T中。而std::forward的任务是当且仅当T中的编码信息表明传递的是个右值时,即T是个非引用类型时,才对fParam实施到右值的强制类型转换。下面是std::forward的简单实现:
template<typename T> T&& forward(typename remove_reference<T>::type& param) { return static_cast<T&&>(param); }
如果传递给f的是个Widget左值,则T推导为Widget&,所以具现化的结果是:
Widget& && forward(typename remove_reference<Widget&>::type& param) { return static_cast<Widget& &&>(param); }
std::remove_reference<Widget&>::type产生的结果是Widget,而且引用折叠在返回值和强制转换中发生了作用,因此最终版本是:
Widget& forward(Widget& param) { return static_cast<Widget&>(param); }
因此,当左值实参传递给f时,std::forward的实例化结果是:接收一个左值引用,并返回一个左值引用,std::forward中的强制类型转换没有做任何事。
如果传递给f的是个右值Widget,则T的推导结果是Widget,而f内部的std::forward就成了std::forward<Widget>,forward的具现化就是:
Widget&& forward(typename remove_reference<Widget>::type& param) { return static_cast<Widget&&>(param); }
实施了std::remove_reference之后,就是:
Widget&& forward(Widget& param) { return static_cast<Widget&&>(param); }
这里没有发生引用折叠。而forward返回的是右值引用,因此最终的结果就是传递给函数f的右值实参会作为右值转发到someFunc函数。
引用折叠会出现在四种场景中,模板实例化是最常见的一种;第二种是auto变量的类型推导,它本质上和模板实例化一模一样:
Widget w; auto&& w1 = w; auto &&w2 = widgetFactory();
w是个左值,auto的推导结果为Widget&,将其带入到w1的声明中,结果是Widget& &&w1=w,应用引用折叠之后,最终的结果是Widget& w1=w,所以,w1是左值引用。
以右值初始化w2,auto的类型推导结果为Widget,将其带入到w2的声明中,结果是Widget&& w2=widgetFactory(),所以w2是个右值引用。
引用折叠的第三种场景是typedef和别名声明:如果在typedef的创建或评估求值的过程中出现了引用的引用,则会实施引用折叠:
template<typename T> class Widget { public: typedef T&& RvalueRefToT; … }; Widget<int&> w;
在Widget中以int&带入T的位置,应用引用折叠之后,结果是:
typedef int& RvalueRefToT;
这个结果表明,为typedef选取的名字有些名不副实:当以左值引用类型实例化Widget时,RvalueRefToT实际上成了左值引用。
最后一种会发生引用折叠的场景是decltype中。如果在分析一个设计decltype的类型推导中出现了引用的引用,则会实施引用折叠。
29;假定移动操作不存在、成本高、未使用
整个C++98标准库已为C++11彻底返修过,就是为了能向那些移动比复制更快的类型中添加移动操作,而且库组件也被修订以便能够充分利用移动操作的优势。然而对于你自己程序中或者使用的库中的,那些没有为C++11做出相应调整的类型,C++11编译器也可能无能为力,尽管C++11编译器愿意为那些缺少移动操作的类生成移动操作,但是这仅限于那些没有声明复制操作、移动操作或析构函数的类(条款17)。如果数据成员或者基类已经禁用了移动操作(delete),也会阻止编译器生成移动操作。
即使那些显式支持移动操作的类型,也可能不会像想象中那样带来那么大的利好。比如在C++11的标准库中,所有的容器都支持移动操作,但是却不能断言所有的容器移动都是成本低廉的。对于某些容器而言,根本没有成本低廉的途径来移动其内容,而对于另一些容器,虽然确实有成本低廉的移动操作,但又有一些附加条件造成其容器元素不能满足。
比如std::array这个C++11中引入的新容器类型,它本质上就是带有STL接口的内建数组。这一点和其他标准容器有着根本差异,因为其他容器都是将其内容放在堆上,从而在概念上,只需持有一个指向堆内存的指针即可,因此所谓的容器移动,仅仅就是把指向容器内容的指针从源容器复制到目标容器,然后把源容器的指针置空即可,这才使得容器移动在常数时间内完成成为可能:
然而std::array没有这样的指针,因为其内容数据是直接存储在对象内的:
如果Widget的移动操作比复制操作快,从而移动一个Widget的std::array自然也比复制更快。std::array虽然支持移动操作,但是无论是移动还是复制std::array,都需要线性时间的复杂度,这是因为容器内的每个元素都必须逐一复制或移动,这跟常说的“移动容器和赋值一堆指针一样成本低廉了”大相径庭。
另外,std::string支持常量时间的移动以及线性时间的复制。然而,很多string的实现都采用了小型字符串优化(small string optimization,SSO),这种机制是将较小的字符串(比如不超过15个字符的字符串)存储在std::string对象内的缓冲区中,而不是使用堆上分配的内存,这种情况下,对小型字符串实施移动并不比复制快。
即使是那些支持快速移动操作的类型,一些看似万无一失的移动场景还是以复制副本而告终。条款14解释过,标准库中一些容器操作提供了强烈的异常安全保证,为了确保依赖于这种保证的那些C++98遗留代码在升级到C++11后不会破坏这样的保证,底层的复制操作只有在已知移动操作不会抛出异常的情况下才会使用移动操作。因此只要移动操作没有加noexcept声明,即使某个类型的移动操作比赋值操作更快,编译器仍会强制调用一个复制操作。
总结而言,下列场景下,C++11的移动操作不会带来明显的好处:没有移动操作,待移动的对象不提供移动操作;移动未能更快,待移动的对象虽然有移动操作,但是其并不比复制更快;移动不可用,移动本可以发生的语境下,要求移动不抛出异常,但是移动操作却未加上noexcept声明;原对象是个左值。
30:熟悉完美转发的失败情形
所谓完美转发,转发是指一个函数把自己的形参传递给另一个函数,目的就是为了让第二个函数接收与第一个函数相同的对象,这就排除了按值传递形参,因为它们只是原始调用者所传递之物的副本;想要目的函数能够处理原始传入对象,指针形参也只能出局,因为我们不能强制调用者只能传递指针。因此,转发都是指处理形参为引用类型的场景。
完美的意思就是,我们不仅转发对象,还要转发其特征:类型、左值还是右值、是否带有const或volatile等饰词。因此,完美转发只和万能引用打交道,因为只有万能引用可以把传入的实参是左值还是右值这一信息加以编码。
下面的fwd是个函数模板,它接收可变长形参。标准容器的置入函数,以及std::make_shared和std::make_unique就是这样的形式:
template<typename... Ts> void fwd(Ts&&... params) // accept any arguments { f(std::forward<Ts>(params)...); // forward them to f }
如果给定目标函数f和转发函数fwd,当以某特定实参调用f会执行某操作,而用同一实参调用fwd却执行不同的操作,这种情况称为完美转发失败。有若干实参会导致失败:
1:大括号初始化物
void f(const std::vector<int>& v); f({ 1, 2, 3 }); // fine, "{1, 2, 3}" implicitly fwd({ 1, 2, 3 }); // error! doesn‘t compile
上面的代码中,f可以调用成功,{1,2,3}隐式转换为临时的std::vector<int>对象,f的形参就绑定到了该对象。而对于fwd而言却发生了编译错误,这是因为向未声明为std::initializer_list类型的函数模板形参传递大括号初始化物,叫做“非推导语境”。换句话说,由于fwd的形参未声明为std::initializer_list,编译器禁止在fwd的调用过程中,从表达式{1,2,3}出发推导类型。
解决方法很简单:
auto il = { 1, 2, 3 }; // il‘s type deduced to be std::initializer_list<int> fwd(il); // fine, perfect-forwards il to f
2:0和NULL作为空指针
条款8提到过,若把0或NULL以空指针的名义传递给模板,则类型推导的结果将是整型,而非所传递实参的指针类型。因此,0或NULL都不能用作空指针进行完美转发,这里需要使用nullptr。
3:仅有声明的整型static const成员变量
C++规定,不需要给出类中整型static const成员变量的定义,仅声明时指定初始值即可。因为编译器会根据这些成员的初始值实施常量传播(const propagation),因而也就不必再为它们保留内存:
class Widget { public: static const std::size_t MinVals = 28; // MinVals‘ declaration … }; std::vector<int> widgetData; widgetData.reserve(Widget::MinVals); // use of MinVals
尽管Widget::MinVals没有定义,还是可以用它来指定widgetData的初始容量。方法就是把值28替换到所有用到MinVals的地方。如果有对Minvals取地址的需求,比如创建了指向MinVals的指针,这种情况下MinVals就需要内存了,上面的代码能通过编译,但是如果不为MinVals提供定义,在链接阶段就会报错:undefined reference to `xxx‘
void f(std::size_t val); f(Widget::MinVals); // fine, treated as "f(28)" fwd(Widget::MinVals); // error! shouldn‘t link
上面针对fwd的调用能够通过编译,但是会发生连接错误,原因是相同的。尽管源代码并没有对MinVals取地址,但是fwd的形参是万能引用,在编译器生成的机器码中,引用通常是当做指针来处理的,指针和引用本质上是同一事物。因而MinVals按引用传递和按指针传递就没有什么区别了。因此,按引用传递MinVals时要求MinVals有定义(有的编译器可能不做这样的要求)。解决办法就是添加MinVals的定义:
const std::size_t Widget::MinVals; // in Widget‘s .cpp file
4:重载的函数名字和模板名字
void f(int (*pf)(int)); // pf = "processing function" void f(int pf(int)); // declares same f as above int processVal(int value); int processVal(int value, int priority); f(processVal); // fine
上面的调用能够成功,f要求的实参是个指向函数的指针,尽管processVal不是指针,它只是一个名字,但是编译器还是能够知道它需要的是哪个processVal,就是能匹配f形参类型的那个。因此,编译器会选择那个接收一个int形参的processVal,然后把那个函数地址传递给f。
这里之所以可以调用成功,是因为f的声明使得编译器可以弄清楚那个版本的processVal符合要求。但是fwd就不行了,它是一个函数模板,没有任何关于类型要求的信息,这使得编译器无法决定应该传递哪个重载版本:
fwd(processVal); // error! which processVal?
在使用函数模板的场景中有同样的问题,函数模板不是代表一个函数,而是很多函数:
template<typename T> T workOnVal(T param) { … } fwd(workOnVal); // error! which workOnVal instantiation?
要使得fwd这种实施完美转发的函数能接受重载函数名或模板名,只有手动指定需要哪个重载版本或者实例。比如:
using ProcessFuncType = int (*)(int); // see Item 9 ProcessFuncType processValPtr = processVal; // specify needed signature for processVal fwd(processValPtr); // fine fwd(static_cast<ProcessFuncType>(workOnVal)); // also fine
5:位域
最后一种完美转发的失败场景,是使用位域作为函数实参:
struct IPv4Header { std::uint32_t version:4, IHL:4, DSCP:6, ECN:2, totalLength:16; … }; void f(std::size_t sz); // function to call IPv4Header h; f(h.totalLength); // fine fwd(h.totalLength); // error!
fwd的形参是个引用,而h.totalLength是个非const的位域。C++规定,非const引用不能绑定到位域,因为位域是不可能有办法对其直接取址的。可以传递位域的仅有的形参种类就是按值传递,以及常量引用。按值传递的情况,被调用函数收到的是位域值的副本;常量引用的情况,标准要求引用实际绑定到存储在某种标准整型中的位域值的副本,常量引用不可能绑定到位域,它们绑定到的是常规对象,其中复制了位域的值。
要把位域传递给fwd,很简单:
auto length = static_cast<std::uint16_t>(h.totalLength); fwd(length); // forward the copy