目录
C++11是C++语言的一个重要版本,引入了许多新的特性和改进。接下来进行这些新特性的学习!
1.nullptr的引入
在C语言中,NULL表示空地址。而C++中NULL被定义为字面量0。
这里我们通过打印x的类型名,发现NULL的类型名是int,而对于NULL既能够表示指针值,又可以能表示int值,所以C++11引入了nullptr关键字来表示空指针!
2.列表初始化
2.1.语法层面
我们先通过一段代码来学习一下,列表初始化这个新特性
struct Point
{
int _x;
int _y;
};
class Date
{
public:
Date(int year, int month, int day)
:_year(year)
, _month(month)
, _day(day)
{
cout << "Date(int year, int month, int day)" << endl;
}
private:
int _year;
int _month;
int _day;
};
// 语法层面上
// 一切都可以用列表初始化
// 并且可以省略掉=
void part1_0()
{
int i = 0;
int j = { 0 };
int k{ 0 };
int array1[] = { 1, 2, 3, 4, 5 };
int array2[]{ 1, 2, 3, 4, 5 };
int array3[5] = { 0 };
int array4[5]{ 0 };
Point p1 = { 1, 2 };
Point p2{ 1, 2 };
// 日期类需要传入参数
Date d1(2023, 11, 25);
// 类型转换 构造+拷贝构造 优化直接构造
Date d2 = { 2023, 11, 25 };
Date d3{ 2023, 11, 25 };
const Date& d4 = { 2023, 11, 25 };
Date* pd1 = new Date[3]{ d1, d2, d3 };
Date* pd2 = new Date[3]{ {2022, 11, 25}, {2022, 11, 26}, {2022, 11, 27} };
}
通过这段代码的学习,我们学习到了以后我们定义变量构造对象时可以不用在借助=操作符,可以直接通过{ }来实现,但是列表初始化只是为了实现这个功能吗?当然不是的,我们接下来通过底层来探讨列表初始化。
2.2.列表初始化的作用
如图:我们在日期类中,定义初始化函数是通过传入3个int类型的参数,当我们传入4个参数时会报错:
- “初始化”: 无法从“initializer list”转换为“Date”
- 无构造函数可以接受源类型,或构造函数重载决策不明确
这两个问题第二个是根本上的问题,因为传入的参数数量不匹配。这里涉及先进行构造再进行拷贝构造,来传入参数实现!
- 第一个报错是什么意思呢?
- 为什么vector类型可以随意的增加参数的数量而不会报错呢?
答:因为vector以及大部分的STL容器中在C++11版本后支持initializer list这个结构,不支持这个结构的结构体,无法进行多参数的调整转化,传入参数需要与构造函数参数个数匹配。
为了进一步验证这个initializer list结构的作用,我们通过手搓一个vector但是不实现initializer list模块,来探讨一下能不能进行随意地增加参数!重生之C++学习:vector-CSDN博客
我们在这个博客中最终的代码,对这段代码进行测试
结果是:我们无法的实现参数的随意增加!!!
当我们在vector模块增加一个由initializer_list支持的构造函数,我们就能够实现我们的随意增加减少我们传入的参数了!
my_vector(initializer_list<T> il)
{
reserve(il.size());
for (auto& e : il)
{
push_back(e);
}
}
讲到这里,我们已经知道了列表初始化的作用:简化代码,提供了更灵活的初始化方式。
map<string, string> dict = {{"insert", "插入"}, {"get","获取"} };
for (auto& kv : dict)
{
cout << kv.first << ":" << kv.second << endl;
}
如上,我们通过初始化列表对map的便捷初始化
2.3.initializer list
initializer list本质上就是一个类,内部封装着这几个函数
通过这段代码我们发现il1的类型是初始化列表,也就是我们通过方括号的形式传入参数的本质就是通过传入初始化列表这个对象给对应的容器,来实现容器的构造,那么大部分的容器也就是通过支持传入initializer list这个对象的构造函数来实现{ }的多个参数的传入,而对于没有实现这个构造函数的自然就必须按照自身的构造函数规则进行了。
// the type of il is an initializer_list
auto il1 = { 10, 20, 30, 40, 50 };
cout << typeid(il1).name() << endl;
3.auto和decltype
auto 和 decltype都可以推导对象的类型,而auto是用来定义变量时,作为一个简易的语法糖,不能够用来实例化对象。
// auto推导类型,其中i是已定义的一个int类型,d是double类型
auto j = i;
auto ret = i * d;
// 类型以字符串形式获取到
cout << typeid(j).name() << endl;
cout << typeid(ret).name() << endl;
打印内容为:int和double,这里auto分别推导出j、ret两个变量的类型
// auto在设置时并没有实例化作为实际类型的能力,auto应用场景不足
vector<auto ret> v1;
auto (ret) y;
假设我们在某些场景中,需要推导出一个复合类型的实际类型是什么,并且将这个类型用STL容器存放,显然在上面的讲解中我们发现auto无法胜任这个场景。所以C++11提供了decltype这个关键字。
// 用ret的类型去实例化vector
// decltype可以推导对象的类型。
// 可以用来模板实参,或者再定义对象
vector<decltype(ret)> v;
v.push_back(1);
v.push_back(1.1);
for (auto e : v)
{
cout << e << " ";
}
cout << endl;
decltype跟auto一致都可以推导出对象的类型,不过decltype可以作为具体的对象类型来使用。
4. 右值引用
4.1.左值和右值
左值(lvalue)
左值是指那些可以出现在赋值操作左侧的表达式。它们通常表示一个对象的身份,即它们在内存中的位置。左值有一个持久的存储位置,并且可以通过取地址操作符
&
获得其地址。大部分变量(包括全局变量、局部变量、静态变量等)都是左值。
// 左值 表示可以取地址的值
int i = 0;
int j = i;
右值(rvalue)
右值是指那些只能出现在赋值操作右侧的表达式。它们通常表示临时对象或即将被销毁的对象,因此没有持久的存储位置。右值不能通过取地址操作符
&
获取其地址。在C++11之前,右值主要包括字面量、临时对象以及函数调用返回的非引用对象。
// 右值
10;
int func();
i + j;
func();
一言以蔽之:只要某一个值能取到地址的就是左值,右值无法取到地址,因为临时变量和将要销毁的变量的地址在当前生命周期即将释放。
4.2.左值和右值引用
// 左值引用
int& r_i = i;
int& r_j = j;
// 10虽然是右值,但是const int&属于左值引用,对常量
const int& r_c = 10;
// 右值不能进行左值引用
// int& r1 = 10;
// int& r2 = i + j;
// 右值引用,对临时值、常量进行引用
int&& rr1 = 10;
int&& rr2 = i + j;
int&& rr3 = func();
// 右值引用不能绑定左值
// int&& rr4 = i;
// 通过move可以将左值进行右值引用
int&& rr4 = move(i);
左值引用的语法是一个&,而右值引用是&&,并且左值引用不能绑定右值,右值引用不能绑定左值,但是我们可以通过move函数实现右值引用绑定左值。
4.3.右值引用和移动语义
在C++11中,引入了右值引用(rvalue reference)的概念,使用
&&
符号表示。右值引用允许我们绑定到右值,从而可以高效地处理临时对象或即将被销毁的对象,避免不必要的拷贝操作。这种技术被称为移动语义(move semantics)。通过移动语义,我们可以将资源(如动态分配的内存、文件句柄等)从一个对象“移动”到另一个对象,而不是复制它们。这通常比复制更快,并且可以避免不必要的资源分配和释放。
另外:右值引用和移动语义一般用于具有深拷贝的场景
那么接下来我们通过三个场景的学习来体会一下右值引用和移动语义……
part1:
首先我们要知道为什么我们可以使用左值引用?左值引用需要可以取得到左值的地址,因为左值的在当前模块的声明周期较长。
int func()
{
int i = 10;
int& j = i;
return i;
}
在这个场景中,我们能修改func的返回类型为int&(左值引用返回值)吗,很显然是不行的,因为i变量在函数模块中可以被引用为j,这时他的生命周期较长。而当我们return时,这个i已经变成了即将销毁的对象(将亡值),所以不能够被引用了,这也就是左值引用无法解决的场景。
那么这时我们修改一下,将这个函数变成右值引用返回,这时这个问题就解决了。
int&& func()
{
int i = 10;
int& j = i;
return move(i);
}
int main()
{
int&& rr1 = func();
}
联系一下右值引用和移动语义的作用,会减少不必要的拷贝工作,如图我们发现在func中的i的地址最终移动到了rr1中
part2:
但是减少不必要的拷贝没有得到较好的体现, 接着我们用新的场景来体现,代码部分还是从以往博客的综合处获得 重生之C++学习:string的实现-CSDN博客
void test()
{
myString::my_string s1;
s1 = myString::to_string(1234);
for (auto& e : s1)
{
cout << e << " ";
}
}
我们写一个测试函数,在这个函数中我们想要实现的逻辑创建字符串对象s1,然后通过to_string函数实现数字转化为字符串形式。接下来我们来研究一下实现这个功能的代码
主要是因为我们当前的my_string这个类中,只支持了左值引用的拷贝构造函数,和=操作符重载,而当我们希望将右值的这个ret通过这两个函数传入給s1时,需要进行拷贝构造出一个临时对象,然后在=给s1,这里就多了一步临时变量的创建和销毁,因此在上图中我们发现ret和s1对应的_str存在两份地址,但是明明表示的就是一个东西。
这里的原因是:编译器会默认将右值通过构造一个新的左值来进行左值引用,所以并不是右值可以作为左值引用类型的参数。所以就会出现这个新的左值可能带来不必要的拷贝,尤其是一些复杂的类型(因为内部可能维护了很多变量)。
接下来我们给这个string支持右值引用,这里利用了编译器会自动选择更加符合的类型的函数进行调用,那么对于右值引用类型就不会去创建一个临时变量,来实现左值引用传入了。那么就减少了这一步临时变量的创建和释放
my_string(my_string&& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
my_string& operator=(my_string&& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
return *this;
}
接着我们再次打开调试窗口 发现 我们支持右值引用后,ret和s1的_str共用一块地址了,也就是地址实现了从一个将要释放的快要结束生命周期的临时变量移动到了另一个对象这个就是移动语义。
所以使用“右值引用”可以帮助我们完成一些左值引用存在缺陷的场景,这些场景往往都是需要额外开辟和释放空间来实现,右值往左值的转化,来间接进行左值的操作,而C++11提供右值引用这个语法来进行右值直接向生命周期长的对象进行转移。
part3:
当我们有了移动构造后,C++11就支持了效率高的函数传值返回
vector<vector<int>> Func()
{
vector<vector<int>> vv;
// 进行操作A
return vv;
}
void Func1(vector<vector<int>>& vv)
{
// 进行操作A
}
void test()
{
// C++11 直接移动构造进行高效传值返回
vector<vector<int>> ret = Func();
// C++98 为了保证效率需要传入引用操作
vector<vector<int>> ret2;
Func1(ret2);
}
这样子代码也能更加简洁,和解决了C++98传值返回会创建临时变量进行深拷贝的问题!!!
4.4.move和完美转发
4.4.1.move的探究
我们知道move的作用就是将一个左值的属性转为右值,那这个move的实际应用场景是怎么样的呢?接下来我们用个代码块来探讨一下。
void func2(const int& x)
{
cout << "进入左值" << endl;
}
void func2(int&& x)
{
cout << "进入右值" << endl;
}
// 一个右值被右值引用以后变成了左值
void func1(int&& x)
{
func2(x);
func2(move(2));
}
void test()
{
func1(1);
}
当我们在test函数中,给func1函数传入右值1,然后进入func2(),这时func2()重载了左值类型和右值类型,本着编译器会将参数传入更加匹配的重载函数,所以第一次打印的会是“进入右值”,但是实际上却是相反的……
对于右值被右值引用后,编译器会将右值的属性改为左值。
原因如下:
- 右值引用的作用,是将一个对象的资源转移到另一个资源上,那么经过右值引用后,这个对象必须能够被修改,才能实现对象本身的资源转移
- 右值是不可以被修改的,所以当进行右值引用后,编译器会将属性变为左值
这个就是move的使用场景了,通过move我们把因为进入右值引用变成左值的右值,转化为右值,来实现连续多个右值的场景。
这里我们以STL的容器为例:C++11,STL的容器都支持了右值引用这个语法,也就是list、vector、string这类的容器都支持了这个语法。所以如果我们在一个嵌套的容器中插入右值,那么我们就需要通过move函数保障每一个模块都为右值传给下一个右值引用。
我们发现当在嵌套的容器中传入一个右值,我们需要不断的实现迭代,让每一次传给下一个右值引用的参数都为右值,这样子我们才能保障移动语义的可行性,才能实现对象的转移,减少不必要的拷贝了,这就是move的实际场景的作用了。
另外,move只是一个函数,move后的返回值变成右值,但是原变量依旧是左值
4.4.2.完美转发
C++中的完美转发是指在函数模板中将参数以原样传递给其他函数,同时保留参数的值类别(左值或右值)。这样可以实现更灵活的参数传递,特别是在涉及到转发函数参数时非常有用。
void func2(const int& x)
{
cout << "进入左值" << endl;
}
void func2(int&& x)
{
cout << "进入右值" << endl;
}
// 完美转发
template<typename T>
void func1(T&& t)
{
func2(forward<T>(t));
}
int main()
{
func1(1);
int i = 10;
func1(i);
func1(move(i));
}
此时func1为完美转发,本质上是通过函数模版来实现“万能引用”,当我们传入右值时,通过传入下一层的forward<T>(t)使得,传入下一层的类型和我们上一层传入func1保持一致,也就是就算是传入右值夜不需要我们特别的设置为move(t),forward<T>(t)会自动转化为原类型,这样就实现了泛型编程。
5.类的延展
5.1.默认成员函数的延展
移动动构造函数(Move Constructor) 和 移动赋值运算符(Move Assignment Operator):
- 这两个函数是C++11中引入的,用于支持移动语义。它们允许对象通过资源的“窃取”而不是复制来初始化或赋值,这通常更高效。
- 如果类没有显式定义移动构造函数或移动赋值运算符,但定义了拷贝构造函数、拷贝赋值运算符或析构函数(这三个中的任何一个),则编译器不会自动生成移动构造函数或移动赋值运算符。
- 如果类中没有显式定义这些函数,并且满足某些条件(例如没有用户定义的析构函数、拷贝构造函数或拷贝赋值运算符,且所有非静态数据成员都可移动),编译器会生成默认的移动构造函数和移动赋值运算符。
而这两个延展的函数,我们在4.3这个章节中进行了主要的讲解,这里就不再赘述,值得注意的是,如果需要得到编译器自动生成的移动构造函数和移动赋值运算符,我们就不能显性的定义并实现拷贝构造函数、拷贝赋值运算符或析构函数(这三个中的任何一个),而实际开发中,析构函数一般是必不可少的,所以这个语法也大概没什么作用!(可能是我才疏学浅,望指正)
5.2.default和delete关键字
强制生成默认函数的关键字default:
C++11可以让你更好的控制要使用的默认函数。假设你要使用某个默认的函数,但是因为一些原 因这个函数没有默认生成。这时候我们就可以使用default让编译器默认生成。
class Student
{
public:
Student()
:_id("")
,_name("")
{}
// 我们提及了当有析构函数时,移动构造不会自动生成
// 但是我们可以通过default让编译器生成
Student(Studnet&& S) = default
~Student()
{}
private:
string _id;
string _name;
}
禁止生成默认函数的关键字delete:
如果能想要限制某些默认函数的生成,在C++98中,是该函数设置成private,并且只声明补丁 已,这样只要其他人想要调用就会报错。在C++11中更简单,只需在该函数声明加上=delete即 可,该语法指示编译器不生成对应函数的默认版本,称=delete修饰的函数为删除函数。
class Student
{
public:
Student()
:_id("")
,_name("")
{}
// 这种情况下,三种默认函数都没有,移动构造会自动生成
// 但是我们可以通过delete不让编译器自动生成
Student(Studnet&& S) = delete
private:
string _id;
string _name;
}
上面这两个例子主打的就是逆反心理!接着我们通过文心一言的回答来看看哪些函数可以用这两个关键字呢?
在C++中,当你定义一个类而没有显式地为其实现某些特定的成员函数时,编译器会自动为你生成这些成员函数。这些自动生成的成员函数被称为默认成员函数。以下是C++编译器默认生成的成员函数列表:
- 默认构造函数(Default Constructor):
- 如果类中没有定义任何构造函数,编译器会生成一个无参的默认构造函数。这个构造函数不执行任何操作,除非类中有虚函数或虚基类,此时会进行虚表初始化。
- 析构函数(Destructor):
- 每个类都有一个析构函数,用于在对象生命周期结束时释放资源。如果没有显式定义析构函数,编译器会生成一个析构函数,它不执行任何特定的清理操作,除非类中有成员对象需要被销毁。
- 拷贝构造函数(Copy Constructor):
- 用于创建一个新对象作为现有对象的副本。如果没有显式定义拷贝构造函数,编译器会生成一个默认的拷贝构造函数,它执行成员到成员的浅拷贝。
- 拷贝赋值运算符(Copy Assignment Operator):
- 用于将一个对象的值赋给另一个已存在的对象。如果没有显式定义拷贝赋值运算符,编译器会生成一个默认的拷贝赋值运算符,它执行成员到成员的浅拷贝。
从C++11开始,还引入了以下默认生成的成员函数:
- 移动构造函数(Move Constructor):
- 用于通过一个右值(通常是一个临时对象)来初始化对象。如果没有显式定义移动构造函数,并且满足一定条件(例如没有定义拷贝构造函数、拷贝赋值运算符、析构函数,并且所有成员都可移动),编译器会生成一个默认的移动构造函数。
- 移动赋值运算符(Move Assignment Operator):
- 用于通过一个右值来赋值给已存在的对象。与移动构造函数类似,如果没有显式定义移动赋值运算符,并且满足一定条件,编译器会生成一个默认的移动赋值运算符。
需要注意的是,这些默认生成的成员函数的行为通常是执行浅拷贝或浅移动。这意味着,如果类的成员包含指针或动态分配的资源,那么这些默认函数可能不会正确管理这些资源,导致资源泄漏或其他问题。因此,程序员应该显式定义这些函数以确保正确的资源管理。
尽管编译器默认生成看起来方便,但是实际上还是自己手搓的香啊!