【C++11】右值引用与移动构造、万能引用与完美转发

本文详细介绍了C++11引入的右值引用概念,包括左值引用和右值引用的区别,以及右值引用在减少拷贝、提高效率方面的意义。移动构造和移动赋值用于优化对象构造和赋值过程,避免不必要的深拷贝。此外,文章还讨论了默认成员函数、`default`和`delete`关键字的作用,并展示了STL中如何利用右值引用提高效率。最后,介绍了模板中的万能引用和完美转发技术,用于在参数传递中保持原始类型的属性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

一、右值引用

1.1 左值引用和右值引用

1.2 左值引用与右值引用比较

1.3 右值引用的使用场景和意义

二、移动构造

2.1 移动构造的实现

2.2 移动赋值

2.3 默认成员函数

2.4 default关键字

2.5 delete 关键字

2.6 STL中的移动构造

二、完美转发

2.1 模板中的万能引用

2.2 完美转发


一、右值引用

1.1 左值引用和右值引用

传统的C++语法中就有引用的语法,而C++11中新增了右值引用语法特性,所以从现在我们之前学习的引用就叫做左值引用。

其实,无论左值引用还是右值引用,都是给对象起别名。

什么是左值?什么是左值引用?

        左值是一个表述数据的表达式(如变量名或解引用的指针),我们可以获取它的地址+可以对它赋值,左值可以出现赋值符号的左边,右值不能出现在赋值符号的左边。定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址。

        左值引用就是给左值的引用,给左值取别名。

左值:可以取地址 (最重要的特征)

左值还可以对其进行赋值,但是 const 修饰的变量不能再次进行修改,不过 const 修饰的后的左值也仍然是左值。

左值引用,即引用左值:

那什么是右值?什么是右值引用?

        右值也是一个表示数据的表达式,如:字面常量,表达式返回值,函数返回值(不能是左值引用返回)等等。右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边,右值不能取地址。

        右值引用就是对右值的引用,给右值起别名。

常见的右值  --- (不能取地址,不能出现在赋值符号左边):

右值引用就是对右值的引用,给右值起别名。就是在这种情况下使用的:

需要注意的是右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址,就比如上图中可以将 rr1重新赋值并且取地址,本质上rr1出现在赋值符号的左边,rr1 本质上成为了一个左值。

1.2 左值引用与右值引用比较

左值引用总结:

  1. 左值引用只能引用左值,不能引用右值。
  2. 但是const左值引用既可以引用左值,也可以引用右值。

为什么const左值可以引用呢?

        左值引用后,可以通过该变量改变引用的右值。所以加上const,该变量就不能被改变,所以左值引用就能引用右值,是利用了权限的缩小进行的引用。

右值直接使用右值引用接收就行,为什么要允许 const 左值能引用右值?这个规定为什么会存在呢?

        我们都知道,函数传参会生成临时变量,有时我们为了减少拷贝,会将函数参数设置为引用传参,那如果指写成普通的引用传参,那今后我们调用该函数,只能传递左值,无法传入右值。那在C++11之前,没有右值引用,只能使用const引用传参来接收实参。

 所以,当传入引用传参时,建议加上const。

右值引用总结:

  1. 右值引用只能引用右值,不能引用左值。
  2. 但是右值引用可以引用 move 后的左值(当需要右值引用引用一个左值时,可以通过move函数将左值转化为右值)。

1.3 右值引用的使用场景和意义

左值引用解决了哪些问题:

  1. 做参数。
    1. 减少拷贝,提高效率。
    2. 作输出型参数。
  2. 做返回值
    1. 减少拷贝,提高效率。
    2. 引用返回,可以返回修改的对象(如map中的operator[ ])。

前面我们看到左值引用既可以引用左值又可以引用右值,那么为什么C++11还要提出右值引用呢?是不是化蛇添足呢?下面我们来看看左值引用的短板,右值引用是如何补齐这个短板的!

左值引用的短板:

当函数返回对象是一个局部对象,出了函数作用域就不存在了,就不能使用左值引用返回,只能传值返回。例如:string to_string(int value)函数中可以看到,这里只能使用传值返回,传值返回会导致至少依次拷贝构造(如果是一些旧的编译器可能进行两次拷贝构造)

 再比如说例题杨辉三角:118. 杨辉三角

返回的对象也不能使用左值引用返回,因为我们创建的数组是局部变量,函数调用完就销毁了,所以只能进行拷贝返回。而这个二维数组的拷贝返回消耗就非常大了。

 那写下来我们探究一下 to_string(int value) 接口返回不使用引用,对象被拷贝了几次。

参数-fno-elide-constructors是关闭g++所有的编译优化。

发现如果不加上引用,聪明的编译器不加以处理的话,那 to_string(int value)这个返回值会被拷贝两次。

这两次拷贝的时机与原因,详见下图:

二、移动构造

2.1 移动构造的实现

所以我们应该对右值属性的返回采取一些措施,来提高程序的运行效率。

