之前偷懒一直没看这部分,现在开始看很多C++风格代码确实离不开C++ 11引入的新特性,尤其智能指针右值引用等,本来只想写这两个,但是用到的auto、python风格的for循环、lambda表达式等小特性也逐渐影响了设计者的代码风格,我在一段时间十分抗拒使用这种非常巧妙的写法,慢慢又变成会看不会写了,用和不用相当忐忑,因此就有了这个总结,确保忘记了也能很快拾起来;这里只记录用到且必要的,逐渐记录,追求完整版的还是移步官方文档。

RAII机制

资源获取即初始化(Resource Acquisition Is Initialization,RAII)不是C++11的新特性,而是C++之父很早提出的编程思想。RAII就是将资源和对象的生命周期绑定,对象创建自动获取资源,对象生命周期内资源始终有效,对象生命周期结束资源释放,避免了内存泄漏的问题。RAII类模板是申请、释放资源的基本手段,智能指针就是RAII的其中一种典型应用。

C++智能指针

智能指针是C++ 11引入重要新特性,用于解决raw指针需要手动管理问题,例如由malloc、free或者new、delete操作错误或者失败、悬空指针造成的,智能指针会在生命周期结束时自动释放内存,无需手动调用内存释放函数,也不会留下悬空指针;

独占指针unique_ptr

类模板定义

智能指针本质是一个类模板,unique_ptr是独享指针,代表一个unique_ptr只负责管理一个内存对象,因此拷贝构造、赋值构造都是禁止的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T,typename D = default_delete<T>>
class unique_ptr
{
private:
pointer ptr; //内置指针
public:
explicit unique_ptr(pointer p) noexcept; //explicit表示禁用隐式转换,noexcept表示不会抛出异常
~ unique_ptr() noexcept;
unique_ptr(const unique_ptr&) = delete; //禁用unique_ptr的拷贝构造
unique_ptr& operator = (const unique_ptr&) = delete; //禁用unique_ptr赋值函数
unique_ptr(unique_ptr&&) noexcept;
unique_ptr& operator = (unique_ptr&&) noexcept;

T& operator*() const; //*重载函数
T*operator->()const noexcept; //->重载函数
};

定义方式、作为函数参数

记录了unique_ptr的三种定义方式,取地址问题,特殊的是unique_ptr不能调用任何拷贝构造和赋值构造,因此函数左值传递是禁止的,只能进行右值传递、引用传递和地址传递(均无拷贝);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <iostream>
#include <memory> //智能指针需要
using namespace std;
class Person
{
public:
string name;
Person(){cout << "This is non-parameter constructor!"<<endl;}
Person(const string& name):name(name){cout<<"Parameter constructor name="<<name<<endl;}
~Person(){cout << "This is destructor!"<<endl;};
};

void test1(unique_ptr<Person>&p){ //智能指针引用传递
cout<<"test func:"<<p->name<<endl;
}
void test2(unique_ptr<Person>*p){ //智能指针地址传递
cout<<"test func:"<<(*p)->name<<endl;
}
void test3(unique_ptr<Person>p){ //智能指针值传递
cout<<"test func:"<<p->name<<endl;
}
void test4(unique_ptr<Person>&&p){ //智能指针右值传递
cout<<"test func:"<<p->name<<endl;
}

int main(){
Person *p = new Person("Eden"); //不会调用析构函数

//三种智能指针管理定义方法:结束时均会自动调用析构函数
unique_ptr<Person>p1(p);
//unique_ptr<Person>p1(new Person("Eden"));
//unique_ptr<Person>p1=make_unique<Person>("Eden"); //C++14标准

//地址问题
cout<<p<<endl; //raw指针地址
cout<<p1.get()<<endl; //获取智能指针指向的raw指针地址,同上
//cout<<p1<<endl; //Vscode会报错:需要Person类的重载<<函数支持,返回指向的raw指针,同上
cout<<&p<<endl; //智能指针本身类地址

//一般情况,智能指针和raw等效使用
cout<<p->name<<endl;
cout<<p1->name<<endl;

//特殊情况:多个独占指针管理一个内存,或者使用了构造函数或者赋值函数
//函数传值只能是引用、地址、或者右值传值

//unique_ptr<Person>p2(p); //非法,同一个内存冲突
test1(p1); //合法,引用传递
test2(&p1); //合法,地址传递
//test3(p1); //非法,函数左值传递,本质是一种拷贝构造
test3(move(p1)); //合法,值传递也可以使用move值传递
//test4(move(p1)); //合法,右值引用,调用unique的移动构造

return 0;
}
注意,move(p1)后p1就成为了空指针,后面不能再使用,所以test3、4二选一单独测试;此外,智能指针不支持指针运算,例如加减、++等;

