左、右值引用

左值

1.定义

左值是一个在内存中具有持久存储位置的表达式,它可以出现在赋值表达式(=)的左侧或右侧(通常在左边)

  • 左值通常指的是局部变量或者具有持久存储的实体包括:
  1. 命名空间作用域的变量:例如全局变量或静态成员变量,它们在程序的整个运行期间都存在。
  2. 局部静态变量:在函数内部定义的静态局部变量,它们在第一次使用时初始化,并在程序的其余部分保持其值。
  3. 局部非静态变量:在函数内部定义的局部变量(自动变量),它们在进入函数时创建,并在函数退出时销毁。
  4. 类成员变量:对象的成员变量,它们与对象一起被创建和销毁。
  5. 数组:具有确定大小和存储位置的数组。

具有持久存储位置的变量可以被多次访问和修改,它们可以作为函数参数的左值引用传递,以避免复制并允许修改原始变量

语法

1
2
int a = 10;
int& refToA = a; // 定义一个左值引用,绑定到变量a上

2.左值引用

左值引用是C++的一个引用类型,必须绑定到一个对象上,相当于给这个对象起别名,从而可以通过左值引用来操作被绑定的变量

注意事项:

  • 绑定:左值引用必须绑定到一个有效的对象上,不能绑定到临时对象、匿名对象、右值上
  • 作用:左值引用允许对原始对象进行操作,包括修改和访问
  • 生命周期:左值引用的生命周期与它绑定的对象的生命周期相关联。如果引用的对象超出作用域而失效,那么左值引用本身也会变得无效

函数如果要返回引用类型的变量的时候需要格外注意,不能返回函数体内的一个局部变量的引用!

3.与指针的区别

左值引用和指针很像,但是仍有以下的区别:

  • 左值引用必须在定义时立即初始化,即绑定一个持久存在的对象;而指针可以不在定义的时候初始化
  • 左值不能绑定到临时变量和匿名对象上,但是指针可以,甚至是执行nullptr

3.1问题代码

1
2
3
4
5
int& test(){
int a=10;
int &ref = a;
return ref;
}

这个代码就是个典型的问题代码,因为它返回了局部变量的引用,这样的话,由于函数结束a的内存就被释放了,所以返回的那个引用,就不知道指向哪里了。修改方法:

1
2
3
4
5
int& test(){
static int a=10;
int &ref = a;
return ref;
}

由于上述代码的返回值是int&,所以调用函数的时候,=左边的变量应该是引用类型,比如:int &c = test();

3.2左值引用会调构造函数么

创建左值引用不会再次调用构造函数

比如:

1
2
3
class Person;
Person p(10); // 调用构造函数
Person& p_ref = p; // 不调用构造函数

创建空指针其实也不会调用构造函数,只有创建对象是才会

1
2
Person* p1 = new Person();  // 调用构造函数
Person* p2; // 不调用构造函数

4.左值引用的作用

1.函数参数

使用左值引用作为函数参数,可以避免不必要的拷贝(不会调用拷贝构造函数),并允许函数修改原始对象

1
2
3
4
5
6
7
8
9
void increment(int& number) {
++number; // 直接修改传入的变量
}

int main() {
int a = 5;
increment(a); // a 现在是 6
return 0;
}

如果形参类型除了左值引用还有const,比如fun(const int& a),作用如下:

  • 避免不必要的拷贝
  • 表面意图,提高可读性
  • 保持不变性

2.返回左值引用以实现链式操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Wrapper {
public:
int value;
Wrapper(int val) : value(val) {}
Wrapper& operator=(const Wrapper& other) {
value = other.value;
return *this; // 返回左值引用,支持连续赋值
}
};

int main() {
Wrapper w1(1);
Wrapper w2(2);
w1 = w2 = Wrapper(3); // 连续赋值
return 0;
}

右值

1.定义

右值指的是那些在内存中不具有持久存储位置,通常在表达式中出现作为临时对象的值,在表达式求值后就会被销毁

  • 右值通常包括以下这些:

1.字面量

1
int x = 10; // '10' 是一个整数字面量,它是一个右值

在这里,10 是一个整数字面量,它没有存储在内存的特定位置,仅在表达式中存在。

2.表达式结果

1
2
3
int a = 5;
int b = 10;
int c = a + b; // a + b 是一个表达式,其结果是右值

a + b 这个表达式的结果是一个临时值,它存储在某个临时的内存位置,不能被多次使用。

3.函数返回值或函数的调用结果

1
2
3
4
5
6
7
8
9
int fun(){
int a = 10; // a是一个变量,因此是一个左值
return a; // 这里a作为返回值,是一个右值
}

int main() {
int val = fun(); // fun() 调用的结果是右值,被赋值给左值 val
return 0;
}

在函数返回时,会把左值变成右值来在表达式中赋值,且离开函数作用域时,左值因为是局部变量,本身也没了

4.对象的临时拷贝

1
2
3
4
5
std::string createString() {
return std::string("Hello");
}

std::string mainString = createString(); // createString() 的返回值是一个临时的右值

调用 createString() 时返回的 std::string 对象是一个临时对象,它在赋值给 mainString 之前是一个右值。注意,这种复制会调用==拷贝==构造函数