右值分为:

  1. 内置类型右值,又称为纯右值,比如 x+y 的结果。
  2. 自定义类型右值,又称为将亡值,比如 局部对象 str 作返回值时。

我们可以为 string 类提供一个移动构造接口,不去调用拷贝构造。即,当将亡值作为返回值返回时,不要进行深拷贝,而是进行资源的转移。

移动构造的本质就是及那个参数右值的资源窃取过来,占位己有,那么就不用做深拷贝了,所以它叫做移动构造,就是窃取别人的资源来构造自己。

// s1.swap(s2)
void swap(string& s)
{
	::swap(_str, s._str);
	::swap(_size, s._size);
	::swap(_capacity, s._capacity);
}

// 移动构造
string(string&& s)
	:_str(nullptr)
	, _size(0)
	, _capacity(0)
{
	cout << "string(string&& s) -- 移动构造" << endl;
	swap(s);
}

接下来我们写两句代码来验证,如果添加了移动构造,对于右值数据进行构造对象,会出现什么场景:

int main()
{
    Brant::string str1("hello,Brant");
    cout << "str1构造完成" << endl;
    cout << "--------------" << endl;
    Brant::string str2(str1);
    cout << "--------------" << endl;
    Brant::string str3(move(str1));
    cout << "--------------" << endl;
    Brant::string ret = Brant::to_string(-3456);
    return 0;
}

发现str3的构造使用的是移动构造,并且to_string返回右值时调用的也是移动构造。

注意:

        不要轻易 move 一个左值属性的数据。

我们将 srt1 进行move的前后对比,我们来看 str1 的情况:

 move之后:

所以,添加移动构造后,同一段 to_string(int value) 的函数返回,就会是不同的调用情况:

(注意,如果是拷贝构造,拷贝构造是深拷贝,拷贝构造会根据右值创建一个新的对象给临时变量;而移动构造只是拿着右值的数据,将数据进行了转移。差别自然是很大的)

即,像上图举例的传值返回的情况中,我们就可以设计移动构造来提高效率。

 当没有移动构造接口时,程序为什么会走拷贝构造?

        因为拷贝构造参数是const 左值引用,const 左值引用既能被左值调用,也能被右值调用。

2.2 移动赋值

不仅仅有移动构造,还有移动赋值:

再我们自己的string类中,再去调用to_string(1234),不过这次是将to_string(1234)返回的右值对象赋值给 ret1 对象,这时调用的是移动赋值。

// 赋值重载
string& operator=(const string& s)
{
	cout << "string& operator=(string s) -- 深拷贝" << endl;
	string tmp(s);
	swap(tmp);
	return *this;
}

// 移动赋值
string& operator=(string&& s)
{
	cout << "string& operator=(string&& s) -- 移动赋值" << endl;
	swap(s);
	return *this;
}
int main()
{
	Brant::string ret1;
	ret1 = Brant::to_string(1234);
	return 0;
}

2.3 默认成员函数

原来的C++11之前,类中有 6 个默认成员函数:

1.构造函数;2.析构函数;3.拷贝构造函数;4.赋值运算符重载函数(拷贝赋值)5.取地址运算符重载函数;6.const 取地址运算符重载函数;

重要的是前4个函数,后两个用处不大。默认成员函数即我们不书写编译器会默认生成的。

引入了右值引用后,C++11则添加了两个默认成员函数:移动构造函数与移动赋值(运算符重载)函数。

移动构造函数的注意点:

  1. 生成条件:自己实现了移动构造函数,编译器不会提供拷贝构造函数。如果没有实现移动构造函数,并且析构函数、拷贝构造函数、拷贝赋值函数都没有实现的话,那么编译器会自动生成一个移动构造函数。
  2. 执行方式:默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个类是否实现了移动构造,如果实现了就调用自身的移动构造函数,没有实现就调用拷贝构造函数。(默认移动构造与移动赋值执行方式相似)。

移动赋值函数的注意点:

  • 生成条件:自己实现了移动构造函数,编译器不会提供拷贝构造函数。如果没有实现移动构造函数,并且析构函数、拷贝构造函数、拷贝赋值函数都没有实现的话,那么编译器会自动生成一个移动构造函数。
  • 执行方式:默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节进行拷贝,自定义类型成员,则需要看这个类是否实现了移动赋值函数,如果实现了就调用自身的移动赋值函数,没有实现就调用拷贝赋值函数(默认移动赋值与移动构造执行方式相似)。

接下来我们添加一个Person类,类中使用我们自己实现的string类,便于观察。

class Person
{
public:
	Person(const char* name = "", int age = 0)
		:_name(name)
		, _age(age)
	{}
private:
	Brant::string _name;
	int _age;
};

int main()
{
	Person s1;
	Person s2 = s1;            //拷贝构造
	Person s3 = std::move(s1); //移动构造
	Person s4;              
	s4 = std::move(s2);        //移动赋值
	return 0;
}

运行结果如下:

