目录
作用:C++11中引用了右值引用和移动语义,可以避免无谓的复制,提高了程序的性能。
1 什么是左值、右值
可以从2个角度判断:
-
左值可以取地址,位于等号左边
-
右值无法取地址,位于等号右边
int a = 6;
-
a可以通过&取地址,位于等号左边,所以a是左值。
-
6位于等号右边,6无法通过&取地址,所以6是右值。
struct A {
A(int a = 0) {
a_ = a;
}
int a_;
};
A a = A();
-
同样的,a可以通过&取地址,位于等号左边,所以a是左值。
-
A()是一个临时值,没法通过&取地址,位于等号右边,所以A()是个右值。
可见左右值的概念很清晰,有地址的变量就是左值,没有地址的值、临时值就是右值。
2 什么是左值引用、右值引用
引用的本质就是别名,可以通过引用修改变量的值,传参时传引用可以避免拷贝。
2.1 左值引用
左值引用:能指向左值,不能指向右值的就是左值引用
int a = 5;
int &ref_a = a; // 左值引用指向左值,编译通过
int &ref_a = 5; // 左值引用指向了右值,会编译失败
引用是变量的别名,由于右值没有地址,没法被修改,所以左值引用无法指向右值。
但是,const左值引用是可以指向右值的:
const int &ref_a = 5; // 编译通过
const左值引用不会修改指向值,因此可以指向右值,这也是为什么要使用const &作为函数参数的原因之一,如std::vector 的 push_back() :
void push_back(const value_type& val);
如果没有const,vec.push_back(5)这样的代码就无法通过编译。
2.2 右值引用
右值引用的标志是&&,顾名思义,右值引用专门为右值而生,可以指向右值,不能指向左值
int &&ref_a_right = 6; // 编译通过
ref_a_right = 7; // 右值引用可以修改右值
int a = 6;
int &&ref_a_left = a; // 编译不通过,右值引用指向左值。
2.3 对左右值引用的本质的讨论
2.3.1 右值引用有办法指向左值吗?
有办法,使用std::move()
int a = 5; // a是个左值
int &ref_a_left = a; // 左值引用指向左值
int &&ref_a_right = std::move(a); // 通过std::move()将左值转化为右值,可以被右值引用指向
cout << a << endl; // 打印结果:5
在上边的代码里,看上去是左值a通过std::move()移动到了右值ref_a_right中,那是不是a里边就没有值了?并不是,打印出a的值仍然是5。
std::move()是一个非常有迷惑性的函数:
-
不理解左右值概念的人往往以为它能把一个变量里的内容移动到另一个变量;
-
但事实上std::move()移动不了什么,唯一的功能是左值强制转化为右值,让右值引用可以指向左值。其实现等同于一个类型转换:static_cast<T&&>(lvalue)。所以,单纯的std::move()不会有性能提升。
同样的,右值引用能指向右值,本质上也是把右值提升为一个左值,并定义一个右值引用通过std::move()指向该左值:
int &&ref_a = 5;
ref_a = 6;
等同于以下代码:
int temp = 5;
int &&ref_a = std::move(temp);
ref_a = 6;
此时:
cout << temp << endl; // 输出结果:6
cout << ref_a << endl; // 输出结果:6
2.3.2 左值引用、右值引用本身是左值还是右值?
被声明出来的左、右值引用都是左值。因为被声明出来的左右值引用是有地址的,也都位于等号左边。
#include <iostream>
#include <memory>
using namespace std;
// 形参是个右值引用
void func(int&& right_value) {
right_value = 8;
}
int main() {
int a = 5; // a是左值
int &ref_a_left = a; // ref_a_left是一个左值引用
int &&ref_a_right = std::move(a); // ref_a_right是一个右值引用
// func(a); // 编译不通过,a是左值,func()参数要求右值
// func(ref_a_left) // 编译不通过,ref_a_left是左值引用,本身也是左值
// func(ref_a_right) // 编译不通过,ref_a_right是右值引用,本身也是左值
func(std::move(a)); // 编译通过
func(std::move(ref_a_left)); // 编译通过
func(std::move(ref_a_right)); // 编译通过
func(5); // 编译通过,5是右值
cout << &a << endl;
cout << &ref_a_left << endl;
cout << &ref_a_right << endl;
return 0;
}
运行结果:
0x7ffef6f7f430
0x7ffef6f7f430
0x7ffef6f7f430
看完后可能会有一个疑问,std::move()会返回一个右值引用int &&,它是左值还是右值呢?从表达式int &&ref_a_right = std::move(a)来看,右值引用ref指向必须是右值,所以move返回的int &&是右值。所以右值引用即可能是左值,有可能是右值吗?确实如此:右值引用既可以是左值也可以是右值,如果有名称则为左值,否则是右值。
或者说:作为函数返回值的&&是右值,直接声明出来的&&是左值。这同样也符合前面章节对左值右值的判定方式。其实引用和普通变量是一样的,int &&ref_a_right = std::move(a)和int a = 5没有什么区别,等号左边就是左值,右边就是右值。
最后,从上述分析中我们得到如下结论:
-
从性能上讲,左右值引用没有区别,传参使用左右值引用都可以避免拷贝。
-
右值引用可以直接指向右值,也可以通过std::move()指向左值;而左值引用只能指向左值(const 左值引用也能指向右值)。
-
作为函数形参时,右值引用更灵活。虽然const 左值引用也可以做到左右值都接受,但它无法修改,有一定局限性。
void func1(const int& n) {
n += 1; // 编译失败,const左值引用不能修改指向的变量
}
void func2(int&& n) {
n += 1; // 编译通过
}
int main() {
func1(5);
func2(5);
return 0;
}
3 右值引用和std::move()使用场景
std::move()只是类型转换工具,不会对性能有好处。
右值引用作为函数形参时更具有灵活性,他们有什么实际应用场景吗?
3.1 右值引用优化性能,避免深拷贝
3.1.1 浅拷贝重复释放
对于含有堆内存的类,我们需要提供深拷贝的拷贝构造函数,如果使用默认构造函数,会导致堆内存的重复删除,比如下面代码:
#include <iostream>
using namespace std;
class A
{
public:
A() :m_ptr(new int(6)){
cout << "创建 A" << endl;
}
~A() {
cout << "销毁 A, m_ptr: " << m_ptr << endl;
delete m_ptr;
m_ptr = nullptr;
}
private:
int* m_ptr;
};
A get(bool flag) {
A a;
A b;
cout << "ready return" << endl;
if (flag)
return a;
else
return b;
}
int main() {
{
A a = get(false); // 运行报错
}
cout << "main finish" << endl;
return 0;
}
运行结果:
创建 A
创建 A
ready return
销毁 A, m_ptr: 0x556f2e56e2e0
销毁 A, m_ptr: 0x556f2e56deb0
销毁 A, m_ptr: 0x556f2e56e2e0
free(): double free detected in tcache 2
Aborted (core dumped)
3.1.2 深拷贝构造函数
在上面的代码中,默认构造函数是浅拷贝,main函数的a和get()函数的b会指向同一个指针m_ptr,在析构的时候会导致重复删除该指针。正确的做法是提供深拷贝的拷贝构造函数,比如下面的代码:
#include <iostream>
using namespace std;
class A
{
public:
A() :m_ptr(new int(6)) {
cout << "默认构造 A" << endl;
}
A(const A& a) :m_ptr(new int(*a.m_ptr)){
cout << "深拷贝构造 A" << endl;
}
~A() {
cout << "释放 A, m_ptr : " << m_ptr << endl;
delete m_ptr;
m_ptr = nullptr;
}
private:
int* m_ptr;
};
A get(bool flag) {
A a;
A b;
cout << "ready return" << endl;
if (flag)
return a;
else
return b;
}
int main() {
{
A a = get(false); // 正确运行
}
cout << "main finish" << endl;
return 0;
}
运行结果:
默认构造 A
默认构造 A
ready return
深拷贝构造 A
释放 A, m_ptr : 0x5558385cb2e0
释放 A, m_ptr : 0x5558385caeb0
释放 A, m_ptr : 0x5558385cb300
main finish
3.1.3 移动构造函数
这样就可以保证拷贝构造时的安全性,但有时这种拷贝构造却是不必要的,比如上面代码中的拷贝构造就是不必要的。上面代码中的get()函数会返回临时变量,然后通过这个临时变量拷贝构造了一个新的对象b,临时变量在拷贝构造完成之后就销毁了,如果堆内存很大,那么,这个拷贝构造的代价会很大,带来了额外的性能损耗。有没有办法避免临时对象的拷贝构造呢?答案是肯定的。看下面的代码:
#include <iostream>
using namespace std;
class A
{
public:
A() :m_ptr(new int(6)) {
cout << "默认构造 A" << endl;
}
A(const A& a) :m_ptr(new int(*a.m_ptr)){
cout << "深拷贝构造 A" << endl;
}
// 移动构造函数,可以浅拷贝
A(A&& a) :m_ptr(a.m_ptr){
a.m_ptr = nullptr; // 为防止a析构时delete data,提前置空其m_ptr
cout << "移动构造 A" << endl;
}
~A() {
cout << "释放 A, m_ptr : " << m_ptr << endl;
delete m_ptr;
m_ptr = nullptr;
}
private:
int* m_ptr;
};
// 为了避免返回值优化,此函数故意这样写
A get(bool flag) {
A a;
A b;
cout << "ready return" << endl;
if (flag)
return a;
else
return b;
}
int main() {
{
A a = get(false); // 正确运行
}
cout << "main finish" << endl;
return 0;
}
运行结果:
默认构造 A
默认构造 A
ready return
移动构造 A
释放 A, m_ptr : 0
释放 A, m_ptr : 0x557c88591eb0
释放 A, m_ptr : 0x557c885922e0
main finish
上面代码中没有了拷贝构造,取而代之的是移动构造(Move Construct)。从移动构造函数的实现中可以看到,它的参数是一个右值引用类型的参数 A&& ,这里没有深拷贝,只有浅拷贝,这样就避免了对临时对象的深拷贝,提高了性能。这里的 A&& 用来根据参数是左值还是右值来建立分支,如果是临时值,则会选择移动拷贝构造函数。移动拷贝构造函数只是将临时对象的资源做了浅拷贝,不需要对其进行深拷贝,从而避免了额外的拷贝,提高性能。这也就是所谓的移动语义(move 语义),右值引用的一个重要目的就是用来支持移动语义的。
移动语义可以将资源(堆、系统对象等)通过浅拷贝方式从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,可以大幅度提高C++应用程序的性能,消除临时对象的维护(创建和销毁)对性能的影响。
3.2 移动(move)语义
move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转义,没有内存拷贝。要move语义起作用,核心在于需要对应类型的构造函数支持。
#include <iostream>
#include <memory>
#include <string.h>
#include <vector>
using namespace std;
#if 1
class MyString
{
private:
char* m_data;
size_t m_len;
void copy_data(const char *s) {
m_data = new char[m_len + 1];
memcpy(m_data, s, m_len);
m_data[m_len] = '\0';
}
public:
MyString() {
m_data = NULL;
m_len = 0;
}
MyString(const char* p) {
m_len = strlen(p);
copy_data(p);
}
MyString(const MyString& str) {
m_len = str.m_len;
copy_data(str.m_data);
cout << "复制构造被调用, source : " << str.m_data << endl;
}
MyString& operator= (const MyString& str) {
if (this != &str) {
m_len = str.m_len;
copy_data(str.m_data);
}
cout << "拷贝赋值被调用, source : " << str.m_data << endl;
return *this;
}
// 用C++11的有右值引用来定义和两个函数
MyString(MyString&& str) {
cout << "移动构造被调用, source : " << str.m_data << endl;
m_len = str.m_len;
m_data = str.m_data; // 避免了不必要的拷贝
str.m_len = 0;
str.m_data = NULL;
}
MyString& operator= (MyString&& str) {
cout << "移动赋值被调用, source : " << str.m_data << endl;
if (this != &str) {
m_len = str.m_len;
m_data = str.m_data; // 避免了不必要的拷贝
str.m_len = 0;
str.m_data = NULL;
}
return *this;
}
virtual ~MyString() {
if (m_data) free(m_data);
}
};
int main() {
MyString a;
a = MyString("Hello"); // 移动赋值
MyString b = a; // 复制构造
MyString c = std::move(a); // 移动构造 将左值转为右值
std::vector<MyString> vec;
vec.push_back(MyString("World")); // 移动构造 将左值转为右值
return 0;
}
运行结果:
移动赋值被调用, source : Hello
复制构造被调用, source : Hello
移动构造被调用, source : Hello
移动构造被调用, source : World
有了右值引用和转义语义,我们在设计和实现类时,对于需要动态申请大量资源的类,应该设计右值引用的拷贝构造函数和赋值函数,以提高应用程序的效率。
3.3 forward完美转发
forward完美转发实现了参数在传递过程中保持其值属性的功能,即若是左值,则传递之后仍然是左值,若是右值,则传递之后仍然是右值。
现存在一个函数:
Template<class T>
void func(T &&val);
根据前面所描述的,这种引用类型既可以是左值引用,亦可以对右值引用。
但要注意,引用以后,这个val值它本质上是一个左值!
int &&a = 10;
int &&b = a; // 错误
注意这里,a是一个右值引用,但其本身a也有内存名字,所以a本身是一个左值,但如果我们用std::forward(val),就会按照参数原来的类型转发;
int &&a = 10;
int &&b = std::forward<int>(a); // 正确
例子1:
#include <iostream>
using namespace std;
template <typename T>
void Print(T &t){
cout << "L " << t << endl;
}
template <typename T>
void Print(T &&t){
cout << "R " << t << endl;
}
template <typename T>
void func(T &&t) {
Print(t); // 因为t这个参数本身是右值引用,为左值
Print(std::move(t)); // 因为t是右值引用,所以move后,为右值
Print(std::forward<T>(t)); // forward完美转发,为T原来的属性
}
int main() {
int a = 10;
int b = 20;
cout << "--- func(1)" << endl;
func(1);
cout << "--- func(a)" << endl;
func(a);
cout << "--- std::forward<int>(b)" << endl;
func(std::forward<int>(b));
cout << "--- std::forward<int&>(b)" << endl;
func(std::forward<int&>(b));
cout << "--- std::forward<int&&>(b)" << endl;
func(std::forward<int&&>(b));
return 0;
}
运行结果:
--- func(1)
L 1
R 1
R 1
--- func(a)
L 10
R 10
L 10
--- std::forward<int>(b)
L 20
R 20
R 20
--- std::forward<int&>(b)
L 20
R 20
L 20
--- std::forward<int&&>(b)
L 20
R 20
R 20
例子2:
#include <iostream>
#include <memory>
#include <string.h>
#include <vector>
using namespace std;
class A
{
public:
int *m_ptr = nullptr;
int m_nSize = 0;
public:
A() :m_ptr(nullptr), m_nSize(0){}
A(int *ptr, int nSize) {
m_nSize = nSize;
m_ptr = new int[m_nSize];
printf("A(int *ptr, int size) --> m_ptr : %p\n", m_ptr);
if (m_ptr) {
memcpy(m_ptr, ptr, sizeof(sizeof(int) * nSize));
}
}
// 左值引用实现深拷贝
A(const A &other) {
m_nSize = other.m_nSize;
if (other.m_ptr) {
printf("A(const A &other) --> m_ptr : %p\n", m_ptr);
if (m_ptr) delete[] m_ptr;
printf("delete[] m_ptr\n");
m_ptr = new int[m_nSize];
memcpy(m_ptr, other.m_ptr, sizeof(sizeof(int) * m_nSize));
}
else {
if (m_ptr) delete[] m_ptr;
m_ptr = nullptr;
}
cout << "A(const int &other) end!" << endl;
}
// 右值引用实现移动构造
A(A &&other) {
m_ptr = nullptr;
m_nSize = other.m_nSize;
if (other.m_ptr) {
m_ptr = std::move(other.m_ptr); // 移动语义
other.m_ptr = nullptr;
}
}
~A() {
if (m_ptr) {
delete[] m_ptr;
m_ptr = nullptr;
}
}
void deleteptr() {
if (m_ptr) {
delete[] m_ptr;
m_ptr = nullptr;
}
}
};
int main() {
int arr[] = {1, 2, 3};
A a(arr, sizeof(arr) / sizeof(arr[0]));
cout << "m_ptr in a Addr : " << a.m_ptr << endl;
A b(a); // 深拷贝
cout << "m_ptr in b Addr : " << b.m_ptr << endl;
b.deleteptr();
A c(std::forward<A>(a)); // 移动构造
cout << "m_ptr in c Addr : " << c.m_ptr << endl;
c.deleteptr();
vector<int> vect{1, 2, 3, 4, 5};
cout << "before move vect size : " << vect.size() << endl;
vector<int> vect1 = std::move(vect);
cout << "after move vect size : " << vect.size() << endl;
cout << "new move vect1 size : " << vect1.size() << endl;
return 0;
}
运行结果:
A(int *ptr, int size) --> m_ptr : 0x5640915a4eb0
m_ptr in a Addr : 0x5640915a4eb0
A(const A &other) --> m_ptr : (nil)
delete[] m_ptr
A(const int &other) end!
m_ptr in b Addr : 0x5640915a52e0
m_ptr in c Addr : 0x5640915a4eb0
before move vect size : 5
after move vect size : 0
new move vect1 size : 5
3.4 emplace_back减少内存拷贝和移动
对于STL容器,C++11后引入了emplace_back接口。
emplace_back是就地构造,不用构造后再次复制到容器中,因此效率更高。
考虑这样的语句:
vector<string> testVec;
testVec.push_back(string(16, 'a'));
上述语句足够简单易懂,将一个string对象添加到testVec中。底层实现:
-
首先,string(16, 'a')会创建一个string类型的临时对象,这涉及到一次string构造过程。
-
其次,vector内会创建一个新的string对象,这是第二次构造。
-
最后push_back结束时,最开始的临时对象会被析构。加在一起,这两行代码会涉及到两次string构造和一次析构。
C++11可以用emplace_back代替push_back,emplace_back可以直接在vector中构建一个对象,而非创建一个临时对象,再放进vector,再销毁。emplace_back可以省略一次构建和一次析构,从而达到优化的目的。
例子:
#include <iostream>
#include <memory>
#include <string>
#include <vector>
#ifdef GCC
#include <sys/time.h>
#else
#include <ctime>
#endif // GCC
using namespace std;
class TimeInterval
{
private:
std::string detail;
#ifdef GCC
timeval start, end;
#else
clock_t start, end;
#endif // GCC
protected:
void init() {
#ifdef GCC
gettimeofday(&start, NULL);
#else
start = clock();
#endif
}
public:
TimeInterval(const std::string& d) : detail(d) {
init();
}
TimeInterval() {
init();
}
~TimeInterval() {
#ifdef GCC
gettimeofday(&end, NULL);
cout << detail
<< 1000 * (end.tv_sec - start.tv_sec)
+ (end.tv_usec - start.tv_usec) / 1000
<< " ms" << endl;
#else
end = clock();
cout << detail
<< (double)(end - start)
<< " ms" << endl;
#endif
}
};
#define TIME_INTERVAL_SCOPE(d) std::shared_ptr<TimeInterval> \
time_interval_scope_begin = std::make_shared<TimeInterval>(d)
int main() {
vector<string> v;
int count = 1000000;
v.reserve(count); // 预分配一百万大小,排除掉分配内存的时间
{
TIME_INTERVAL_SCOPE("push_back string : ");
for (int i = 0; i < count; ++ i) {
string temp("hello world");
v.push_back(string("hello world")); // push_back(const string &), 参数是左值引用,内部做深拷贝
}
}
v.clear();
{
TIME_INTERVAL_SCOPE("push_back move(string) : ");
for (int i = 0; i < count; ++ i) {
string temp("hello world");
v.push_back(std::move(temp)); // push_back(string &&), 参数是右值引用
}
}
v.clear();
{
TIME_INTERVAL_SCOPE("push_back(string):");
for (int i = 0; i < count; ++ i)
{
v.push_back(string("hello world"));// push_back(string &&), 参数是右值引用
}
}
v.clear();
{
TIME_INTERVAL_SCOPE("push_back(c string) : ");
for (int i = 0; i < count; ++ i) {
v.push_back("hello world"); // push_back(string &&), 参数是右值引用
}
}
v.clear();
{
TIME_INTERVAL_SCOPE("emplace_back(c string) : ");
for (int i = 0; i < count; ++ i) {
v.emplace_back("hello world"); // 只有一次构造函数,不调用拷贝构造函数,速度最快
}
}
}
运行结果:
push_back string : 52739 ms
push_back move(string) : 30652 ms
push_back(string):30511 ms
push_back(c string) : 29711 ms
emplace_back(c string) : 23544 ms
第1种方法耗时最长,原因显而易见,将调用左值引用的push_back,且将会调用一次string的拷贝构造函数,比较耗时,这里的string还算很短的,如果很长的话,差异会更大。
第2、3、4种方法耗时基本一样,参数为右值,将调用右值引用的push_back,故调用string的移动构造函数,移动构造函数耗时比拷贝构造函数少,因为不需要重新分配内存空间。
第5种方法耗时最少,因为emplace_back只调用构造函数,没有移动构造函数,也没有拷贝构造函数。
3.5 小结
C++11在性能上做了很大的改进,最大程度减少了内存移动和复制,通过右值引用、forward、emplace和一些无序容器我们可以大幅度改进程序性能。
-
右值引用仅仅是通过改变资源的所有者(剪切方式而不是拷贝方式)来避免内存的拷贝,能大幅提高性能。
-
forward能根据参数的实际类型转发给正确的函数(参数用&&的方式)。
-
emplace系列函数通过直接构造对象的方式避免了内存的拷贝和移动。
本专栏知识点是通过<零声教育>的系统学习,进行梳理总结写下文章,对c/c++linux课程感兴趣的读者,可以点击链接,详细查看详细的服务