一、Pointer to member(指针成员)与copy constructor(拷贝构造函数)
当一个类中出现一个指针成员变量时,就需要十分小心的实现拷贝构造函数。一不小心就会出现memory leak(内存泄漏)或者crtls valid heap pointer(block)(浅拷贝问题)。
浅拷贝
这里我有一个HasPtrMem
类有一个成员变量int* d;
,具体见下方代码:
#include <iostream> using namespace std; class HasPtrMem { public: HasPtrMem() :d(new int(0)) { cout << "call constructor : " << ++n_cstr << endl; } ~HasPtrMem() { delete d; d = nullptr; cout << "call destructor : " << ++n_dstr << endl; } // 为了测试方便 将作用范围设置为public public: int* d; }; int main() { HasPtrMem a; HasPtrMem b(a); cout << "*a.d:" << *a.d << endl; cout << "*b.d:" << *b.d << endl; return 0; }
我们在构造时在堆区分配一个int类型大小的内存,在析构时被释放掉之前堆区分配的内存。在主函数中,我们定义了一个对象a,然后再通过拷贝函数初始化对象b(注意:这里的拷贝构造函数是采用系统默认生成的)。等价于下面的代码:
HasPtrMem(const HasPtrMem& h) :d(h.d) { cout << "call default copy constructor : " << ++n_cptr << endl; }
只是做了一个浅拷贝(相当于俩个对象的指针变量都指向了同一块开辟的空间)。那么在析构函数中执行delete时就会造成crtls valid heap pointer(block)错误(这里我姑且称为重复释放堆区内存错误)。因为在调用一个析构函数后,那么成员指针变量d就成了悬挂指针。因为此时d指向的是一块被释放的内存,所以当再次调用析构函数时就会造成严重的运行错误。
调试情况下的错误:
运行情况下的错误:
这里就需要我们自己去编写深拷贝构造函数。具体代码如下:
HasPtrMem(const HasPtrMem& h) :d(new int(*h.d)) { cout << "call deep copy constructor : " << ++n_cptr << endl; }
二、移动语义
虽然说为了指针成员变量编写拷贝构造函数是必然的,但是在一些情况下,我们并不需要这样的拷贝构造函数。例如下面这种情况:
#include <iostream> using namespace std; class HasPtrMem { public: HasPtrMem() :d(new int(0)) { cout << "call constructor : " << ++n_cstr << endl; } HasPtrMem(const HasPtrMem& h) :d(new int(*h.d)) { cout << "call copy constructor : " << ++n_cptr << endl; } HasPtrMem operator++(int) // i++ { HasPtrMem old = *this;//拷贝构造 ++old.d; return old;//拷贝构造 } ~HasPtrMem() { // 这里为了防止报错 注释掉释放语句 //delete d; //d = nullptr; cout << "call destructor : " << ++n_dstr << endl; } // 为了测试方便 将作用范围设置为public public: int* d; // 记录每个函数被调用的次数 static int n_cstr; static int n_dstr; static int n_cptr; }; int HasPtrMem::n_cstr = 0; int HasPtrMem::n_dstr = 0; int HasPtrMem::n_cptr = 0; int main() { HasPtrMem a; a++; return 0; }
二、移动语义
虽然说为了指针成员变量编写拷贝构造函数是必然的,但是在一些情况下,我们并不需要这样的拷贝构造函数。例如下面这种情况:
#include <iostream> using namespace std; class HasPtrMem { public: HasPtrMem() :d(new int(0)) { cout << "call constructor : " << ++n_cstr << endl; } HasPtrMem(const HasPtrMem& h) :d(new int(*h.d)) { cout << "call copy constructor : " << ++n_cptr << endl; } HasPtrMem operator++(int) // i++ { HasPtrMem old = *this;//拷贝构造 ++old.d; return old;//拷贝构造 } ~HasPtrMem() { // 这里为了防止报错 注释掉释放语句 //delete d; //d = nullptr; cout << "call destructor : " << ++n_dstr << endl; } // 为了测试方便 将作用范围设置为public public: int* d; // 记录每个函数被调用的次数 static int n_cstr; static int n_dstr; static int n_cptr; }; int HasPtrMem::n_cstr = 0; int HasPtrMem::n_dstr = 0; int HasPtrMem::n_cptr = 0; int main() { HasPtrMem a; a++; return 0; }
运行结果:
call constructor : 1 call copy constructor : 1 call copy constructor : 2 call destructor : 1 call destructor : 2 call destructor : 3
是不是很意外,为什么调用了三次构造函数呢????
第一次调用是HasPtrMem a;语句引起的,调用的是无参构造函数。
第二次调用是HasPtrMem old = *this;语句引起的,调用的是拷贝构造函数。
第三次调用是return old;语句引起的,调用的是拷贝构造函数,用来将对象返回。
这里最致命的问题在于:拷贝构造函数调用的次数。测试类中我们只有一个指针成员变量,但是如果在实际生产中有庞大的指针成员变量,那么来回拷贝就十分的影响效率。并且这里的拷贝是毫无意义的,不会影响最后的结果。而且这种影响效率的代码程序员是很难发现的。(但是现在较为智能的编译器就会进行优化的)
例如下面这中情况:
#include <iostream> using namespace std; class HasPtrMem { public: HasPtrMem() :d(new int(0)) { cout << "call constructor : " << ++n_cstr << endl; } HasPtrMem(const HasPtrMem& h) :d(new int(*h.d)) { cout << "call copy constructor : " << ++n_cptr << endl; } ~HasPtrMem() { // 这里为了防止报错 注释掉释放语句 //delete d; //d = nullptr; cout << "call destructor : " << ++n_dstr << endl; } // 为了测试方便 将作用范围设置为public public: int* d; // 记录每个函数被调用的次数 static int n_cstr; static int n_dstr; static int n_cptr; }; int HasPtrMem::n_cstr = 0; int HasPtrMem::n_dstr = 0; int HasPtrMem::n_cptr = 0; HasPtrMem GetTemp() { return HasPtrMem(); } int main() { HasPtrMem a = GetTemp(); return 0; }
运行结果:
只有一次构造和析构的调用,这就是编译器对函数返回值进行优化的效果。
call constructor : 1 call destructor : 1
C++11的处理方式-移动语义
我们还是以这段代码为例:
HasPtrMem a = GetTemp();
在下图的上半部分可以看到从临时变量中拷贝构造函数a的做法、就是在拷贝时分配新的堆内存,并从临时对象的堆内存中拷贝之a.d。在构造函数完成后,临时对象将进行析构,其所拥有的内存资源也会被释放。
而下半部分则是在构造时使得a.d指向临时对象的堆内存资源。同时我们保证临时对象不释放所指向的堆内存,那么在构造函数完成后,临时对象被析构,a就从其中偷偷的拿到了临时对象所拥有的内存资源了。
将上述这种情况称为移动语义(Move semantics)。
具体看下面这段代码,我在HasPtrMem这个类中添加一个移动构造函数HasPtrMem(HasPtrMem&& h) 。这与拷贝构造函数不同之处在于,移动拷贝构造函数接受一个右值引用的参数类型。在这个函数中我们可以看到移动构造函数使用了参数h的成员d初始化了this对象的成员d(这里类似于浅拷贝),然后把h的成员变量d置为nullptr。这就是移动构造的全部流程。
这里所谓的“偷内存”,本质上就是this.d = h.d;h.d=nullptr;这俩行代码。说白了就是将别人申请好内存转交给自己,重点在于转交哦,也就是h.d=nullptr;语句的含义。
这里我分别打印了临时变量申请的空间地址和主函数最终接受的空间地址,我们观察俩者是否一致。
#include <iostream> using namespace std; class HasPtrMem { public: HasPtrMem() :d(new int(0)) { cout << "call constructor : " << ++n_cstr << endl; } HasPtrMem(const HasPtrMem& h) :d(new int(*h.d)) { cout << "call copy constructor : " << ++n_cptr << endl; } HasPtrMem(HasPtrMem&& h) :d(h.d) { h.d = nullptr;//将临时值的指针成员置为空 cout << "Move the constructor : " << ++n_mvtr << endl; } ~HasPtrMem() { delete d; d = nullptr; cout << "call destructor : " << ++n_dstr << endl; } // 为了测试方便 将作用范围设置为public public: int* d; // 记录每个函数被调用的次数 static int n_cstr; static int n_dstr; static int n_cptr; static int n_mvtr; }; int HasPtrMem::n_cstr = 0; int HasPtrMem::n_dstr = 0; int HasPtrMem::n_cptr = 0; int HasPtrMem::n_mvtr = 0; HasPtrMem GetTemp() { HasPtrMem h; cout << "Resource from " << __func__ << ": " << hex << h.d << endl; return h; } int main() { HasPtrMem a = GetTemp(); cout << "Resource from " << __func__ << ": " << hex << a.d << endl; return 0; }
运行结果:
call constructor : 1 Resource from GetTemp: 000002761E7616D0 Move the constructor : 1 call destructor : 1 Resource from main: 000002761E7616D0 call destructor : 2
可以看到这里没有调用拷贝构造函数,而是调用了移动构造函数,移动构造的结果就是使GetTemp中的h的指针成员变量h.d和mian函数中的a的指针成员变量a.d都指向了相同的堆区地址。这就有点像浅拷贝咯。该堆区内存在函数返回时,成功的避开了被析构的过程,而且变成了a变量的资源。如果这个操作不是仅仅4个字节的移动而且非常庞大的堆内存移动时,那么效率高的不是一星半点哦。
为什么需要移动语义?
这里你可以会说上面的GetTemp函数我完全可以用引用或者指针的形式传入,就可以避免上述这个问题。但是我想说的是通过返回值的形式返回可以使用链式编程。类似于cout << GetTemp().d << endl;语句。
移动语义并不是新概念,在C++98/03中,它就已经存在了,例如:智能指针的拷贝、列表拼接(list::splice)、容器内的置换(swap on containers)等等,这些操作都包含了从一个对象向另一个对象的资源转义的过程。
一旦用到临时变量,移动构造语义就会被执行。
左值、右值和右值引用
在C/C++语言中,我们对于左值和右值应该都不陌生。也常常听到编译器抱怨“不可以修改的左值”。一般来说,在等号左侧的称为左值,在等号右侧的称为右值。
在C++11中,右值是由俩个新概念组成,一个是将亡值(xvalue,eXpiring Value),另一个是纯右值(prvlaue,Pure Rvalue)。
纯右值就是C++98标准中右值的概念,用于辨别临时变量和一些不跟对象关联的值。例如:非引用返回的函数返回的临时变量值、a=1+3中的1+3产生的临时值、2、‘c’、true、lambda表达式等等,这些都称为纯右值。
将亡值是C++11新引入的概念,用于和右值引用相关的表达式。这种表达式通常是 要被移动的对象,比如返回右值引用T&&的函数返回值、std::move的返回值(后面有介绍)、或者转换为T&&
的类型转换的返回值(后面有介绍)。
在C++11中,所有的值都必属于左值、将亡值、纯右值三者之一。
当实际上我们只对左值有概念,而对于右值的定义很少,也很难归类。
在C++11中,右值引用就是对一个右值进行引用的类型。由于右值没有名称所以我们只能通过引用的方式找打它的存在。例如
T&& a = RetureRvalue();
假设RetureRvalue方法会返回一个右值,那么我们就可以声明一个变量a进行右值引用,等价于RetrureRvalue方法返回的临时变量。
C++98声明的引用叫左值引用。C++11声明的引用叫右值引用。无论是左值引用还是右值引用都必须在定义时候初始化。
C++98的引用
在C++98标准中就已经规定过左值引用能否绑定到右值上,初始化由右值进行完成。例如:
int RetureRvalue() { return 1; } int main() { int& a = RetureRvalue();//error:initial value of reference to non-const must be an lvalue const int& b = RetureRvalue(); return 0; }
a的初始化会报纸编译错误,b可以正常初始化。
下面图中就是抛出的错误:
翻译过来就是:引用非const对象的初始值必须是左值
这里你可能有个疑惑就是为什么加了const就可以执行了呢?
因为在C++98标准中常量左值引用就是一个”万能“的引用类型。它可以接受非常量左值、常量右值、右值对其进行初始化。而且在使用右值进行初始化时候,常量左值引用还可以像右值引用一样将右值的生命期延长。这与C++11的右值引用唯一区别就是常量左值所引用的右值只能是只读的。
测试常量左值引用
我实现了一个结构体Copyable,其中手动实现了一个拷贝构造函数。分别测试值传递和引用传递调用的拷贝构造函数的次数。代码如下:
#include <iostream> using namespace std; struct Copyable { Copyable(){} Copyable(const Copyable& other) //实现拷贝构造函数 方便观察日志输出 { cout << "Copied" << endl; } }; Copyable ReturnRvalue() { return Copyable(); } void AcceptVal(Copyable) // 这里因为我不会使用参数,所以我省略参数,防止编译器抱怨说未使用的参数 { } void AcceptRef(const Copyable&) { } int main() { cout << "Pass by value: " << endl; AcceptVal(ReturnRvalue()); // 临时值被拷贝传入 cout << "Pass by reference: " << endl; AcceptRef(ReturnRvalue()); // 临时值被引用传入 return 0; }
运行结果:
由于我的vs2022最低标准库为C++14,所以这次我使用的vc6.0环境进行测试。
这里对按值传递的调用做一个解释:
第一个copied是ReturnRvalue
方法返回调用的,第二个copied是AcceptVal
方法形参进行拷贝。
各种引用类型以及可以引用的类型
下面列出了在C++11中各种引用类型可以引用的值的类型。注:只要能够绑定右值,就能延长右值的生命周期。
判断引用类型
有时候我们不知道该类型是否为引用类型,所以在<type_traits>
头文件中提供了三个模板类:is_rvalue_reference
、is_lvalue_reference
、is_reference
。例如:
#include <type_traits> cout << boolalpha << is_rvalue_reference<int&&>::value << endl; // true cout << boolalpha << is_lvalue_reference<int&>::value << endl; // true cout << boolalpha << is_reference<int&>::value << endl; // true
三、std::move
强制转换右值
在C++11中,标准库<utility>
中提供了一个方法std::move
,它的功能是将一个左值强制转换为右值且延长生命周期,所以千万不要别它的名字所忽悠哦。std::move
等价于 static_cast<T&&>(lvalue)
。
证明std::move
延长生命周期
创建了一个Moveable
类,并实现了移动构造函数以及拷贝构造函数。正常调用move
方法,就会导致a.p
的资源被剥夺,变成nullptr
。
#include <iostream> using namespace std; class Moveable { public: Moveable() :p(new int(3)) {} ~Moveable() // 析构函数 { delete p; p = nullptr; } Moveable(const Moveable& m):p(new int(*m.p)){} // 拷贝构造函数 Moveable(Moveable&& m):p(m.p) // 移动构造函数 { m.p = nullptr; } public: int* p; }; int main() { Moveable a; Moveable c(move(a)); if(a.p != nullptr) { cout << "a.p =" << *a.p << endl; } else { cout << "a.p = nullptr" << endl; } return 0; }
运行结果
a.p = nullptr
成功验证了我们的猜想,move
方法本质就是调用移动构造函数,上述的列子中a.p
就变成了悬挂指针,在访问时就会造成验证错误(如下图)。
一般来说,要使用
move
方法就必须清楚a.p
将不再被使用,而且需要转换成为右值引用还是一个生命周期将要结束的对象。
使用场景
这里定义了俩个类:HugeMem
和Moveable
,其中Moveable
类中有一个成员变量是HugeMem
类型的。在Moveable
类的移动构造函数中我们使用了move
方法。将传入的m.ptr
强制转换为右值,用于Moveable
类初始化。
#include <iostream> using namespace std; class HugeMem { public: HugeMem(int size):size(size > 0 ? size : 1) { ptr = new int[size]; } ~HugeMem() { delete[] ptr; ptr = nullptr; } HugeMem(HugeMem&& hm):size(hm.size),ptr(hm.ptr) // 移动构造函数 { hm.ptr = nullptr; hm.size = 0; } public: int* ptr; int size; }; class Moveable { public: Moveable():ptr(new int(3)), h(1024){} ~Moveable() { delete ptr; ptr = nullptr; } Moveable(Moveable&& m):ptr(m.ptr),h(move(m.h)) { m.ptr = nullptr; } public: int* ptr; HugeMem h; }; Moveable GetTemp() { Moveable temp = Moveable(); cout << hex << "Huge Mem from " << __func__ << " " << temp.h.ptr << endl; return temp; } int main() { Moveable a(GetTemp()); cout << hex << "Huge Mem from " << __func__ << " " << a.h.ptr << endl; return 0; }
这里因为GetTemp()这行代码执行完成后就会被释放,所以刚好转交过去是不会有任何问题的。
假如这里不使用move会有什么问题呢?
这里需要你注释掉HugeMem类的移动过构造函数,然后将Moveable(Moveable&& m):ptr(m.ptr),h(move(m.h))改为Moveable(Moveable&& m):ptr(m.ptr),h(m.h)。由于改动较少,这里我就不贴代码了。只展示一下运行结果,至于为什么会错误读者应该很清楚了吧。
总结
为了保证移动语义的传递,我们在添加移动构造函数时需要记得使用move方法,它可以使得拥有如堆内存、文件句柄等资源的成员变为右值,这样在进行移动构造时就可以实现移动语义。如果没有移动构造函数,实现拷贝构造函数也可以实现相同的效果,但是效率会有所下降。
四、再次认识移动语义
使用const此u是移动构造函数(坑)
前面说过,移动语义必须要修改临时变量的值。那么加上const就会导致无法实现移动语义,
例如下面这俩种情况:
Moveable(const Moveable&&); const Moveable RetureVal();
这俩种写法都会导致临时变量变成常量右值,你已经无法进行修改,所有也就无法进行移动语义。
在C++11中,拷贝构造、移动构造函数总共有三个:
T Object(T&); 移动赋值函数 T Object(const T&); 拷贝构造函数 T Object(T&&); 移动构造函数
判断移动类型
有时候我们不知道该类型是否为移动类型,我们就可以使用<type_traits>头文件中提供了三个模板类:is_move_constructible、is_trivially_move_assignable、is_nothrow_move_assignable。
#include <type_traits> cout << is_move_constructible<Moveable>::value << endl; cout << is_trivially_move_assignable<Moveable>::value << endl; cout << is_nothrow_move_assignable<Moveable>::value << endl;
解剖swap库函数
下面是swap的源码:
_CONSTEXPR20 void swap(_Ty& _Left, _Ty& _Right) noexcept( is_nothrow_move_constructible_v<_Ty>&& is_nothrow_move_assignable_v<_Ty>) { _Ty _Tmp = _STD move(_Left); _Left = _STD move(_Right); _Right = _STD move(_Tmp); }
这里考虑到有些小伙伴看不懂源码,所以将上述代码进行简化,如下:
template<typename T> void swap(T& a, T& b) { T temp(move(a)); a = move(b); b = move(temp); }
如果T是可以移动的话(就是可以被move),那么移动构造和移动赋值将会被用于这个置换。上述代码中,a先将资源转交给temp,然后b将资源转交给a,最后temp将资源转交给b。从而实现了一个交换动作。整个过程,代码都只会按照移动语义进行指针交换,不会有资源的释放和申请。但是如果T不可移动却可拷贝,那么拷贝语义会被用来进行交换。这就和普通的交换没区别。因此只要支持移动语义,那么就可以使用一个通用的模板进行高效的置换。
移动构造函数抛出异常
在构造函数中抛出异常是一件非常可怕的事,移动构造函数也不例外。异常可能会导致指针成为悬挂指针。所以为了不抛出异常使用noexcept
关键字是有必要的。
std::move_if_noexcept
在标准库中,有 std::move_if_noexcept
模板函数可以代替move函数。
- 当该函数中没有
noexcept
关键字时:返回一个左值引用然后使用拷贝构造。 - 当该函数中有
noexcept
关键字时:返回一个右值引用然后使用移动构造。
具体请看下面这个例子:
我定义了俩个结构体分别是Maythrow
和Nothrow
。其中Nothrow
中的移动构造函数使用noexcept
修饰。
#include <iostream> using namespace std; struct Maythrow { Maythrow(){} Maythrow(const Maythrow&) { cout << "Maythorow copy constructor." << endl; } Maythrow(const Maythrow&&) { cout << "Maythorow move constructor." << endl; } }; struct Nothrow { Nothrow() {} Nothrow(const Nothrow&) { cout << "Nothrow copy constructor." << endl; } Nothrow(const Nothrow&&) noexcept { cout << "Nothrow move constructor." << endl; } }; int main() { Maythrow m; Nothrow n; Maythrow mt = move_if_noexcept(m); Nothrow nt = move_if_noexcept(n); return 0; }
运行结果:
Maythorow copy constructor. Nothrow move constructor.
这里我们可以很直观的看到move_if_noexcept
的作用。
注意:这种方式是一种牺牲性能换取安全的一种保护措施,并且还需要你为移动构造函数添加noexcept
关键字修饰,否则将不会有任何的性能提升。
五、完美转发
在函数模板中,完全按照模板中参数类型的要求将参数传递给函数模板中调用另外一个函数。
尝试手动实现完美转发
例如下面这段代码:
template <typename T> void A1(T t) { A2(t); }
很明显A1函数就是一个转发功能,实际的目标函数是A2。
那么对于A2函数而言:它希望给A1传入左值对象那么A2就能获得左值对象;给A1传入右值对象那么A2就能获得右值对象。
但这并不是一件容易的事。在上面这个例子中,A1仅仅是最基本的转发,这就会导致将参数传给A2时造成一个额外的对象拷贝,这种转发只能叫做正确的转发,不能称为完美的转发。所以我们可以将A2的参数改为引用类型,这样就会有额外的拷贝开销了,且这样的函数对类型的接受能力就有所下降了。
我们要考虑到A2函数可能会收到左值引用和右值引用。这时你可能会想到上面我们不是刚说过万能的常量左值类型吗?
template <typename T> void A1(const T& t) { A2(t); }
上述这个例子看似很完美,但是A2不能接受A1给的常量左值引用类型,这时就需要重载来解决了,但会造成代码冗余。假如A2需要的是右值引用,那么就无法左值参数了,那么就不能使用移动语义。
那么C++11如果解决的呢?首先引入了一条“引用折叠”的新规则,用来配合新的模板推到规则实现完美转发。
C++11实现完美转发
首先我们来看下面这段代码:
typedef const int T; typedef T& TR; TR& v = 1;
在C++11中,这种会发生引用折叠,就是对于复杂的未知表达式折叠为已知的简单表达式。
具体折叠规则见下方:
TR的类型定义 | 声明v的类型 | v的实际类型 |
T& | TR | A& |
T& | TR& | A& |
T& | TR&& | A& |
T&& | TR | A&& |
T&& | TR& | A& |
T&& | TR&& | A&& |
这里教你一个巧记方法:
- 定义中出现左值引用,引用折叠优先折叠为左值引用。
- 对于模板来说,当转发函数的实参是类型
T
的一个左值引用,那么模板参数类型被推导为T&
类型; - 转发函数的实参是类型
T
的一个右值引用时,那么模板参数类型被推导为T&&
类型。
这里我们对前面写的手动转发的代码进行一个改写:
template <typename T> void A1(T&& t) { A2(static_cast<T&&>(t)); }
左值引用
假如我们给A1
传入一个T
类型的左值引用,那么转发就相当于下面这段代码。
template <typename T> void A1(T& &&t) { A2(static_cast<T& &&>(t)); }
然后我们使用引用折叠规则:
template <typename T> void A1(T& t) { A2(static_cast<T&>(t)); }
右值引用
假如我们给A1
传入一个T
类型的右值引用,那么转发就相当于下面这段代码。
template <typename T> void A1(T&& &&t) { A2(static_cast<T&& &&>(t)); }
然后我们使用引用折叠规则:
template <typename T> void A1(T&& t) { A2(static_cast<T&&>(t)); }
这里就体现static_cast
的作用咯,对于一个右值来说,当它使用右值引用表达式引用的时候,那么这个右值引用就是个左值,所以我们需要使用move
将左值转为右值,而move
本质上就是static_cast
。
C++11 forward
方法
在C++11中,有一个专门用于完美转发的函数叫forward
。我们继续改进前面的完美转发函数:
template <typename T> void A1(T&& t) { { A2(forward(t)); } }
move与forward没有本质区别,之所以还设计出一个新的方法,我想是为了让每个名字对应不同的用途吧。
完美转发实例
下面使用了四种类型的值对完美转发进行测试。
#include <iostream> using namespace std; void RunCode(int&& m) { cout << "rvalue ref" << endl; } void RunCode(int& m) { cout << "lvalue ref" << endl; } void RunCode(const int&& m) { cout << "const rvalue ref" << endl; } void RunCode(const int& m) { cout << "const lvalue ref" << endl; } template<typename T> void PerfectForward(T&& t) { RunCode(forward<T>(t)); } int main() { int a = 0; int b = 0; const int c = 1; const int d = 0; PerfectForward(a); // 左值引用 PerfectForward(move(b)); // 右值引用 PerfectForward(c); // 常量左值引用 PerfectForward(move(d)); // 常量右值引用 return 0; }
运行结果:
lvalue ref rvalue ref const lvalue ref const rvalue ref
可以看到,所有的转发都被正确的送到了对应的重载函数体内。
完美转发之包装
下面这个例子比较简单,这里就不做过多解释,读者请自行观看。
#include <iostream> using namespace std; template<typename T, typename U> void PerfectForward(T&& t, U& Func) { cout << t << "\tforwarded..."; Func(forward<T>(t)); } void RunCode(double&& m) { cout << __func__ << endl; } void RunHome(double&& h) { cout << __func__ << endl; } void RunComp(double&& c) { cout << __func__ << endl; } int main() { PerfectForward(1.5, RunComp); PerfectForward(8, RunCode); PerfectForward(7.9, RunHome); return 0; }
运行结果:
1.5 forwarded... RunComp 8 forwarded... RunCode 7.9 forwarded... RunHome
这种就类似于钩子函数
的效果,既能提高性能又使得代码编写简化,完美转发顾名思义–完美。
例如make_pair
、make_unique
等方法中均使用了完美转发,读者有兴趣自行去看底层源码。
留言
由于种种原因导致这篇文章写了一个礼拜,又因为这块内容比较多且复杂,所以有些部分描述可能不太清楚,如有任何疑惑,均可在评论区讨论。欢迎指正。