因为我们没有实现移动构造,并且没有实现析构、拷贝、拷贝赋值函数,所以编译器会默认生成的移动构造,该移动构造会去调用自定义类型的移动构造,

 如果我们添加一个析构函数,则不会默认生成移动构造,则结果会统统调用拷贝构造。

2.4 default关键字

C++11可以让你更好的控制要使用的默认函数。假设你要使用某个默认的函数,但是因为一些原因这个函数没有默认生成。比如下面这个场景:

如果我们实现析构、拷贝构造、拷贝赋值中的一个,仍然想让编译器为我们生成移动构造或移动赋值,我么可以使用default关键字来让编译器强制生成。

 但是!如果强制生成了移动构造,那析构、拷贝构造、赋值拷贝、移动赋值这些函数就不会被编译器默认生成!

所以,我们还要将要使用的函数也进行强制默认生成处理:

关于 default 关键字,其实并不会应用在上面这种场景,而常应用在:

比如某些类涉及深拷贝,所以书写了拷贝构造,这种就会导致默认构造函数无法生成,因为拷贝构造也算是一种构造,所以我们可以使用default关键字让编译器强制生成构造函数。

2.5 delete 关键字

如果能想要限制某些默认函数的生成,在C++98中,将该函数设置称为private属性,并且不进行定义,这样只要其他人想要调用就会报错。在C++11中更简单,只需要在该函数声明后加上 delete 关键字即可,该语法指示编译器不生成对应函数的默认版本,称=delete修饰的函数为删除函数。

接下来我们来设计一个特殊类

  •  该类只能在堆上创建对象,不能在栈或静态区等空间创建。

那我们就可以使用 delete 关键字删除该类的析构函数,这样对象就不在栈上或静态区中创建了,而只能使用 new 创建,而 new 创建就是在堆上创建对象。 

 那问题又来了,如果该类中会开辟额外的空间,我们将析构函数删除了,则会导致内存泄漏问题,那应该如果处理呢?如下图:

这样的话我们只能额外提供一个 Destroy 函数用于释放开辟的空间。但是Destory 函数也只能释放类额外开辟的空间,那类本身的空间该如何释放呢?

故我们可以使用 operator delete 函数释放类本身的空间。

关于operator delete 和 delete 的区别可以看这篇博客:C++的delete以及operator delete重载。

 到此,这个类的对象就只能在堆上创建了,并且处理好了其空间释放的问题。

2.6 STL中的移动构造

STL容器插入接口函数也增加了右值引用版本:

vector、list的push_back函数

 

 此时库中的push_back也支持了右值进行插入,这样其插入就会调用移动构造,效率也是得到了极大的提升。

二、完美转发

2.1 模板中的万能引用

比如下面这段有着模板参数的函数:

注意:一定要是模板才能触发引用折叠!!!

template<typename T>
void PerfectForward(T&& t)
{
 Fun(t);
}

t 既能引用左值,也能引用右值。这种现象也被称作为引用折叠,如果传来左值,那两个引用就会变成一个引用,就会进行左值的处理,如果传入的是右值,也会进行左值的处理。

下面是一段验证的代码(无论是左值还是右值都会被折叠为左值):

  • 模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。
  • 模板的万能引用只是提供了能够接收同时接收左值引用和右值引用的能力,
  • 但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值,
  • 我们希望能够在传递过程中保持它的左值或者右值的属性, 就需要用我们下面学习的完美转发

2.2 完美转发

而完美转发会在传参过程中保留对象原生类型的属性。

添加完美转发之后的结果:

完美转发在实际中的使用场景:

例如在我们自己编写的 list 中,想让右值数组走专门处理右值的函数,就不得不使用完美转发,因为在将数据的传参过程中,万能引用会将其都退化为左值属性,所以我们可以将关键的传参处加上完美转发。

template<class T>
struct ListNode
{
	ListNode* _next = nullptr;
	ListNode* _prev = nullptr;
	T _data;
};
template<class T>
class List
{
	typedef ListNode<T> Node;
public:
	List()
	{
		_head = new Node;
		_head->_next = _head;
		_head->_prev = _head;
	}
	void PushBack(T&& x)
	{
		//Insert(_head, x);
		Insert(_head, std::forward<T>(x));
	}
	void PushFront(T&& x)
	{
		//Insert(_head->_next, x);
		Insert(_head->_next, std::forward<T>(x));
	}
	void Insert(Node* pos, T&& x)
	{
		Node* prev = pos->_prev;
		Node* newnode = new Node;
		newnode->_data = std::forward<T>(x); // 关键位置
		// prev newnode pos
		prev->_next = newnode;
		newnode->_prev = prev;
		newnode->_next = pos;
		pos->_prev = newnode;
	}
	void Insert(Node* pos, const T& x)
	{
		Node* prev = pos->_prev;
		Node* newnode = new Node;
		newnode->_data = x; // 关键位置
		// prev newnode pos
		prev->_next = newnode;
		newnode->_prev = prev;
		newnode->_next = pos;
		pos->_prev = newnode;
	}
private:
	Node* _head;
};

效果如下:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Brant_zero2022

素材免费分享不求打赏,只求关注

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值