unique_ptr指针赋值

unique_ptr赋值写法不能直接触发拷贝、赋值构造,因此只能使用特殊的方法,如匿名赋值函数返回赋值等,连续的赋值,p1会先析构上一个管理的内存对象,再绑定新的对象:

1
2
3
4
5
6
7
8
9
10
11
12
//匿名赋值:
unique_ptr<Person>p1; //构造函数
p1=unique_ptr<Person>(new Person("Eden"));
cout<<p1.get()<<endl; //记录初始内存

//局部变量函数赋值
unique_ptr<Person> test(){ //构造函数
unique_ptr<Person>pp(new Person("Mike"));
return pp;
}
p1 = test(); //先析构p1的Eden,再管理Mike
cout<<p1.get()<<endl; //内存变化

空unique_ptr指针

给智能指针对象置nullptr,会触发析构函数调用使其置空判断智能指针是否为空可以判断其是否为nullptr;

unique_ptr控制权的转移

假设现在有函数void func(Person *p),需要接收一个raw指针p,而目前unique_ptr管理着p对应的内存,存在对应的智能指针p1,则需要区分get和release两种方法,它们都会返回对应的raw指针,但release完全交出了内存的控制权,并且p1从此变成空指针;

1
2
void func(p1.get());//表示将p1对应的raw指针传入,但是func只使用,p1负责释放;
void func(p1.release());//表示将p1对应的raw指针控制权交给func,func还需要对该内存进行释放

类似的,前文提到,如果是智能指针作为参数

1
2
void func(unique_ptr<Person> p) //传入move(p1),p1成为空指针,控制权转入func;
void func(unique_ptr<Person> &p)//传入左值p1,p成为p1的别名,p1仍然具有控制权;

reset与swap方法

1
2
3
4
5
6
7
unique_ptr<Person>p1(new Person("Eden"));
p1.reset(); //释放p1智能指针管理的内存资源,p1成为空指针
p1.reset(nullptr); //同上
p1.reset(new Person("Mike")); //释放内存资源,且重定向到新对象Mike

unique_ptr<Person>p2(new Person("Mike"));
p1.swap(p2); //交换p1、p2管理的内存对象;

exit析构异常

unique_ptr在一些情况下仍然会发生野指针、内存泄漏等,例如exit方法的退出会导致全局智能指针无法析构对应的内存;

1
2
3
4
5
unique_ptr<Person>p1(new Person("Eden")); //global无法析构成功,需要return 0;
int main(){
unique_ptr<Person>p2(new Person("Mike")); //局部可以析构
exit(0);
}

智能指针数组

记录了指针定义数组的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//普通指针数组:
int *p = new int[2]{0,0}; //整型
cout<< p[0]<<endl;

//自定义类
Person *p = new Person[2]{string("Eden"),string("Mike")};
cout<< p[0].name<<endl;


//智能指针数组
unique_ptr<int[]> p(new int[2]{1,1}); //整型
cout<<p[0]<<endl;

//自定义类
unique_ptr<Person[]> p(new Person[2]{string("Eden"),string("Mike")});
cout<<p[0].name<<endl;

共享指针shared_ptr

一个内存不能够被多个unique_ptr指针共同管理,而一个内存可以被多个shared_ptr指针进行管理,从而实现内存的共享,内存的共享往往需要引入计数机制,当内存对象和新的shared_ptr绑定,shared_ptr引用计数会加1,当一个内存对应的引用计数为0,才会释放这个内存对象。

