当前位置: 首页 > article >正文

【C++ 第十八章】C++11 新增语法(2)

 

前情回顾:

【C++11 新增语法(1):1~6 点】

        C++11出现与历史、花括号统一初始化、initializer_list初始化列表、 auto、decltype、nullptr、STL的一些新变化

本文会使用到自己模拟实现的 string 和 list 类,为了更好的观察各种函数的构造过程,建议先将本文最后的 string 和 list 代码拷贝下来创建一个 string.h / list.h 文件

7 右值引用和移动语义

 7.1 左值引用和右值引用

传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们
之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。
 

什么是左值?什么是左值引用?
左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址+可以对它赋
,左值可以出现赋值符号的左边,右值不能出现在赋值符号左边。定义时const修饰符后的左
值,不能给他赋值,但是可以取它的地址(可以赋值:定义时;且可以取地址,因此 const int 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;
}

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

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);

	// 这里编译会报错:error C2106: “=”: 左操作数必须为左值
	// 下面这三个不是左值,都是右值,因此不能赋值
	10 = 1; 
	x + y = 1; 
	fmin(x, y) = 1; 
	return 0;
}

需要注意的是右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,且可
以取到该位置的地址,也就是说例如:不能取字面量10的地址,但是rr1引用后,可以对rr1取地
址,也可以修改rr1。如果不想rr1被修改,可以用const int&& rr1 去引用,是不是感觉很神奇,
这个了解一下实际中右值引用的使用场景并不在于此,这个特性也不重要。

这里也隐含的说明了,右值引用 其实也是一种左值(使用一个 “左值” 存储右值,所以可以取地址和修改值)

int main()
{
	double x = 1.1, y = 2.2;
	int&& rr1 = 10;   // 10 是右值,rr1 是右值引用
	const double&& rr2 = x + y;   // x + y 是右值,rr2 是右值引用
	rr1 = 20;
	rr2 = 5.5;  // 报错:rr2 被 const 修饰了
	return 0;
}

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

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

int main()
{
	// 左值引用只能引用左值,不能引用右值。
	int a = 10;
	int& ra1 = a; // ra为a的别名

	//int& ra2 = 10;   // 编译失败,因为10是右值
	// const左值引用既可引用左值,也可引用右值。
	const int& ra3 = 10;  // 10 是右值
	const int& ra4 = a;   // a 是左值
	return 0;
}

右值引用总结:
1. 右值引用只能右值,不能引用左值。
2. 但是右值引用可以move以后的左值。

int main()
{
	// 右值引用只能右值,不能引用左值。
	int&& r1 = 10;

	// error C2440: “初始化”: 无法从“int”转换为“int &&”
	// message : 无法将左值绑定到右值引用
	int a = 10;
	int&& r2 = a;

	// 右值引用可以引用move以后的左值
	int&& r3 = std::move(a);
	return 0;
}

 move是一个函数,传入左值对象或右值对象,返回一个带有右值属性的该对象


7.3 右值引用使用场景和意义

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

⭐我们之后的讲解会围绕我们自己模拟实现 string 的代码 ,该 string.h 代码在文章的末尾 

🚩关于 move 函数的作用

当需要用右值引用引用一个左值时,可以通过 move 函数将左值转化为右值。C++11中,std::move() 函数位于 头文件中,该函数名字具有迷惑性,
它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义

🚩左值引用的使用场景:

做参数和做返回值都可以提高效率。

void func1(my::string s)
{}
void func2(const my::string& s)
{}
int main()
{
	my::string s1("hello world");
	// func1和func2的调用我们可以看到左值引用做参数减少了拷贝,提高效率的使用场景和价值
	func1(s1);   // 传值过去 func1,需要一次拷贝
	func2(s1);  //   传值过去 func2 ,引用接收,无需拷贝

	// 在上面的 string.h 代码中
	// string operator+=(char ch) 传值返回存在深拷贝
	// string& operator+=(char ch) 传左值引用没有拷贝提高了效率
	s1 += '!';
	return 0;
}

