C++11 常量折叠、引用折叠、完美转发与移动语义
常量折叠
常量折叠:在编译器里进行语法分析的时候,将常量表达式计算求值,并用求得的值来替换表达式,放入常量表,可以算作一种编译优化,但是变量的名称是有效范围内还是可用的,并且在编译的时候从常量表中的直接替换,并不涉及到该变量的内存地址。
可折叠的常量像宏一样,在预编译阶段对常量的引用一律被替换为常量所对应的值,就和普通的宏替换没什么区别,并且,编译器不会为该常量分配空间。现在回顾起来,当时是多么的天真,被现象迷惑了,常量折叠确实会像宏一样把对常量的引用替换为常量对应的值,但是,并非不给该常量分配空间,如下代码:
#define PI 3.14 int main(){ const int r = 10; int p = pI; //这里会在预编译阶段产生宏替换,PI直接替换为3.14,其实就是int p = 3.14; int len = 2*r; //这里会发生常量折叠,也就是对常量r的引用会替换成他对应的值,相当于int len = 2*10; return 0; }
所以对折叠变量a所指向的地址空间进行操作,并不能改变a所指向的值。
这么来说折叠变量的效果跟宏定义很类似,但又有做不同,宏是字符常量,在预编译完宏替换完成后,该宏名字会消失,所有对宏的引用已经全部被替换为它所对应的值,编译器当然没有必要再维护这个符号。而常量折叠发生的情况是,对常量的引用全部替换为该常量值,但是,常量名并不会消失,编译器会把他放入到符号表中,同时,会为该变量分配空间,栈空间或者全局空间。
为了能更清楚的体现出常量折叠,下面做几个对照实验,看代码和输出便了然:
int main(){ int i0 = 11; const int i=0; //定义常量i int *j = (int *) &i; //看到这里能对i进行取值,判断i必然后自己的内存空间 *j=1; //对j指向的内存进行修改 printf("%d\n%d\n%d\n%d\n",&i,j,i,*j); //观看实验效果 const int ck = 9; //这个对照实验是为了观察,对常量ck的引用时,会产生的效果 int ik = ck; int i1 = 5; //这个对照实验是为了区别,对常量和变量的引用有什么区别 int i2 = i1; return 0; }
上面的代码会输出:
0012ff7c 0012ff7c 0 1
这能说明什么,至少能说明两点:
1、i j地址相同,指向同一块空间,i虽然是可折叠常量,但是,i确实有自己的空间
2、i j指向同一块内存,但是*j = 1对内存进行修改后,按道理来说,*j==1,i也应该等于1,而实验结果确实i实实在在的等于0,这是为什么呢,就是本文所说的内容,i是可折叠常量,在编译阶段对i的引用已经别替换为i的值了,也就是说
printf("%d\n%d\n%d\n%d\n",&i,j,i,*j);
中的i已经被替换,其实已经被改为
printf("%d\n%d\n%d\n%d\n",&i,j,0,*j);
为了使实验更具说服力,直接上汇编代码,比较实验的不同点:
4: int main() 5: { 00401030 push ebp 00401031 mov ebp,esp 00401033 sub esp,5Ch 00401036 push ebx 00401037 push esi 00401038 push edi 00401039 lea edi,[ebp-5Ch] 0040103C mov ecx,17h 00401041 mov eax,0CCCCCCCCh 00401046 rep stos dword ptr [edi] 6: int i0 = 11; 00401048 mov dword ptr [ebp-4],0Bh 7: 8: const int i=0; 0040104F mov dword ptr [ebp-8],0 //睁大眼睛,编译器确实为常量i分配了栈空间,并赋值为0 9: int *j = (int *) &i; 00401056 lea eax,[ebp-8] 00401059 mov dword ptr [ebp-0Ch],eax 10: *j=1; 0040105C mov ecx,dword ptr [ebp-0Ch] 0040105F mov dword ptr [ecx],1 11: //再看看下面的对比实验,看出对常量的引用和变量的引用的区别 12: const int ck = 9; 00401065 mov dword ptr [ebp-10h],9 //为常量分配栈空间 13: int ik = ck; 0040106C mov dword ptr [ebp-14h],9 //看到否,对常量ck的引用,会直接替换为常量的值9,再看下面的实验 14: 15: int i1 = 5; 00401073 mov dword ptr [ebp-18h],5 16: int i2 = i1; //这里引用变量i1,对i2进行赋值,然后看到否,对常量i1引用没有替换成i1的值,而是去栈中先取出i1的值,到edx寄存器中,然后再把值mov到i2所在的内存中 0040107A mov edx,dword ptr [ebp-18h] 0040107D mov dword ptr [ebp-1Ch],edx 17: 18: 19: return 0; 00401080 xor eax,eax 20: 21: }
通过上述实验的分析可以容易看出,对可折叠的常量的引用会被替换为该常量的值,而对变量的引用就需要访问变量的内存。
总结:常量折叠说的是,在编译阶段,对该变量进行值替换,同时,该常量拥有自己的内存空间,并非像宏定义一样不分配空间,需澄清这点
引用折叠
在C++中,直接声明引用的引用是不合法的,但允许“间接”声明引用的引用却是被允许的。尽管如此“间接”声明的引用的引用还是要被转换成单一引用。转换的规则是引用折叠规则。
引用折叠规则:
X& & <=> X&, 左值引用的左值引用等价于左值引用
X& && <=> X&, 左值引用的右值引用等价于左值引用
X&& & <=> X&, 右值引用的左值引用等价于左值引用
X&& && <=> X&&,右值引用的右值引用等价于右值引用
完美转发
函数模板可以将自己的参数“完美”地转发给内部调用的其它函数成为完美转发。所谓完美,即不仅能准确地转发参数的值,还能保证被转发参数的左、右值属性不变。C++11 标准为 C++ 引入了右值引用和移动语义,因此很多场景中是否实现完美转发,直接决定了该参数的传递过程使用的是拷贝语义(调用拷贝构造函数)还是移动语义(调用移动构造函数)。完美转发通过声明右值引用和引用折叠规则,然后使用forword<T>()函数接收外界传入的左、右值实现。
可拷贝和可移动的概念
在面向对象中,有的类是可以拷贝的,例如车、房等他们的属性是可以复制的,可以调用拷贝构造函数,有点类的对象则是独一无二的,或者类的资源是独一无二的,比如 IO 、 std::unique_ptr等,他们不可以复制,但是可以把资源交出所有权给新的对象,称为可以移动的。
C++11最重要的一个改进之一就是引入了move语义,这样在一些对象的构造时可以获取到已有的资源(如内存)而不需要通过拷贝,申请新的内存,这样移动而非拷贝将会大幅度提升性能。例如有些右值即将消亡析构,这个时候我们用移动构造函数可以接管他们的资源。
移动构造函数和移动赋值函数
考虑这样一个类A,里面的成员i 具有一个500的堆数组
#include <iostream> #include <cstring> using namespace std; class A{ public: A():i(new int[500]){ cout<<"class A construct!"<<endl; } A(const A &a):i(new int[500]){ memcpy(i, a.i,500*sizeof(int)); cout<<"class A copy!"<<endl; } ~A(){ delete []i; cout<<"class A destruct!"<<endl; } private: int *i; }; A get_A_value(){ return A(); } void pass_A_by_value(A a){ } int main(){ A a = get_A_value(); return 0; }
编译时为了看到临时对象拷贝我们关闭了编译器省略复制构造的优化
g++ main.cpp -o main.exe -fno-elide-constructors -std=c++11
运行时可以看到
class A construct! class A copy! class A destruct! class A copy! class A destruct! class A destruct!
发生了一次构造和两次拷贝!在每次拷贝中数组都得重新申请内存,而被拷贝后的对象很快就会析构,这无疑是一种浪费。
我们在类中加上移动构造函数:
... #include <iostream> A(A &&a)noexcept :i(a.i) { a.i = nullptr; cout<< "class A move"<<endl; } ...
然后编译、执行;可以看到输出为
class A construct! class A move class A destruct! class A move class A destruct! class A destruct!
原先的两次构造变成了两次移动!!在移动构造函数中,我们做了什么呢,我们只是获取了被移动对象的资源(这里是内存)的所有权,同时把被移动对象的成员指针置为空(以避免移动过来的内存被析构),这个过程中没有新内存的申请和分配,在大量对象的系统中,移动构造相对与拷贝构造可以显著提高性能!这里noexcept
告诉编译器这里不会抛出异常,从而让编译器省一些操作(这个也是保证了STL容器在重新分配内存的时候(知道是noexpect)而使用移动构造而不是拷贝构造函数),通常移动构造都不会抛出异常的。
@note: 这里仅仅为了演示,用 -fno-elide-constructions 关闭了g++编译器会省略函数返回值时临时对象的拷贝的优化。虽然编译器很多时候可以为我们进行优化,有些时候编译器优化不了的还是需要了解和运用移动语义的。
除了移动构造函数,移动赋值运算符应该一并给写出来。
A &operator =(A &&rhs) noexcept{ // check self assignment if(this != &rhs){ delete []i; i = rhs.i; rhs.i = nullptr; } cout<< "class A move and assignment"<<std::endl; return *this; }
小结移动构造和移动赋值
- 偷梁换柱直接“浅拷贝”右值引用的对象的成员;
- 需要把原先右值引用的指针成员置为 nullptr,以避免右值在析构的时候把我们浅拷贝的资源给释放了;
- 移动构造函数需要先检查一下是否是自赋值,然后才能先delet自己的成员内存再浅拷贝右值的成员,始终记住第2条。
std::move()
std::move(lvalue) 的作用就是把一个左值转换为右值。
int lv = 4; int &lr = lv;// 正确,lr是l的左值引用 int &&rr = lv; // 错误,不可以把右值引用绑定到一个左值
如果使用std::move 函数
int &&rr = std::move(lv); // 正确,把左值转换为右值
可以看到 std::move的作用是把左值转换为右值的。
让我们看一看 std::move 的源码实现:
// FUNCTION TEMPLATE move template <class _Ty> _NODISCARD constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept { // forward _Arg as movable return static_cast<remove_reference_t<_Ty>&&>(_Arg); }
可以看到std::move 是一个模板函数,通过remove_\reference_t获得模板参数的原本类型,然后把值转换为该类型的右值。用C++大师 Scott Meyers 的在《Effective Modern C++》中的话说, std::move 是个cast ,not a move。
值得注意的是: 使用move意味着,把一个左值转换为右值,原先的值不应该继续再使用(承诺即将废弃)
使用 std::move 实现一个高效的 swap 函数
我们可以使用 move语义实现一个 交换操作,swap;
在不使用 Move 语义的情况下
swap(A &a1, A &a2){
A tmp(a1); // 拷贝构造函数一次,涉及大量数据的拷贝
a1 = a2; // 拷贝赋值函数调用,涉及大量数据的拷贝
a2 = tmp; // 拷贝赋值函数调用,涉及大量数据的拷贝
}
如果使用 Move语义,即加上移动构造函数和移动赋值函数:
void swap_A(A &a1, A &a2){
A tmp(std::move(a1)); // a1 转为右值,移动构造函数调用,低成本
a1 = std::move(a2); // a2 转为右值,移动赋值函数调用,低成本
a2 = std::move(tmp); // tmp 转为右值移动给a2
}
可以看到move语义确实可以提高性能,事实上, move语义广泛地用于标准库的容器中。C++11标准库里的std::swap 也是基于移动语义实现的。
说到了 swap, 那就不得不说一下啊 move-and-swap 技术了
Move and swap 技巧
看下面一段代码,实现了一个 unique_ptr ,和标准的std::unqiue_ptr的含义一致,智能指针的一种。
template<typename T> class unique_ptr{ T* ptr; public: explicit unique_ptr(T* p = nullptr) { ptr = p; } ~unique_ptr() { delete ptr; } // move constructor unique_ptr(unique_ptr&& source) // note the rvalue reference { ptr = source.ptr; source.ptr = nullptr; } /* unique_ptr& operator=(unique_ptr&& source) // 这里使用右值引用 { if (this != &source) // beware of self-assignment { delete ptr; // release the old resource ptr = source.ptr; // acquire the new resource source.ptr = nullptr; } return *this; } */ // move and swap idiom replace the move assignment operator unique_ptr& operator=(unique_ptr rhs) // 这里不用引用,会调用移动构造函数 { std::swap(ptr, rhs.ptr); // std::swap(*this,rhs) // is also ok return *this; } T* operator->() const { return ptr; } T& operator*() const { return *ptr; } };
在这里如果要按照常规办法写移动赋值函数,函数体内需要写一堆检查自赋值等冗长的代码。使用 move-and-swap语义,只用简短的两行就可以写出来。 在移动赋值函数中 source 是个局部对象,这样在形参传递过来的时候必须要调用拷贝构造函数(这里没有实现则不可调用)或者移动构造函数(事实上仅限右值可以传进来了)。然后 std::swap 负责把原先的资源和source 进行交换,完成了移动赋值。这样写节省了很多代码,很优雅。
总结
1) 我们用对象 a 初始化对象 b,然后对象 a 我们就不在使用了,但是对象 a 的空间还在(在析构之前),拷贝构造函数实际上就是把 a 对象的内容复制一份到 b 中,那么为什么我们不能直接使用 a 的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷;
2) 拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制。浅层复制之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。所以我们只要避免第一个指针释放空间就可以了。避免的方法就是将第一个指针(比如 a->value)置为 NULL,这样在调用析构函数的时候,由于有判断是否为 NULL 的语句,所以析构 a 的时候并不会回收a->value指向的空间;
3) 移动构造函数的参数和拷贝构造函数不同,拷贝构造函数的参数是一个左值引用,但是移动构造函数的参数是一个右值引用。意味着,移动构造函数的参数是一个右值或者将亡值的引用。只用一个右值,或者将亡值初始化另一个对象的时候,才会调用移动构造函数。移动实现的是对象值真实的转移(源对象到目的对象):源对象将丢失其内容,其内容将被目的对象占有。move语句可以将一个左值变成一个将亡值。
参考C++常量折叠_yby4769250的博客-CSDN博客、C++ 11的移动语义 - 行者孙 - 博客园 (cnblogs.com)