shared_ptr构造

shared_ptr支持拷贝构造函数、赋值,因此shared_ptr支持的构造方式比unique_ptr多一种,和unique_ptr在C++14才引入make_unique不同,C++11就引入了make_shared

1
2
3
4
5
6
7
8
9
Person* p = new Person("Eden");
shared_ptr<Person>p1(p);

shared_ptr<Person>p1(new Person("Eden"));

shared_ptr<Person>p1=make_shared<Person>("Eden");

//支持拷贝构造
shared_ptr<Person>p2(p1);

shared_ptr的特性方法

共享指针提供了计数、判断是否共享、获取raw指针等方法;

1
2
3
4
5
6
shared_ptr<Person>p1=make_shared<Person>("Eden");
cout<<p1.use_count()<<endl; //1,目前的内存计数为1
cout<<p1.unique()<<endl; //1,目前内存为p1独享
cout<<p1.get()<<endl; //返回raw指针对应的内存地址

//判空、reset、swap、exit异常、数组化同unique_ptr,shared_ptr无release方法;

不要使用一个raw指针去初始化多个shared_ptr:

1
2
3
Person*p = new Person("Eden");  
shared_ptr<Person>p1(p);
shared_ptr<Person>p2(p); //非法

make_shared构造的优越性

在共享指针中往往更加推荐使用make_shared来构造智能指针,这是因为:

  1. make_shared分配内存更加高效控制块(强引用计数、弱引用计数等)和内存对象一并分配的,而其他构造方法需要单独分配两次,性能较弱。

  2. make_shared更加安全:如果是new的方法,可能先分配一个内存,再分配一个控制块管理这个内存,但是如果控制块空间分配失败,可能导致原先的内存泄漏

make_shared缺点shared_ptr强引用数降为0,内存对象回收;weak_ptr弱引用计数为0,控制块内存回收。如果二者一起分配,只有weak_ptr的引用数为0,内存对象才能一并回收,导致内存没有及时释放

shared_ptr赋值

直接赋值就可,很少会用到unique_ptr的匿名赋值函数赋值;赋值的含义是将p2也加入到这块内存的“管理者”,无论p2是新指针,还是原来指向其他内存的指针,如果是后者,p2原内存的引用数-1,引用数归零p2还会将原来内存析构掉;

1
2
3
shared_ptr<Person>p1=make_shared<Person>("Eden");
shared_ptr<Person>p2;
p2=p1; //赋值

shared_ptr作函数参数

以下方法都是合法的函数传递方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void test1(shared_ptr<Person>&p){  //智能指针引用传递
cout<<"test func:"<<p->name<<endl;
}
void test2(shared_ptr<Person>*p){ //智能指针地址传递
cout<<"test func:"<<(*p)->name<<endl;
}
void test3(shared_ptr<Person>p){ //智能指针值传递
cout<<"test func:"<<p->name<<endl;
}
void test4(shared_ptr<Person>&&p){ //智能指针右值传递
cout<<"test func:"<<p->name<<endl;
}

//for test:
test1(p1); //合法,引用传递
test2(&p1); //合法,地址传递
test3(p1); //合法,值传递是一种拷贝构造
test3(move(p1)); //合法,值传递也可以使用move值传递
test4(move(p2)); //合法,右值引用,调用shared的移动构造

unique_ptr转换至shared_ptr

一个内存被多个shared_ptr共享,谈不上指针具有控制权,因此shared_ptr是没有release()方法的,unique_ptr可以通过move方法转换成share_ptr指针,转换后前者变成空指针,控制权落入共享指针手里;

1
2
unique_ptr<Person>p(new Person("Eden"));
shared_ptr<Person> p1 = std::move(p); //p成为空指针;
shared_ptr在多线程环境读写同一块内存需要加锁,一般环境使用unique_ptr占用资源更少、效率更好;

弱引用指针weak_ptr

shared_ptr循环引用问题

