目录
一、左值引用与右值引用
1. 左值和右值的概念
1.1 左值
1.2 右值
1.3 左值与右值的区分
2. 左值引用与右值引用
2.1 左值引用与右值引用的使用方法
2.2 左值引用的可引用范围
2.3 右值引用的可引用范围
3. 右值引用的作用
3.1 减少传值返回的拷贝
3.2 插入时的右值引用
4. 右值引用可以被修改
5. 右值引用后的值被视为左值
6. 完美引用
7. 完美转发
二、新的类功能
1. C++11中新增的默认成员函数
2. default关键字
3. delete关键字
一、左值引用与右值引用
1. 左值和右值的概念
1.1 左值
在以前,大家都应该学过引用,在C++中,引用还是比较常用的,因为它是给变量取别名,可以减少一层拷贝。但是,在C++11增加了右值引用后,我们以前所使用的引用都应该叫做“左值引用”。
左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址,也可以对它赋值。左值一般出现在符号的左边,但也可能出现做符号的右边。const修饰的左值,不能给他赋值,但是可以取它的地址。而左值引用,其实就是给左值取别名。
在以前,大家可能听说过“在赋值符号左边的就是左值”,或者“不能修改的就是左值”。这两种说法其实都是错误的。例如将一个变量的赋值给另一个变量时,该表达式的左右两个值都是左值;const修饰的左值,虽然也是左值,但是它却不允许修改。
如下图中的所有变量,其实都是左值。其中的“int* p1 = &d;”中的p1和d其实都是左值。
1.2 右值
右值也是一个数据的表达式。如字面常量、表达式返回值、函数返回值(不能是左值引用返回)等待。右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边。右值不能取地址。右值引用是对右值的引用,给右值取别名。
当然,不能简单的将右值理解为“在赋值符号右边的值”或“不能修改的值就是右值”。这两种理解都是错误的。例如在上面的左值的图中,就有在赋值符号右边,但是是左值的值;也有被const修饰无法被修改,但是是左值的值。
如下图中的都是一些常见的右值:
1.3 左值与右值的区分
要区分左值与右值,只需要记住一个标准:“左值可以取地址,右值不能被取地址”。这就是左值和右值的本质区别。
因为左值一般是被放在地址空间中的,有明确的存储地址;而右值一般可能是计算中产生的中间值,也可能是被保存在寄存器上的一些值,总的来讲就是,右值并没有被保存在地址空间中,也就无法取地址。
2. 左值引用与右值引用
2.1 左值引用与右值引用的使用方法
左值引用的方法很简单,就是大家以前所学习的引用,即在变量类型的右边加上一个“&”即可:
右值引用的使用也很简单,在变量类型的右边加上两个“&&”即可:
2.2 左值引用的可引用范围
一般来讲,左值引用只能引用左值。但是,经过const修饰的左值引用,既可以引用左值,也可以引用右值:
const左值引用可以引用右值的原因很简单。右值都是不能被修改的值,普通的左值引用是可以修改的,这就会导致权限的放大,因此会报错;而const左值引用是不能修改的,此时是权限平移,也就可以引用右值了。
2.3 右值引用的可引用范围
一般来讲,右值引用只能引用右值,不能引用左值。但是存在一种特殊情况,那就是右值引用可以引用move以后的左值:
3. 右值引用的作用
左值引用的作用我们都很清楚了,一般都是用于函数的返回值和参数等。在这些场景中,可以提高效率和减少拷贝。大家都很清楚,这里就不再赘述了。
既然有了左值引用,那右值引用的作用是什么呢?其实,右值引用的作用之一,就是补齐左值引用的缺陷。
3.1 减少传值返回的拷贝
要了解右值引用的这个作用,我们就要先看看左值引用原来存在的问题。
一般来讲,左值引用就是用于当返回值或做函数参数,例如在下面的函数中:
在上图中,有一个函数模板,它的参数和返回值都是const T&,从这里看,并没有什么问题,使用const T&做参数,也解决了右值不能被左值引用的问题。
但是,在上图中返回的x在func()函数结束后并不会被销毁。如果,我们想返回的值是func()函数的作用域内,出了作用域就会被销毁的局部变量,此时该变量就不能用左值引用的方式返回。如下图:
如果运行上面的程序,就会出现报错。因为func()函数返回的变量出了作用域就会被销毁,如果用左值引用,就会出现非法寻址。在这种情况下,如果只有左值引用,就不能使用传引用返回,而必须使用传值返回。在上面的图中返回的值仅仅是一个变量,如果在未来,这个返回值是一个需要深拷贝二维数组或者其他包含了大量数据的数据结构,如set或map。此时使用传值返回就需要进行大量的拷贝,代价很高。
第一个解决方案就是,我们可以通过new一个对象来解决这个问题。但是new出来的对象是需要自己释放的,如果不释放就会导致内存泄漏。因此,这样写虽然可以解决问题, 但是却有内存泄漏的隐患,毕竟new的对象太多时,我们不一定能记得所有需要释放的对象。
而第二个解决方案就是, 我们可以给该函数多加一个输出型参数来接收:
虽然加输出型参数不失为一个解决方案,但是使用起来却并不方便。
那如果我们既不想new对象,也不想传输出型参数狙解决拷贝的问题呢?这里其实就可以用“右值引用”。当然,要解决这个问题,并不是单单将返回值修改为右值引用即可:
原因有两个。一个是此处返回的ret是一个左值,它是无法被右值引用的;而最重要的原因在于,此处的ret是一个局部变量,出了作用域就会销毁。左值引用不起效是因为它无法解决局部变量出了作用域会被销毁的问题。右值引用是引用右值的别名,同样无法解决这一问题。
为了更好的解释右值引用的作用,先准备以下一个类:
#pragma once
#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <string.h>
#include <assert.h>
using namespace std;
namespace mystring
{
class string
{
public:
typedef char* iterator;
//构造与析构相关函数
//构造与析构相关函数
string(const char* str = "")//字符串及无参构造
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
string(const string& s)//拷贝构造
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "深拷贝" << endl;
string tmp(s._str);
swap(tmp);
}
string& operator=(const string& s)//“=”重载
{
cout << "拷贝赋值" << endl;
string tmp(s);
swap(tmp);
return *this;
}
~string()//析构函数
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
iterator begin()//iterator的begin()迭代
{
return _str;
}
iterator end()//iterator的end()迭代
{
return _str + _size;
}
string& operator+=(char ch)//运算符+=重载
{
push_back(ch);
return *this;
}
void swap(string& s)//类的字符串交换
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
void reserve(size_t capacity)//空间扩容
{
char* str = new char[capacity + 1];
strcpy(str, _str);
delete[] _str;
_str = str;
_capacity = capacity;
}
char& operator[](size_t pos)//[]运算符重载
{
assert(pos < _size);
return _str[pos];
}
void push_back(char ch)//单字符插入
{
if (_size == _capacity)//容量不够扩容
{
size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(newcapacity);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
string to_string(int value)//数字转字符串
{
bool flag = true;
if (value < 0)
{
flag = false;
value = 0 - value;
}
string str;
while (value > 0)
{
int x = value % 10;
value /= 10;
str += ('0' + x);
}
if (flag == false)
{
str += '-';
}
std::reverse(str.begin(), str.end());
return str;
}
}
这个string类里面仅保留的构造,拷贝构造和赋值,并且在调用拷贝构造和赋值时会打印一条语句,便于接下来的试验。
在这个类的基础上,再搭配一个to_string()函数:
有了上面的代码后,我们再写出以下代码:
用我们自己写的一个类进行构造。to_string()函数在返回时,一般来讲会需要进行两次拷贝。因为值返回的时候并不是直接返回,而是会生成一个临时变量,将要返回的值拷贝给临时变量,然后再将临时变量的值拷贝给接收对象。
但是,这种方式返回时,就会平白无故多一次拷贝,因此编译器会对这一行为进行优化,不再生成临时拷贝,而是直接将返回值拷贝给接收对象。
当然,编译器并不会对所以返回值都进行优化。因为这种优化一般是将一个栈帧中的值拷贝给另一个栈帧中的值。如果这个值不在栈帧中,例如这个值是一个全局变量,那么编译器就无法优化。 当然,还有一些情况不能优化,例如下图:
总的来讲,如果编译器无法优化,就会生成一份临时拷贝。
但是,这里依然没有完全解决问题,因为哪怕进行了优化,依然需要进行一次拷贝构造,如果返回的对象有大量数据,依然会造成效率损失。
因此,就有了另一种处理方案,就是“右值引用”。例如,在类中提供一个右值引用为参数的拷贝构造。由于编译器的匹配机制,就会将一些传右值进行构造的对象,调用右值引用的拷贝构造。右值拷贝又分为两种:
(1)普通的右值,被称为纯右值
(2)即将被销毁的右值,被称为将亡值
在右值引用中,如果某些右值是将亡值,即在完成交换后就会被销毁的值,此时就可以直接将双方的值进行交换。
在这里,因为是直接交换两个对象中的数据,所以不会进行拷贝,只会交换指针。通过这种方式,就可以在数据量非常庞大的情况下提高效率。
但是,这种拷贝不要随意使用。因为它是将两个对象的值进行交换,如果使用不当,就可能导致变量的值被修改。如下图:
在这个程序里面,构造了s1,和s2。s2在构造的时候,将s1转化为了右值,此时它就会去调移动拷贝构造。但是,移动拷贝构造中是交换双方的值。这就会导致s1的数据被修改:
因此,这种使用右值引用移动构造的方法,只能适用于将亡值。因为将亡值马上就要被销毁,哪怕交换数据后也不会造成什么影响。
既然拷贝构造可以采用移动构造,那么拷贝赋值也是可以采用移动赋值的:
在编译器中,在对返回值进行优化时,虽然返回值是左值,但是编译器会将返回值识别为右值,然后采用交换数据的方式来避免拷贝。
由此,左值引用和右值引用虽然都可以减少拷贝,但是它们的原理并不一样。
左值引用是别名,直接起作用。而右值引用是间接起作用,实现移动拷贝和移动赋值,在拷贝的场景中,如果是右值(将亡值),就转移资源。
3.2 插入时的右值引用
在插入时,也是可能需要进行拷贝构造的。例如如下程序:
在这里,用的是我们自己写的string,所以在插入中进行拷贝构造时,会调用我们自己写的拷贝构造。在这里,提前将移动拷贝屏蔽,运行该程序:
在只有拷贝构造的情况下,此时无论传的是左值还是右值,都是调用拷贝构造进行深拷贝。
但是,如果将移动拷贝放出来,再次运行该程序:
同样的程序,此时其中传入右值插入的就会调用移动拷贝,在处理大量数据的情况下,就会有很高的提升。
如果没有移动拷贝,在插入时就会为传入的字符串单独开辟一块空间,然后再将这片空间中的内容拷贝到指定的位置。但是有了移动构造后,传入的右值就是直接将其与指定位置的数据进行交换。因为传入的右值在被使用时都被视作临时变量,使用完就销毁。在这种情况下,就可以使用移动拷贝提高效率。
因此,在很多容器的插入接口中,也都提供了右值引用的插入版本:
4. 右值引用可以被修改
大家知道,右值是不能被修改的。但是,右值经过右值引用后,其实就可以被修改:
原因是右值不能被修改,是因为右值原本在地址空间上是没有地址的。但是在被右值引用后,右值会被存储在栈上,这也就导致右值在地址空间上有了地址,因此,此时右值就可以被修改。
基于右值被右值引用后可以被修改的特性,便有了const的右值引用:
当对右值引用加上const修饰后,该右值就无法再被修改。
其实这一特性的存在原因很简单。在上面我们讲了可以通过右值引用,将将亡值的数据与接收对象的数据交换。而将亡值是右值,如果将亡值没有明确的地址,那它就无法和接收对象的数据完成交换。
要注意,虽然右值引用后右值可以被修改,但它修改的并不是对应的常量,而是在对应地址上存储的值。因为常量是不允许修改的。
5. 右值引用后的值被视为左值
在上文中,我们已经介绍过,在stl容器中的插入函数都实现了右值引用的版本。在这里我们也可以先尝试一下让自己写的list提供右值引用的插入。
先写以下测试程序:
在这个程序里面,用的是stl的list。运行程序:
进行了三次移动拷贝。再将这个list换成我们自己写的list并运行:
可以看到,在换成我们自己写的list后就是进行了4次深拷贝。在数据量够大的情况下,效率差距就会非常大。
因此,在这里,我们就需要对我们自己写的list进行改造,使其提供右值引用的插入版本:
将程序修改完后,我们再次运行程序:
可以看到,虽然我们已经在list中提供了右值引用的版本,但是在插入时,依然是“深拷贝”。上文中讲了,右值经过右值引用后就可以被修改。既然右值已经可以被修改了,就说明它不再是一个右值,而是一个左值。因此,一个右值经过右值引用传参后,其实它再次被使用时,是被当做一个左值来使用的。这就会导致在list中除了最开始的push_back(),其他函数都是调用的左值引用的函数。
如果想保持它的右值属性,可以给每个右值引用的函数中将左值用move()强制转为右值:
此时再运行程序:
此时就成功实现了移动拷贝。
6. 完美引用
在上文中说了,当一个右值被右值引用后,当它被再次使用时,它就不再是右值,而是左值。因此,在二次使用时如果想将其当做右值使用,就要用move()将其转换为右值。
但是,如果我们传入的右值被二次使用时是右值,就可以使用“完美引用”。
“完美引用”只能用于有模板的情况。如果没有模板,则无法使用:
在上面的程序中,分别提供了左值引用和右值引用的func()函数。该程序也根据传入的左值或右值调用了不同的函数。但如果将左值引用的函数去掉:
可以看到,此时就会报错。因为右值引用是无法引用左值的。
但是,如果这个函数有右值,则会不同:
当有模板时,这个函数就既可以接收左值,也可以接受右值了。
这种有模板的右值引用,就被叫做“完美引用”。当传入的值是左值时,编译器就会折叠一个“&”,将其看做“左值引用”;当传入的值是右值时,就正常使用,将其看做“右值引用”。
7. 完美转发
“完美引用”虽然在引用的时候,编译器可以根据它是左值还是右值取进行左值引用或右值引用,但也仅仅是在引用的时候可以识别。在引用过来进行二次使用后,它依然会将其统一看做“左值”:
在上面的程序中,虽然我们传过去的值既有左值也有右值,但在其被二次传递时,就被视作了左值。于是全部都是调用左值引用。
当然,我们也可以在二次传递时加上move()将其转为右值:
通过move()的方式,就会全部都进行右值引用。
但是,如果我们想让传入的左值和右值保持原来的属性呢?就是说传入的是左值,在二次传递时就传递左值;如果传入的是右值,在二次传递时,就传递右值。此时,就可以使用“完美转发”。
“完美转发”可以在传参的过程中保留对象原生类型属性。要使用时,直接使用“std::forward<>”即可:
在forward<>后面的变量要用“()”包裹起来,否则可能出现编译错误。
二、新的类功能
1. C++11中新增的默认成员函数
大家知道,C++中有六大默认成员函数,分别是构造函数、拷贝构造函数、拷贝赋值重载、析构函数、取地址重载和const取地址重载。其中最重要的就是前四个。这些默认成员函数如果我们不自己写,编译器就会帮我们自动生成。
在C++11后,类中又新增了两个默认成员函数:移动构造函数和移动赋值重载。
移动构造函数和移动赋值重载,虽然它们也可以被编译器自动生成, 但是与原来的六大默认成员函数的只要不写就自动生成不同,这两个默认成员函数要自动生成是有一定条件的。
如果我们没有实现移动构造函数或移动赋值重载函数,且没有实现拷贝构造、拷贝赋值重载和析构函数中的任意一个,编译器就会自动生成一个移动构造函数或移动赋值重载函数。
默认生成的移动构造函数和移动拷贝函数,对于内置类型会执行逐成员按字节拷贝;对于自定义成员,如果它实现了移动构造和移动赋值重载,就调用它的移动构造和移动赋值重载。没有实现,则掉用拷贝构造和拷贝赋值。
注意:如果你提供了移动构造或移动赋值,编译器就不会自动提供拷贝构造和拷贝赋值
2. default关键字
根据上文中的内容可以知道,移动构造和移动赋值的默认生成条件非常苛刻,必须要保证拷贝构造函数、赋值重载函数和析构函数都没有实现才会自动生成。但如果在某些情况下,必须要自己实现在这三个默认成员中的一员,这就意味着如果想用移动构造和移动赋值,我们就必须要自己写。
但是,这样就会很烦。因此,当遇到必须要实现拷贝构造、赋值重载和析构函数中的任意一员,编译器自动提供的移动构造和移动赋值又满足需求的情况下,就可以使用default关键字。
该关键字可以强制生成某个默认成员函数:
可以看到,在上面的程序中,我们并没有自己写移动构造,但是在用了default关键字强制生成移动拷贝后,依然可以正常使用
3. delete关键字
在C++11之前,如果不想让类中的某个默认成员函数被调用,就会将它放在私有成员中。
但是这种方法也是有缺陷的。将默认成员函数放在私有中,虽然可以避免外部调用,但是却无法避免内部调用:
可以看到,虽然拷贝构造是私有,但是却无法阻止类内部的拷贝构造。存在缺陷。当然,我们也可以将对应的默认成员函数在类中“只声明不实现”,这样在调用时就会无法找到定义报错。
在C++11之后,就可以使用delete关键字实现这一操作了。
与default关键字强制生成某个默认成员函数不同,delete关键字可以限制某些默认函数的生成。要禁止某个默认成员函数,在该默认成员函数的声明后面加上“=delete”即可。被=delete修饰的函数称为删除函数。
文章来源:https://www.toymoban.com/news/detail-426300.html
被delete所修饰的默认成员函数,无论是类外还是类内部,都无法使用文章来源地址https://www.toymoban.com/news/detail-426300.html
到了这里,关于初识C++之左值引用与右值引用的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!