深入理解C++11: 02右值引用
1. 指针成员与拷贝构造
当我们编写一个新类时,需要注意两点:
缺省的拷贝构造函数和赋值操作的行为是否满足我们的预期要求。如果不满足的话,我们就必须自己声明和定义我们需要的这两个函数
通常情况下(不是所有情况),当对象的所有状态都存储在对象中时,缺省拷贝构造函数的所有行为都和我们所预期的一样,如下面的复数类。
class Complex
{
private:
double real;
double imag;
public:
Complex(double r, double i):real(r),imag(i){}
//此处忽略细节
};
由于我们没有明确声明拷贝构造函数,因此编译器将会为我们合成一个缺省的拷贝构造函数,它的行为就是复制这两个数据成员,这正好满足我们的需求。
假设我们有一个MyString类,在其中有一个char*的数据成员,我们用它来指向MyString类对象所代表的字符串:
MyString.h
#ifndef __MY_STRING__
#define __MY_STRING__
class MyString
{
public:
MyString(const char* cp = "");
~MyString();
private:
char *m_pData;
};
#endif
MyString.cpp
#define _CRT_SECURE_NO_WARNINGS
#include "MyString.h"
#include <iostream>
MyString::MyString(const char* cp)
: m_pData(new char[strlen(cp)+1])
{
strcpy(m_pData, cp);
}
MyString::~MyString()
{
delete [] m_pData;
}
main.cpp
int main(void)
{
MyString str1("University");
MyString str2(str1);
return 0;
}
**说明:**MyString类的缺省拷贝构造函数将只会对那个指针进行复制,最终导致两个MyString对象指向同一块内存空间。当第一个MyString对象销毁时,其指向的内存也已经被销毁了。此时第二次对象销毁时就会出现内存重复释放的情况。
若果缺省的行为和我们期望的行为不一样,那我们就需要显式的去声明和定义一个拷贝构造函数。
#ifndef __MY_STRING__
#define __MY_STRING__
class MyString
{
public:
MyString(const char* cp = "");
~MyString();
MyString(const MyString& other);
private:
char *m_pData;
};
#endif
#define _CRT_SECURE_NO_WARNINGS
#include "MyString.h"
#include <iostream>
MyString::MyString(const char* cp)
: m_pData(new char[strlen(cp)+1])
{
strcpy(m_pData, cp);
}
MyString::~MyString()
{
delete [] m_pData;
}
MyString::MyString(const MyString& other)
:m_pData(new char[strlen(other.m_pData)+1])
{
strcpy(m_pData, other.m_pData);
}
自定义拷贝构造函数来实现“深拷贝”(deep copy),这样我们可确保每个MyString都拥有一份数据的私有拷贝。
2. 移动语义
拷贝构造函数为指针成员分配新的内存再进行内容拷贝的做法在C++编程中几乎是一条准则。但是在有的时候,我们确实不需要这样的拷贝构造语义。我们来看下面的代码:
/*
* @date: 2019/6/2
* @author: binbinzhang
* @email: binbin_erices@163.com
*/
#include <iostream>
using namespace std;
class HasPtrMem
{
public:
HasPtrMem(): d(new int(0))
{
cout << "Construct: " << ++n_cstr << endl;
}
HasPtrMem(const HasPtrMem& other) : d(new int(*other.d))
{
cout << "Copy Construct: " << ++n_cptr << endl;
}
~HasPtrMem()
{
cout << "Destruct: " << ++n_dstr << endl;
}
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;
}
编译运行结果:
zbb@ubuntu:~/ProC$ g++ HasPtrMem.cpp -fno-elide-constructors
zbb@ubuntu:~/ProC$ ./a.out
Construct: 1
Copy Construct: 1
Destruct: 1
Copy Construct: 2
Destruct: 2
Destruct: 3
注意这里编译选项必须要加 -fno-elide-constructors, 否则编译器会做优化。看到结果为
zbb@ubuntu:~/ProC$ ./a.out
Construct: 1
Destruct: 1
-fno-elide-constructors 的深入理解
参考man手册, 节选自man g++部分:
-fno-elide-constructors
The C++ standard allows an implementation to omit creating a temporary that is only used to initialize another object of the same type.
Specifying this option disables that optimization, and forces G++ to call the copy constructor in all cases.
C++标准允许一种(编译器)实现省略创建一个只是为了初始化另一个同类型对象的临时对象。指定这个参数( -fno-elide-constructors)将关闭这种优化,强制G++在所有情况下调用拷贝构造函数。
输出结果分析 :
构造函数被调用一次,在GetTemp函数中 HasPtrMem()表达式显式地调用了构造函数
拷贝构造函数调用了两次,一次是从GetTemp函数中 HasPtrMem()生成的变量上拷贝构造出一个临时值,以用作GetTemp的返回值;另外一次是由临时值构造出main函数中变量a调用的。
在我们的Demo中,类HasPtrMem只有一个int类型的指针,而如果HasPtrMem的指针指向非常大的堆内存数据,那么拷贝构造的过程就会非常昂贵。可以想象,一旦情况发生,main函数中 a 的初始化表达式的执行速度将相当堪忧。更令人堪忧的是,临时变量的产生和销毁以及拷贝的发生对于程序员来说基本是透明的,不会影响程序的正确性,因此即使该问题导致程序的性能不如预期,也不容易被程序员察觉。
对于临时对象来说,按照C++的语义,临时对象将在语句结束的时候被析构,会释放它所包含的堆内存资源。而 a 在拷贝构造的时候,又会被分配堆内存。这样的一来一去看起来没有太大的意义,那我们能否在临时对象构造a的时候不分配内存,即不使用所谓的拷贝构造语义?答案是肯定的,C++11标准给出了移动语义。
在C++11中,这样的“偷走”临时变量中资源的构造函数,就被称为“移动构造函数”。而像这样"偷"的行为,则被称为“移动语义”(move semantics)。换种说法就是“移为己用”。
#include <iostream>
using namespace std;
class HasPtrMem
{
public:
HasPtrMem(): d(new int(0))
{
cout << "Construct: " << ++n_cstr << endl;
}
HasPtrMem(const HasPtrMem& other) : d(new int(*other.d))
{
cout << "Copy Construct: " << ++n_cptr << endl;
}
//移动构造函数
HasPtrMem(HasPtrMem&& other) : d(other.d)
{
other.d = nullptr;
cout << "Move Construct: " << ++n_mvtr << endl;
}
~HasPtrMem()
{
delete d;
cout << "Destruct: " << ++n_dstr << endl;
}
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;
}
编译运行结果:
zbb@ubuntu:~/ProC$ g++ HasPtrMem_.cpp -fno-elide-constructors -std=c++11
zbb@ubuntu:~/ProC$ ./a.out
Construct: 1
Resource From GetTemp: 0x1dadc20
Move Construct: 1
Destruct: 1
Move Construct: 2
Destruct: 2
Resource From main: 0x1dadc20
Destruct: 3
移动构造函数接受了一个右值引用的参数。右值下面会讲到,现可以简单理解为临时变量的引用。移动构造函数使用了参数other的成员d初始化了本对象的成员d(而不是像拷贝构造函数一样需要分配内存, 然后将内容依次拷贝到新分配的内存中),而other的成员d被置为nullptr,这就完成了移动构造的全过程。
//移动构造函数
HasPtrMem(HasPtrMem&& other) : d(other.d)
{
other.d = nullptr;
cout << "Move Construct: " << ++n_mvtr << endl;
}
other的成员被置为nullptr的原因:
这是因为在移动构造完成以后,临时对象会立即被析构,如果不改变other.d(临时对象的指针成员)的话,则临时对象会析构掉本来我们“偷”来的堆内存,这样一来,本对象的d指针就成了一个悬挂指针。如果我们对指针进行解引用,将会产生严重的运行时错误。
3. 左值、右值和右值引用
左值与右值的判断方法:
- 在复制表达式中,出现在等号左边的就是“左值”, 而在等号右边的被称为“右值”。
- 还有一种被广泛认同的说法:可以取地址的,有名字的就是左值,反之不能被取地址的,没有名字的就是右值。
a = b + c;
在这个加法赋值表达式中,&a是允许操作的,但&(b+c)操作则不被允许。因此 a 是左值, b+c为右值。
在 C++11 中右值是由两个概念构成的,一个是将亡值(xvalue, eXpiring Value),另一个是纯右值(prvalue, Pure Value)。
纯右值就是C++98标准中的右值概念。讲的是辨识临时对象和一些不跟对象关联的值。比如非引用返回的函数返回的临时对象就是一个纯右值。一些运算表达式,比如1+3产生的临时变量值,也是纯右值。而不跟对象关联的字面量值,比如2, ‘b’, true,也是纯右值。此外,类型转换函数的返回值、lambda表达式等也都是右值。
将亡值是C++11新增的跟右值引用相关的表达式,这样表达式通常是将要被移动的对象(移为他用),比如返回右值引用T&& 的函数返回值,std::move的返回值,或者转化为T&&的类型转换函数的返回值。而剩下的,可以标识函数,对象的值都属于左值。
在C++11中所有值都必须属于左值,纯右值,将亡值的一种。
在C++11中,右值引用就是对一个右值进行引用的类型。实际上,由于右值通常不具有名字,我们可以通过引用的方式找到它的存在。通常情况下 ,我们只能是从右值表达式获取其引用。
T &&a = ReturnRValue();
假设ReturnRValue返回一个右值,我们声明一个名为a的右值引用,其值等于ReturnRValue函数返回的临时变量的值。
为了区别C++98中的引用类型,我们称C++98中的引用为“左值引用”(lvalue reference)。右值引用和左值引用都是属于 引用类型。无论声明一个左值引用还是右值引用,都必须立即初始化。其原因可以理解为:引用类型本身并不拥有所绑定对象的内存,只是该对象的别名。左值引用是具名变量的别名,而右值引用可以是不具名(匿名)变量的别名。
ReturnRValue函数返回的右值在表达式语句结束后,其生命周期就终结了。通过右值引用的声明,该右值有获得了新的生命周期。其生命周期将与右值引用a的生命周期一样。
对比下面的表达式:
T b = ReturnRValue();
我们刚才的右值引用变量声明,就会少一次对象的析构和一次对象的构造。因为 a 是右值引用,直接绑定了 ReturnRValue()返回的临时值。而 b 只有由临时值构造出来的,临时值在表达式结束后会析构因此就会多一次析构和构造的开销。
需要注意的是:能够声明右值引用的a的前提是ReturnRValue返回的是一个右值。通常情况下,右值引用是不能够绑定到任何左值的。
int c;
int && d = c;//编译错误T & e = ReturnRValue();//编译出错
const T & f = ReturnRValue();/编译正常
出现上述结果是因为:常量左值引用在C++98标准中开始就是个万能的引用类型。它可以接受非常量左值、常量左值、右值对其进行初始化。而且在使用右值对其进行初始化时,常量左值引用还可以像右值引用一样将右值的生命期延长。不过相对比右值引用,常量左值引用所引用的右值它的"余生"中只能是可读的。
const bool & blShow = true;
const bool blShow = true;
在语法上,前者直接使用了右值并为其延长生命周期,后者的右值在表达式结束后就进行销毁。
在C++98中我们也可以使用常量左值引用来减少临时对象的开销,代码如下:
#include <iostream>
using namespace std;
struct Copyable
{
Copyable(){}
Copyable(const Copyable& other)
{
cout << "Copied" <<endl;
}
};
Copyable ReturnRValue()
{
return Copyable();
}
void AcceptValue(Copyable)
{
}
void AcceptRef(const Copyable&)
{
}
int main(void)
{
cout <<"Pass by value: "<<endl;
AcceptValue(ReturnRValue());//临时值被拷贝传入
cout <<"Pass by reference: "<<endl;
AcceptRef(ReturnRValue());//临时值被作为引用传入
return 0;
}
编译运行:
zbb@ubuntu:~/ProC$ g++ Copyable.cpp -fno-elide-constructors
zbb@ubuntu:~/ProC$ ./a.out
Pass by value:
Copied
Copied
Pass by reference:
Copied
由于使用了左值引用,临时对象就直接作为函数的参数,而不需要在拷贝一次。
在C++11中,我们可以在上述代码中声明void AcceptRValueRef(Copyable &&){},同样可以减少临时变量拷贝的开销,进一步的我们还可以在AcceptRValueRef中修改该临时值。修改一个临时值的一样通常不大,除非使用了移动语义。
void AcceptRValueRef(Copyable && s) { Copyable new = std::move(s); }
使用移动语义的前提是Copyable类中还需要添加一个以右值引用为参数的移动构造函数
Copyable(Copyable && other) { //省略实现移动语义 }
为了语义的完整C++11中还存在着常量右值引用:
const T && crvalueref = ReturnRValue();
但是,1. 右值引用主要就是为了移动语义,而移动语义需要右值是可以被修改的,那么常量右值引用在移动语义中就没有用武之地;2. 如果要引用右值且让右值不可以更改,常量左值引用往往就够用了。
下表列出在C++11中各种引用类型可以引用的数值类型,需要注意的是:只要能够绑定右值的引用类型,都能够延长右值的生命周期。
引用类型 | 非常量左值 | 常量左值 | 非常量右值 | 常量右值 | 注记 |
---|---|---|---|---|---|
非常量左值引用 | Y | N | N | N | 无 |
常量左值引用 | Y | Y | Y | Y | 全能类型,用于拷贝语义 |
非常量右值引用 | N | N | Y | N | 用于移动语义,完美转发 |
常量右值引用 | N | N | Y | Y | 暂无用途 |
4. std::move强制转换为右值
在C++11中,标准库在中提供了一个有用的函数std::move。功能将一个左值强制转换为一个右值引用,然后我们可以通过右值引用来使用该值,以便用于移动语义。
从实现上说,std::move等价于
static_cast<T&&>(lValue);
需要注意的是,被转化的左值,其生命期并没有随着左右值的转化而改变。
#include <iostream>
using namespace std;
class Moveable
{
public:
Moveable():d(new int(3))
{
}
~Moveable()
{
delete d;
}
Moveable(const Moveable& other): d(new int(*other.d))
{
}
Moveable(Moveable&& other): d(other.d)
{
other.d = nullptr;
}
int *d;
};
int main(void)
{
Moveable a;
Moveable c(std::move(a));
cout<<*a.d<<endl;
return 0;
}
zbb@ubuntu:~/ProC$ g++ move.cpp -fno-elide-constructors -std=c++11
zbb@ubuntu:~/ProC$ ./a.out
段错误 (核心已转储)
这是一个典型的误用std::move的例子。事实上,要使用该函数必须是程序员清楚需要转换的时候。比如上面的程序,程序员应该知道被转换为右值的a不可以在使用。在更多时候,我们需要转换成右值引用的还是一个确实生命周期即将结束的对象。看下面一段实例代码:
#include <iostream>
using namespace std;
class HugeMem
{
public:
HugeMem(int size):m_iLength(size >0 ? size:1)
{
m_pBuffer = new int[m_iLength];
}
~HugeMem()
{
delete [] m_pBuffer;
}
HugeMem(HugeMem&& other)
: m_pBuffer(other.m_pBuffer)
, m_iLength(other.m_iLength)
{
other.m_pBuffer = nullptr;
}
int *m_pBuffer;
int m_iLength;
};
class Moveable
{
public:
Moveable():d(new int(3)),h(1024)
{
}
~Moveable()
{
delete d;
}
Moveable(Moveable&& other)
: d(other.d)
, h(move(other.h))
{
other.d = nullptr;
}
int *d;
HugeMem h;
};
Moveable GetTemp()
{
Moveable tmp = Moveable();
cout << hex << "Huge Mem from "<<__func__<<" @" <<tmp.h.m_pBuffer<<endl;
return tmp;
}
int main(void)
{
Moveable a(GetTemp());
cout << hex << "Huge Mem from "<<__func__<<" @" <<a.h.m_pBuffer<<endl;
return 0;
}
运行结果:
zbb@ubuntu:~/ProC$ g++ move_.cpp -fno-elide-constructors -std=c++11
zbb@ubuntu:~/ProC$ ./a.out
Huge Mem from GetTemp @0x981c40
Huge Mem from main @0x981c40
在Moveable的移动构造函数中,我们使用了std::move函数。该函数将other.h强制转换为右值,以迫使Moveable中的 h 能够能够实现移动构造。这里使用std::move,是因为other.h是other的成员,既然other将在表达式结束后被析构,其成员也会被析构,不存在之前生存期不对的问题。如果这里不适用std::move(other.h)表达式,直接使用other.h,程序将不会调用移动构造函数,将会调用默认的拷贝构造函数。
h(move(other.h))修改为h(other.h)执行结果:
zbb@ubuntu:~/ProC$ g++ move_.cpp -fno-elide-constructors -std=c++11 move_.cpp: In constructor ‘Moveable::Moveable(Moveable&&)’: move_.cpp:39:18: error: use of deleted function ‘constexpr HugeMem::HugeMem(const HugeMem&)’ , h(other.h) ^ move_.cpp:5:7: note: ‘constexpr HugeMem::HugeMem(const HugeMem&)’ is implicitly declared as deleted because ‘HugeMem’ declares a move constructor or move assignment operator class HugeMem ^
5. 总结
C++11中采用了右值引用的方式,使得移动构造函数能够有效地获得这些右值临时值,以帮助程序员完成移动语义。通过移动语义,库的实现者可以巧妙地将各种形如堆内存的资源放入对象中,而不用担心诸如函数传递过程中带来过大的资源释放、申请开销。