weak_ptr不算一个独立的指针类型,它往往配合shared_ptr一起使用,因为多线程环境的shared_ptr设计并不完美,考虑一种循环引用的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <memory> //智能指针需要
using namespace std;
class Person_B;
class Person_A
{
public:
string name;
shared_ptr<Person_B>pb;
Person_A(){cout << "This is Person_A non-parameter constructor!"<<endl;}
Person_A(const string& name):name(name){cout<<"Person_A Parameter constructor name="<<name<<endl;}
~Person_A(){cout << "This is Person_A destructor!"<<endl;};
};

class Person_B
{
public:
string name;
shared_ptr<Person_A>pa;
Person_B(){cout << "This is class Person_B non-parameter constructor!"<<endl;}
Person_B(const string& name):name(name){cout<<"Person_B Parameter constructor name="<<name<<endl;}
~Person_B(){cout << "This is Person_B destructor!"<<endl;};
};

int main(){
shared_ptr<Person_A>p1=make_shared<Person_A>();
shared_ptr<Person_B>p2=make_shared<Person_B>();

//建立循环引用
p1->pb = p2;
p2->pa = p1;

//测试内存计数:均为2
cout<<p1.use_count()<<endl;
cout<<p1.use_count()<<endl;
cout<<p1->pb.use_count()<<endl;
cout<<p2->pa.use_count()<<endl;
return 0;
}
循环引用会导致内存的引用数始终高于0,因此上述进程结束后不会调用析构函数,最后导致内存泄漏。这种情况需要将类内指针定义成weak指针,weak指针不会增加内存块的计数,最后的计数输出全为1,能够正常释放p1、p2位置的内存;
1
2
3
4
5
6
7
8
9
Class Person_A{
......
weak_ptr<Person_B>pb;
};

Class Person_B{
......
weak_ptr<Person_A>pa;
};

weak_ptr赋值

上面已经用过赋值语法了,weak_ptr基本不会自己构造一个指针(它没有重载*、->的函数,不能通过这些方式访问对象),因此只写赋值,赋值来源可以是weak_ptr也可以是shared_ptr;

1
2
3
shared_ptr<Person_A>pa;
weak_ptr<Person_A>pw;
pw = pa;

weak_ptr特性方法

1
2
3
4
expired()  //判断shared_ptr资源是否过期
lock() //返回对应的share_ptr,如果其资源过期,返回空share_ptr,这是一个原子操作;

reset/swap同unique_ptr

一个用到weak_ptr的重要方面是,保证shared_ptr在操作时是线程安全的:

1
2
3
4
shared_ptr<Person_A>pa = make_shared<Person>("Eden");
weak_ptr<Person_A>pw = pa;
shared_ptr<Person_A>ps = pw.lock(); //确保对ps的操作是线程安全(不是绝对,仍需要同步互斥机制)
......

参考: 1. C++ 智能指针总结

  1. C++11神器之智能指针

左值、右值与左右值引用

左值、右值

C++所有值都可以划分成左值和右值:

  • 左值是能放在等号左侧,能够被赋值的值(C99定义了只有特殊的左值能被修改,除了数组、const修饰、不完整类型(例如未定义完整的类和结构体)等),更准确而言,能够对其进行取地址操作,就算左值;因为左值通常作为一个长期存在的变量,代表一定的内存位置,例如int a = 0const int b = 3int *p = &a;其中a、b(尽管不能对const赋值)、p、*p都是左值;注意,常量字符串也是左值,如"Eden"(const char[],cout<<&"Eden"是合法操作),如果对这个字符进行运算,如"Eden"+1,则又变成了右值;

  • 右值是不能放在等号左侧,不能被取地址的值;右值通常是一种返回值含义,只作临时变量,在内存中没有特定的位置,常见的右值是常数(数字)、表达式返回值(a+b、a+1)

左值引用和右值引用