🚩左值引用的短板:

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

两次拷贝构造:先拷贝构造生成一个临时对象,然后再传递临时对象回去,拷贝构造给 ret2

这个过程通常会被编译器优化,不生成临时对象,直接使用返回的对象拷贝给 ret2(少了一次拷贝)


因为本质就是 传值返回,因此这里至少还是会有一次拷贝构造

🚩使用右值引用 移动构造函数

为了更好的优化传值返回,这里引出 使用右值引用的 移动构造函数

移动构造:右值引用接收的 拷贝构造函数

string(string&& s)
{}

右值又分为:

纯右值:内置类型右值

将亡值:类内置的右值(如 自定义类型的 匿名对象)

将亡值,顾名思义即是即将要销毁消亡的数据值

传值返回会生成一个临时对象,该临时对象即是将亡值,拷贝给其他变量后会销毁掉,既然使用完就会销毁,不如直接将该 临时对象的资源 swap 转移交换给 其他变量,这样只需几步swap交换程序,无需进行深拷贝(消耗不少效率),这就是移动构造的原理

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

调用拷贝构造函数时,识别到参数为右值,则会匹配到下面这个移动构造,使用 string&& 右值引用接收,然后将该右值s(将亡值)的资源直接交换

// 移动赋值
string& operator=(string&& s)
{
	cout << "string& operator=(string&& s) -- 移动拷贝" << endl;

	swap(s);
	return *this;
}

 

小结:如果拷贝对象是右值(将亡值),则调用移动构造函数,直接资源交换;否则调用深拷贝构造,执行深拷贝

移动构造使得 传值返回 真正的没有了 拷贝构造:

(1)编译器优化前传值返回,一次拷贝构造生成临时对象(右值),再调用移动构造

(2)编译器优化后传值返回,不生成临时对象(右值),本来返回对象需要一次拷贝构造,此时编译器会将 返回对象move成一个右值属性的对象,则整个过程只需要调用一个移动构造

(编译器自动将 函数返回值 move 成一个右值,即用即销毁,出了函数作用域就销毁,因此此处转换成 右值不会有事)


深拷贝的类,移动构造才有意义,像是日期类这样的浅拷贝类型,就没有必要上 移动构造

(日期类只有内置类型的成员变量)

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

在 string 类中增加移动赋值函数,再去调用 to_string(1234),不过这次是将 to_string(1234) 返回的右值对象赋值给ret1对象,这时调用的是移动赋值。

// 移动赋值
string& operator=(string&& s)
{
	cout << "string& operator=(string&& s) -- 移动拷贝" << endl;

	swap(s);
	return *this;
}

调用 to_string 函数,传值 1234 ,函数传值返回一个临时对象(右值)此时 ret 进行赋值操作,识别到函数返回一个右值,则调用移动赋值函数,直接交换资源即可,避免了深拷贝

my::string to_string(int value) {
	//....
	return str;
}

my::string ret;
ret = to_string(1234);

一一解析这里每条打印信息:

main函数中创建一个 string的 ret ,调用直接构造

to_string 函数中创建一个 string 的 str,调用直接构造

to_string 函数返回 str,由于这里是赋值操作,不是拷贝操作(编译器无法直接优化),需要生成临时对象,不过编译器聪明的识别出 str 是即将销毁的值(出了作用域销毁),因此将 str move 转换为 右值,调用 移动拷贝构造 生成临时对象(右值),该临时对象传回main函数中,因为是右值,则调用 移动赋值函数,赋值给 ret 变量

🚩push_back 函数重载 右值引用版本(借助 list<string> 的 push-back 使用举例)

由于 右值是将亡值,可以调用移动拷贝 和 移动赋值,极大提高效率

因此,C++11 右值出现后,其他各种库函数都增加了 以右值引用作为参数的重载,提高不少效率

如上图:push_back 一个对象 s1,则调用深拷贝

push_back 一个匿名对象 s1 ,调用 移动构造(匿名对象是右值)

 

因此,如果自己模拟实现 list 的话,list 内部还要写另一个右值引用版本的 push_back 函数

