谈谈C++的左值右值,左右引用,移动语意及完美转发
转载自
谈谈C++的左值右值,左右引用,移动语意及完美转发 - 知乎 (zhihu.com)
题记
写了VS2019下STL剖析的std::any,std::bind,std::string,std::tuple四文,但tuple那一文没有多少人看,也没有什么赞。tuple我挺认真的写了,就觉得奇怪,问了几个朋友,说写的不够简单与浅出,STL源码中一些写法本身就看不懂,第一章节就抛出那么多技术点,一下就流失了,所以想试试写一篇基础一些的,尽量举例与通俗些。
为了更好理解语法,建议用clang++编译器,msvc容易魔改和强兼容,并关闭掉构造函数优化:-fno-elide-constructors,因为一些情况下编译器可以跳过拷贝,移动构造函数而直接创造对象。
为了更好理解语法,建议用clang++编译器,msvc容易魔改和强兼容,并关闭掉构造函数优化:-fno-elide-constructors,因为一些情况下编译器可以跳过拷贝,移动构造函数而直接创造对象。
- 谈谈C++的左值右值,左右引用,移动语意及完美转发
- 题记
- 一、左值和右值
- 1.1 讨论左右值
- 1.2 认识左右值
- 二、左值引用与右值引用
- 2.1 普通左右引用
- 2.2 特别的const T&
- 2.3 左值转为右值引用
- 三、移动语意与完美转发
- 3.1 探讨深浅拷贝构造的不足
- 3.2 引入C++11的移动语意
- 3.3 std::move作用与源码中的引用折叠
- 3.4 万能引用与std::forward完美转发
- 3.5 评论区的static_cast<T&&>与std::forward替换讨论
- 结语
一、左值和右值
1.1 讨论左右值
C++的表达式化分为三种,左值(lvalue),纯右值(prvaule),亡值(xvalue),一般而言将纯右值与亡值合一起称为右值。通常左值,右值是是针对表达式的
实际上左值与右值并不是什么新引入概念,以前就有,有的说是来自C语言时在等号左边是是左值,右边是右值,也有的说能取地址的是左值,不能取地址的(放寄存器中值)是右值,也有的说有名字的是左值,没有名字的是右值,但这几种说法都可以轻松被推翻。
1.反第一种说法:
1 int a = 3; // a是左值,3是右值 2 int b; // b是左值 3 // a在等号右边,但a是左值,可以取地址 4 b = a; 5 // "Hello Word"在等号右边,字符串字面量,是左值 6 const char* pStr = "Hello Word";
2.反第二种说法,首先右值也可以放内存中的,取地址这点还是有点争议的,看一下代码
1 class TestClassA { 2 int m_iSize; 3 char* m_pkData; 4 }; 5 6 // 注意要求传入的rValue是右值,函数体内右值引用实际是左值 7 // 所以能对rValue取地址,这点后面第三章节完美转发还会再详细讨论 8 TestClassA* operator& (TestClassA&& rValue) 9 { 10 return &rValue; 11 } 12 13 // 本来TestClassA()生成临时变量,是右值,但被“&”操作了。 14 // 这里我们手动重载了&,所以标准说法是不能用内置&取地址 15 TestClassA* pkA = &TestClassA(100);
3.反第三种说法,看一下代码
1 // 字符串字面量没有名字,是左值, 2 // 我们可以这样直接原始取地址 3 &("Hello World"); 4 5 enum eTestEnum 6 { 7 TE_ZERO = 0, 8 TE_ONE, 9 TE_TWO, 10 }; 11 // 下面表达式,TE_TWO是一个枚举项,算是有名字,实际是右值 12 eTestEnum en1 = TE_TWO;
1.2 认识左右值
所以说了那么多,灵魂拷问一下:到底什么是左值?什么是右值?
我也想找一种特别有效的区分左右值的方法,找了很久,读了不少资料,暂时还没有发现特别行之有效的手段,甚至编译器对同样定义,左右值认定随版本都发生过变化。暂时理解认为左值是在内存的表达式,能够用内置的&进行取地址,其它可以算右值,右值更多的是一种“值”的表达,其中广义上右值又包含了纯右值与亡值,亡值一般就是我们说的生命周期即将结束的表达式,上面的TestClassA(100)是一个典型代表。参考已有资料,咱们也总结一些常见可能搞混的点。
容易错误的左值:
1.字符串字面量,如:"Hello"
2.内置的前++与前--,如:++a
3.变量类型是右值引用的表达式,如:TestClassA&& ra = TestClassA(1000);
,ra这里是左值,这个特别重要,后面涉及万能引用与完美转发,第三章细讲
4.转型为左值引用的表达式,如:static_cast<double&>(fValue)
;
5.内置*解引用的表达式,如:*pkValue
容易错误的右值:
1.非字符串的字面量以及枚举项,如:nullptr
,true
2.内置的后++与后--,如:a--
3.内置的算术,逻辑,比较表达式,如:a+b
,a&b
,a||b
,a<b
4.内置取地址表达式,this指针,如:&a
5.lamda表达式,如:[](int a){ return 2*a; }
6.转型为非引用的表达式,如:static_cast<double>(fValue)
,(float)42
7.转型为右值引用的表达式,如:static_cast<double&&>(fValue)
,std::move(x)
;
二、左值引用与右值引用
2.1 普通左右引用
引用是变量的别名,必初始化,C++引入的,C语言只有指针,没有引用。引用操作从反汇编层面看可以说完全指针一样,从使用层面来说的确降低大家都指针的理解成本,传参时引用本意减少拷贝,提高性能,但由于是编译器的内部转为为指针,有时比指针的灵活性弱一点点,像原来构造函数const T&左引用存在一定的缺陷,右值引用带来的移动语义就是来弥补。
指向左值的引用就是左引用,我们单个&来表示,C++11前一直使用的;对右值的引用是右引用,我们用&&来表示,如下面代码示例:
1 int a1 = 100; 2 // c1对左值a1引用,左引用 3 int& c1 = a1; 4 // b1对右值200的引用,右引用 5 int&& b1 = 200; 6 // rkTA1对右值TestClassA(1000)进行右引用 7 TestClassA&& rkTA1 = TestClassA(1000); 8 // 无法对右值( a1++)进行左引用,编译失败 9 int& c2 = a1++; // error 10 // 无法对左值a1进行直接右引用,编译失败 11 int&& b2 = a1; // error 12 // 无法对右值TestClassA(1000)进行左引用,编译失败 13 TestClassA& rkTA2 = TestClassA(1000); // error
我们可以看到,正常情况下左右引用只能处理对应的左右值,不能随意配对,否则编译失败。
2.2 特别的const T&
有没有直接对右值进行左引用的?还真有,那就是const T&,常量左引用,能接受右值,对右值进行这种形式左引用写法也不少,其生命周期被延续。
1 // 对右值101的常量左引用 2 const int& a2 = 101; 3 // 对右值TestClassA(1000)进行常量左引用 4 const TestClassA& rkTA2 = TestClassA(1000);
以前老师傅对我们说,如果C++中,函数传参,不改变参数时,尤其是大数据,尽量使用const T&。我们常用的拷贝构造函数T(const T&)参数是这个形式,vector容器的函数push_back(const value_type& val)参数也是,有没有注意到,这类函数同时也是接受右值的,简单比如:
1 // 函数TestClassAFunc1参数为const T&形式,可以接受右值 2 void TestClassAFunc1(const TestClassA& refTA) 3 { 4 std::cout << "TestClassAFunc1" << refTA.m_iSize << std::endl; 5 } 6 // TestClassA(1001)是右值,能够编译 7 TestClassAFunc1(TestClassA(1001));
2.3 左值转为右值引用
对于右值来说,不能真正取地址,引用实质就是操作地址指针,理论上无法进行左引用。对于左值来说,可以取地址,虽然无法直接进行右引用,但可以间接进行右引用,如下方法。
1 //可以将左值转为右值,再进行右引用 2 TestClassA kTA2(1000); 3 // 使用std::move转为右值引用 4 TestClassA&& c3 = std::move(kTA2); 5 // 使用static_cast转为右值引用 6 TestClassA&& c4 = static_cast<TestClassA&&>(kTA2); 7 // 使用C风格强转为右值引用 8 TestClassA&& c5 = (TestClassA&&)kTA2; 9 // 使用std::forwad<T&&>为右值引用 10 TestClassA&& c6 = std::forward<TestClassA&&>(kTA2);
三、移动语意与完美转发
前面都算是介绍一些概念,我们回到这些概念的更核心思考上。介绍前先说我理解的观点铺垫一下:移动语意对带有资源托管的对象,资源可以转移的且转移后能保证安全的,特别适合,还能降低析构复杂度,一些天生具有不可复制性对象也特别适合,比如unique_ptr。通俗理解就是一些带资源的对象需要拷贝时,想要有浅拷贝的效率,还想要深拷贝析构时安全的效果,正所谓鱼和熊掌不可兼得,那么被拷贝者资源权转给拷贝者,自己不再使用,来保证安全和高效。其它对象,移动语意可能就没有什么明显的优势
3.1 探讨深浅拷贝构造的不足
以TestClassA为例,讨论浅拷贝,深拷贝,移动语意的拷贝,先看一下C++11前的代码:
// TestClassA类 class TestClassA { public: TestClassA(int iSize):m_iSize(iSize) { m_pkData = new char[m_iSize]; } TestClassA(const TestClassA& rTA):m_iSize(rTA.m_iSize) { m_pkData = new char[m_iSize]; memcpy(m_pkData,rTA.m_pkData,m_iSize); } ~TestClassA() { if(m_pkData) { delete []m_pkData; m_pkData = nullptr; } } int m_iSize; char* m_pkData; };
这个例子是一个具有资源拖管的例子,何为是拖管,就是我们只记录资源Data的指针,而非记录资源全部信息到我们类对象里面。构造时我们从堆上用new申请内存供资源后面使用,析构时我们从堆上释放内存,回收掉资源,中间过程资源读写操作都通过指针m_pkData来完成。C++对象构造与析构函数的成对调用,也保证了安全性与不发生泄露。
1.先说浅拷贝,我们重写一下TestClassA
1 // TestClassA类 2 class TestClassA 3 { 4 public: 5 TestClassA(int iSize):m_iSize(iSize) 6 { 7 m_pkData = new char[m_iSize]; 8 } 9 10 TestClassA(const TestClassA& rTA):m_iSize(rTA.m_iSize) 11 { 12 m_pkData = new char[m_iSize]; 13 memcpy(m_pkData,rTA.m_pkData,m_iSize); 14 } 15 16 ~TestClassA() 17 { 18 if(m_pkData) 19 { 20 delete []m_pkData; 21 m_pkData = nullptr; 22 } 23 } 24 25 int m_iSize; 26 char* m_pkData; 27 };
这个例子是一个具有资源拖管的例子,何为是拖管,就是我们只记录资源Data的指针,而非记录资源全部信息到我们类对象里面。构造时我们从堆上用new申请内存供资源后面使用,析构时我们从堆上释放内存,回收掉资源,中间过程资源读写操作都通过指针m_pkData来完成。C++对象构造与析构函数的成对调用,也保证了安全性与不发生泄露。
1.先说浅拷贝,我们重写一下TestClassA
1 class TestClassA 2 { 3 public: 4 TestClassA(int iSize):m_iSize(iSize) 5 { 6 m_pkData = new char[m_iSize]; 7 } 8 ~TestClassA() 9 { 10 if(m_pkData) 11 { 12 delete []m_pkData; 13 m_pkData = nullptr; 14 } 15 } 16 int m_iSize; 17 char* m_pkData; 18 };
当我们写出执行代码时:
1 TestClassA a1(999); 2 TestClassA a2(a1);
用a1对a2进行拷贝初始化,我们没有手动写出拷贝构造函数,编译器会帮我们自动生成,直接将数据直接复制,称为浅拷贝。将a1中的m_iSize与m_pkData直接拷贝到a2中,a2的m_pkData并没有真正new内存,两者共用一块内存地址,当最后都析构时,这块内存会被析构两次,发生crash,这显然不是我们想要的,于是我们需要深拷贝来解决。
2.说说深拷贝,就是本章开头代码中手动实现的拷贝构造函数:TestClassA(const TestClassA& rTA)
执行TestClassA a2(a1);
时,手写拷贝构造函数会被调用,a2会申请一块大小为a1.m_iSize新的内存,然后将
a1.m_pdData对应内存数据一个一个拷贝过来,就是完全深入的复制了一遍。在析构时,各自释放自己的内存,不会有安全问题,所以C++11之前都是这么做的。
这里假设m_iSize为100万,假设一个字节一个字节拷贝,那么就要进行100万次内存到寄存器再到内存的操作,这是很慢的,随m_iSize越大,我们付出的代价越高,假设我们a1内存不再访问,这种深拷贝是不是有点浪费?
3.2 引入C++11的移动语意
举个生活的例子,小明今年3岁了,爸妈响应号召,又给他添加了一个弟弟,现在4个月了,需要换一个66码的衣服,这时可以选择新买一件,也可以选择穿小明小时候留下来的。重新买需要花钱,有一定经济成本,相当于我们刚才的深拷贝,穿小明的,相当于废物利用,重新焕发价值,不花钱也不产生家庭经济压力,就相当于我们的移动语意,一个对象的资源在销毁前,我们将其转移给其它对象再用起来,这样能减少资源带来的构造开销,程序获得更高的效能。
在C++11前我们模拟一下这个过程,再加一个构造函数
1 class TestClassA 2 { 3 public: 4 ...... 5 TestClassA(TestClassA& rTA,bool bMove = false):m_iSize(rTA.m_iSize) 6 { 7 if(bMove) 8 { 9 m_pkData = rTA.m_pkData; 10 rTA.m_pkData = nullptr; 11 } 12 else 13 { 14 m_pkData = new char[m_iSize]; 15 memcpy(m_pkData,rTA.m_pkData,m_iSize); 16 } 17 } 18 }; 19 20 TestClassA a2(a1,true);
构造时如果需要“移动”构造,我们将第二个参数bMove设为true,将资源指针从a1直接转移到a2,然后置空a1的资源指针,a2没有进行内存申请与拷贝,保持高效;当不需要移动构造,直接传为false,走原来的深拷贝流程。功能没有问题,为了照顾“移动”构造,第一个参数不能为const T&引用,一旦加了const就不能将传入对象rTA的m_pkData指针置空,不加const又不能接受像TestClassA a2(TestClassA(1000),true);
右值写法;同时这种写法还多了像尾巴一样的bool参数;另外不加const,a1是const对象也不能编译,这样我们还是再要写一个const T&的构造,这问题又变的糟乱了。
所以C++11直接统一,增加&&右值引用,简事情变简单,想要深拷贝的就T(const T&)实现;想要移动拷贝的就实现T(T&&)。两者都实现时,一旦传入是个右值,就优先触发移动拷贝构造调用,使用移动语意,转移资源,减少拷贝。
1 class TestClassA 2 { 3 public: 4 ...... 5 // 增加移动构造,方便使用移动语意 6 TestClassA(TestClassA&& rTA): 7 m_iSize(rTA.m_iSize), 8 m_pkData(rTA.m_pkData) 9 { 10 rTA.m_pkData =nullptr; 11 } 12 }; 13 // 用右值TestClassA(1000)来触发a1的移动构造 14 TestClassA a1(TestClassA(1000));
从C++11开始,如果我们没有手写移动拷贝构造函数,编译器会不会帮我们生成一个呢?只有下面4个函数同时没有手动定义时,编译器才会帮我们生成移动拷贝构造函数,原型为:T(const T& )
- 1.复制构造函数
- 2.复制赋值运算符
- 3.移动赋值运算符
- 4.析构函数
3.3 std::move作用与源码中的引用折叠
既然移动语意有时那么好,原来是个左值,在某些情景下又想后绪触发移动语意怎么办呢?用我们前面2.3小节中左值转为右值引用方法,std::move是典型的一种,构造时就能触发移动语意的调用了。这么看来原来C++11并不是为了有意增加复杂度的,而是为了某些情景下所做的功能与优化,也就是你不在意这些情景的情能消耗的话,完全可以选择不用。
1 // 用std::move(a2)将左值a2转换成右引用,从而 2 // 能触发a3的移动构造 3 TestClassA a2(1000); 4 TestClassA a3(std::move(a2));
2.3节我们也给了几个方法转为右值引用,我们来看看std::move实现,取自clang++里面源码
1 template<typename _Tp>
2 constexpr typename std::remove_reference<_Tp>::type&&
3 move(_Tp&& __t) noexcept
4 { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }
move是模板函数,模板参数_Tp,传入函数参数__t时,自然能推导出参数类型_Tp,不过其强转与返回值类型却是typename std::remove_reference<_Tp>::type&&
为什么呢?因为C++的引用折叠机制,有下面4条规则
- T& & 折叠成 T&
- T& && 折叠成 T&
- T&& & 折叠成 T&
- T&& && 折叠成 T&&
如果传入的__t本身就是一个左引用,强转也会添加&&右引用,最后会折叠为左引用,显然不是我们想要的,一旦用remove_reference去掉引用后,我们再加上&&,就能明确是右引用了,是不错!里面函数体也很简单,就是直接static_cast强转,可以看出并没有做什么操作,也没有高大上的写法,只上转为右引用后,可以让我们后绪触发移动语意的相关函数,比如移动构造,移动赋值构造。move主要针对本身是左值的表达式转为右引用,如果本身是右值,加上也没有什么意义,到这std::move就说清了。
3.4 万能引用与std::forward完美转发
我们在1.2中说到一个右值引用,其表达式是左值,可能大家还没有建立深刻的印象,我们看下面代码:
1 void TestLValueOrRValue(TestClassA& rA) 2 { 3 std::cout << "left Value " << rA.m_iSize << std::endl; 4 } 5 void TestLValueOrRValue(TestClassA&& rA) 6 { 7 std::cout << "right Value " << rA.m_iSize << std::endl; 8 } 9 void TestFun() 10 { 11 TestLValueOrRValue(TestClassA(1111)); 12 TestClassA&& ra2222 = TestClassA(2222); 13 TestLValueOrRValue(ra2222); 14 } 15 // 执行TestFun 16 TestFun();
TestClassA(1111)本身是右值,会调用TestLValueOrRValue右值引用版本,所以第一处打印是right Value 1111。对于第二处,ra2222是右值TestClassA(2222)的引用,但ra2222这个表达式本身是个左值,所以会调用TestLValueOrRValue左值引用版本,打印为:left Value 2222。同理第一处时,在TestLValueOrRValue函数内部表达式rA实际也已经成了左值,如果在里面再调一次TestLValueOrRValue(rA)会打印:left Value 1111,有兴趣的可以试一下。
TestFun输出结果: right Value 1111 , left Value 2222
问题来了,如果函数体内还想保持右值引用,怎么办呢,老办法,std::move(rA)一下就可以了;但有时也需要保留传左值引用,如果用了move会将左引用改成右引用,想函数传进来是左引用,函数体内也保持左引用,传进来是右引用,函数体内也保持右引用,想要万金油啊,但有没有办法呢?答案是:还真有,模板函数的万能引用与完美转发来了。
万能引用写法如下:
1 template<typename T> 2 void func1(T&& param) 3 { 4 ...... 5 }; 6 template<typename ...T> 7 void func2( T&&... params) 8 { 9 ...... 10 };
且有下面规定:
1.万能限定必须是函数模板,可以模板参数是单个,也可以是多个模板参数,形式为T&&
2.万能引用可以接受左值,也可以接受右值,而不像前面普通函数TestLValueOrRValue一样,必须与左右引用形式对应
3.万能引用的T不能被再修饰,否则转为普通右值引用,不能被cv修饰限定
4.如果想在模板类中的模板函数使用万能引用,不能使用模板类的参数,否则转为普通右值引用,如下代码样例:
1 template<class T> 2 struct Test 3 { 4 // a参数来源T,源自模板类,不是万能引用,为右值引用 5 // b参数来源U,源自模板函数,是万能引用 6 template<class U> 7 A(T&& a, U&& b, int c); 8 }
有了前面的万能引用,实现了接受左右引用,函数体内要有个类似std::move()再保持原来引用关系就完美了,std::forward()来了,就是因为能完美正确的保持原来的左右引用关系并向下层次转化,不再因右引用表达式是左值从而变掉,所以叫完美转发,到这大家估计理解了为什么叫完美转发,这个中文翻译太棒了。
1 // 处理左值作为左引用或者右引用 2 template<typename _Tp> 3 constexpr _Tp&& 4 forward(typename std::remove_reference<_Tp>::type& __t) noexcept 5 { return static_cast<_Tp&&>(__t); } 6 7 // 处理右值作为右引用 8 template<typename _Tp> 9 constexpr _Tp&& 10 forward(typename std::remove_reference<_Tp>::type&& __t) noexcept 11 { 12 static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument" 13 " substituting _Tp is an lvalue reference type"); 14 return static_cast<_Tp&&>(__t); 15 }
3.5 评论区的static_cast<T&&>与std::forward替换讨论
感谢评论区
提出一个问题,也很值得讨论。就是标准库里面的实现,都是static_cast<_Tp&&>(__t)
,我能不能直接代替呢? 这个问题个人感觉挺复杂的,我的观点是在模板这种万能引用作用范围内,基本上可以直接替换,但std::forward更安全,可以直接编译期检查出不合理隐式转换的代码,进行报错。因为这种情况下,涉及到对临时的右值进行左引用了,这是不安全的。不过我测试一下clang++和MSVC默认情况下对static_cast<_Tp&&>(__t)
都给出了敬告:
clang++:warning: returning reference to local temporary object [-Wreturn-stack-address]
MSVC:warning C4172: returning address of local variable or temporary
看一下测试代码:
1 template <class _Ty> 2 constexpr _Ty&& MyForward( 3 remove_reference_t<_Ty>& _Arg) noexcept { 4 return static_cast<_Ty&&>(_Arg); 5 } 6 7 template <class _Ty> 8 constexpr _Ty&& MyForward(remove_reference_t<_Ty>&& _Arg) noexcept { 9 //static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call"); 10 return static_cast<_Ty&&>(_Arg); 11 } 12 13 template<class T1, class T2> 14 T1&& StaticCastFun(T1&& a1, T2&& a2) 15 { 16 return static_cast<T1&&>(a2); 17 } 18 19 template<class T1, class T2> 20 T1&& ForwardCastFun(T1&& a1, T2&& a2) 21 { 22 return MyForward<T1>(a2); 23 } 24 25 struct TypeB{}; 26 27 struct TypeA 28 { 29 TypeA() { cout << "Default Create" << endl;} 30 TypeA(const TypeA& ) { cout << "From TypeA Create" << endl; } 31 ~TypeA() { cout << "Del" << endl;} 32 TypeA(const TypeB&) { cout << "From TypeB Create" << endl; } 33 }; 34 void ForwardTypeTest(const TypeA a, const TypeB b) 35 { 36 cout << "StaticCastFun" << endl; 37 StaticCastFun(a, b); 38 cout << "ForwardCastFun" << endl; 39 ForwardCastFun(a, b); 40 } 41 42 int main() 43 { 44 TypeA ta1; 45 TypeB tb1; 46 ForwardTypeTest(ta1,tb1); 47 return 0; 48 }
我将完美转发std::forward拷贝过来,改成MyForward,因为我们对隐式的对右值进行了左引用,不安全,直接触发了static_assert(!is_lvalue_reference_v<_Ty>
编译期断言,为了编译过,测试代码注掉了这一行,从这里可以看出最好还是用std::forward。
略为分析一下代码:TypeA能用TypeB进行构造,主要靠TypeA(const TypeB&)
这个带参的构造,没有这个转换就不能讨论这个话题了。在StaticCastFun中,直接用static_cast<T1&&>(a2)
将TypeB对象构造出一个临时TypeA对象,并当左引用返回,这是很不安全的,只有一层,编译器也能发现。对于ForwardCastFun里面调了MyForward,这是一个显示的实例化模板函数,形式为MyForward<const TypeA&>(const TypeA&&)
,a2先临时构造出TypeA的对象,为一个亡值,也就是右值,再传入MyForward中,因些能触发static_assert断言,如果去掉断言能编译过,经过MyForwad函数绕了一层,编译器也无法发现这个错误了,想必标准库大佬以前发现过这个问题加上断言。