5.使用 std::move

1
2
3
4
5
6
7
8
9
std::string getString() {
std::string s = "Hello";
return std::move(s); // 将 s 转换为右值引用
}

int main() {
std::string s2 = getString(); // 使用移动赋值符,避免拷贝
return 0;
}

在这里,std::move(s) 将左值 s 转换为右值引用,这样 getString() 函数的返回值就是一个右值的引用,main 中的 s2 通过==移动==构造函数而非拷贝构造函数来接收这个字符串

2.右值引用

右值引用也是一个引用类型,相当于给一个右值起个别名,从而可以通过右值引用来操作被绑定的变量

注意事项:

  • 绑定:右值引用必须绑定到右值上,比如临时变量,表达式的值,函数调用结果或是被std::move()转成右值的左值
  • 生命周期:右值引用的生命周期与它绑定的对象的生命周期相关联。一旦被绑定的右值失效,右值引用也会立即失效
  • 不允许多个右值引用绑定同一个右值:右值引用通常绑定到临时对象,因此不允许两个右值引用指向同一个对象,因为这可能导致资源的多次释放

3.右值引用的作用

1.移动语义(Move Senmantic)

移动语义是一个编程中的概念,它们可以实现在程序运行时,移动一个即将被销毁的对象(右值)的所有权,以避免复制操作,提高程序性能(通常用于对象的初始化)==由于移动了所有权,故移动后不能再操作原对象==

通常只有分配了资源(动态内存、锁,文件描述符…)的类/结构体实例,才需要用到移动语义,普通变量如int不能用移动语义

要实现移动语义,必须通过以下2个方式:

  • 移动构造函数
  • 移动赋值运算

这2种方式的输入形参都是右值引用

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
#include <iostream>
#include <string>

class MyString {
public:
MyString(const char* data) : m_data(new char[strlen(data) + 1]) {
strcpy(m_data, data);
}

// 移动构造函数
MyString(MyString&& other) noexcept : m_data(other.m_data) {
other.m_data = nullptr;
}

// 移动赋值运算符
MyString& operator=(MyString&& other) noexcept {
if (this != &other) {
delete[] m_data;
m_data = other.m_data;
other.m_data = nullptr;
}
return *this;
}

~MyString() {
delete[] m_data;
}

const char* GetData() const {
return m_data;
}

private:
char* m_data;
};

int main() {
MyString str1("Hello");

// 使用移动构造函数
MyString str2 = std::move(str1); //将左值转为 右值引用

std::cout << "str1: " << str1.GetData() << std::endl; // 输出为空
std::cout << "str2: " << str2.GetData() << std::endl; // 输出"Hello"

// 使用移动赋值运算符
MyString str3("World");
str3 = std::move(str2);

std::cout << "str2: " << str2.GetData() << std::endl; // 输出为空
std::cout << "str3: " << str3.GetData() << std::endl; // 输出"Hello"
}

从上面的例子可以看出,只要我们实现了一个类的移动构造函数 或者 移动赋值运算符,那么我们在初始化这个类的新对象时,就可以用到移动语义了

std::move()是C++11中引入的一个标准库的函数,用于将一个左值强制转为右值引用,以触发移动语义

std::move()可以将任何左值转成右值引用,但是是否能触发移动语义,得看std::move()的这个类,是否定义了移动构造函数、赋值符之类的

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
#include <iostream>
#include <utility>

class Resource {
public:
Resource(int *p) : ptr(p) {}

// 移动构造函数
Resource(Resource &&r) : ptr(r.ptr) {
r.ptr = nullptr;
std::cout<<"移动构造函数"<<std::endl;
}

// 移动赋值运算符
Resource &operator=(Resource &&r) {
if (this != &r) {
delete ptr;
ptr = r.ptr;
r.ptr = nullptr;
}
std::cout<<"移动赋值"<<std::endl;
return *this;
}

~Resource() { delete ptr; }
private:
int *ptr;
};

Resource&& createResource() {
int *p = new int(42);
return std::move(Resource(p));
}

int main() {
Resource&& r2 = createResource(); //直接操作右值,不会触发移动语义
}

从上面的2个实例可以看出,移动语义有2种常见的使用范式:

1.函数返回就返回普通变量,然后在需要使用移动语义时,手动使用std::move()将这个值转成右值引用,进而调动移动构造函数,触发移动语义

1
2
cv::Mat a(100,100,CV_8UC1); // 左值
cv::Mat c = std::move(a); // 将左值转成右值引用,触发移动语义

2.函数return一个右值引用,这样的话因为返回值是cv::Mat所以会触发一个移动构造函数,如果不这样的话,应该是一个拷贝构造函数,我觉得差不多,这样反而难以理解,所以最好别用这个方式了

1
2
3
4
5
6
7
8
cv::Mat getMat()
{
cv::Mat a(100,100,CV_8UC1);
return std::move(a);
}

cv::Mat a = getMat(); // 左值
cv::Mat c = std::move(a); // 将左值转成右值引用,触发移动语义

对函数形参做移动语义的时候要格外小心,特别是传入的是左值引用时

你传入的这个形参别的地方可能也要用,你这里用移动语义的话,就相当于抢占了这个资源,可能会引发很多问题