insert 函数也要写 右值引用版本

void push_back(const T& data){
    insert(it, data);
}
// 右值引用版本
void push_back(T&& data){
    insert(it, data);
}


void insert(iterator it, const T& data)
{}
void insert(iterator it, T&& data)
{}
int main() {
    my::list<my::string> lt;
    // 匿名对象,具有常性的临时对象,在 push_back 函数参数识别成右值
    lt.push_back(my::string("22222222222222222222222222222"));

    return 0;
}

我们这里 push_back 一个右值,会调用右值版本的 push_back 函数,但是调试可以发现,insert 函数却没有如愿的调用 右值版本的 insert 函数

因为 右值引用本身的属性是左值:因此 push_back 函数的右值引用 data 是 左值

因此会调用到 非右值版本的 insert 函数 insert(iterator it, const T& data)

为什么说 右值引用 需要是左值:上面刚学的移动构造中 swap(s),这个 右值引用 s 必须是可以修改的,才能进行资源交换,如果是右值则不能修改

因此,需要使用 move 将右值引用的左值属性 修改成 右值属性

// 右值引用版本
void push_back(T&& data){
    insert(it, move(data)); // move一下
}


void insert(iterator it, T&& x){
    Node* newNode = new Node(move(x));  // move一下
    //....
}


// 构造函数也要右值版本
ListNode(T&& x)
    :_data(move(x))  // move一下
    // ....
{}

从这里就知道,传递一个 右值,容易“退化成”左值,如果想要 右值属性保持不变,需要在右值传递的路径上 使用 move将 右值引用转化为右值继续传递

🚩【代码演示】对 list 的 push_back 的各种使用

注释已经讲得比较清楚了 

int main()
{
    my::list<my::string> lt;    // 创建一个以string为节点数据类型的 list:其中创建头节点,会 “直接构造”一个 string,调用头节点构造函数,给 string 赋初值 T(),调用 string 的“拷贝构造”
    my::string s1("111111111111111111111");  // 调用“直接构造”
    lt.push_back(s1);  // 因为 list 节点类型为 string,这里会调用“拷贝构造”存储数据(相当于给新节点里面已存在的 string,重新拷贝)


    // 匿名对象,具有常性的临时对象,在 push_back 函数参数识别成右值:调用右值引用版本的 push_back,这个push_back 调用 右值引用版本的 insert ,这个 insert 里面需要创建新节点,则调用 移动构造函数(因为一路上的传递都使用 move 保持了右值属性,最后就能被识别成右值而调用移动构造)
    lt.push_back(my::string("22222222222222222222222222222"));

    // 隐式类型转换成 string,中间生成string的临时对象识别成右值:"直接构造" 一个 string,这个string是临时对象,push进链表调用 "移动构造"
    lt.push_back("3333333333333333333333333333");

    // 这里直接将 s1 变成右值,则底层直接调用移动构造则不用拷贝构造:将左值 s1 move返回一个 右值属性的 s1对象
    lt.push_back(move(s1));


    // 注意:move是一个表达式,将 s1 放进去,返回一个 右值类型的 s1,而并不会将 s1 本身属性改变,因此下面的 push_back 还是原来的 s1
    move(s1); // 没有改变 s1 属性,只是move返回一个 右值类型的 s1
    lt.push_back(s1);

    my::string&& r1 = my::string("22222222222222222222222222222");
    // r1(右值引用本身)的属性是左值还是右值?-> 左值

    return 0;
}

因此,以后传数据,使用 匿名对象或隐式类型转换的数据,会调用移动函数,代码运行效率都会大幅提升

7.4 左右值引用的底层

在汇编底层,左值引用和右值引用都是 指针,原理都相同!!

左值引用和右值引用 底层都是相同的程序概念

只是在 语法层面有了限制和区分,如果左值引用一个右值,会报错,也只是表面的语法层不允许,底层是可以的

因此 一个左值可以 move 或者类型强制转换 成 一个带有右值属性的对象