C++11引入了右值引用特性,因此以往我们的引用类型都被称为左值引用,以往左值引用的目的就是给内存对象起别名,避免开辟新的空间以及数据拷贝,例如先看一些左值引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int &b = a;  //b是a的别名,修改b等于修改a
const int& b = a; //b和a仍是别名,只是值均不能修改
int *&p = a; //p是指针a的别名,a类型是int *,修改指针p就是修改指针a;
int *&p = *a; //*a是指针,p是*a的别名,修改p等于修改*a,但修改*a意味着是修改指向int变量的指针
//4区分于5:
int &p = *a; //*a是指针,p是*a的别名,修改p等于修改*a,修改*a意味着*a指向的int变量
//看懂4、5跳过例子:
/***************
y=10;
int *x=&y;
int **a=&x;
int *&p1 = *a; //情况3:p1==x,修改p1等于修改x;
int &p2 = *a; //错误,*a是指针,p是int类型
int &p2 = *x //情况4: p2 == *x,p2=5等效将y修改成5
***************/

然后来看右值引用,右值引用的特点是,一定是对右值进行引用,为了区分和左值的引用,右值引用使用了两个"&",例如:

1
2
3
int &&a = 10; //右侧量全部都是右值,左值是非法的,如b=10;int &&a = b;;
int &&a = x+1;
int &&a = x+y;
右值引用的作用就是将右值转换成了左值,换言之它给右值起别名,并且延长了右值的生命周期(但仍短于一般左值),例如此时a全是左值,对a赋值和取地址是合法的,注意此时a作为左值,仍然遵循左值赋值条件,即非数组、const修饰、不完整类型等才可赋值:
1
2
3
4
5
6
a = 20 ; //OK
int *b = & a;
*b = 20 ; //OK

const int && a = 10;
const int *b = & a; //ok,b取地址也必须保留const
有没有发现问题回到了我们在C++基础里面的内容,此时const修饰的是b指针指向的内存,所以肯定有:
1
2
3
4
5
*b = 20 ; //非法

int y = 20;
b = &y; //OK
*b = 30 //仍然非法,y是int *,但b仍保留const int *;
因此我们说右值引用返回的左值如果被const修饰,仍然不能对这个左值赋值;const此时修饰的是int变量,即指针指向的内存变量是不能修改的,而指针本身可以修改,因此指向新的y内存没有问题;

左值和右值引用转换问题

在没有引入右值引用时,左值和右值的一些转换是这样的:

1
2
3
4
5
6
x=10;y=10;
int a = x + y; //右值被存储到一个左值变量空间

int a[3]={1,2,3};
int *p =a; //int *p =&a[0];
*(p+1) = 3; //p+1是右值,*(p+1)是左值,修改a[1]
我们对这些转换以及习以为常,对于引入了右值引用,会带来更加复杂的机制,当然这些机制本身也是为了优化代码的性能;例如如果左值引用需要对右值进行引用,则需要使用const修饰代表不会对右值进行更改:相当于将右值绑定到左值引用,可以对这个左值引用取地址,但不能对其进行赋值;
1
2
3
4
5
6
7
//illegal
int &a = 10;
int &a = x+1;

//legal
const int &a = 10 ; //右值对象,左值引用必须加const
const int &a = x+1 ;
反过来,根据定义,对左值直接进行右值引用是非法的,左值如何进行间接的右值引用呢?则需要使用std::move将左值强制转换成右值,std::move代表将某个对象的资源给移动过来,这是移动语义的重要用法,后面再提,这里是:
1
2
3
4
5
int a = 10; 
int *p = &a;

int &&a1=std::move(a); //右值引用a1成为左值a的别名,修改a1等于修改a变量值
int *&&p1 = std::move(p); //右值引用p1成为左值指针p的别名,修改p1等于修改指针p的指向

现在左右值引用仍停留在变量赋值的层面,实际上左右值引用更大的应用场景是函数的应用,例如左值引用:

1
2
3
4
int &func1(){
int temp =2;
return temp; //危险行为
} //返回左值引用,是左值返回
既然能进行左值引用,返回对应的肯定是某一块内存空间,使用引用的目的是其一我们可以代替指针的方法直接对变量(操作形参==操作实参)进行操作,修改其值;其二是避免了重复开辟空间进行数据拷贝;提高了效率和空间利用率;

