C++面试难点系列-左右值/const引用

C++面试难点系列-左右值/const引用

  • 简介
  • 左右值概念
    • 取地址
  • 引用分类
    • 左值引用
    • 右值引用
    • const 引用
  • 右值引用存在的意义

简介

做一个简单的开篇,这部分主要介绍的是C++面试中经常会遇到的难点,左右值引用和const引用。后续还会讲一些其他的C++需要掌握,但是又比较难讲明白的知识点,帮助自己以及大家巩固这些理论基础知识。

左右值概念

在我们讲到后面的左右值之前,我先来给大家普及一下左右值的概念。
先看左值,左值从概念上来说就是能用在赋值语句等号左边的内容,而右值就是不能作为左值的值。在c++中一个值只可能是左值或者右值,不存在既是左值,又是右值。但是,有些人可能在这边就会有疑问,“那为什么有些左值可以出现在赋值等号的右边?”。关于这个问题,我们要搞清楚右值属性和右值的区别。一个左值可以拥有右值属性,也就是说可以有这种性质,但是并不是右值。
举一个简单的例子来说明一下左值和右值的区别:
左值就是一个有名字的稳固的房子,
右值是一个没有名字的房子,是个危房
左值具有左右值属性,左值属性就是这个房子的名字,右值就是这个具体的人,是对象的值。
比如说:

int a = 2;
&a;

上面这行代码来看,a是左值,因为这行代码的意思是我有一块内存,地址比如是0x01,这块地址上面要赋一个值为2,然后这个a就是这块内存的一个名字。
而对于这个2这个值本身来说,它可能一开始一会有一块内存存储,但是这块内存没有一个名字,所有这个2就是右值。

取地址

对于去地址来说,只能作用于左值。还是回到上面的例子,第二行对a进行取地址。实际就是取出对应的地址0x01,为什么我们说取地址只能作用在左值呢?因为&a本身的作用是取出a这块内存对应的地址,需要知道这块内存的名字我才能取到对应的地址。而右值是没有这个名字的,比如&1,这个1在内存中可能存在无数个,没有一个具体的标识,系统是无法分辨这个1具体是哪里的1的。

引用分类

引用的三种形式,左值引用,右值引用,const引用。

左值引用

直接看例子

int a = 2;
int &b = a;
int &c;
int &c = 1;

第二行b取引用,这个是没有什么问题的。从第一行可以看出,a是这块分配的内存的名字,2是赋值到这块内存的值。第二行将b取引用表示的就是给a指向的这块内存取了另外的一个名字。而第三行c是编译不通过的,因为这个c不是任何内存的名字。第四行也是错误的,因为1是个右值,而&c只能给左值取另外的名字。

右值引用

右值引用我们沿用上面的比喻,右值就是一个没有名字的危房,那么右值引用就是给没有名字的危房来取名字。取右值引用需要使用&&。
见例子:

int &&a = 1;
int b = 10;
int &&c = b;

第一行正确,因为1为右值,可以使用引用给这个右值取名为a,而第三行就会报错,因为b是左值,所以没有取右值引用。
我们再看一个例子:

#include 

using namespace std;

int main()
{
	int&& a = 10;
	int& b = a;
	int* p = &a;
	cout <<  &a << endl;
	cout <<  &b << endl;
	cout <<  *p << endl;
	return 0;
}

上述的程序的输出为
0053CC28
0053CC28
10
这里一个和上面不同的地方在于b是通过a进行赋值的,而不是直接赋值为10,a在取右值引用之后就是一个左值,因此赋值给b没什么问题。

const 引用

上面有说到int& a = 1;是会报错的,因为1本身为右值,但是如果前面加了const,就不会报错了,即const int& a = 1;
这个const相当于是加了一个保护,使得1这块内存的地址可以取名字。

右值引用存在的意义

上面通过一些例子简单介绍了三种引用的语法以及使用方法。左值引用和const引用我想大多数人应该用的比较多,理解起来也比较简单。而右值引用作为一个用的多但是真正理解比较困难的语法,需要单独拎出来讲解一下其存在的意义。
同样还是通过例子来看:

#include 

class Buffer {

};

Buffer getBuffer()
{
    Buffer buf;
    return buf;
}

void setBuffer(Buffer& buf)
{

}

int main()
{
    Buffer buf = getBuffer();
    setBuffer(buf);
    return 0;
}

这段代码可以看出,有一个Buffer类,一个getBuffer用于获取这个Buffer对象,一个setBuffer用于设置Buffer对象。上述代码这种写法,会涉及到两次对象的生成,一次出现在getBuffer内部的创建,因此出现在Buffer对象获取是临时使用一个对象赋值。有些比较敏感的同学可能会发现这样的使用方式是否有些多余。
那么我们怎么去油画这样的内存使用呢?
第一种方法,既然赋值多余。那么干脆就不用了。

int main()
{
	setBuffer(getBuffer());
    return 0;
}