int x = 10;
string&& r1 = move(x);
string&& r2 = (string&&)x;

⭐关于 C++的语法的“口是心非”

在语法层,说右值不能取地址,其实底层是使用一块临时空间存储该右值,且该空间也是有地址的

还有说 左右值引用就是没有取地址,没有开空间,只是取了一个别名 而底层确实取地址开空间

因此,C++的语法很多时候都“口是心非”,当语法层和底层的表现不一样时,一定要注意区分,学习C++不要过度关注底层,明白底层和语法层两者有时的表现是不一样的就行

但也不能不了解 底层,否则下面这句代码可能就难理解

将左值 x 强转只是为了让其通过语法层的检验而已,他们底层本来就相同

string&& r2 = (string&&)x;

其实左右值没有这么复杂,两者是可以通过类型转换来回直接切换的

void func(const my::string& s)
{
    cout << "void func(bit::string& s)" << endl;
}

void func(my::string&& s)
{
    cout << "void func(bit::string&& s)" << endl;
}


int main()
{
    my::string s1("1111111");
    func(s1);

    func((my::string&&)s1); // 左值强转成右值


    func(my::string("1111111"));  // 右值
    func((my::string&)my::string("1111111"));   // 右值强转成左值

    return 0;
}

⭐为什么平时一般都使用 move 而不是 强转来将左值变成右值?

因为强转需要写具体类型,限制性大

7.5 万能引用

关于 函数的左右值引用 参数

// 函数参数可以是左值引用也可以是右值引用
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; }

关于 模板的左右值引用参数 

(T&& x) 这里不叫右值引用:是万能引用,同时可以接收 左右值引用,会自己推导数据是 左值 or 右值(因此具有模板的”适配性“)

template<class T>
void Func(T&& x) {
	​
}

前段文章中讲解的某些函数,传过来的既有左值也有右值,因此既要考虑左值引用,也要考虑右值引用,(可能就需要写两个不同参数的 重载函数),这个函数模板就可以帮助解决这个问题

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;
}

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

7.6 完美转发

// 函数参数可以是左值引用也可以是右值引用
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);  // 右值
	return 0;
}

模板 PerfectForward 接收右值,右值退化成 左值,如果想要维持住 右值属性,可以使用完美转发

完美转发会自动识别 该对象的属性,并维持其本身的属性:

模版实例化是左值引用,保持属性直接传参给 Fun
模版实例化是右值引用,右值引用属性会退化成左值,转换成右值属性再传参给Fun

为什么不能使用move 直接将 右值引用修改成 右值属性?

因为这个是模板,也可能接收 左值,如果一律使用 move 会导致左值也变成右值

因此,在这类可能会接收左右值的模板,就需要 使用完美转发,维持属性

其他场景,一般使用 move 即可

// 函数参数可以是左值引用也可以是右值引用
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(forward<T>(t));  // 使用完美转发
}

int main() {
	PerfectForward(10);  // 右值
	return 0;
}

再次声明,模板中的双引用&& 不是右值引用,而是万能引用!

下面这个 Args&& 不是右值引用, 这个是函数模板,Args&& 是识别 左右值的“万能引用”


8. 新的类功能

8.1 新增的两个默认类

默认成员函数
原来C++类中,有6个默认成员函数:
1. 构造函数
2. 析构函数
3. 拷贝构造函数
4. 拷贝赋值重载
5. 取地址重载
6. const 取地址重载

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

C++11 新增了两个:移动构造函数和移动赋值运算符重载

针对移动构造函数和移动赋值运算符重载有一些需要注意的点如下:

如果你自己没有实现移动构造函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个(指这三种一个都没显式实现)。那么编译器会自动生成一个默认移动构造。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造。

如果你自己没有实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个(指这三种一个都没显式实现),那么编译器会自动生成一个默认移动赋值。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值。(默认移动赋值跟上面移动构造完全类似)

图示过程:

如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值。

🚩为什么默认的两个移动函数的生成条件如此苛刻?

