目录
简介
在过往,我们学习的都是C++98里面的知识,但是C++是一门会更新迭代的语言,所以会根据现版本C++不太方便的地方进行调整
虽然有时候,很多人对C++的后续更新颇有微词(鸡肋且难用),但C++的一些更新中还是有值得我们学习的地方的
今天我们就来学习一下C++11这个版本的相关内容吧
左值引用与右值引用
左值引用与右值引用是什么
左值和右值都是表达数据的表达式,但是很好区分,我们来举几个例子:
以下是常见左值:
int main()
{
// 常见左值
int* p = new int(10);
int b = 1;
const int c = b;
*p = 10;
string s("1111111111");
s[0];
return 0;
}
以下是常见右值:
double x = 1.1, y = 2.2;
// 常见右值(如下)
10;
x + y;
fmin(x, y);
string("1111");
我们能很明显地看到,右值都是如匿名对象,常量,表达式返回值之类的
要区分左值右值也很简单,我们就看其能不能取地址即可
int main()
{
// 常见左值
int* p = new int(10);
int b = 1;
const int c = b;
*p = 10;
string s("1111111111");
s[0];
double x = 1.1, y = 2.2;
// 常见右值(如下)
10;
x + y;
fmin(x, y);
string("1111");
///
cout << &p << endl;
cout << &b << endl;
cout << &c << endl;
cout << &s << endl;
///
cout << &10 << endl;
cout << &(x + y) << endl;
cout << &(string("11111111")) << endl;
return 0;
}
今天我们要讲的左值引用就是给左值取别名,右值引用就是给右值取别名
左值引用就是我们平日里接触到的引用,而我们的右值引用则是一个新的知识点
左值引用就是正常的用&
右值引用则是&&
举个例子,如下:
// 常见左值
int* p = new int(10);
int b = 1;
const int c = b;
*p = 10;
string s("1111111111");
s[0];
double x = 1.1, y = 2.2;
// 常见右值(如下)
10;
x + y;
fmin(x, y);
string("1111");
// 左值引用
int& r1 = b;
int*& r2 = p;
int& r3 = *p;
string& r4 = s;
// 右值引用给右值取别名
int&& rr1 = 10;
double&& rr2 = x+y;
double&& rr3 = fmin(x,y);
我们从底层来聊一聊这两个引用
语法层上讲的是,我们的引用是不开空间的,但其实在底层是开了空间的,就像指针一样
因为在底层,根本就没有什么引用、取别名,只有地址
而左值引用与右值引用这两个,在底层其实都是开空间取地址,接着进行之后的行为
所以,在语法层上说,右值不能取地址,但真的不能取地址吗?
其实右值就是不能修改的常量,匿名对象等,但这是C++11新增的内容,在C++11里面才有了右值这个概念
那在之前,我们遇到右值的时候,就无法应对了?我们来看一个例子:
int& a = 10;
const int& a = 10;
这样就可以了,所以很多时候,很多东西,都是语法层上的概念,很多操作都是在理解了底层之后,才能明白的
但是初学不建议就直接库库学底层,因为很多时候,底层与语法层是相悖的,这会打破你对语法层的信心,因为真正要用的还是语法层来写代码
左值引用与右值引用的比较
我们可以试试,看看能不能用左值引用引用右值,能不能用右值引用引用左值
/
// 左值引用引用给右值取别名:不能直接引用,但是const 左值引用可以
const int& rx1 = 10;
const double& rx2 = x+y;
const double& rx3 = fmin(x, y);
const string& rx4 = string("11111");
这个知识点我们在上文中有所说明,因为历史发展的原因,所以以前的程序员也有相应的办法引用左值引用引用右值,这个方法就是加上const
而我们的右值引用想要直接引用左值也是不现实的,如下:
int&& rrx1 = b;
int*&& rrx2 = p;
int&& rrx3 = *p;
string&& rrx4 = s;
string&& rrx5 = s;
所以如果我们需要用左值引用引用右值的话,我们就需要将右值转换为左值
而这个转化的方法就是move
这个函数的使用方法相当简单,我们只需要像正常函数调用一样,就可以将还过去的值转换成右值
如下:
// 右值引用引用给左值取别名:不能直接引用,但是move(左值)以后右值引用可以引用
int&& rrx1 = move(b);
int*&& rrx2 = move(p);
int&& rrx3 = move(*p);
string&& rrx4 = move(s);
可能会有人好奇,这个move是如何实现的,底层是怎么样的?
其实这个非常简单,我们的右值和左值都会在底层开空间,底层也没有引用、取别名的概念,有的只有地址与空间,而我们的很多行为说实话在底层是可以的,只不过在语法层不让你这么做
所以我们的操作可能看起来非常难以置信,但是你只要对底层有所了解,那么就会觉得这个东西其实很正常
我们的move,其实就是强制类型转换
我们来看这么一个例子:
string&& rrx4 = move(s);
string&& rrx5 = (string&&)s;
这两种代码是同样的效果,都能够将左值转换为右值
所以其实move的本质就是强转
右值引用的使用场景与C++11中STL的新变化
既然,在C++11以前,我们就可以用const左值引用来引用右值,那么我们再整一个右值引用出来,是否有点过于鸡肋了,因为我右值和左值都可以被左值引用给引用,只不过就是加不加const而已
但其实,发明这个右值引用是有大用的,因为左值引用虽然解决了大部分问题,但是有些问题是解决不了的,而且这个问题使人非常地困扰
比如:
假设我们在一个函数里面创建了一个二维数组,里面一共有100个小数组
vector<vector<int>> vv(100, vector<int>(0));
return vv;
这时候我们再来看,如果我们直接返回的话,我们就需要调用我们的拷贝构造,届时我们会拷贝总计101个数组,100个是里面的100个小数组,还有一个是存储这些小数组外围的数组
而且!!!
我们返回的时候,会先将其拷贝给一个临时对象,然后这个临时对象再拷贝一次给我们接收返回值的变量,图示如下:
但是这么一想,好像有点不太对啊,这个二位数组是我们在函数里面开辟的,但是一旦退出函数就会自动销毁,这个都要没了,还要我拷贝一遍,就不能直接将资源交换过来吗?
答案是可以的,而这个就会涉及到我们右值引用的使用
我们需要先写一个我们自己的string来做演示,代码如下:
// 拷贝构造
// s2(s1)
string(const string& s)
:_str(nullptr)
{
cout << "string(const string& s) -- 深拷贝" << endl;
reserve(s._capacity);
for (auto ch : s)
{
push_back(ch);
}
}
// 赋值重载
string& operator=(const string& s)
{
cout << "string& operator=(const string& s) -- 深拷贝" << endl;
if (this != &s)
{
_str[0] = '\0';
_size = 0;
reserve(s._capacity);
for (auto ch : s)
{
push_back(ch);
}
}
return *this;
}
由于其他函数如析构、c_str等等,这些不是我们需要讨论的,所以我们就只展示了两个我们现在要讲解的函数——拷贝构造与赋值重载
试想一下,我们的函数里面要返回的那个值,出了函数之后就会自动销毁,那我们为什么不直接将这个资源给给接收返回值的变量呢?
但是我们的函数是先将返回值拷贝给一个临时变量,然后临时变量再拷贝给接收返回值的变量
既然是拷贝构造,那我们能不能另外写一种构造,这种构造就是专门为右值引用,或者说是为了这种返回资源但是需要重复拷贝的情况
所以我们就另外写一个构造,这个构造对标的是拷贝构造,其名字为——移动构造
移动,顾名思义,就是将资源直接移动,移动的对象为纯右值,或者是将亡值
纯右值就是内置类型比如10,x + y 这种,将亡值就像是我们上面写的vector<vector<int>> 这种出了作用域就会被销毁的值
我们的移动构造,首先参数需要接收的是右值,所以我们就会用到右值引用
然后我们的函数体,就只需要执行一个交换资源的逻辑即可
// 移动构造
// 临时创建的对象,不能取地址,用完就要消亡
// 深拷贝的类,移动构造才有意义
string(string&& s)
{
cout << "string(string&& s) -- 移动拷贝" << endl;
swap(s);
}
但是我们需要注意的是
vector<vector<int>> vv(100, vector<int>(0));
return vv;
上面这串代码中的vv是右值还是左值?
可能会有人觉得这是右值,因为马上就要被销毁了
但是别忘了我们是怎么区分右值与左值的——能取地址的就是左值,右值不能取地址
上面那个vv可以取地址吗?当然可以,所以他是一个左值,那么我们用这左值去构造一个临时变量,匹配到的只能是拷贝构造,但是我们的临时变量不能取地址,所以是一个右值,那么匹配到的就是移动构造,就相当于只用执行一个swap操作
我们总结一下(上述还没有讲到编译器的优化,下文会讲解):
在使用移动构造之前,二维数组要返回需要两次拷贝,一次是拷贝给临时变量,一次是临时变量拷贝给接收返回值的变量
在使用移动构造之后,二维数组要返回需要一次拷贝,一次移动,并且移动的消耗可以忽略不计
所以就相当于我们省略了一次拷贝的消耗,相比于原来效率提高了50%
但是接下来还有更厉害的
编译器面对这种情况的时候,一般都会选择优化处理
我们先来讨论没有写移动构造时候的情况,没有优化之前,会生成一个临时变量
但是编译器觉得这个临时变量的生成有点费事儿,所以就不生成这个临时变量了,转变为直接拷贝构造给接收返回值的变量,图示如下:
这时我们再来讨论一下有移动构造的情况
首先编译器还是会优化,不再生成临时变量,转变为直接将资源拷贝
但是这时我们有了移动构造,所以编译器就将返回的值隐式转化为右值,然后去匹配移动构造
而且,我们的移动构造只有一个swap(转换资源)的消耗,近乎等于没有消耗
所以没有写移动构造之前的优化是从两个拷贝构造变成一个拷贝构造
但是有了移动构造之后的优化,是从一个拷贝一个移动变成了一个移动,简单来说,优化后就不需要拷贝了,消耗直接降为了近乎没有
这就是移动构造,这就是右值引用!!!
但是有的编译器优化的有点离谱,这个优化是从不要临时变量变成那个在函数里面的变量也不要了,直接将函数里面那个待返回的变量设置成就是main函数里面待接收的变量,就是说,两个变量是同一个
所以这时,就变成了只有一个构造,不存在什么拷贝构造、移动构造一说,直接就是构造
这时候再来看,这个移动构造在极端情况下,好像并没有想象中的那么有用
其实是的,但也不是,因为我们还有一种情况没有讲,如下:
vector<vector<int>> test1()
{
vector<vector<int>> vv(100, vector<int>(0));
return vv;
}
int main()
{
vector<vector<int>> vvv;
vvv = test1();
return 0;
}
我们能看到,这是典型的赋值重载的场景,因为vvv是已经存在的对象,所以我们就不是构造,而是赋值
这时候,编译器在没有优化之前的处理是这样的:
首先vvv会有一个构造,然后vv也会有一个构造,然后返回的vv会先执行一个拷贝构造给一个临时变量,然后再执行一个赋值重载的逻辑,图示如下:
但是在优化之后,临时变量会被删除,所以就变成了直接赋值,如下:
这时候我们就又有一个和赋值重载对标的右值引用版本函数出现了——移动赋值
这个其实大逻辑还是转移资源,代码如下(这里以string的代码为例):
// 移动赋值
string& operator=(string&& s)
{
cout << "string& operator=(string&& s) -- 移动拷贝" << endl;
swap(s);
return *this;
}
我们能看到,这相比赋值重载的效率提升是巨大的
而且在编译器优化之后(优化之前的逻辑和移动构造基本上一摸一样),就会变成直接移动赋值:
而且,再激进的编译器,也不会像拷贝构造那样直接构造,而只会是赋值重载
所以我们的右值引用,还是相当有用的
我们可以直接看看STL中的容器的变化:
我们就展示这几个,其他的各位可以自行去观察,我们可以发现的是,这些容器里面都有移动构造与移动赋值这两个,这其实也是C++11之后STL改变了的一个地方
并且!!!!!!
这些容器的接口在C++11之后也有右值引用
完美转发
这时我们就需要来讲解一种情况了:
void push_back(const T& x)
{
insert(end(), x);
}
void push_back(T&& x)
{
insert(end(), move(x));
}
iterator insert(iterator pos, const T& x)
{
Node* cur = pos._node;
Node* newnode = new Node(x);
Node* prev = cur->_prev;
// prev newnode cur
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
return iterator(newnode);
}
iterator insert(iterator pos, T&& x)
{
Node* cur = pos._node;
Node* newnode = new Node(move(x));
Node* prev = cur->_prev;
// prev newnode cur
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
return iterator(newnode);
}
我们能看到,我们的push_back调用的是insert
而这里我们写的是move,这是因为我们直到,我们传过去的肯定是一个右值,但是各位有没有考虑过,这里为什么我们还要加上一个move才对?
我们在来讲一个知识点,这个知识点和模板有关,就是:
如果是模板加&&的话,这代表的不是右值,而是万能引用
也就是说如果是模板(仅限是模板的情况!!!),那么&&就代表,你传左值,这个值就是左值,是右值,那这个值就是右值
void data(int& t)
{
cout << "左值" << endl;
}
void data(int&& t)
{
cout << "右值" << endl;
}
template<class T>
void func(T&& t)
{
data(t);
}
int main()
{
int a = 10;
func(a);
func(move(a));
return 0;
}
我们再来看看这种情况,如上,我们写了一个模板的万能引用,所以这里我们传什么值就是什么值的引用
然后我们看到,下面有一个左值一个右值分别做为参数传过去
按理来说,这时的结果应该打印一个左值,一个右值才对
但是我们会看到,这时的结果是两个左值
是不是我们func函数里面没有加move呢?我们试一下:
template<class T>
void func(T&& t)
{
data(move(t));
}
切忌不要病急乱投医啊,这时候变成了两个右值,我们move之后不就变成了必定为右值了吗
所以不可取
这里就要说一个你听了可能会吐血的结论啊:
引用右值的引用本身是左值
什么意思呢,举个例子:
int&& r1 = 10;
我们会看到,如上是一个右值引用,r1引用了10这个右值,但是,我们 r1 可不可以取地址?
答案是可以,这也就代表着这是一个左值
而C++11为什么要这么做呢?这当然也是有原因的,这是因为如果这是一个右值的话,那我们还怎么交换资源,看个例子:
string(string&& s)
{
cout << "string(string&& s) -- 移动拷贝" << endl;
swap(s);
}
void swap(string& s)
{
swap(_str, s._str);
swap(_size, s._size);
swap(_capacity, s._capacity);
}
我们可以看到,如果引用右值的引用是右值的话,那我们连swap都传不过去,那么还怎么交换资源,这就意味着,肯定是左值,才能交换资源,进入swap的逻辑
这也是无可奈何的
所以C++11为了解决这个问题,又多给了一个东西——forword(完美转发)
我们能看到,这有一个函数模板,但是里面重载了一个operator(),这就意味着我们可以像函数一样调用这个forward
这个完美转发的作用就是:你传什么过来,我的返回值就是什么类型的
就比如,你传了一个左值,我返回的就是一个左值,传一个右值,那我返回的就是一个右值
所以我们的代码可以这么改:
void data(int& t)
{
cout << "左值" << endl;
}
void data(int&& t)
{
cout << "右值" << endl;
}
template<class T>
void func(T&& t)
{
data(forward<T>(t));
}
int main()
{
int a = 10;
func(a);
func(move(10));
return 0;
}
这个完美转发,我们可以将其使用到上面说的insert、push_back上面,但其实那个我们知道是右值了,所以其实使用的价值并没有那么大
真正使用场景是下文中我们要讲到的可变参数模板(这个我们在讲完新的类功能之后再进行讲解)
新的类功能
在C++11出来了之后,我们的类就不一样了
以前,我们的类有6个默认构造:
- 构造函数
- 析构函数
- 拷贝构造
- 赋值重载
- 取地址
- const取地址
但是从C++11开始,就不再是6个了,而是8个:
- 移动构造
- 移动赋值
这两个的生成条件相当严格
我们的移动构造,就是如果你没有显示写 析构、拷贝构造、赋值重载,并且没有显示写移动构造或移动赋值
那么,编译器才会显示生成移动构造或者是移动赋值
为什么要这样要求呢,其实也很好理解:
如果一个类,显示写了析构,就代表有资源要销毁,那么如果是默认生成的拷贝与赋值重载,就都是浅拷贝,所以,需要显示写析构,就需要显示写拷贝与赋值重载
那么,如果写了这三个了,自然也要显示写移动构造与移动赋值,因为这样才有用啊
如果没有显示写的话,就代表里面没有资源需要销毁、转移,那么我的移动构造又不用干事情,就会自动生成
同时。默认生成的移动构造和移动赋值,对成员的处理也是相同的
默认对内置成员进行浅拷贝
对自定义类型则是调用他的移动构造和移动赋值
所以,这个默认生成的对什么类是最有用的呢?
其实就是有自定义成员比如vector,然后里面没有自己开空间,所以我们默认生成的移动构造和移动赋值会直接去调用他的移动构造和移动赋值,这样就相当有用
另外,我们在C++11还新增了两个关键字——default(新用法)、delete
default的用法就是,当我们在默认成员函数后面加上default之后,我们就能够让编译器强制生成该默认成员函数
就比如,我在构造函数后面加上了default,并且我不显示写构造,那么即使我后面写了拷贝构造,编译器也会帮我强制生成一个构造函数
class test
{
public:
// 强制生成构造函数
test() = dault;
};
另一个就是我们的delete了
我们的delete的用法就是限制某些默认函数的生成
在C++11以前,想要限制某个成员函数的生成(或者说不想让别人用),只能用private,并且需要在类里面有声明
但是C++11之后,我们要实现这个操作,只需要在那个默认成员函数后面加上一个delete即可,如下:
class Person
{
public:
Person(const char* name = "", int age = 0)
:_name(name)
, _age(age)
{}
/
Person(const Person& p) = delete;
/
private:
bit::string _name;
int _age;
};
可变参数模板
我们可以看一个我们都非常熟悉的函数做例子:printf
我们会看到,printf后面的...
这代表的是,我们可以传任意参数过去,任意类型,任意数量
而我们今天的可变模板参数也是这个效果
但是我们需要记住的是,可变参数模板本质上就是一个模板,所以,看似是我们直接传然后其他地方用一个可变参数模板接收,其实本质上是编译器在底层生成了对应的函数而已
我们先来看看是怎么写的:
// Args是一个模板参数包,args是一个函数形参参数包
// 声明一个参数包Args...args,这个参数包中可以包含0到任意个模板参数。
template <class ...Args>
void test(Args... args)
{}
我们会看到,这个模板是在那个Args前面加三个点,但是在函数参数那里又是在前面加三个点
这也没有办法,我们只能记住
另外,不是非得写Args和args,我们写其他的名字也行,如下:
template <class ...T>
void test(T... t)
{}
这样子其实和上面的代码是同样的效果,只不过,大家都是用的Args和args(参数的意思)
这也算是一个小小的潜规则吧,我们尽量还是使用Args和args,这样子也不会增加别人的阅读负担
接下来我们要讲一个很奇怪的东西:
template <class ...Args>
void test(Args... args)
{
cout << sizeof...(args) << endl;
}
int main()
{
test(1, "xxx", 2.2);
return 0;
}
大家通过观察不难发现,那个sizeof那里是不是特别奇怪,因为我们在模板参数那里,...是加在Args前面的,在函数参数那里,...是加在Args后面的,但是在我们的sizeof,确实加在sizeof和args中间,属实有点怪
而我们如果在里面再写一个函数A的话,...就是在A的参数里面的args的后面了
有些时候我们需要记住,记不住也没太大关系,可以试一试,一般情况下,像sizeof那样的是少数,大部分时候,我们只需要记住模板参数那里,函数参数那里,已经函数体里面基本都是在args后面(可能是隔了个括号的后面)
接下来我们来看看如何展开参数包
下文共有三个方法
方法1:
因为我们并不知道到底传了几个参数过来,所以我们可以写一个函数,该函数有两个参数,一个是模板,一个是可变参数,如下:
template<class T, class ...Args>
void Print(T t, Args... args)
{
}
然后,我们在这个函数里面只需要做两件事,一件是打印,一件是递归
首先我们需要打印的是T,这其实就相当于我们将可变模板参数里面的第一个给拿出来了,然后我们再将后续的参数依次递归,然后每次取出最前面的一个来打印
但是如果当args为空的时候,就会因为找不到相应的函数而报错,所以我们需要再写一个无参的Print,总代码如下:
void Print()
{
cout << endl;
}
template<class T, class ...Args>
void Print(T t, Args... args)
{
cout << t << " ";
Print(args...);
}
template <class ...Args>
void showlist(Args... args)
{
Print(args...);
}
int main()
{
showlist(1);
showlist(1, "xxx");
showlist(1, "xxx", 2.2);
return 0;
}
方法2:
我们如果打印一个可变参数模板要写那么多个函数的话,多少有点费事了
所以我们可以写一个简洁点的,但是这个略微有点不好懂
首先我们需要知道,数组,就是arr[] = {......},当我们写的时候,里面的元素个数arr会自动推导
那我们可以直接将可变参数模板写进去,然后让arr自己推导我们到底传了多少个参数上去
然后,我们就可以只写一个函数Print,然后不需要写无参的版本,因为我们这是用arr数组推导过个数的,所以不会出现无参的情况
template<class T>
void Print(T t)
{
cout << t << " ";
}
template <class ...Args>
void showlist(Args... args)
{
int arr[] = { (Print(args),0)... };
cout << endl;
}
int main()
{
showlist(1);
showlist(1, "xxx");
showlist(1, "xxx", 2.2);
return 0;
}
但是我们在上面的代码中会看到(Print(args) , 0)
这其实就是一个逗号表达式,因为我们写这个arr数组的初始化,最终是要将值给放进去的
而我们逗号表达式最终的结果是逗号表达式的最后一个值,所以我们最后就会将0给放进arr里面
如果我们不加这个逗号表达式的话,我们的Print是没有返回值的,所以我们的arr就不知道要放什么值进去,进而报错
当然如果你不想写逗号,你也可以尝试给Print加上一个int的返回值,随便返回一个int即可,如下:
template<class T>
int Print(T t)
{
cout << t << " ";
return 0;
}
template <class ...Args>
void showlist(Args... args)
{
int arr[] = { Print(args)... };
cout << endl;
}
int main()
{
showlist(1);
showlist(1, "xxx");
showlist(1, "xxx", 2.2);
return 0;
}
方法3:
我们上面的两种方法,都要另写一个函数
如果我们不想写的话,想直接打印也可以,我们可以直接写cout
但是我们需要注意,cout的返回值是ostream类型的,是不可拷贝的,所以我们需要写一个逗号表达式,将逗号表达式最后面的值放进arr数组里面
template <class ...Args>
void showlist(Args... args)
{
int arr[] = { (cout << args << " ", 0)... };
cout << endl;
}
int main()
{
showlist(1);
showlist(1, "xxx");
showlist(1, "xxx", 2.2);
return 0;
}
可变参数模板的应用——emplace_back
首先我们可以看到,emplae_back用到的就是我们上面讲到的可变模板参数
emplae_back和push_back主要的差别就在于,push_back是直接传要插入的值进去,这个值可能是一个int,一个char,或是一个pair,一个string
但是我们的emplae_back也可以做到这样子,除此之外,emplae_back还能做到直接将参数传过去
就比如我们的push_back只能传pair,我们的emplae_back却能传pair的参数过去让其自己构造
举个例子:
int main()
{
list<pair<int, char>> ls;
pair<int, char> p1(1, 'x');
ls.emplace_back(p1);
ls.emplace_back(1, 'x');
return 0;
}
其主要逻辑就是:
如果你传了一个值过来,我就将这个值不断转发,一直转发到构造函数那里,然后构造函数再根据传过来的东西进行构造操作
这里我们就拿list来举例子
首先我们的emplace_back归根结底是一个尾插,所以主要的逻辑和push_back是一样的
而我们的push_back采用的是复用insert,所以我们的emplace_back也可以这么干
template <class... Args>
void emplace_back(Args&&... args)
{
insert(end(), std::forward<Args>(args)...);
}
注意,这里要用到我们前面说到的forward(完美转发)
这是因为我们的可变参数模板用的是万能引用(模板的&&代表万能引用,上文中有详细说明)
所以我们并不知道,传过来的是一个左值还是右值,这里就需要用到完美转发
然后因为我们是复用的insert,所以我们的insert也需要多写一个可变参数模板的版本
template <class... Args>
iterator insert(iterator pos, Args&&... args)
{
Node* cur = pos._node;
Node* newnode = new Node(std::forward<Args>(args)...);
Node* prev = cur->_prev;
// prev newnode cur
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
return iterator(newnode);
}
我们可以可以看到,这里主要的变化就是再新建节点那里
因为我们主要的逻辑还是插入,所以大逻辑并不受可变参数模板的影响
最主要的还是在新建节点那里,这里我们选择直接将args传过去给Node的构造函数
因为我们传过去的是一个可变参数模板,所以我们的构造函数也需要实现一个可变参数模板的版本
template <class... Args>
ListNode(Args&&... args)
: _next(nullptr)
, _prev(nullptr)
, _data(std::forward<Args>(args)...)
{}
我们可以看到,现在我们给构造函数也写了一个可变参数模板的版本
这时我们的_data就接收到了可变参数模板
如果这里的_data是一个int类型的参数,那么传过来的就是一个int,就会直接构造,这样的话就是和push_back是一样的
但是如果这里的_data是一个pair,或者是一个string之类的,那么我们传过来的参数就会在这里执行一个构造的操作
我们的push_back是构造完之后,再拷贝过来到insert新插入的节点那里,这里会执行依次拷贝
但是我们的emplace_back则是直接构造,并不会拷贝
所以,push_back能做的emplace_back也可以做,push_back不能做的emplace_back也可以做
其实从某种意义上来讲,push_back已经完全可以被emplace_back代替了,只不过由于一些历史原因,或是新手并不能理解emplace_back,所以才会用push_back,但是还是建议各位以后可以直接用emplace_back
lambda表达式
这个东西其实是别的语言在用,C++看到这个好用抄过来的,但是确实香
首先,我们之前学过仿函数的相关知识,但是现实中直接给整数进行一个排序是没有什么意义的,一般情况下都是给一个结构体进行排序,这个结构体里面可能包含了某件商品的价格,评价等等
就好比我们在京东上面买东西,我们如果要排序的话,有一个综合排序,价格排序等等
所以这就要求我们在排序的时候写一个仿函数传过去
一般情况下,这并没有什么问题,但是每个人的命名不一样,有的人规规矩矩,你能看得懂
ComparePriceGreater
比如这个,我们能看得出来是按价格排升序
但是如果是这样呢:
Compare1
这你怎么看
另外,有些时候写仿函数也不太方便,一些小的逻辑比较还专门写一个仿函数,虽然可以,但如果能直接在表达式中写就好了
所以就有了我们的lambda表达式的引入
这里我们先来看看lambda表达式的格式:
[capture-list] (parameters) mutable -> return-type { statement }
捕捉列表 参数 mutable 返回值 函数体
首先我们要明白,这是一个表达式,所以我们是可以用一个返回值接收的,并且选择用auto来接收是一个非常好的选择
然后,我们的lambda表达式并不是每一个都必须要写
[capture-list] (parameters) mutable -> return-type { statement }
捕捉列表 参数 mutable 返回值 函数体
1 0 0 0 1
如上,我们下面写了1的就代表不能省略,写了0的就代表可以省略
参数可以省略是因为我们有时候是无参的,mutable和捕捉列表有关,这个后面讲,也可以省略
返回值也可以省略,因为我们的lambda表达式可以根据函数体最后返回的返回值来自行推导类型
举个例子:
int main()
{
auto add1 = [](int x, int y)->int {return x + y; };
auto add2 = [](int x, int y) {return x + y; };
return 0;
}
这两个lambda表达式的效果是完全一样的
auto func1 = []
{
cout << "hello world" << endl;
};
func1();
接着我们再来谈谈捕捉列表和mutable
首先,我们的lambda表达式默认是用不了除了全局变量的变量的
所以我们就需要捕捉外面的变量,举个例子:
int a = 0, b = 1;
auto swap2 = [a, b]()
{
int tmp = a;
a = b;
b = tmp;
};
这样就代表我们将a、b;两个变量给捕捉到了,这时候我们就可以在lambda表达式里面使用这两个变量
这时候还是没有问题的,但是如果我们将这两个变量改动一下呢:
int a = 0, b = 1;
auto swap2 = [a, b]()
{
a++;
b++;
};
这时候你会发现,报错了
这是因为我们捕捉过来的变量默认为const,所以是不能修改的,如果要修改,那么我们就需要加上一个mutable才可以对其进行修改操作
接着我们运行一下如下代码看看:
int a = 0, b = 1;
auto swap2 = [a, b]()mutable
{
int tmp = a;
a = b;
b = tmp;
};
swap2();
cout << a << b;
我们会发现,这并没有修改a、b,相当于我们在里面交换的结果并没有影响到外面
或者说,这种捕捉本质是一种拷贝,对外面的变量并没有影响
面对这种问题,我们有两种解决方案
一种是不捕捉,选择在参数部分直接传引用,然后在调用lambda返回值的时候,再将两个变量传进去,如下:
int a = 0, b = 1;
auto swap2 = [](int& a, int& b)
{
int tmp = a;
a = b;
b = tmp;
};
swap2(a, b);
cout << a << b;
这固然是一种方法,但是这不是我们主要想说的
我们还有一种更好的方法,就是直接引用捕捉
int a = 0, b = 1;
auto swap3 = [&a, &b]()
{
int tmp = a;
a = b;
b = tmp;
};
swap3();
cout << a << b;
引用捕捉就是在捕捉的值前面加上一个&,这样就代表我们在lambda表达式里面的改变会影响到外面
接着我们再来讲一讲其他几种捕捉
- [=] 这种情况代表拷贝捕捉所有变量
- [&] 这种情况代表引用捕捉所有变量
- [this] 这种情况代表拷贝捕捉this指针
也就是说,我们上面的代码其实也可以这样改:
int a = 0, b = 1;
auto swap3 = [&]()
{
int tmp = a;
a = b;
b = tmp;
};
swap3();
cout << a << b;
最后我们再来看看lambda表达式的底层
首先,引入眼帘的是一个lambda表达式,接着我们转到汇编代码看看:
我们拿vs2022的编译器做演示
我们会看到有一个operator()
而据我们所知,operator() 是我们写了一个类之后,在那个类里面写一个operator(),以达到仿函数的效果
其实究其本质,lambda表达式的本质就是仿函数
包装器
包装器有两个,一个叫做function,一个叫做bind(绑定)
function
首先这是一个模板,而他的用法也较为奇怪,这从某种意义上来讲,算是语法这儿给开了一个绿灯
这就是用法,ret是待包装的返回类型,后面括号括起来的可变参数是我们写进去的,函数或者其他要包装的东西的参数类型
举个例子,假设我们写了一个函数,这时候我们要将这个函数包装一下:
int f(int a, char b)
{
return a + b;
}
int main()
{
int a = 2;
function<int(int, char)> f1 = f;
f(a, 'x');
return 0;
}
而我们的function,就是用来包装,或者叫做适配用的
就比如,我们都玩过游戏,就拿很火的火影忍者来说
我们点的每一个键,都代表了一个操作指令
假设你点了一技能,那么就会给你适配一个效果,这时候可能飞天,位移,丢什么东西之类的
这些操作效果,我们都会用到function
用这个function搭配一些其他的操作按键之类的
可能会有这么一种操作:map<按键, function>
这里我们用到的是map,就代表这,当我们使用了map,将对应的按键给给这个map,这时候的function就会被找到,而这个function包装着其他函数,包装着什么,不知道,只要功能没错,那么出错了就是我另外的地方有问题,这有点高内聚低耦合的意思了,但是这里就不做深入讨论
我们现在初步学习这个function,我们只需要知道这个东西很重要即可
接下来我们来具体讲讲function封装的几种情况:
当我们封装普通函数时:
int f(int a, char b)
{
return a + b;
}
int main()
{
int a = 2;
function<int(int, char)> f1 = f;
f(a, 'x');
return 0;
}
当我们封装仿函数时:
struct Functor
{
public:
int operator() (int a, int b)
{
return a + b;
}
};
int main()
{
function<int(int, int)> f2 = Functor();
return 0;
}
当我们封装lambda表达式时:
function<int(int, int)> f3 = [](int a, int b) {return a + b; };
当我们封装类的静态成员函数时:
其实静态成员函数的封装和普通函数的封装是一样的:
class Plus
{
public:
static int plusi(int a, int b)
{
return a + b;
}
};
int main()
{
function<int(int, int)> f4 = &Plus::plusi;
return 0;
}
当我们封装类的普通成员函数时:
这里就要注意了,这里有一个大坑
就是,我们的普通成员函数是有一个隐含的this指针的
所以我们在写function的时候,需要记得将这个this指针给加上
class Plus
{
public:
int plusd(int a, int b)
{
return a + b;
}
};
int main()
{
Plus pd;
function<int(Plus*, int, int)> f5 = &Plus::plusd;
function<int(Plus, int, int)> f6 = &Plus::plusd;
cout << f5(&pd, 1, 2) << endl;
cout << f6(pd, 1, 2) << endl;
return 0;
}
我们可以看到,如上两种包装方法都是可以的
第一种,因为this指针是指针,所以用*很正常,能理解
但是第二种,指针为什么不用指针类型也可以?
这其实就需要我们对底层有所了解之后才能明白了
因为function的本质是先将那个成员函数的地址先存着
然后在拿到我们传过去的类的时候,再去进一步调用
但是,无论是不是取地址,我们会发现,都能调用到这个函数,所以,我们这两种写法就都是对的
bind
这个bind,会更离谱
首先这个bind也是一个包装器,也是可以包装诸如函数这样的东西
而我们的bind的使用是,bind后面加(),然后()的第一个位置写可调用对象,比如函数,仿函数,lambda表达式这种
然后后面跟的,是参数顺序
这里我们就需要引入一个新的东西——placeholders
这个placeholders里面的_1、_2这些,就是我们要写上去的参数顺序
我们来看几个例子就懂了:
using placeholders::_1;
using placeholders::_2;
using placeholders::_3;
int sub(int a, int b)
{
return (a - b) * 10;
}
int main()
{
auto f1 = bind(sub, _1, _2);
cout << f1(10, 5) << endl;
auto f2 = bind(sub, _2, _1);
cout << f2(10, 5) << endl;
return 0;
}
如上,我们写了两个bind,而我们的sub就是可调用对象
后面跟的_1、_2就是参数的顺序
如上,我们写的第一个bind是_1、_2,而我们传的参数是10、5,那么第一个参数就是10,第二个就是5
然后,第二个bind是_2、_1,而我们传的参数是10、5,那么第一个参数就是5,第二个就是10
所以得出的结果就完全不一样
然后我们还有一条特性就是:
我们可以固定其中一个参数,以达到改变传递参数个数的效果
再来两个例子:
using placeholders::_1;
using placeholders::_2;
using placeholders::_3;
int sub(int a, int b)
{
return (a - b) * 10;
}
int main()
{
auto f3 = bind(sub, _1, 100);
cout << f3(5) << endl;
auto f4 = bind(sub, 100, _1);
cout << f4(5) << endl;
return 0;
}
我们先来看第一个例子,我们先是将100放在原本_2的位置,这就意味着,我们第二个参数固定传100,然后_1代表着我们待会儿要传的参数
最后的结果就是,传了个5过去,变成了 (5 - 100) * 10 = -950
而第二个例子就是,第一个固定传100,第二个变要传的,但是这时要注意的是,我们的_1代表的,是第一个参数,是调用bind返回值之后的第一个参数,所以只要有参数,就一定是从_1开始
接着我们再来想一想,我们刚才的function那里,不是写了一个类的成员函数的包装吗
当我们遇到普通成员函数的时候,我们就必须要去传一个类变量,这很麻烦且不方便
所以我们也可以用bind包装一下,改变参数的个数:
using placeholders::_1;
using placeholders::_2;
using placeholders::_3;
class Plus
{
public:
int plusd(int a, int b)
{
return a + b;
}
};
int main()
{
function<int(int, int)> f1 = bind(&Plus::plusd, Plus(), _1, _2);
cout << f1(3, 31) << endl;
return 0;
}
我真的很讨厌3月31,真的真的
结语
看到这里,这篇博客有关C++11部分知识讲解就讲完啦~( ̄▽ ̄)~*
由于这一篇文章的篇幅已经来到了1w8,所以关于异常与智能指针的讲解就不放在这一篇进行讲解了,如果有需要的可以关注博主,本博主会持续更新哒
最后如果觉得对你有帮助的话,希望可以多多支持博主喔(○` 3′○)