引入
C++11作为C++标准的一个重要版本,引入了许多令人振奋的新特性,极大地丰富了这门编程语言的功能和表达能力。本章将为您介绍C++11的一些主要变化和改进,为接下来的章节铺垫。
文章目录
引入
一、列表初始化
1、1 {} 初始化
1、2 std::initializer_list的介绍
二、声明
2、1 auto关键字的引入
2、2 decltype关键字
2、3 nullptr
三、智能指针
四、范围for
五、STL中一些变化
5、1 新容器
5、2 新接口
六、右值引用和移动语义
6、1 左值引用和右值引用
6、1、1 左值与左值引用
6、1、2 右值与右值引用
6、2 左值引用与右值引用比较
6、3 右值引用的使用场景与意义
6、3、1 移动构造
6、3、2 移动赋值
七、总结
🙋♂️ 作者:@Ggggggtm 🙋♂️
👀 专栏:C++ 👀
💥 标题:C++11 💥
❣️ 寄语:与其忙着诉苦,不如低头赶路,奋路前行,终将遇到一番好风景 ❣️
一、列表初始化
1、1 {} 初始化
在C++98中,标准允许使用花括号{}对数组或者结构体元素进行统一的列表初始值设定。比如:
struct Point { int _x; int _y; }; int main() { int array1[] = { 1, 2, 3, 4, 5 }; int array2[5] = { 0 }; Point p = { 1, 2 }; return 0; }
在C++11中,引入了列表初始化的概念,允许我们使用花括号来初始化各种类型的对象,使其可用于所有的内置类型和用户自定义的类型,都可以使用统一的初始化语法,从而减少了初始化的歧义性和错误。 使用初始化列表时,可添加等号(=),也可不添加。具体如下:
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; }; int main() { int x1 = 1; int x2 = { 2 }; int x3 { 2 }; // 都是在调用构造函数 Date d1(2022, 11, 22); Date d2 = {2022, 11, 11}; Date d3{ 2022, 11, 11 }; return 0; }
我们发现,C++11列表初始化允许我们使用花括号来初始化各种类型的对象后,可能会在某些情况下带来一些方便,但实际上看起来还是有一点不适应的。
1、2 std::initializer_list的介绍
std::initializer_list 是 C++11 标准库中引入的一个特殊容器,它用于方便地初始化数据。它允许我们在列表初始化(也称为花括号初始化)的情况下,以逗号分隔的值列表的形式传递一组元素。
使用 std::initializer_list 可以将一组值作为参数传递给函数或构造函数,从而简化代码的编写,并提高可读性。它的主要作用是用于表示一个不可修改的序列,类似于数组或容器,但没有提供像容器操作那样丰富的接口。
下面是
s
std::initializer_list 的主要特点和用法:
定义和声明:std::initializer_list 是一个模板类,位于
<initializer_list>
头文件中。可以通过以下语法来声明并初始化一个 std::initializer_list 对象:std::initializer_list<T> list = {value1, value2, ...};
使用范围:std::initializer_list 可以用于任何需要一组值作为参数的上下文中,例如函数参数、构造函数参数等。
迭代器和大小:std::initializer_list 提供了类似容器的迭代器和大小函数,可以使用
begin()
和end()
来获取迭代器,size()
来获取序列的大小。元素访问:std::initializer_list 并不提供随机访问元素的功能,只能顺序遍历元素。可以使用 std::initializer_list 的迭代器或范围-based for 循环来访问元素。
具体我们可结合如下实例理解:
int main() { // 调用支持list (initializer_list<value_type> il)类似这样的构造函数 vector<int> v1 = { 1, 2, 3, 4, 5, 6 }; vector<int> v2{ 1, 2, 3, 4, 5, 6 }; list<int> lt1 = { 1, 2, 3, 4, 5, 6 }; list<int> lt2{ 1, 2, 3, 4, 5, 6 }; auto x = { 1, 2, 3, 4, 5, 6 }; cout << typeid(x).name() << endl; vector<Date> v4 = { { 2022, 1, 1 }, {2022, 11, 11} }; string s1 = "11111"; // 构造 map<string, string> dict = { { "sort", "排序" }, { "insert", "插入" } }; // 赋值重载 initializer_list<pair<const string, string>> kvil = { { "left", "左边" }, { "left", "左边" } }; dict = kvil; return 0; }
我们先看一下运行结果:
同时我们再看一下C++的标准库的介绍文档:
二、声明
2、1 auto关键字的引入
C++11引入了 auto 关键字,允许变量的类型根据初始化表达式进行自动推导。这种类型推导机制不仅简化了代码书写,还减少了代码中的重复信息,提高了代码的可读性和维护性。
在C++98中 auto 是一个存储类型的说明符,表明变量是局部自动存储类型,但是局部域中定义局部的变量默认就是自动存储类型,所以 auto 就没什么价值了。C++11中废弃 auto 原来的用法,将其用于实现自动类型推断。这样要求必须进行显示初始化,让编译器将定义对象的类型设置为初始化值的类型。具体我们看如下代码:
int main() { int i = 10; auto p = &i; auto pf = strcpy; cout << typeid(p).name() << endl; // int * cout << typeid(pf).name() << endl; // char * (__cdecl*)(char *,char const *) 函数指针 map<string, string> dict = { {"sort", "排序"}, {"insert", "插入"} }; //如下auto的作用体现 //map<string, string>::iterator it = dict.begin(); auto it = dict.begin(); return 0; }
auto关键字可以方便地简化代码,尤其是当变量类型较为复杂或使用模板时。它可以更灵活地适应不同的场景,并且减少了编写冗长类型名称的工作。
2、2 decltype关键字
除了 auto 关键字外,C++11还引入了 decltype 关键字。decltype关键字则用于获取表达式的类型,而不是进行类型推导。它主要用于在编译时获取表达式的静态类型信息,并且保留了const、引用修饰符等特性。
有些同学可能会感觉 auto 和 decltype 非常的相似。其实他们是有所区别的,具体看如下代码:
int main() { int x = 10; // typeid拿到只是类型的字符串,不能用这个再去定义对象什么的 //typeid(x).name() y = 20; 错误 // decltype(x) int decltype(x) y1 = 20.22; auto y2 = 20.22; cout << y1 << endl; cout << y2 << endl; return 0; }
运行结果如下图:
2、3 nullptr
由于C++中NULL被定义成字面量0,这样就可能回带来一些问题,因为0既能指针常量,又能表示整形常量。所以出于清晰和安全的角度考虑,C++11中新增了nullptr,用于表示空指针。
#ifndef NULL #ifdef __cplusplus #define NULL 0 #else #define NULL ((void *)0) #endif #endif
三、智能指针
后面会专门对智能指针进行详细解释,此处就不再过多解释。
四、范围for
C++11引入了范围for循环(Range-based for loop),可以更简洁地遍历容器或者其它可迭代对象的元素。具体使用方式是:“for (type variable : range)”。
范围for循环的底层原理是通过迭代器(iterator)实现的。迭代器是访问容器内元素的一种方式,通过指向容器中的特定位置来遍历元素。范围for循环会自动遍历整个容器中的元素,并使用迭代器实现循环。
下面是范围for循环使用与底层原理的详解:
- 使用方式:
- 针对容器:对于容器类型(比如vector、list等),范围for循环按顺序遍历容器中的每个元素,将每个元素赋值给变量,直到遍历完所有元素。
- 针对数组:对于数组类型,范围for循环也能正常工作,类似于按顺序访问数组中的每个元素。
- 自定义类型:如果要对自定义类型使用范围for循环,需要定义相应的迭代器接口或者提供begin()和end()成员函数,以便定义迭代的开始和结束位置。
示例代码如下:
std::vector<int> vec = {1, 2, 3, 4, 5}; for (int num : vec) { std::cout << num << " "; } // 输出:1 2 3 4 5 int arr[] = {6, 7, 8, 9, 10}; for (int num : arr) { std::cout << num << " "; } // 输出:6 7 8 9 10
底层原理: 范围for循环的底层原理是通过使用迭代器实现的。迭代器是遍历容器或者其它可迭代对象的一种通用方式,提供了对元素的访问和操作。
范围for循环的执行过程如下:
- 对于容器类型,编译器会自动调用容器的begin()和end()成员函数获取容器的起始位置和结束位置的迭代器。
- 对于数组类型,编译器将数组名转换为指向数组首元素的指针作为起始位置,以及指向数组最后一个元素的下一个位置的指针作为结束位置的迭代器。
- 循环开始时,将起始位置的迭代器赋值给临时变量,然后判断迭代器是否达到结束位置,如果未达到,则执行循环体内的代码。
- 每次循环迭代时,将迭代器指向的元素赋给循环变量,并使迭代器前进到下一个位置。
- 直到迭代器达到结束位置,循环停止。
范围for循环的引入简化了代码编写,使得遍历容器等可迭代对象更加方便和易读。
五、STL中一些变化
5、1 新容器
C++11STL中引入了一些新的容器,如下图:
C语言中的数组和C++11中的array是两种不同的数据类型,它们在以下几个方面存在一些对比和区别:
类型安全性:
- C语言中的数组没有类型安全性检查,可以存储任意类型的数据。这就意味着您可以将一个元素类型不匹配的值赋给数组,这可能导致不可预测的结果或者错误。
- C++11中的array是一个模板类,它对数组的类型进行了严格控制。只能存储指定类型的元素,当我们尝试存储不匹配的类型时,会在编译过程中产生错误,提供了更好的类型安全性。
大小确定性:
- C语言中的数组在创建时需要显式指定大小,并且在使用时无法动态改变大小。这意味着数组长度是固定的,不能根据需求进行扩展或收缩。
- C++11中的array也需要在创建时指定大小,但它提供了size()和max_size()等成员函数来获取数组的大小信息。同时,由于C++的特性,您可以使用动态数组(如vector)来代替array,动态数组具备灵活调整大小的能力。
内存管理:
- C语言中的数组通过指针实现,其内存管理需要手动进行。数组的声明不会自动分配内存空间,需要使用malloc()或者calloc()等函数进行显式的内存分配和释放操作。
- C++11中的array作为一个容器类,它在栈上分配内存,并在其作用域结束时自动释放。无需显式调用释放内存函数,这样更方便地管理和避免内存泄漏。
拷贝行为:
- C语言中的数组不能直接进行拷贝,只能通过遍历数组元素进行手动赋值或者使用memcpy()函数来实现数组拷贝。
- C++11中的array可以进行拷贝和移动操作,支持复制构造函数和赋值操作符的使用。
forward_list 底层就是单链表。重点是unordered_map和unordered_set,其他的大家了解一下即可。
5、2 新接口
如果我们再细细去看会发现基本每个容器中都增加了一些C++11的方法,但是其实很多都是用得比较少的。比如提供了cbegin和cend方法返回const迭代器等等,但是实际意义不大,因为begin和end也是可以返回const迭代器的,这些都是属于锦上添花的操作。
实际上C++11更新后,容器中增加的新方法最后用的插入接口函数的右值引用版本:rempalce_back/emplace。下面我们会详细解释这些接口。
六、右值引用和移动语义
6、1 左值引用和右值引用
传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。
6、1、1 左值与左值引用
什么是左值?什么是左值引用?
左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址+可以对它赋值,左值可以出现赋值符号的左边或右边。定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址。左值引用就是给左值的引用,给左值取别名。
下面详细解释左值和左值引用:
左值:
- 左值可以表示具有名称的变量、对象或数据成员,例如:int a = 10;、int array[10];、std::string str = "Hello";
- 左值可以出现在赋值语句的左边或右边,例如:a = 20;、int b = a;;
- 对一个左值进行取地址操作(&)时,可以获取其在内存中的位置;
- 左值有持久性,意味着它们在表达式执行完后仍然存在。
左值引用:
- 左值引用是使用符号“&”声明的引用类型,例如:int& ref = 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; }
6、1、2 右值与右值引用
右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(这个不能是左值引用返回)等等,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能取地址。右值引用就是对右值的引用,给右值取别名。
右值引用(Rvalue Reference)是一种新的引用类型,用于绑定到右值上。它的语法是在类型前面添加两个连续的“&”符号,例如int&&。右值引用可以延长右值的生命周期,也可以让我们知道一个表达式是右值。
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; const double&& rr2 = x + y; rr1 = 20; rr2 = 5.5; // 报错 return 0; }
6、2 左值引用与右值引用比较
左值引用总结:
- 左值引用只能引用左值,不能引用右值。
- 但是const左值引用既可引用左值,也可引用右值。
右值引用总结:
- 右值引用只能右值,不能引用左值。
- 但是右值引用可以move以后的左值。
具体我们可结合如下代码对左值引用和右值引用进行对比理解:
int main() { // 左值引用只能引用左值,不能引用右值。 int a = 10; int& ra1 = a; // ra为a的别名 //int& ra2 = 10; // 编译失败,因为10是右值 // const左值引用既可引用左值,也可引用右值。 const int& ra3 = 10; const int& ra4 = a; // 右值引用只能右值,不能引用左值。 int&& r1 = 10; // error C2440: “初始化”: 无法从“int”转换为“int &&” // message : 无法将左值绑定到右值引用 int a = 10; int&& r2 = a; // 错误 // 右值引用可以引用move以后的左值 int&& r3 = std::move(a); return 0; }
std::move是一个函数模板,定义在<utility>头文件中。它接受一个对象作为参数,并将其转换为右值引用。调用std::move后,原始对象的值仍然有效,但不能再保证其状态的有效性。std::move用于标记一个对象为可移动的,并将其传递给需要右值引用参数的函数,以实现资源的移动而不是复制。
6、3 右值引用的使用场景与意义
6、3、1 移动构造
我们知道,引用就是别名,其价值是为了减少拷贝。但是左值引用可以解决一些问题,同时也会遇到一些特殊情况解决不了拷贝的问题,具体如下:
- 做参数:a、减少拷贝,提高效率。b、做输出型参数,减少拷贝。
- 做返回值:a、引用返回可减少拷贝,提高效率。b、引用返回,可对返回值进行修改。
但是并不是任何情况下返回值都可以是引用返回的。当返回值是临时变量(出了函数的作用域就销毁),就不可以作为引用返回。此时拷贝就是必不可少的。如下场景:
这种情况就是避免不了需要进行拷贝,如下图:
大部分编译器会对上述情况进行优化,不会产生中间的临时变量。直接使用str拷贝构造ret。那有没有很好的办法解决拷贝这种情况呢?
当然,我们也可以选择使用输出型参数,但是并不符合我们的使用习惯。具体如下:
在C++11中,引入了移动构造。对比拷贝构造具体代码如下:
// 移动构造 string(string&& s) :_str(nullptr) , _size(0) , _capacity(0) { cout << "string(string&& s) -- 资源转移" << endl; swap(s); // 资源转移 } // 拷贝构造 string(const string& s) :_str(nullptr) { cout << "string(const string& s) -- 拷贝构造(深拷贝)" << endl; //string tmp(s._str); //swap(s); _str = new char[s._capacity+1]; strcpy(_str, s._str); _size = s._size; _capacity = s._capacity; }
因为上述的 str 出了作用域就会销毁,我们将它视为将亡值,也就是右值。在既有拷贝构造和移动构造的情况下,编译器会匹配最移动构造。因为编译器会匹配最适合的参数。那么这里就不会采用拷贝构造了,而是选择移动构造。
大部分编译器也会对上述进行优化。直接将 str 的资源转移到 ret 中。对比拷贝构造,我们能够明显的感觉出来,移动构造并没有进行申请资源,而是直接将将亡值的资源进行转移。移动构造减少了资源的申请和拷贝,提高了效率。
6、3、2 移动赋值
移动赋值与移动构造的原理大同小异。具体情况如下:
上图的情况必须要进行拷贝构造和拷贝赋值。在C++11中引入移动构造后,同时也引入的移动赋值,对比移动复制和拷贝赋值具体代码如下:
// 拷贝赋值 string& operator=(const string& s) { cout << "string& operator=(string s) -- 拷贝赋值(深拷贝)" << endl; string tmp(s); swap(tmp); return *this; } // 移动赋值 string& operator=(string&& s) { cout << "string& operator=(string s) -- 移动赋值(资源移动)" << endl; swap(s); return *this; }
同样,这里的 str 会被识别成将亡值(右值),不会再调用拷贝构造和拷贝赋值。会调用移动构造和移动复制。移动构造和移动复制都是对右值的资源进行移动转移,减少了资源的申请和拷贝,从而提高效率。
移动构造和移动赋值操作相对于拷贝构造和拷贝赋值操作的优点有以下几个方面:
性能更高:移动操作能够直接将资源(比如堆上的内存)从一个对象转移到另一个对象,而无需进行复制和销毁。这样可以消除额外的内存分配和释放开销,提高程序的性能。
减少内存拷贝:移动操作通过转移资源的所有权,避免了不必要的内存拷贝过程。对于大型对象或频繁进行内存操作的情况下,移动操作可以显著减少内存拷贝的次数,提高程序的效率。
资源管理效率:移动操作使得资源管理更加高效。在移动语义中,资源的所有权转移给了目标对象,源对象则不再拥有该资源。这意味着在移动后,源对象不再需要释放或删除资源。这样一来,在一些场景下可以避免重复释放资源或导致资源泄漏的问题。
容器的性能提升:使用移动构造和移动赋值可以有效提高容器操作(比如动态数组、动态字符串等)的性能。很多标准库容器都提供了移动语义的支持,通过移动操作,可以快速将对象移入或移出容器,而不会进行额外的拷贝操作。
需要注意的是,移动操作通常适用于临时对象、将要被销毁的对象以及右值引用的情况下,而拷贝操作适用于需要保留原始对象的情况。对于用户自定义的类,为了充分利用移动操作,通常需要显式实现移动构造函数和移动赋值运算符,并确保正确处理资源的转移和释放。
七、总结
由于C++11更新的重要内且常用容较多,就分为两篇内容进行详解。本篇内容的细节较多,重点在于右值引用和移动语义。下篇文章内容依然较多且为重要。本篇文章的讲解就到这里,感谢阅读ovo~