这种用法确实会减少一次拷贝,但是getBuffer返回的对象是个将忘值,将亡值在这个函数调用结束之后就会被销毁,因此之前才会需要使用临时对象将这个值的内容拷贝出来。这时候除了使用临时对象,还可以使用const引用。const引用可以将这个将亡值的生命周期延长到和这个const引用一样的长度。因此这样调用就没什么问题,也不会增加拷贝。

void setBuffer(const Buffer& buf)
{

}

int main()
{
	const Buffer& buf = getBuffer();
	setBuffer(buf);
    return 0;
}

但是这就必须把setBuffer的参数设置为const类型,因为c++中不带const的可以转换为const,但是const不能转换为不带const。
因此setBuffer需要改成这样。这样的话,直接调用getBuffer也不会出问题了。

void setBuffer(const Buffer& buf)
{

}

说了这么多,下面终于要讲到重点了!!!
上面的例子可以看出,其实这个getBuffer的将亡值的生命周期很短,我们都在想办法延长这个将亡值的生命周期,因为这个将亡值内部的一些内容我们可以直接重用,这样就可以进一步的优化性能。但是const类型属于常量,原则上我们没法使用加了const的引用获取内容。并且函数setBuffer传入的值也无法判断这个值是否是一个将亡值。因此为了解决这些问题,就引入了右值引用。我们再写一个这样的重载函数。

#include 

class Buffer {
    Buffer() {
        char* buf = new char[100];
    }
    ~Buffer() {
        if (buf != nullptr) {
            delete[] buf;
            buf = nullptr;
        }
    }

    char* buf = nullptr;
};

Buffer getBuffer()
{
    Buffer buf;
    return buf;
}

void setBuffer(const Buffer& buf)
{
	cout << "const  value" << endl;
}

void setBuffer(Buffer&& buf)
{
	cout << "rvalue" << endl;
} 

int main()
{
	const Buffer& buf = getBuffer();
	setBuffer(buf);
	setBuffer(getBuffer());
    return 0;
}

上述代码中,setBuffer(buf);会调用const类型的setBuffer,而setBuffer(getBuffer());会调用右值的setBuffer,这样的话就可以获取到这个将亡值的内容,比如我们在内部这样使用。

void setBuffer(Buffer&& buf)
{
	char* buf1 = buf.buf;
	buf.buf = nullptr;
	cout << "rvalue" << endl;
} 

这样就获取到了将亡值内部的内存。当然我们也可以使用move函数将这个buf直接move到新的对象上。但是使用move之后之前buf就不能使用了。

void setBuffer(Buffer&& buf)
{
	Buffer buf1 = std::move(buf);
	cout << "rvalue" << endl;
} 

在c++11之后,当函数试图返回一个对象的时候首先会尝试的调用移动构造函数,移动构造函数找不到才会调用拷贝构造函数,这样的话可以减少拷贝对象带来的性能开销。

class Buffer {
    Buffer() {
        char* buf = new char[100];
    }
    
    Buffer(Buffer&& buf) {
    }
    
    ~Buffer() {
        if (buf != nullptr) {
            delete[] buf;
            buf = nullptr;
        }
    }

    char* buf = nullptr;
};

移动构造函数就是把这个将要销毁的资源进行重用来达到提升性能的作用。我们可以在移动构造函数中将这个将亡对象的内容给到本身对象来进行重用。

本次右值引用就到这,有错帮忙指正。

补充:
右值除了实现移动语义,还有完美转发的功能,主要是在函数模板中使用,可以将自己的参数完美的转发到内部调用的函数中。完美是指不仅能准确的转发参数的值,同时可以保证被转发的参数的左右值属性。
c++11之前无法调用右值引用的方法,c++11之后通过forward函数可以调用到右值引用的函数。
原理是借用万能引用也就是例子中的T &&,通过引用的方式接收左右值;引用折叠的规则为,参数为左值或者左值引用,则T &&转化为int &;如果为右值或者右值引用,T &&转化为 int &&。到这一步的话只是将函数的参数进行转发,但是调用的问题的还没解决,forward就是 用来解决调用的问题。forward调用时,如果T为左值,t会转化为T类型的左值;如果T为右值,t会转化为T类型的右值。也就是说forward的作用是解引用,将左右值的引用去掉。

void func1(int &n) {
	...
}

void func1(int &&n) {
	...
}

// c++11 之前
template <typename T>
void func(T t) {
	func1(t);
}
// c++11 之后
template <typename T>
void func(T &&t) {
	func1(std::forward(t));
}

int main()
{
    int a = 10;
    func(10); // 这个通过转化之后就会转化成int &&t; 调用func1
    func(a);  // 这个通过转化之后就会转化成int &t; 调用func1
	int i = 5;
	int &m = i;
	int &&n = 100;
	func(m);  // m是左值
	func(n);  // n也是左值
	func(static_cast<int &>(m));  // m是左值引用,强转
	func(static_cast<int &&>(n));  // n是右值引用,强转
}

你可能感兴趣的:(C++面试难点,c++)