1. 引用
1.1 左值引用
C++中提出引用的概念,引用本质就是给变量起别名。
- 普通引用只能引用左值,常引用既可以引用左值,也可以引用右值。
当类类型的变量作为函数参数时
#include<iostream>
using namespace std;
class A
{
public:
A(int data = 0) : m_data(data)
{
}
A(const A& that)
{
cout << "拷贝构造" << endl;
m_data = that.m_data;
}
//为了支持对象调用定义为常函数
void print() const
{
cout << "m_data = " << m_data << endl;
}
private:
int m_data;
};
//为了支持对象,就需要定义为常引用
void fun(const A &a) //如果不加const就会多调用一次拷贝构造
{
a.print();
}
int main()
{
int a = 1;
int& r_a = a;
cout << "a = " << a << endl;
cout << "r_a = " << r_a << endl;
cout << "&a = " << &a << endl;
cout << "&r_a = " << &r_a << endl;
A c_a(10);
fun(c_a);
//常对象
const A c_b(20);
fun(c_b);
return 0;
}左值和右值
值得一提的是,左值的英文简写为"lvalue",右值的英文简写为"rvalue"。很多人认为它们分别是"left value", "right value"的缩写,其实不然。- lvalue是"loactor value"的缩写,可意为存储在内存中,有明确存储地址(可寻址)的数据
- rvalue译为"read-value",指的是那些可以提供数据值的数据(不一定可以寻址,例如存储于寄存器中的数据)
通常情况下,判断某个表达式是左值还是右值,最常用的有以下2种方法:
- 可位于赋值号(=)左侧的表达式就是左值,反之,只能位于赋值号右侧的表达式就是右值。
- 举个例子:cpp其中变量a就是一个左值,而字面量5就是一个右值,C++中的左值也可以当做右值使用。
int a = 5; 5 = a; //错误,5不能为左值- 例如:cpp
int b = 10; //b是一个左值 a = b; //a,b都是左值,只不过将b可以当做右值使用
- 例如:
- 举个例子:
- 有名称的,可以获取到存储地址的表达式即为左值,反之则是右值。
- 以上定义的变量a,b为例,a和b是变量名,且通过&a和&b可以获得他们的存储地址,因此a和b都是左值
- 反之,字面量5,10,它们既没有名称,也无法获取其存储地址(字面量通常存储在寄存器中,或者和代码存储在一起)
- 因此5,10都是右值。
普通的引用就是左值引用。我们平常讲的引用就是左值引用。
格式
类型 &引用名 = 左值; //只能引用左值const 类型 &引用名 = 左值/右值; //既可以引用左值,也可以引用右值。尽管如此,他还是左值引用举例
cppint a = 1; //int &r_a = 1; //error int &r_a = a; const int &r_a1 = a; const int &r_a2 = 1;
1.2 右值引用
所谓右值引用就是必须绑定到右值的引用。我们通过&&而不是&来获得右值引用。
需要注意的和声明左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化。
格式
类型 &&引用名 = 右值; //只能引用右值举例
cppint a = 1; int&& r_a1 = 1; //int&& r_a1 = a; //error 右值引用只能引用右值 r_a1 = 2; cout << r_a1 << endl;C++语法上是支持定义常量右值引用的,例如:
const int&& a = 10; //编译器不会报错但这种定义出来的右值引用并无实际用处。一方面,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限。其次,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成。
2. 移动语义
在很多情况下会发生对象拷贝的现象,对象拷贝之后就被销毁了,在这种情况下,对象移动而非对象拷贝会大幅度提升性能。对象的移动是指将一个对象的资源(如动态分配的内存、文件句柄等)从一个对象转移到另一个对象,而不是进行复制操作。移动操作比复制操作通常更高效,因为它避免了资源的复制和释放。
移动语义的格式
std::move(左值/右值);//返回值是一个右值引用举例可以看到使用移动语义时只创建了一个对象
cpp#include<iostream> using namespace std; class A { public: A(int data = 0) : m_data(data) { cout << "构造函数" << endl; } A(const A& that) { cout << "拷贝构造函数" << endl; m_data = that.m_data; } ~A() { cout << "析构函数" << endl; } void print() const { cout << "m_data = "<< m_data << endl; } private: int m_data; }; int main() { A a1(10); //A&& a2 = std::move(a1); A a2(a1); return 0; }- 在C++中,右值引用是一种特殊的引用,它只能绑定到临时对象(也称为右值)。
std::move将对象转换为右值以后,即临时对象的引用,由于临时对象已经消失了,所以无意义
cpp#include<iostream> using namespace std; class A { public: A(int data = 0) : m_data(data) { cout << "构造函数" << endl; } A(const A& that) { cout << "拷贝构造函数" << endl; m_data = that.m_data; } ~A() { cout << "析构函数" << endl; } void print() const { cout << "m_data = " << m_data << endl; } private: int m_data; }; //左值引用和右值引用可以有效重载 void fun1(const A&a) { cout << "左值引用" << endl; a.print(); }; void fun2(const A&& a) { cout << "右值引用" << endl; a.print(); }; int main() { A a1(10); A &&a2 = std::move(a1); //A a2(a1); fun1(a1); //使用 std::move 将对象转换为右值以后,该对象的状态变为未定义,所以在 std::move 之后就不应再使用该对象了。 //fun2(a2); //可以这样 A a2 = std::move(a1); fun2(a2); fun2(std::move(a1)); return 0; }- 在C++中,右值引用是一种特殊的引用,它只能绑定到临时对象(也称为右值)。
移动操作背后的思想就是将左值的处理和右值的处理分离:
拷贝操作(拷贝构造/拷贝赋值)接收左值
cppA a1(10); A a2(a1);//拷贝构造 A a3 = a1;//拷贝构造 A a4(20); a4 = a1;//拷贝赋值移动操作也分为:移动构造和移动赋值。移动操作(移动构造/移动赋值)接收右值。
移动构造和移动赋值接收非 const 的右值引用参数,除了完成资源的移动,
移动操作还必须确保移动后销毁"源对象"是安全的,而且一旦资源移动完成,源对象必须不再拥有被移动了的资源(即资源被移动后的对象所有,源对象不再拥有资源)。
由于移动操作"窃取"资源,通常不分配资源,因此通常不会抛出异常,对于不抛出异常的函数,在新标准中应该使用 noexcept 加以声明。
在C++11标准之前,如果想用其它对象初始化一个同类的新对象,只能借助类中的复制(拷贝构造函数、拷贝构造函数的实现原理很简单,就是为新对象复制一份和其它对象一模一样的数据)。
需要注意的是,当类中拥有指针类型的成员变量时,拷贝构造函数中需要以深拷贝(而非浅拷贝)的方式复制该指针成员。
拷贝构造
cpp#include<iostream> using namespace std; class A { public: A(int data = 0) : m_data(new int(data)) { cout << "构造函数" << endl; } A(const A& that) { cout << "拷贝构造函数" << endl; m_data = new int(*that.m_data); } ~A() { cout << "析构函数" << endl; delete m_data; } private: int *m_data; }; A getA() { return A(10); } int main() { A a = getA(); return 0; }如上所示,我们为A类自定义了一个拷贝构造函数,该函数在拷贝 m_data 指针成员时,必须采用深拷贝的方式。即拷贝该指针成员本身的同时,还要拷贝指针指向的内存资源。否则一旦多个对象中的指针成员指向同一块堆空间,这些对象将同时就会对该空间释放多次,这是不允许的。
可以看到,程序中定义了一个可返回A对象的 getA() 函数,用于在 main() 主函数中初始化A对象,其整个初始化的流程包含以下几个阶段:
- 执行 getA() 函数内部的 A(10) 语句,即调用A类的默认构造函数生成一个匿名对象。
- 执行 return A(10) 语句,会调用拷贝构造函数复制一份之前生成的匿名对象。
- 并将其作为 getA() 函数的返回值(函数体执行完毕之前,匿名对象会被析构销毁)
- 执行 A a = getA() 语句,再调用一次拷贝构造函数,将之前拷贝得到的临时对象复制给a
- (此代码执行完毕, getA() 函数返回的临时对象会被析构)
- 程序执行结束前,会自行调用A类的析构函数销毁。
注意:目前多数编译器都会对程序中发生的拷贝操作进行优化,因此如果我们使用VS 2022,MinGw等这些编译器运行此程序时,看到的往往是优化后的输出结果:
构造函数 析构函数如果不希望优化,则使用下面命令
g++ 源文件.cpp -fno-elide-constructors
则输出如下构造函数 拷贝构造函数 析构函数 拷贝构造函数 析构函数 析构函数如上所示,利用拷贝构造函数实现对a对象的初始化,底层实际上进行了2次拷贝(而且是深拷贝)操作。
当然,对于仅申请少量堆空间的临时对象来说,深拷贝的执行效率依旧可以接受,但如果临时对象中的指针成员申请了大量的堆空间,那么2次深拷贝操作势必会影响a对象初始化的执行效率。
事实上,此问题一直存留在以C++98/03标准编写的C++程序中。由于临时变量的产生,销毁以及发生的拷贝操作。本身就是很隐晦的(编译器对这些过程做了专门的优化),且并不会影响程序的正确性,因此很少进入程序员的视野。那么当类中包含指针类型的成员变量,使用其它对象来初始化同类对象时,怎样才能避免深拷贝导致的效率问题呢?
C++11标准引入了解决方案,该标准中引入了右值引用的语法,借助它可以实现移动语义。
2.1 移动构造函数
C++移动构造函数(移动语义的具体实现)
所谓移动语义,指的就是以移动而非拷贝的方式初始化类对象。简单的理解,移动语义指的就是将其他对象(通常是临时对象)拥有的内存资源"移为已用"。
以前面程序中的A类为例,该类的成员都包含一个整形的指针成员,其默认指向的是容纳一个整形变量的堆空间。当使用getA() 函数返回的临时对象初始化a时,我们只需要将临时对象的m_data 指针直接浅拷贝给a.m_data ,然后修改该临时对象中m_data 指针的指向(通常另其指向 NULL),这样就完成了a.m_data 的初始化。
思考:为什么时浅拷贝而不是深拷贝?
因为只有一个对象占有资源,所以直接把资源给新的对象占有即可
事实上,对于程序执行过程中产生的临时对象,往往只用于传递数据(没有其它的用处),并且会很快会被销毁。因此在使用临时对象初始化新对象时,我们可以将其包含的指针成员指向的内存资源直接移给新对象所有,无需再新拷贝一份,这大大提高了初始化的执行效率。
移动构造语法
由于移动操作"窃取"资源,通常不分配资源,因此通常不会抛出异常,对于不抛出异常的函数,在新标准中应该使用noexcept 加以声明g++ 源文件.cpp -fno-elide-constructors
类名(类名&& 变量名) noexcept
{
// 实现
}- 参数为"&&"类型,因为是移动操作
- 参数不必设置为const,因为需要改变
- 在构造函数后添加"noexcept"关键字,确保移动构造函数不会抛出异常
修改上述案例为移动语义
#include<iostream>
using namespace std;
class A
{
public:
A(int data = 0) : m_data(new int(data))
{
cout << "构造函数" << endl;
}
A(const A& that)
{
cout << "拷贝构造函数" << endl;
m_data = new int(*that.m_data);
}
A(A&& that) noexcept
{
cout << "移动构造函数" << endl;
m_data = that.m_data;
that.m_data = NULL;
}
~A()
{
cout << "析构函数" << endl;
delete m_data;
}
private:
int *m_data;
};
A getA()
{
return A(10);
}
int main()
{
A a = getA();
return 0;
}可以看到,在之前A类的基础上,我们又手动为其添加了一个移动构造函数,和其它构造函数不同。此构造函数使用右值引用形式的参数,又称为移动构造函数,并且在此构造函数中,m_data指针变量采用的是浅拷贝的复制方式,同时在函数内部重置了that.m_data,有效避免了"同一块对空间释放多次"情况的发生。
使用g++ test.cpp -fno-elide-constructors -o test编译后的结果如图所示
PS D:\Rescours\C++\Demo\Day@IDemo_C++> g++ test.cpp -fno-elide-constructors -o test
PS D:\Rescours\C++\Demo\Day@IDemo_C++> .\test.exe
构造函数
移动构造函数
析构函数
移动构造函数
析构函数
析构函数通过执行结果我们不难得知,当为A类添加移动构造函数之后,使用临时对象初始化对象过程中产生的2次拷贝操作,都转由移动构造函数完成。
为什么由原来的调用拷贝构造函数变成了移动构造函数?
我们知道,非const右值引用只能操作右值,程序执行结果中产生的临时对象(例如函数返回值、lambda 表达式等)既无名称也无法获取其存储地址,所以属于右值。当类中同时包含拷贝构造函数和移动构造函数时,如果使用临时对象初始化当前类的对象,编译器会优先调用移动构造函数来完成此操作。只有当类中没有合适的移动构造函数时,编译器才会退而求其次,调用拷贝构造函数。
思考下面的语句调用什么函数?
A a1(10);
A a2(a1); //拷贝构造在实际开发中,通常在类中自定义移动构造函数的同时,会再为其自定义一个适当的拷贝构造函数。由此当用户利用右值初始化类对象时,会调用移动构造函数;使用左值(非右值)初始化类对象时,会调用拷贝构造函数。
默认情况下,左值初始化同类对象只能通过拷贝构造函数完成,如果想调用移动构造函数,则必须使用右值进行初始化。std::move(左值/右值);//它可以将左值强制转换成对应的右值形式,由此便可以使用移动构造函数
move本意为"移动",但该函数并不能移动任何数据,它的功能很简单,就是将某个左值强制转化为右值基于move()函数特殊的功能,其常用于实现移动语义。
#include<iostream>
using namespace std;
class A
{
public:
A(int data = 0) : m_data(new int(data))
{
cout << "构造函数" << endl;
}
A(const A& that)
{
cout << "拷贝构造函数" << endl;
m_data = new int(*that.m_data);
}
A(A&& that)
{
cout << "移动构造函数" << endl;
m_data = that.m_data;
that.m_data = NULL; //这一步很重要,不置空会引起double free异常
}
~A()
{
cout << "析构函数" << endl;
//在C++中,对空指针执行delete操作是安全的。
//当你尝试对一个空指针执行delete操作时,编译器或运行时环境会检查指针是否为空,
//如果为空,则不会执行任何操作。因此,对空指针执行delete操作不会产生任何副作用或错误。
delete m_data;
}
void print()
{
cout << "*m_data = " << *m_data << endl;
}
private:
int* m_data;
};
int main()
{
A a1(10);
A a2(a1);
a1.print();
a2.print();
A a3 = std::move(a1);
//注意,调用拷贝构造函数,并不影响A对象,但如果调用移动构造函数,由于函数内部会重置m_data 指针的指向为NULL
//a1.print();//error a1.m_data已经是空指针,不再拥有内部资源
a3.print();
return 0;
}通过观察程序的输出结果,以及对比a2和a3初始化操作不难得知,A对象作为左值,直接用于初始化a2对象,其底层调用的是拷贝构造函数。而通过调用move()函数可以得到A对象的右值形式,用其初始化a3对象,编译器会优先调用移动构造函数。
移动语义只是让对象失去了原有的资源,并不影响析构。
当类中有其他类类型的成员变量时,如果该类以移动语义创建,则调用移动语义。如果以拷贝构造创建,则调用拷贝构造
#include <iostream>
using namespace std;
class first {
public:
first():num(new int(0))
{
cout << "first construct!" << endl;
}
//移动构造函数
first(first&& d) :num(d.num)
{
d.num = NULL;
cout << "first move construct!" << endl;
}
//拷贝构造函数
first(first& d)
{
num = new int(*d.num);
cout << "first copy construct!" << endl;
}
~first()
{
cout << "first deconstruct!" << endl;
delete num;
}
public: //这里应该是 private,使用 public 是为了更方便说明问题
int* num;
};
class second
{
public:
second() :fir() {}
//用 first 类的移动构造函数初始化 fir
second(second&& sec) :fir(move(sec.fir))
{
cout << "second move construct" << endl;
}
second(second& sec) :fir(sec.fir)
{
cout << "second copy construct" << endl;
}
public: //这里也应该是 private,使用 public 是为了更方便说明问题
first fir;
};
int main()
{
second oth;
second oth2 = move(oth);
//second oth3 = oth;
//cout << "oth.fir.num << endl; //程序报运行时错误
return 0;
}2.2 移动赋值函数
同拷贝赋值函数一样,移动语义也有移动赋值函数。
格式如下:
cpp类名& operator=(类名&& 对象名) noexcept { // 实现 }- 参数为"&&"类型,因为是移动操作
- 参数不必设置为const,因为需要改变
- 在函数后添加"noexcept"关键字,确保移动赋值运算符函数不会抛出异常
- 与拷贝赋值运算符一样,函数返回自身引用
- 在函数执行前,应该检测自我赋值的情况
实例
cpp#include<iostream> using namespace std; class A { public: A(int data = 0) : m_data(new int(data)) { cout << "构造函数" << endl; } A(const A& that) { cout << "拷贝构造函数" << endl; m_data = new int(*that.m_data); } A(A&& that) { cout << "移动构造函数" << endl; m_data = that.m_data; that.m_data = NULL; } A& operator=(const A& that) { cout << "拷贝赋值函数" << endl; if (this != &that) { //释放旧资源 delete m_data; //分配新资源 m_data = new int(*that.m_data); } return *this; } A& operator=(A&& that) { cout << "移动赋值函数" << endl; if (this != &that) { //释放旧资源 delete m_data; m_data = that.m_data; that.m_data = NULL; } return *this; } ~A() { cout << "析构函数" << endl; delete m_data; } void print() { cout << "*m_data = " << *m_data << endl; } private: int* m_data; }; int main() { A a1(10); A a2(20); //a2 = a1; //a2 = std::move(a1); a1 = std::move(a1); return 0; }默认情况下(任何构造函数都不写的情况下),编译器会为一个类生成以下函数:
- 一个默认的构造函数(无参构造)
X() - 一个拷贝构造函数(浅拷贝)
X(const X&) - 一个拷贝赋值函数(浅拷贝)
X& operator=(const X&) - 一个移动构造函数
X(X&&) - 一个移动赋值函数
X& operator=(X&&) - 一个析构函数
~X()