但是引用返回的很大问题是如果像上述函数一样,返回一个局部变量的引用,我们使用引用int &a = func1()去引用这块空间,那么当函数调用完毕函数栈被释放,我们仍然对a进行读取或者赋值等操作,就会造成悬空引用

以下两种情况是右值返回的情况,在Vscode的g++中,func3如果按照func2的写法是能通过编译的,说明std::move(value)延长了变量的生命周期,但返回局部变量的引用仍然是不安全的行为;可能在这里你会有这样的问题:返回的是int&&右值引用,右值引用不应该相当于一个左值吗,为什么返回的是右值?实际上我们接收返回值:int&& a = func3();这里只有右值才可以进行右值引用,所以func3()仍然称为右值返回。

1
2
3
4
5
6
7
int func2(){   //返回一个常数或者表达式等非引用类型,是右值返回
return 0;
}

int &&func3(){ //返回右值引用,是右值返回
return std::move(value);
}

移动语义:移动构造函数

右值引用的引入是为了给移动语义铺路,移动语义是C++11引入的又一个新特性,它代表将资源的使用权从一个对象(临时对象、右值对象)转移到另一个对象,而无需进行拷贝,提高了代码性能。

拷贝构造函数涉及浅拷贝、深拷贝的问题,如果需要安全地操作内存,深拷贝是必要的操作,但是对于临时对象也调用深拷贝是一种性能开销浪费,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <memory> //智能指针需要
#include <cstring>
using namespace std;
class Person
{
public:
char *name;
Person():name(nullptr){cout << "This is non-parameter constructor!"<<endl;}
~Person(){
delete[]name;
cout << "This is destructor!"<<endl;
};
Person(const Person &p){ //传入右值参数,左值引用必须使用const
if(p.name){ //深拷贝
name = new char[strlen(p.name)+1];
strcpy(name,p.name);
}
else
name = nullptr;
cout<<"This is COPY constructor!"<<endl;
}

};

Person test(void){ //返回一个临时变量,即右值
return Person();
}

int main(){
Person p=test();
return 0;
}
这里使用了test函数返回一个类临时变量,name如果存在,会先从无参构造的匿名对象传递到临时变量,再从临时变量传递到实例P,经历了两次深拷贝;以往这个问题由编译器进行专门的优化,使得只经历一次无参构造和一次销毁;禁用这个优化,在g++环境执行g++ demo.cpp -fno-elide-constructors(禁用拷贝构造优化)得到:
1
2
3
4
5
6
This is non-parameter constructor!  #Person无参构造产生匿名对象
This is COPY constructor! #形参传值导致无参构造
This is destructor! #参数传递完毕,匿名对象销毁
This is COPY constructor! #等号赋值引发无参构造
This is destructor! #test函数临时变量销毁
This is destructor! #类P销毁

此时引入一个移动构造函数,接受参数是类的右值引用,指针类型也可以直接链表传值,并且将原来资源置空:

1
2
3
4
Person(Person &&p):name(p.name){
p.name = nullptr;
cout<<"This is MOVE constructor!"<<endl;
}
结果为:两次移动构造函数代替了两次拷贝构造函数,也即少了两次深拷贝操作。
1
2
3
4
5
6
This is non-parameter constructor!
This is MOVE constructor! //移动构造
This is destructor!
This is MOVE constructor! //移动构造
This is destructor!
This is destructor!
综上,如果参数是右值,会自动调用移动构造函数,如果没有定义移动构造函数,才会退而求其次使用拷贝构造,但拷贝构造必须使用const修饰参数才能接受右值;如果参数是左值,会自动调用拷贝构造函数,可以使用std::move使其转换成右值并调用移动构造函数,

完美转发perfect forwarding

C++ 11严格地区分了左值和右值,并且为其定制了拷贝构造、移动构造,因此设计函数时我们也需要严格地区分左值参数和右值参数以确保调用函数符合我们的期待(尽管我们很少主动地这样做,但是模板库大量函数是这样做的)。完美转发就是在函数传参时,将参数左右值属性保留地传递给下一个参数,例如:

1
2
3
void test(int t){
return recall(t)
}
这样的函数,传入实参无论是左值还是右值,都通过值传递得到形成左值形参t,因此recall接受的参数就只能是左值,没有延续实参的左右值属性,因此就不是完美转发。

因此这种情况只能约定如前文所述,约定参数(例如int&&/const int&/int&,右值、优先右值、左值);而如果一个程序同时需要区分地接受左值和右值,就需要引入函数模板进行重载,并且使用万能引用,所谓万能引用,是在定义函数模板时在类型后加上&&,此时不表示只接收右值参数,而是任意接收左右值参数,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
using namespace std;

/* 没有int&&可以使用这个
void recall(const int &s){ //注释上面
cout<<"const right_value"<<endl;
}
*/
void recall(int &&n){
n = 20;
cout<<"right_value\t"<<n<<endl;
}
void recall(int &n){
n = 20;
cout<< "left_value\t"<<n<<endl;
}

template<typename T> //函数模板
void test(T&& n){ //万能引用,不是右值引用
recall(n);
}
int main(){
test(10); //输出left_value 20
int n = 10;
test(n); //输出left_value 20
test(move(n)); //输出left_value 20
return 0;
}
然而,这里相当于使用一个形参n去接收值,因此三种情况下n都属于是左值,recall均调用的是左值调用,输出left_value; 如果需要保留实参的左右值特性,以实现所谓perfect forwarding,那么就应该使用forward方法,将模板函数修改为:
1
2
3
4
template<typename T>
void test(T&& n){
recall(forward<T>n); //加入forward
}
分别输出:
1
2
3
test(10);   //输出right_value 20
test(n); //输出left_value 20
test(move(n)); //输出right_value 20
注意,只有形如上述表达才能称为“万能引用”,如果是const T&& nstd:vector<T>&& n等,都是右值引用;特别的如果是类模板,还要确定类成员函数的void test(T&& n)中的T是否已经推导出确定的类型,如果是,则是右值引用,否则就是万能引用。

总的而言,所谓“万能”,是配合forward一起来看的,它能够用一个参数、模板就保留了左右值属性,实现完美转发。

在测试代码时有意思的一件事情是,如果不使用int类型,全部改成string类型,然后测试test("Eden")(该字符串是const char[],会临时绑定到临时对象,而如果是test(str),仍然是左值版本),在不使用forward的情况下也会自动选择右值输出版本输出right_value,这里涉及引用折叠规则(Reference Collapsing)和重载决议(Overload Resolution),T&& n接收string &&类型,不会将其绑定至string &,而接收int&&类型时,能够直接绑定至int &,因此前者使用右值版本,后者使用了左值版本。所以建议实现完美转发时应该加入forward统一处理

成员函数限定符

&&&限定了函数返回左右值类型、函数参数的左右值类型,还有一种特殊的限定,即成员函数的限定符,该写法能够让成员函数限定调用对象是左值还是右值,一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

class Person{
public:
int num;
Person(int num):num(num){};

// 测试时,下面的const需要另外测试,const不能作为重载条件
int get_num()&{cout<<"left"<<endl;return num;} //1 仅左值可调用
int get_num()&&{cout<<"right"<<endl;return num;} //2 仅右值

//需要const时:
int get_num()const &&{cout<<"right"<<endl;return num;} //3 仅右值
int get_num()const &{cout<<"left or right"<<endl;return num;} //4 左右值均可
};

int main(){
Person p1(10);
p1.get_num(); //函数1、4均可
move(p1).get_num(); //函数2、3、4均可
return 0;
}
特殊的是,如果结合const使用,const&表示成员函数既可以被左值对象调用,也可以被右值对象调用,而非专属于左值对象;

参考:

  1. 斗战胜佛美猴王:详解 C++ 左值、右值、左值引用以及右值引用

  2. C语言与CPP编程:一文入魂:再也不用担心我不懂C++移动语义了!

  3. nettee:理解 C/C++ 中的左值和右值

  4. C++11、C++14、C++17、C++20新特性总结