需要没有显式实现析构函数 、拷贝构造、拷贝赋值重载,且没有实现 移动函数

🚩理清一个道理:

当需要显示写析构,说明有资源需要释放

1、说明需要显示写拷贝构造和赋值重载

2、说明需要显示写移动构造和移动赋值

因此他们几个都是同时出现同时消失的

🚩为什么需要默认生成移动函数?

像日期类这样的,成员都是内置类型,没有什么资源管理,是否有移动构造都差不多,因此可以不用显式写拷贝构造和赋值重载

而像是 Person 类,没有成员管理资源,但是有 string 这种非内置类型的成员(万一string很大呢?),这种情况就需要调用默认的移动构造

Person 类的 移动构造 是为了解决成员的拷贝效率问题,不是为了解决自身的拷贝效率问题

自动生成移动构造,对于下面Person这样的类是很有意义的

因为 Person  是右值时,他内部的 string 也是右值,string就可以走移动构造,提高效率了。

#include<utility>
class Person
{
public:
	Person(const char* name = "111111111111", int age = 0)
		:_name(name)
		, _age(age)
	{}


private:
	string _name;
	int _age;
};

int main() {
	Person p1;
	p1 = "2222222222222222222222";

	return 0;
}

这里可以发现,上面的赋值过程,Person 类的 成员string,地址位置变了,就是调用了 string内部 默认的移动赋值

8.2 关于 禁止生成默认函数的关键字 delete

如果能想要限制某些默认函数的生成,在C++98中,是该函数设置成 private,并且只声明不实现,这样只要其他人想要调用就会报错(而且也不能自己实现)。

在C++11中更简单,只需在该函数声明加上 = delete 即可,该语法指示编译器不生成对应函数的默认版本,称 =delete修饰的函数为删除函数。

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

	// C++98
	//1、只声明不实现,且声明为私有
	
private:
	Person(const Person& p);
	Person& operator=(const Person & p);

	// C++11
	// 设置 delete 关键字
	Person(const Person& p) = delete;
	Person& operator=(const Person& p) = delete;
private:
	string _name;
	int _age;
};

会有一些场景:不想被别人拷贝的类,则限制不生成拷贝构造

因为 IO流 中存在缓冲区,直接被拷贝,影响效率,就禁了


http://www.kler.cn/news/284878.html

相关文章:

  • vue3+el-tale封装(编辑、删除、查看详情按钮一起封装)
  • 【HarmonyOS 4.0】@ohos.router 页面路由
  • ★ 算法OJ题 ★ 力扣11 - 盛水最多的容器
  • sqlite3 数据插入效率
  • YOLOv8改进 | 模块缝合 | C2f融合卷积重参数化OREPA【CVPR2022】
  • Having trouble using OpenAI API
  • 回归预测|基于鹅GOOSE优化LightGBM的数据回归预测Matlab程序 多特征输入单输出 2024年优化算法
  • vue3本地运行错误集
  • 5.3 MySql实战
  • Xilinx FPGA在线升级——升级思路
  • 鸿蒙开发5.0【基于Swiper的页面布局】
  • LeetCode 热题100-9 找到字符串中所有字母异位词
  • vscode 未定义标识符 “uint16_t“C/C++(20) 但是可以顺利编译
  • Java算法—插入排序(Insertion Sort)
  • 一种导出PPT到MP4的方法
  • 大数据测试怎么做,数据应用测试、数据平台测试、数据仓库测试
  • ​T​P​一​面​
  • 系统编程-消息队列
  • 力扣2116.判断一个括号字符串是否有效
  • Qt_信号槽机制
  • 计算机网络概述(网络结构)
  • MYSQL——聚合查询
  • B树及其Java实现详解
  • 续:MySQL的半同步模式
  • APO 新发版支持Skywalking Agent接入
  • unity的问题记录(信息管理)
  • 【Java设计模式】责任链模式:构建强大的请求处理机制
  • 技术成神之路:设计模式(十二)模板方法模式
  • SQL存储过程:数据库编程的瑞士军刀
  • Java中的注解(Annotation)