目录标题
- {}初始化
- decltype和auto
- nullptr
- 范围for
- final
- 什么是左值和右值
- 左值引用和右值引用
- 右值引用的意义
- 右值引用的使用
- const右值引用
- 万能引用
- 默认移动构造和移动赋值
{}初始化
在c++98中允许使用{}对数组或者结构体元素进行统一的列表初始值设定,比如说下面有个结构体:
struct date
{
int year;
int mounth;
int day;
};
那么在创建这个结构体对象时就可以使用花括号来对结构体对象进行初始化,比如说下面的代码:
int main()
{
date d1 ={ 2023, 7, 9 };
date d2 ={ 2023, 7, 10 };
return 0;
}
通过调试可以看到这里的d1和d2内容和花括号中的内容都是一样的:
当然c++98的花括号不仅仅可以初始化话结构体还可以对数组也进行初始化,比如说下面的代码:
int main()
{
int arr1[10] = { 1,2,3,4,5,6,7,8,9,10 };
char arr2[10] = { 'a','b','c','d','e' };
return 0;
}
那么c++11就对这个花括号进行了改造,首先就是数组和结构题在使用花括号进行初始化的时候不需要添加等号,比如说下面的代码:
int main()
{
date d1{ 2023,7,9 };
date d2{ 2023,7,10 };
int arr1[10] { 1,2,3,4,5,6,7,8,9,10 };
char arr2[10] { 'a','b','c','d','e' };
return 0;
}
这里没有使用等号但是依然可以完成初始化,c++11对这个的第二个升级点就是{}可以用于所用的内置类型和用户自定义类型进行初始化,比如说下面的代码:
int x = 10;//c++98初始化风格
int y = { 10 };//c++11的初始化方式
int z{ 10 };//c++11的初始化方式
在c++11中这三种方式都可以正常的初始化内置类型变量,我觉得这样的改动是为了更好的适配new,因为在使用new申请空间的时候可以使用花括号顺便进行初始化,并且使用new在创建自定义类型的数组时可以更好的初始化内部的内容比如说下面的代码:
int* p1 = new int[10] {1, 2, 3, 4, 5, 6};
date* p2 = new date[10]{{2023,7,9},{2024,7,10}};
通过调试可以看到这里的p1和p2指针指向的内容跟花括号里面是一样的:
当然这样的用法在vs2013上面是实现不了的,因为vs2013对c++11的适配还不是很好,所以大家要写c++11的话最好在vs2019下进行编写,编译器单独给花括号取了一个类型叫做initiallizer_list,这个类型底层是一个数组,数组的数据存储到常量区里面,类里面有指针指向这个数组开始的地址和结束的地址,然后还提供begin和end函数,这两个函数就是返回那两个地址,这个类还提供类似迭代器的东西
所以大家就可以写出下面这样的代码:
void test2()
{
vector<int> v1 = { 1,2,3,4,5 };
vector<int> v2 { 1,2,3,4,5 };
list<int> it1 = { 1,2 };
list<int> it2 = { 2,3 };
date d1{ 1,1,1 };
auto li = { 1,2,3,4,5 };
}
这里的花括号都会被识别成为initiallizer_list类型,然后用这个花括号里面的内容用来初始化这个类型创建出来的临时对象里面的数据,然后再用这个零时对象构造其他的容器,通过调试我们便可以看到li的类型:
因为vector和list提供了使用initiallizer_list进行初始化的构造函数,所以他们对应的花括号会被转换成为initiallizer_list类型,但是date类没有提供该类型的默认构造函数,所以花括号就不会自动转换成为initiallizer_list类型,又因为date类提供了三个整型参数的构造函数函数类型,所以这里的花括号会直接转换成为构造函数来进行初始化,如果date类连三个整型参数的构造函数都没有提供的话,这里就会直接报错:
那么看到这里我们可以将自己写的vector构造函数进行一下改造,接收一个initiallizer_list的参数然后遍历的这个类型变量里面的数据最好对该数据一个一个的push_back就行, 那么这里的代码就如下:
vector(initializer_list<T> li)
: _start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
reserve(li.size());
initializer_list<T>::itertaor it = li.begin();
while (li != li, end())
{
push_back(*it);
}
}
最后这里大家要注意的一点就是:vector V3={{1,1,1},{2,2,2},{3,3,3}}里面的花括号会被识别成为匿名的构造函数,外面的花括号则会被识别为initiallizer_list,在用initiallizer_list做参数的时候不能使用&来进行传参如果使用的话得添加const,因为中间会发生转化生成一个临时对象,那么这就是c++11带来的{}的改变,希望大家能够理解。
decltype和auto
在前面的学习中我们接触过一个东西叫typeid,这个东西的用法是typeid(变量).name()
它的作用就是得到该变量类型所对应的字符串,比如说下面的代码:
void test3()
{
int x = 10;
auto y = { 1,2,3,4 };
vector<int> z = { 1,2,3,4 };
cout << typeid(x).name() << endl;
cout << typeid(y).name() << endl;
cout << typeid(z).name() << endl;
}
虽然可以使用typeid得到变量类型所形成的字符串,但是不能使用typeid来创建变量,比如说下面的代码:
void test3()
{
int x = 10;
auto y = { 1,2,3,4 };
vector<int> z = { 1,2,3,4 };
typeid(x).name() tmp1;
typeid(y).name() tmp2;
typeid(z).name() tmp3;
}
这段代码的运行结果如下:
在C++98中auto是一个存储类型的说明符,表明变量是局部自动存储类型,但是局部域中定义局部的变量默认就是自动存储类型,所以auto就没什么价值了。C++11中废弃auto原来的用法,将其用于实现自动类型推断。这样要求必须进行显示初始化,让编译器将定义对象的类型设置为初始化值的类型,那么这里的代码就如下:
void test3()
{
vector<int> v = { 1,2,3,4 };
int x = 10;
auto y = v;
cout<<typeid(x).name()<<endl;
cout<<typeid(y).name()<<endl;
}
这段代码的运行结果如下:
如果单独使用auto创建变量但是不进行初始化的话编译器是会报错的:
那么这就是auto的功能,然后就是decltype它也可以根据表达式的类型来创建变量比如说下面的代码:
void test3()
{
int x = 10;
double y = 10.1;
decltype(&x) z = nullptr;
decltype(x * y) f;
cout << typeid(x).name() << endl;
cout << typeid(y).name() << endl;
cout << typeid(z).name() << endl;
cout << typeid(f).name() << endl;
}
代码的运行结果如下:
但是大家在使用decltype的时候得注意一点就是如果内部的表达式是解引用指针的话得到的结果是引用类型,比如说下面的代码:
void test3()
{
int x = 10;
int* p = &x;
decltype(*p) y = x;
}
通过调试可以看到变量y的类型为:
那么这就是decltype的作用希望大家能理解。
nullptr
由于C++中NULL被定义成字面量0,这样就可能回带来一些问题,因为0既能指针常量,又能表示整形常量。所以出于清晰和安全的角度考虑,C++11中新增了nullptr,用于表示空指针。
#define NULL 0
#define NULL ((void *)0)
c++11之所以要添加nullptr就是因为面对下面的情况NULL可能会出现问题
void test1(int x)
{
cout << typeid(x).name() << endl;
}
void test1(int* x)
{
cout << typeid(x).name() << endl;
}
int main()
{
test1(0);
test1(NULL);
test1(nullptr);
return 0;
}
代码的运行结果如下:
那么这就是NULL和nullptr的区别,希望大家能够理解。
范围for
范围for想必大家都应该很熟悉了,它可以帮助我们简化遍历容器写的代码数量,比如说下面的代码:
void test4()
{
vector<int> v1{ 1,2,3,4,5 };
for (auto ch : v1)
{
cout << ch << " ";
}
cout << endl;
vector<int>::iterator it1 = v1.begin();
while (it1 != v1.end())
{
cout << *it1 << " ";
++it1;
}
cout << endl;
}
代码的运行结果如下:
运行的结果一摸一样,并且通过之前的学习大家应该也知道范围for只是一个语法糖,它就是迭代器的一层封装,在底层还是通过调用迭代器来实现的,那么这就是范围for的介绍。
final
final用两个用法第一个就是修饰函数,被final修饰的虚函数是必须得重写的才能调用,比如说下面的代码:
class car
{
public:
virtual void print() final
{
cout << "car" << endl;
}
private:
};
class bmw :public car
{
virtual void print()
{
cout << "bmw car" << endl;
}
};
代码的运行结果如下:
可以看到这里报错了并告诉我们被final修饰的函数是不能被重写的,第二个就是修饰类,如果一个类被final修饰的话这个类是不能被继承的,比如说下面的代码:
class parent final
{
public:
void print()
{
cout << "parent" << endl;
}
private:
};
class child :parent
{
public:
void print()
{
cout << "child" << endl;
}
private:
};
代码的运行结果如下:
这里就报错了并告诉我们被final修饰的类无法被继承,
什么是左值和右值
首先不能根据能修改的值就是左值不能修改的值就是右值这个概念来判断是否是左值还是右值,比如说:
const int c = 10;
变量c无法被修改,但是这个变量c却是一个左值,也不能根据位于等于号的左边或者右边来判断是否为左值或者右值,比如说下面的代码:
const int c = 10;
const int & rc = c;
在创建引用变量rc的时候c位于赋值符号的右边,那这个变量c是右值吗?好像不是的对吧,所以既不能根据是否能修改来判断左值还是右值,也不能根据位于赋值符号的左边还是右边来判断是左值还是右值,那么判断左值还是右值的方法就是根据能否却得地址来判断是左值或者右值,如果能够取得地址就是左值,如果取不到地址就是右值,左值可以出现在等于符号的左边和右边,但是右值只能出现在等于符号的右边,那么这就是判断左值引用和右值引用的方法,比如说下面的代码:
int main()
{
double x = 1.1, y = 2.2;
// 以下几个都是常见的右值
10;
x + y;
fmin(x, y);
return 0;
}
创建了两个double类型的变量,我们可以获取这两个变量的地址,所以x和y我们就称之为左值,但是单独的一个10呢,x+y这个表达式的值呢?函数fmin的返回值呢?你能够得到这些值的地址吗?很明显是取不到这些值的地址的,所以这些值就是右值。
左值引用和右值引用
之前的学习我们知道给一个变量取别名的行为我们称之为引用,那么在这里给一个左值取别名的行为我们就称之为左值引用,给右值取别名的行为我们就称之为右值引用,左值引用是在类型前面添加一个&,那么右值引用就是在类型前面添加两个&,比如说下面的代码:
int main()
{
double x = 1.1, y = 2.2;
// 以下几个都是常见的右值
10;
x + y;
fmin(x, y);
// 以下几个都是对右值的右值引用
int&& rr1 = 10;
double&& rr2 = x + y;
double&& rr3 = fmin(x, y);
//以下几个都是对左值的左值引用
double& rx = x;
double& ry = y;
}
左值引用只能引用左值不能引用右值,但是被const修饰的左值引用还是能够引用右值的,比如说下面的代码:
const int& rx = 10;
const int& ry = add(10,20);
右值引用只能引用右值不能引用左值,但是可以引用move以后的左值,比如说下面的这段代码:
int y=10;
int && rx=10;
int && ry=move(10);
那么这就是左值引用和右值引用之间的联系。
右值引用的意义
在谈右值引用的意义之前我们先来想想引用的意义是什么?我们知道函数在传递参数的时候,或者函数在传值返回的时候会对参数进行拷贝,而面对一些数据量很多的参数时这里拷贝效率会非常的低,所以这里就产生了引用这个东西它可以减少拷贝,我们之前学习的引用都是左值引用,所以左值引用的特点就是减少拷贝,比如说下面的代码:
template<class T>
void func1(const T& x)
{
cout << "func1" << endl;
}
int main()
{
vector<int> v1(10, 0);
func1(v1);//传递的时左值
func1(vector<int>(10, 0));//传递的是右值
return 0;
}
因为参数是const左值引用的类型,所以这里的引用既可以接收左值也可以接收右值,所以使用这样的方式却是减少了参数的拷贝,并且因为函数在返回的时候也会进行拷贝,所以还可以对函数的返回值进行修改让其引用返回比如说下面的代码:
template<class T>
const T& func1(const T& x)
{
cout << "func2" << endl;
}
int main()
{
vector<int> v1(10, 0);
func2(v1);//传递的时左值
func2(vector<int>(10, 0));//传递的是右值
return 0;
}
但是左值引用解决了所有拷贝的问题了吗?根据之前的经验来说的话好像没有对吧,比如说下面的代码:
template<class T>
T func3(const T& x)
{
cout << "func3" << endl;
T x;
return x;
}
int main()
{
vector<int> v1(10, 0);
func3(v1);//传递的时左值
func3(vector<int>(10, 0));//传递的是右值
return 0;
}
这里使用左值引用还可以减少返回值的拷贝吗?好像就不行了对吧,所以左值引用可以解决的问题就是传递参数时的拷贝问题,和函数销毁时对象还在的函数参数返回的拷贝问题,那么右值引用的作用就是解决左值引用没有解决的问题,比如说之前写的oj题杨辉三角:
vector<vector<int>> generate(int numRows) {
vv.resize(numRows);
for (int i = 0; i < numRows; ++i)
{
vv[i].resize(i + 1, 1);
}
for (int i = 2; i < numRows; ++i)
{
for (int j = 1; j < i; ++j)
{
vv[i][j] = vv[i - 1][j] + vv[i - 1][j - 1];
}
}
return vv;
}
这里采用的返回方法就是传值放回,参数在返回的时候会经历一次拷贝,而且这里的返回类型为vector<vector<int>>
在拷贝的时候会消耗很多的资源,所以右值引用就可以在这种场景来发挥功能,那在右值引用出来之前难道就没有办法解决这里的问题吗?答案是有的我们可以使用输出型参数来解决返回值的拷贝问题,那么既然是输出型参数所以这里就得添加一个左值引用的参数,并且返回值也不需要了,那么这里的代码就如下:
void generate(int numRows, vector<vector<int>>& vv) {
vv.resize(numRows);
for (int i = 0; i < numRows; ++i)
{
vv[i].resize(i + 1, 1);
}
for (int i = 2; i < numRows; ++i)
{
for (int j = 1; j < i; ++j)
{
vv[i][j] = vv[i - 1][j] + vv[i - 1][j - 1];
}
}
}
但是这种方法虽然能够解决问题但是用起来的时候却十分的搓,所以后面就有了右值引用来解决这里的问题。
右值引用的使用
这里我通过一个例子来带着大家理解了解右值引用的使用,首先提供一个我们自己写的string容器:
class string
{
public:
typedef char* iterator;
iterator begin(){return _str;}
iterator end() {return _str + _size;}
string(const char* str = "")
:_size(strlen(str))
, _capacity(_size)
{
cout << "string(char* str)" << endl;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// s1.swap(s2)
void swap(string& s)
{
::swap(_str, s._str);
::swap(_size, s._size);
::swap(_capacity, s._capacity);
}
// 拷贝构造
string(const string& s)
{
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;
}
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 = nullptr;
size_t _size = 0;
size_t _capacity = 0; // 不包含最后做标识的\0
};
在前面的学习中大家肯定使用过这么个函数叫做to_string它可以将传递过来的整数转换成为string类型的字符串,比如说下面得代码:
int main()
{
string s1 = to_string(-1234);
cout << s1;
return 0;
}
这段代码的运行结果如下:
可以看到这里确实将-1234转换成为了string的形式,那么下面我们就要实现以下这个函数,首先这个函数的返回类型肯定是string,并且接收一个整型类型的参数:
string to_string(int val)
{
}
因为这里接收的参数有正也有负数,所以我们先创建一个变量用来判断当前的传递过来的数据是正数还是负数,因为正数可以更好的处理,所以如果传递过来的参数是负数的话我们第一步就将负数改成正数,然后再数据进行处理让其变成一个字符串,最后再将符号给他添加上去就可以了,所以这里的代码就如下:
string to_string(int val)
{
bool flag = true;
if (val < 0)
{
val = -val;
flag = false;
}
string tmp;
}
然后就是要对数据进行处理,那么这里的处理方式就是通过除10模10来得到数据上的每一位,然后将其插入道string容器里面,因为每次的除10模10得到的都是数据的最后一位而插入数据使用的是尾插,所以这一步完成之后得到的数据数据其实是反的,所以完成这个循环之后就可以根据flag的值判断是否需要添加负号,最后调用reverse函数将里面的内容进行调换,最后那么这里的代码就如下:
string to_string(int val)
{
bool flag = true;
if (val < 0)
{
val = -val;
flag = false;
}
string tmp;
while (val)
{
int a = val % 10;
tmp.push_back('0'+a);
val /= 10;
}
if (!flag)
{
tmp += '-';
}
reverse(tmp.begin(), tmp.end());
return tmp;
}
将这个函数实现之后我们再来看看下面这个场景:
int main()
{
string s1 = to_string(-1234);
cout << s1;
return 0;
}
我们知道上面的to_string是普通返回,在将返回值给s1之前tmp早已经被销毁了,所以这里并不是拿tmp的值进行初始化,而是先拿tmp的值拷贝构造出来一个临时对象,然后再拿临时对象来初始化s1,所以按道理来说这里本应该进行两次拷贝构造,但是因为结构的原因(函数一返回就拿返回值初始化变量s1)和效率的原因(两次拷贝构造的效率低下)编译器会对这一步进行优化直接拿返回值进行拷贝构造s1不会创建临时变量从而减少了一次拷贝,因为我们在实现string的时候一些函数做了一些标记每当调用一次该函数的时候标记都会显示一次,所以将上面的代码运行就可以看到下面的结果:
这里只调用了一次构造函数,跟我们的预期不大一样,那可能是因为编译器的优化机制不一样,有些编译器优化的机制比较激进,比如说下面的代码:
int main()
{
YCF::string s1;
s1= YCF::to_string(-1234);
return 0;
}
将构造函数的表示语句去掉运行一下上面的代码,便可以看到下面的结果:
因为我们的赋值重载采用的是现代写法所以这里会多出来一次深拷贝,但是有些编译器比较激进即使我们分开写了它也会将其优化成为拷贝构造,所以这里不同的编译器优化的机制是不一样的,虽然这里做出了优化但是优化的结果只不过是让两次拷贝构造变成了一次,它没有完全解决拷贝构造所带来的效率降低的问题啊!所以为了解决这个问题c++11就引进来右值引用这个东西,我们知道const 左值引用既可以引用左值也可以引用右值,比如说下面的代码:
int main()
{
YCF::string s1("abcd");
YCF::string s2(s1);
YCF::string s3(move(YCF::string("abcd")));
return 0;
}
代码的运行结果如下:
那我们要是再提供几个右值引用版本的拷贝构造函数的话,上面函数的调用又会是什么呢?而且c++11还对右值进行了分类,分为纯右值和将亡值,比如说一些内置类型表达式的值就是纯右值,自定义类型表达式的值就是将亡值,而大家根据名字也能够很容易的看出来将亡值就是生命周期快要结束变量,所以在面对将亡值的拷贝构造时你觉得还有必要进行深拷贝吗?很明显深拷贝就是多余的,所以对于右值引用版本的拷贝构造我们采用的策略就是交换内部的数据而不是深拷贝,又因为函数在匹配参数的时候会选择参数最为贴合的一项,所以当我们提供了右值引用版本的拷贝构造时,上面的代码是不会匹配左值引用版本的拷贝构造的,那么右值引用版本的拷贝构造代码如下,就是调用string内部的swap函数交换两个容器的内部数据即可,那么这里的代码如下:
string(string&& s)
{
cout << "string(const string& s) -- 移动拷贝" << endl;
swap(s);
}
那么这里我们就可以使用下面的代码来观察一下右值引用的逻辑:
int main()
{
YCF::string s1("abcd");
YCF::string s2(s1);
YCF::string s3(move(s2));
YCF::string s4(YCF::string("abcd"));
return 0;
}
首先创建一个string变量将里面的值初始化为abcd,然后创建一个变量s2并用s1里面的值拷贝构造s2,因为s1是左值,所以这里的拷贝构造匹配的是左值版本的拷贝构造,所以s2和s1里面的值是一样的比如说下面的图片:
并且显示屏上也打印出来这里调用的是左值引用的深拷贝:
然后在拷贝构造s3的时候传递的参数是move之后的s2,因为被move修饰的值就会被认为是右值,所以这里调用的是右值引用版本的拷贝构造函数,在函数内部认为传过来的s2是一个将亡值,所以调用了swap函数对其内部的数据进行了交换,因为s3还没有创建完成内部的数据都为空,所以交换之后s3的数据与之前s2的数据一摸一样,而s2的数据则全部变成了空,通过调试可以看到此时的数据变成了下面这样:
并且显示屏上显示的也是右值引用版本的拷贝构造函数:
那么s4也是同样的道理这就不多介绍。那么我们再回过头来看看上面的两次拷贝构造,没有移动构造之前将一个参数进行返回会经历两次拷贝构造,但是有了移动构造这里就会变成拷贝构造+移动构造,而经过编译器优化之后这里就会变成拿返回值直接移动构造,但是有些小伙伴会说返回tmp是左值啊怎么可以移动构造呢?那这里编译器自动的添加move是不是就可以变成了右值对吧,所以问题解决了吗?这里还会因为深拷贝而导致浪费很多资源吗?是不是就不会了对吧,那么这就是移动构造的一个用法,有了右值引用之后我们怕传值返回吗?我们还怕深拷贝而导致的效率降低了吗?是不是就不是很担心了对吧,那么解决完移动拷贝之后我们再来看看赋值重载,这里的赋值重载依然是深拷贝,但是在赋值的时候肯定会遇到将亡值的情况,那么这时还需要深拷贝吗?是不是就不需要了对吧,那么这里我们依然可以采用交换内部数据的方式来减少深拷贝,那么这里的代码如下:
// 移动赋值
string& operator=(string&& s)
{
cout << "string& operator=(string s) -- 移动赋值" << endl;
swap(s);
return *this;
}
有了移动赋值之后我们再来看看下面的代码:
int main()
{
YCF::string s1;
s1= YCF::to_string(-1234);
return 0;
}
代码的运行结果如下:
可以看到有了移动构造移动赋值之后这里的效率便会大大的提升。那么看到这里想必大家应该能够明白右值引用的作用,右值引用和左值引用减少拷贝的原理不太一样,左值引用是通过给变量取别名的方式来直接起作用,而右值引用则是间接的起作用,在实现移动构造和移动赋值,在拷贝的场景中,如果是右值(将亡值)的话则通过转移资源的方式来减少拷贝。希望大家能够理解
const右值引用
根据前面的学习我们知道右值引用就是给右值取别名,但是对于右值我们根本就取不到他的地址,更何谈对他的值进行修改呢对吧,所以为什么有const右值引用呢?答案是虽然右值不能取地址,但是给右值取别名后会导致右值被存储到特定的位置,且可以取到该位置的地址并对其进行修改,也就是说例如:不能取字面量10的地址,但是rr1引用之后,即可以对rr1取地址还可以修改rr1的值,比如说下面的代码:
int main()
{
int&& rr1 = 10;
++r1;
cout << rr1 << endl;
cout << &rr1 << endl;
return 0;
}
代码的结果如下:
如果不想让rr1被修改的话,就得使用const对其进行修饰比如说下面的代码:
int main()
{
const int&& r1 = 10;
++r1;
cout << r1 << endl;
cout << &r1 << endl;
return 0;
}
代码的运行结果如下:
因为10的地址我们是取不到的,取了别名之后编译器就会另外开辟一个空间并且在这个空间里面存放一个值10,这个时候改变就不是原来的那个10而是新空间里面的10,为什么要这么做呢?移动构造会交换数据的内部资源,如果右值引用无法被改变内部资源的话那移动构造里面又如何来交换内部的资源呢对吧?那么知道了这一点之后我们就可以来看看下面的代码:
int main()
{
YCF::list<YCF::string> it1;
YCF::string s1("1111");
it1.push_back(move(s1));
it1.push_back("2222");
it1.push_back(YCF::string("3333"));
return 0;
}
list插入节点的时候会创建一个节点,然后再对这个节点里面的内容进行拷贝,上面的代码中list的内部是string类型,所以在插入节点的时候会调用string容器的拷贝构造用于初始化内部内容,所以上面的代码运行结果如下:
可以看到我们上面虽然只插入了3个数据,但是这里却调用了4次深拷贝,但是如果我们使用的是库中的list的话这里的结果就会大不一样:
库中插入三个数据就调用了三次移动构造,那这里的效率是不是就相差的很大了啊对吧,所以接下来我们要干的事情就是将我们自己实现的list修改成库中的那样只调用3次移动构造就可以完成数据的插入,首先 这里传递的形参的数据类型为右值而list的push_buck函数只有一个左值的版本:
void push_back(const T& x)
{
//Node* newnode = new Node(x);
//Node* prev = _head->_prev;
//prev->_next = newnode;
//newnode->_prev = prev;
//_head->_prev = newnode;
//newnode->_next = _head;
//++_size;
insert(end(), x);
}
所以接下来要干的第一件事就是实现一个右值引用版本的push_buck函数,那么这里的实现很简单直接修改一下参数类型调用insert函数即可,那么这里的代码:
void push_back(const T&& x)
{
insert(end(), x);
}
而insert函数也没有右值引用的版本,所以我们还得添加一个右值引用版本的insert函数,那么这里的代码就如下:
iterator insert(iterator pos, T&& x)
{
Node* newnode = new Node(x);
Node* prev = pos._pnode->_prev;
Node* cur = pos._pnode;
prev->_next = newnode;
newnode->_prev = prev;
cur->_prev = newnode;
newnode->_next = cur;
++_size;
return iterator(newnode);
}
这里大家要注意因为这里是右值版本的insert函数,会转移数据内部的内容,所以这里不能加const来修饰,我们可以看到这里insert函数的逻辑是通过创建节点的方式来实现插入,那么我们是不是还得添加一个右值版本的Node构造函数呀对吧,所以这里的代码就如下:
list_node( T&& x )
:_next(nullptr)
, _prev(nullptr)
, _data(x)
{}
我们再将上面的代码运行以下就可以看到下面这样的结果:
经过上面的修改我们发现这里好像没有起任何的作用,那这是为什么呢?那么这里我们可以通过调试来发现这里的问题:首先可以看到因为参数是右值所以这里确实调用了右值版本的push_buck函数:
但是再往下走我们却发现这里的insert调用的确实左值版本的insert:
那这是为什么呢?我们上面说右值引用之后编译器会再开辟一个空间用来存储右值的数据,而且这个空间的地址是可以通过取地址操作符得到的,那这是不是就说明一个右值经过右值引用之后他的属性会变成了一个左值啊,所以这里就会调用左值版本的insert函数,所以这里我们就得在调用insert函数的时候使用move修改一下变量的属性,那么这里的代码就如下:
void push_back( T&& x)
{
insert(end(), move(x));
}
同样的道理insert函数也调用构造函数,所以这里是不是也得使用move来修饰一下让其调用右值版本的insert那么这里的代码就如下:
iterator insert(iterator pos, const T& x)
{
Node* newnode = new Node(move(x));
Node* prev = pos._pnode->_prev;
Node* cur = pos._pnode;
prev->_next = newnode;
newnode->_prev = prev;
cur->_prev = newnode;
newnode->_next = cur;
++_size;
return iterator(newnode);
}
可是当我们再运行一下代码却会发现这里还是没有改变:
那么这是因为在构造函数函数里面还嵌套了一层,拷贝构造data的时候没有使用move,那么这里的代码就如下:
list_node( T&& x = T())
:_next(nullptr)
, _prev(nullptr)
, _data(move(x))
{}
再运行一下代码就可以看到这里结构就和库中的是一样的了:
万能引用
在上面的代码中我们可以发现面对左值我们得写一个函数来单独进行处理,面对右我们又得写一个函数来单独进行单独处理,比如说下面这个简单的例子:
void func1(int& x)
{
cout << "func1(int &)" << endl;
}
void func1(int&& y)
{
cout << "func2(int &&)" << endl;
}
int main()
{
int x = 10;
func1(x);
func1(10);
return 0;
}
这段代码的运行结果如下:
这样写是不是就很麻烦啊,那我们能不能把这两个合二为一呢?我们知道右值引用不能引用左值,只能引用move以后的左值,而左值引用不能引用右值只有const左值引用才能引用左值,但是模板的右值引用即可以引用左值也可以引用右值,我们把这样的引用称为万能引用,这里不仅能传左值和右值还能传const左值和const 右值,比如说下面的代码:
template<class T>
void func1(T&& x)
{
cout << "func1" << endl;
}
int main()
{
int x = 10;
const int y = 20;
func1(x);//左值
func1(10);//右值
func1(y);//const 左值
func1(move(y));//const 右值
return 0;
}
代码的运行结果如下:
这里大家就可以这么进行理解,如果你传过来的参数类型是左值的话这里的函数参数类型就会折叠一下减少一个&将其识别成为左值,如果你传递过来的参数类型是右值的话这里的函数参数类型就会原封不动的识别成为右值,并且在这个函数里面还可以对这个参数进行更改比如说下面的代码:
template<class T>
void func1(T&& x)
{
cout << "func1 :" <<++x <<endl;
}
int main()
{
int x = 10;
const int y = 20;
func1(x);//左值
func1(20);//右值
//func1(y);//const 左值
//func1(move(y));//const 右值
return 0;
}
当然如果传递过来的是const类型的左值或者右值的话这里是不允许修改的,但是这里就会存在一个问题比如说下面的代码:
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<class T>
void func1(T&& x)
{
Fun(x);
}
int main()
{
int x = 10;
const int y = 20;
func1(x);//左值
func1(20);//右值
func1(y);//const 左值
func1(move(y));//const 右值
return 0;
}
代码的运行结果如下:
我们之前说当右值引用,引用了一个右值之后他的右值属性就会变成左值,所以在函数里面再调用其他函数的话就会调用其左值的版本,所以上面的运行结果没有一个是调用右值的,那这里能不能使用move将其改变一下呢?答案是不行的因为move以后本来应该是左值的变量也变成了右值,上面的代码也就会变成下面这样的结果:
所以这个时候就有了一个东西叫做完美转换forward<T>(t)
这个就是保持t原有的属性,你传过来的是左值那么这个参数的属性就是左值,如果你传递过来的是右值那么这个参数的属性就是右值,比如说下面的代码:
void func1(T&& x)
{
//保持原来的属性
Fun(std::forward<T>(x));
}
将其修改成这样之后再运行上面的代码就可以看到正确的运行结果:
看到这里想必大家已经知道了完美转换的功能,那么这里就存在一个问题我们上面实现list的时候用实现了两个版本的push_back,insert,构造函数,那有了万能引用和完美转换之后能不能将上面个的两个版本合二为一呢?答案是不行的因为类模板在容器创建的时候就已经实例化出来了,这个时候再调用其内部函数的时候就已经确定了是左值还是右值,无法再根据函数传递过来的参数再来进行实例化,那么这就是万能引用的讲解。
默认移动构造和移动赋值
针对移动构造函数和移动赋值运算符重载有一些需要注意的点如下:如果你没有自己实现移动构造函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个。那么编译器会自动生成一个默认移动构造。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造。如果你没有自己实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动赋
值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值。(默认移动赋值跟上面移动构造完全类似)如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值。