👦个人主页:@Weraphael
✍🏻作者简介:目前学习C++和算法
✈️专栏:C++航路
🐋 希望大家多多支持,咱一起进步!😁
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注✨
目录
- 前言:STL中一些变化
- • 新容器
- • 容器中的一些新方法
- 一、如何判断左值和右值
- 二、左值引用和右值引用
- 三、左值引用 vs 右值引用
- 四、move函数
- 五、 右值引用使用场景
- 5.1 场景一:移动语义
- 5.2 场景二:STL容器插入接口函数
- 5.3 场景三:完美转发
- 5.3.1 万能引用
- 5.3.2 完美转发保持值的属性
- 六、相关代码
前言:STL中一些变化
• 新容器
在C++11
中新增了以下几个容器(用橘色圈起来):
实际上最有用的是哈希系列unordered_map
和unordered_set
。
剩下的容器array
和forward_list
非常鸡肋,实际上很少使用。
array
容器
文档介绍:点击跳转
在C++11
标准中,引入了一个容器array
,它的底层使用了非类型模板参数,是一个真正意义上的泛型数组(定长数组),这个是用来对标C语言传统数组的。
以下是array
容器的基本用法:
看完以上接口,array
支持的,数组也都是支持的。那么它们有什么区别呢?
- 相同点:
array
也并没有进行初始化。
- 要说有区别的话:
array
对于越界读、写检查更为严格;传统数组越界读写,不会发生报错
【吐槽】虽然对越界行为检查严格 ,但在实际开发中,很少使用array
容器,因为它对标传统数组,连初始化都没有,并且大小也是固定的,因此不够灵活。
相比之下,vector
也是类似于数组的容器,它允许动态改变大小、对于越界读和写检查也一样严格。因此,在功能和实用性上可以全面碾压array
,因此可以说array
是一个鸡肋的容器。
forward_list
容器
文档介绍:点击跳转
以下是forward_list
的常见接口:
forward_list
翻译过来就是单链表,因此一个结点只存值和指向下一个结点的指针。算了,直接开始喷(吐槽)吧。首先先说结论:forward_list
还是一个非常鸡肋的容器。
- 从以上接口可以看出,它只支持头删
pop_front
和头插push_front
。为什么不支持尾删和尾插呢?因为它效率低啊!尾插需要找到尾结点、尾删需要找到尾节点的前一个结点,这些操作都要从头部开始向后遍历,时间复杂度铁铁的O(N)
- 另外,
forward_list
还不提供size()
接口
如果要说forward_list
有优势,那就是内存占用更小(每个结点节省了一个前驱指针),但是它还是比较鸡肋,因此在实际中使用list
会更多一点。
• 容器中的一些新方法
如果我们再细细去看会发现基本每个容器中都增加了一些C++11
的方法,但是其实很多都是用得比较少的。
比如cbegin
和cend
这玩意其实也很鸡肋,因为普通版的begin
和end
都已经重载了const
版本。对于C++
开发人员还是区分得开的。
有坏当然也有好的方面,比如:
- 所有容器均重载了
initializer_list<T>
,使容器初始化更加方便。 - 所有容器均支持移动构造和移动赋值,可以极大提高效率(本篇重点)
- 所有容器均支持右值引用相关插入接口,同样可以提高效率(本篇重点)
一、如何判断左值和右值
要想搞懂左值引用和右值引用,首先要得明白什么是左值和右值。很多人认为在赋值符号左边的就是左值,在赋值符号右边的就是右值。 这其实并不完全正确,比如:
int main()
{
int a = 1; // a是左值
int b = a; // a又变成右值?
}
所以,以上变量a
到底是左值还是右值?(答案是:左值)
- 交给大家简单粗暴的判断左值的方法:可以取地址就是左值。
举个例子,以下是常见的左值:
- 如何判断右值?
右值通常被认为是临时的、一次性的值或者是将亡值。 右值可以出现在赋值符号的右边,但是绝不能出现出现在赋值符号的左边。就这么说吧,只要 不能取地址的就是右值。常见的右值有:字面常量、表达式返回值,函数返回值(临时对象返回)等。
二、左值引用和右值引用
大家只要记住一句话,不管是什么引用,都是取别名。左值引用就是对左值取别名,右值引用就是对右值取别名。
- 首先先来看看左值引用的例子
int main()
{
// 以下的p、b、c、*p都是左值
int* p = new int(0);
int b = 1;
const int c = 2;
// 以下几个是对上面左值的左值引用
int*& rp = p;
int& rb = b;
const int& rc = c;
int& pvalue = *p;
return 0;
}
由上可以看出,左值引用就是C++
刚入门学的那个引用,唯一有区别的还是右值引用。
- 用两个
&&
表示右值引用。
double Min(double x, double y)
{
return x > y ? y : x;
}
int main()
{
// 以下几个都是常见的右值
10;
1.1 + 2.2;
Min(1.1, 2.2);
// 以下几个都是对右值的右值引用
int&& rr1 = 10;
double&& rr2 = 1 + 2;
double&& rr3 = Min(1, 2);
return 0;
}
三、左值引用 vs 右值引用
- 问题1:左值引用能否给右值取别名?
正常来说是不行的,但由于 右值都具有常属性,因此用const
引用即可。
- 问题2:右值引用能否引用左值?
编译器已经给出很明确的报错信息了:无法将右值引用绑定到左值。
但右值引用可以引用move
以后的左值。点击跳转
既然左值引用即可以引用左值,也能引用右值。那么以下情景是否构造函数重载?是否存在调用歧义呢?
答案是当然构成重载,编译器会选择最匹配的参数调用。
四、move函数
-
虽然右值引用不能直接引用左值,但是可以通过调用一个名为
move
函数来获得绑定到左值上的右值引用。 -
move
的主要作用是显式地标识对象为右值,以便编译器能够选择调用移动语义相关的操作,而不是进行拷贝操作。
int a = 0;
int&& rr = move(a);
可以这么理解:当右值引用 引用右值时,则是先将引用的对象的临时资源 “转移” 到特定位置(不会发生拷贝),然后指向该位置中的资源,此时可以对右值进行修改操作。
另外,虽然右值引用引用的是右值,但右值引用本身是可以取地址的
除此之外,语法还支持给右值引用加const
,这样做的含义是 不能修改右值引用后的值。
但我们一般建议不要用const
右值引用,因为使用右值引用就是为了“转移”资源,加了const
还不如直接改用const
左值引用。
注意:不要轻易使用move
函数,左值中的资源可能会被转走。如果此时我们直接将左值move
后构造一个新对象,会导致原本左值中的资源丢失。
五、 右值引用使用场景
5.1 场景一:移动语义
前面我们可以看到左值引用既可以引用左值也可以通过const
引用引用右值,那为什么C++11
还要提出右值引用呢?
既然提出了就一定是为了解决左值引用存在的缺陷,那么我们可以通过分析左值引用的使用场景及核心价值来推断。
【左值引用】
- 使用场景:1. 做输出型参数(形参的改变影响实参)。 2. 做返回值。
- 核心价值:减少拷贝,提高效率。
我们知道,左值引用做返回值是有一定的缺陷的!如果是左值引用做返回值,出了作用域,对象不能被销毁;如果出了作用域,对象被销毁,那么就不能使用左值引用做返回值。
(不知道为什么可以看看这篇博客:点击跳转)
string func()
{
string str("hello world");
return str;
}
int main()
{
string s = func();
cout << s << endl;
return 0;
}
str
是局部对象,出了func
函数作用域,对象销毁,那么就不能用左值引用返回,那么按照惯例只能使用传值返回。而 传值返回是有代价的,对于较大的对象(如大型结构体、类对象等),可能会导致较大的性能开销,因为它需要在内存中复制整个对象的内容。
接下来可以简单的来验证一下,下面是简单模拟实现的string
类,为了更好的观察是否发生了深拷贝行为,在 拷贝构造函数中加入了对应的打印语句。
string.h
#pragma once
#include <iostream>
#include <assert.h>
namespace wj
{
class string
{
public:
typedef char* iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
string(const char* str = "")
:_size(strlen(str))
, _capacity(_size)
{
_str = new char[_capacity + 1];
strcpy(_str, str);
}
void swap(string& s)
{
swap(_str, s._str);
swap(_size, s._size);
swap(_capacity, s._capacity);
}
// 拷贝构造
string(const string& s)
:_str(nullptr)
{
cout << "string(const string& s) -- 深拷贝" << endl;
string tmp(s._str);
swap(tmp);
}
// 赋值重载
string& operator=(const string& s)
{
cout << "string& operator=(string s) -- 深拷贝" << endl;
string tmp(s);
swap(tmp);
return *this;
}
~string()
{
delete[] _str;
_str = nullptr;
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
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';
}
//string operator+=(char ch)
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
const char* c_str() const
{
return _str;
}
private:
char* _str;
size_t _size;
size_t _capacity; // 不包含最后做标识的\0
};
}
Test.cpp
以上代码本来应该是两次拷贝构造,但对于一行的两次拷贝构造,新一点的编译器会优化成一次拷贝构造。虽然优化成一次拷贝构造,但是还是需要拷贝整个对象的内容,但是有很大的消耗。
因此,C++11
中允许使用移动语义之移动构造解决上述问题:在wj::string
中增加移动构造,移动构造本质是窃取别人的资源来构造自己,占位已有,那么就不用做深拷贝了(提高性能),所以它叫做移动构造。
- 移动构造
string(string&& s)
:_str(nullptr)
{
cout << "string(string&& s) -- 移动构造" << endl;
swap(s);
}
接下来再来看看结果:
那么问题来了,对象str
是一个左值啊,它是怎么调用移动构造函数的?
这其实是编译器的优化!当
Myfunc函数
返回一个wj::string
对象时,编译器会在其内部将str
视为右值,并使用移动构造函数来将其内容转移到 s 中。这样做可以避免不必要的拷贝操作,提高了程序的性能
接下来再来看,如果两次的拷贝构造不再同一行,编译器就不能实现优化,那么就是实打实的两次拷贝构造,那么这个消耗是巨大的。(在此之前我屏蔽了移动构造)
第三次打印的结果是无法避免的,因为在调用operator=
会重新开辟空间来深拷贝对象的资源。
因此,C++11
同时也引入了移动语义之移动赋值,用于在对象之间实现资源的高效转移。移动赋值运算符允许将一个对象的资源从另一个对象转移到自身,而不是通过拷贝构造或拷贝赋值运算符来进行资源的复制。
- 移动赋值
// 赋值重载
string& operator=(string&& s)
{
cout << "string& operator=(string& s) -- 移动赋值" << endl;
swap(s);
return *this;
}
接下来再将移动拷贝的代码取消注释,然后再来看看打印结果
综上,移动语义(移动构造 + 移动赋值)弥补了自定义类型中深拷贝的类,必须传值返回的场景。避免不必要的资源复制,提高了程序的性能和效率。
5.2 场景二:STL容器插入接口函数
C++11
标准出来之后,STL
中的容器除了增加移动构造和移动赋值之外,STL
容器插入接口函数也增加了右值引用版本。
那么右值引用版本插入函数的意义是什么呢?
如果list
容器当中存储的是string
对象,那么在调用push_back
向list
容器中插入元素时,可能会有如下几种插入方式:
-
对于第一个一定会完成深拷贝,因为
s
对象是左值,那么lt
对象在调用push_back
一定会选择最合适的,也就是void push_back (const value_type& val);
-
剩下的一定会调用
void push_back (value_type&& val);
。字符串字面量(如"11111111111111"
)时,它会调用右值引用版本的push_back
。这是因为字符串字面量是临时对象,是右值,而push_back
函数的右值引用版本接受右值参数(提高效率)。
5.3 场景三:完美转发
5.3.1 万能引用
在模板中的
&&
不代表右值引用,而是万能引用,其既能接收任意类型的左值和右值。
- 如果传入的实参是左值,那么编译器就会将模板实例化为左值引用,也称做引用折叠。
- 如果传入的实参是右值,那么编译器就会将模板实例化为右值引用。
【基本语法】
template<class T>
void PerfectForward(T&& t)
{
//...
}
下面重载了四个Func
函数,参数类型分别是左值引用、const
左值引用、右值引用和const
右值引用。在主函数中调用PerfectForward
函数时分别传入左值、右值、const
左值和const
右值,在PerfectForward
函数中再调用Func
函数。
我们来判断是否真的很“万能”
void Fun(int& x)
{
cout << "左值引用" << endl;
}
void Fun(const int& x)
{
cout << "const 左值引用" << endl;
}
void Fun(int&& x)
{
cout << "右值引用" << endl;
}
void Fun(const int&& x)
{
cout << "const 右值引用" << endl;
}
template<typename T>
void PerfectForward(T&& t)
{
Fun(t);
}
int main()
{
PerfectForward(10); // 右值
int a;
PerfectForward(a); // 左值
PerfectForward(std::move(a)); // 右值
const int b = 8;
PerfectForward(b); // const 左值
PerfectForward(std::move(b)); // const 右值
return 0;
}
【程序结果】
输出的结果好像和我们一开始说的不太一样,最终都匹配到了左值引用版本的Func
函数。接下来可以分析为什么?
首先先看第一次调用PerfectForward(10)
,由于PerfectForward
函数的参数类型是万能引用,因此在编译器眼中其实是如下这样的:
// 实参10是int类型,那么对应的T应该实例化为int
// 并且实参10是右值,编译器就会将模板实例化为右值引用
template<typename int>
void PerfectForward(int&& t)
{
Fun(t);
}
int main()
{
PerfectForward(10); // 右值
return 0;
}
这下好像有点眉目了,实参(右值)10
传递给形参t
,然后再通过t
去调用Func
函数,而t
虽然引用右值,但是它本身是可以被取到地址,并且可以被修改,所以在PerfectForward
函数中调用Func
函数时会将t
识别成左值。
也就是说,右值经过一次参数传递后其属性会退化成左值,如果想要在这个过程中保持右值的属性,就需要用到完美转发。
5.3.2 完美转发保持值的属性
要想在参数传递过程中保持其原有的属性,需要在传参时调用forward
函数。基本语法如下:
template<class T>
void PerfectForward(T&& t)
{
Func(forward<T>(t));
}
经过完美转发后,调用PerfectForward
函数时传入的是右值就会匹配到右值引用版本的Func
函数,传入的是const
右值就会匹配到const
右值引用版本的Func
函数,这就是完美转发的价值。
六、相关代码
Gitee
仓库链接:点击跳转