在之前我们之前的C++学习中实际上大部分的时间了解的都是C++98对应的知识,也就是C++当中基础的语法以及基本逻辑。但是实际上到C++11之前C++还是存在在一些场景下不是很方便的情况,因此在C++11就引入了一些新的语法特性和提高我们编码效率的工具,就例如之前我们有所了解的范围for,initializer_list等。在本篇当中我们将具体的了解initializer_list是如何实现让不同类型的容器能使用{}来初始化的,还会了解到左值、右值的概念,学习左值引用和右值引用的不同,了解移动构造的等原理,相信通过本篇的学习会了解C++11,会用C++11的特性!!!!
1.C++11发展历史
C++11 是 C++ 的第⼆个主要版本,并且是从 C++98 起的最重要更新。它引⼊了⼤量更改,标准化了既有实践,并改进了对 C++ 程序员可用的抽象。在它最终由 ISO 在 2011 年 8 ⽉ 12 ⽇采纳前,⼈们曾使用名称“C++0x”,因为它曾被期待在 2010 年之前发布。C++03 与 C++11 期间花了 8 年时间,故而这是迄今为止最长的版本间隔。从那时起,C++ 有规律地每 3 年更新⼀次。
C++11 让代码更简洁(语法改进)、更高效(移动语义)、更现代(lambda、智能指针)、还能写并发程序(线程库)。
2.列表初始化
2.1 C++98当中的{}
在C++98当中实际上就使用了{}来实现数组和结构体的初始化。
如下所示:
#include<iostream>
using namespace std;
struct Point
{
int _x;
int _y;
};
int main()
{
int arry1[5] = { 0 };
int arry2[] = { 1,2,3,4,5 };
Point p = { 1,2 };
return 0;
}
2.2 C++11中的{}
在C++11当中就想统一初始化的方式,让一切的对象都能使用{}来进行初始化,那么在C++11当中实现的效果是什么样的呢?接下来就来看以下的代码。
#include<iostream>
#include<vector>
using namespace std;
struct Date
{
Date(int year = 2000, int mouth = 1, int day = 1)
:_year(year),
_mouth(mouth),
_day(day)
{
cout << "Date(int year , int mouth , int day )" << endl;
}
Date(const Date& date)
{
_year = date._year;
_mouth = date._mouth;
_day = date._day;
cout << "Date(const Date& date)" << endl;
}
private:
int _year;
int _mouth;
int _day;
};
int main()
{
//C++98就支持的{}
int arry1[5] = { 0 };
int arry2[] = { 1,2,3,4,5 };
//支持使用{}对内置类型进行初始化
int a{ 1 };
//C++11支持直接使用{}来进行对自定义类型的初始化
Date date1{ 2025 ,1,1 };
Date date2{ 2025 ,2};
Date date3{ 1999 };
vector<Date> v1;
v1.push_back(date1);
v1.push_back(Date(2025, 9, 1));
//相比使用以上创建匿名对象使用{}更加简洁
v1.push_back({ 2025,11,11 });
return 0;
}
2.3 C++11当中的std::initializer_list
以上实现了{}来来实现对象的初始化实际上已经很方便了,但是在一些的情况下还是显得不足,例如以下的情况:
vector<int> v1{1,2,3};
vector<int> v2{1,2,3,4,5};
以上在vector就需要实现不同参数个数的构造函数,那么这时就很繁琐。因此在C++11当中实现了一个容器来实现多参数构造的问题,该容器就是initializer_list,使用的文档如下所示:
initializer_list - C++ Reference
通过文档就可以发现本质上initializer_list底层就是使用数组来实现。并且该容器还支持迭代器
那么有了initializer_list那么对对象容器的初始化就很方便了,无论你传的参数个数是什么样的都能支持。在STL容器当中都支持了initializer_list版本的构造函数。
#include<iostream>
#include<vector>
#include<map>
using namespace std;
int main()
{
initializer_list<int> list1{ 1,2,3,4 };
cout << *(list1.begin()) << endl;
cout << *(list1.end()) << endl;
//以下代码的本质是使用以下的变量构造出对应的initializer_list变量之后再构造出vector
vector<int> v1{ 1,2,3,4,5,6 };
//以下代码也是使用以下的变量构造出initializer_list的对象,该对象当中存储的是pair元素
//最后再使用initializer_list对象初始化map对象
map<string, string> mp1{ {"嗨咯","hello"},{"年","year"} };
return 0;
}
3. 右值引用和移动语义
实际上在C++98当中我们就已经了解到了引用的概念,只不过在之前的学习当中使用的都是左值引用,那么在C++11当中提出了左值引用,那么再了解右值引用之前先来了解什么是左值;什么是右值。
3.1 左值和右值
以下是左值和右值的定义:
• 左值是⼀个表示数据的表达式(如变量名或解引用的指针),⼀般是有持久状态,存储在内存中,我们可以获取它的地址,左值可以出现赋值符号的左边,也可以出现在赋值符号右边。定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址。
• 右值也是⼀个表示数据的表达式,要么是字面值常量、要么是表达式求值过程中创建的临时对象等,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能取地址。
实际上简单来说做左值和右值的最主要的区别就是是否可以取地址,想变量等左值都是可以取地址的,而想临时变量和字面量常量等都是右值都是无法取地址的。
#include<iostream>
#include<vector>
#include<map>
using namespace std;
int main()
{
//以下a,str,arr,t,s,*s都是典型的左值
int a = 1;
const char* str = "hello";
int arr[3] = { 0 };
int* t = new int(1);
*t=4;
string s("world");
s[0] = 'A';
cout << &str << endl;
cout << (void*)&s << endl;
//以下的都是常见的右值
4;
"hello";
min(1, 2);
string("C++");
a + 2;
return 0;
}
3.2 左值引用和右值引用
在以上我们就了解了什么是左值,什么是右值,那么接下来就来继续了解左值引用和右值引用的概念。
以上就是一个典型的左值引用,其本质就是给左值变量起别名。
int a = 1;
int& tmp = a;
那么和左值引用类似,右值引用本质上就是给右值起别名。例如以下示例:
int&& t = 1;
string&& s = "hello";
那么右值引用和左值引用有什么的区别呢?
实际上左值除了能引用左值之外还是可以引用右值的,只不过要使用const进行修饰,否则就会出现权限缩小的问题,这在我们之前的学习就已经了解过了,
const string& t2 = "hello";
但是右值引用就不同了,右值是完全不能引用左值的,只要使用了编译器就会报错。
虽然右值是无法绑定左值的,但是在std当中提供了一个函数move来实现将一个左值转换为右值。
只需要给move传一个左值那么该函数的返回值就是对应的右值,例如以下示例:
int a = 1;
int&& t3 = move(a);
在此需要有一个要注意的点是一个右值引用变量本身是一个左值,例如以上的t3实际上就是左值。
注:语法层面看,左值引用和右值引用都是取别名,不开空间。从汇编底层的角度看上面代码中a和t3汇编层实现,底层都是用指针实现的,没什么区别。底层汇编等实现和上层语法表达的意义有时是背离的,所以不要然到⼀起去理解,互相佐证,这样反而是陷入迷途。
3.3 引用延长生命周期
右值引用能延长临时变量的生命周期,能引用右值的有右值引用之外,带const修饰的左值引用也能实现。
#include<iostream>
#include<vector>
#include<map>
using namespace std;
int main()
{
const string& s1 = "hello";
string&& s2 = s1 + s1;
s2 += " world";
//以下代码会报错
//s1 += " a";
return 0;
}
3.4 左值和右值的参数匹配
在C++98当中无论函数的参数是左值还是右值那么都是会匹配对应的左值版本,但是到了C++11之后就会出现左值匹配左值的版本,const左值匹配const左值的版本,右值匹配右值的版本。
例如以下示例:
#include<iostream>
#include<vector>
#include<map>
using namespace std;
void func(int& a)
{
cout << "左值引用版本" << endl;
}
void func(const int& a)
{
cout << "const左值引用版本" << endl;
}
void func(int&& a)
{
cout << "右值引用版本" << endl;
}
int main()
{
int a = 1;
func(a);
const int b = 1;
func(b);
func(1);
return 0;
}
以上代码输出结果如下所示:
3.5 移动构造和移动赋值
通过之前的学习我们已经了解到了使用传引用能提高函数传参时候的效率,特别是在自定义类型当中,使用引用能大大提升效率。但是实际上之前的引用在一些的场景下还是存在不足,就例如当一个函数当中返回一个临时变量,那么这时候就不能使用到传引用返回,否则就会出现程序崩溃的问题。那么原来在解决该问题的时候只能通过给函数增加一个输出型参数来避免返回的拷贝生成。
那么以上我们在学习了右值引用之后,那么是不是说在函数当中要传临时变量出去时直接使用右值引用返回即可了呢?
这种做法实际上是完全错误的,例如以下的代码当就将函数内的局部变量返回了,x
是局部变量,函数结束后它就被销毁了,返回的是一个右值引用,指向的内存已经无效 → 悬空引用。
int&& foo() {
int x = 10;
return std::move(x); // ⚠️ 错误
}
那么这时候有什么办法能解决自定义类型的传值返回需要调拷贝构造的问题呢?
就例如以下代码的情况
class Solution {
public:
// 传值返回需要拷⻉
string addStrings(string num1, string num2) {
string str;
int end1 = num1.size() - 1, end2 = num2.size() - 1;
// 进位
int next = 0;
while (end1 >= 0 || end2 >= 0)
{
int val1 = end1 >= 0 ? num1[end1--] - '0' : 0;
int val2 = end2 >= 0 ? num2[end2--] - '0' : 0;
int ret = val1 + val2 + next;
next = ret / 10;
ret = ret % 10;
str += ('0' + ret);
}
if (next == 1)
str += '1';
reverse(str.begin(), str.end());
return str;
}
};
class Solution {
public:
// 这⾥的传值返回拷⻉代价就太⼤了
vector<vector<int>> generate(int numRows) {
vector<vector<int>> vv(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的大小很大的时候需要的代价就很大了,那么这时候就需要我们来了解移动语义的相关概念了。
移动语义当中实际上是包括移动构造和移动赋值的,本质上移动构造也是构造函数,移动构造函数要求第⼀个参数是该类类型的引用,但是不同的是要求这个参数是右值引用,如果还有其他参数,额外的参数必须有缺省值。
而移动赋值是⼀个赋值运算符的重载,他跟拷贝赋值构成函数重载,类似拷贝赋值函数,移动赋值函数要求第⼀个参数是该类类型的引用,但是不同的是要求这个参数是右值引用。
实际上移动构造和移动赋值是对那些string、vector类似的内置类型才又作用,因为在这些类型当中才需要在拷贝临时对象的时候直接将其的资源“掠夺”过来,而不是将其的资源拷贝一份。
那么接下来就通过模拟实现string当中的移动构造和移动赋值来进一步的理解移动语义的作用。
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <cstring>
#include <string>
#include <cstdio>
using namespace std;
namespace zhz
{
class string
{
public:
typedef char *iterator;
typedef const char *const_iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
string(const char *str = "")
{
_capacity = strlen(str);
_size = _capacity;
_str = new char[_size + 1];
strcpy(_str, str);
cout << "构造" << endl;
}
string(const string &s)
{
reserve(s._capacity);
for (auto x : s)
{
push_back(x);
}
cout << "拷贝构造" << endl;
}
string(string &&s)
{
swap(s);
cout << "移动构造" << endl;
}
string &operator=(string &&s)
{
cout << "移动赋值" << endl;
if (this != &s)
{
swap(s);
}
return *this;
}
~string()
{
if (_str)
{
delete[] _str;
_size = 0;
_capacity = 0;
}
cout<<"析构"<<endl;
}
void reserve(int n)
{
if (n > _capacity)
{
char *newstr = new char[n + 1];
if (_str)
{
strcpy(newstr, _str);
}
else
{
newstr[0] = '\0';
}
delete[] _str;
_str = newstr;
_capacity = n;
}
}
void push_back(char ch)
{
if (_size >= _capacity)
{
int newcapacity = _capacity == 0 ? 4 : 2 * _capacity;
reserve(newcapacity);
}
_str[_size] = ch;
_size++;
_str[_size] = '\0';
}
string &operator=(const string &s)
{
if (this != &s)
{
_str[0] = '\0';
_size = 0;
reserve(s._capacity);
for (auto x : s)
{
push_back(x);
}
}
cout << "拷贝赋值" << endl;
return *this;
}
char operator[](int n)
{
if (n >= _size)
{
return ' ';
}
return _str[n];
}
size_t size() const
{
return _size;
}
void swap(string &s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
const char* c_str()
{
return _str;
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
private:
char *_str = nullptr;
int _size=0;
int _capacity=0;
};
}
int main()
{
zhz::string s1("aaaa");
zhz::string s2 =s1;
zhz::string s3;
s3 = zhz::string("bbbb");
zhz::string s4(zhz::string("dsdsad"));
zhz::string s5="dsdsd";
return 0;
}
以上的代码当中我们就实现了string类,并且还实现了对应的移动构造和移动赋值,其实在string当中实现这两个函数很简单就只需要将原来的string当中的资源进行转移即可。
以上的代码在VS 2022当中输出的结果如下所示:
但是这时候问题就来了,那就是以上也没有调用到我们实现的移动语义啊,这是不是说明实现出来是没作用的呢?
实际上以上会输出结果当中没有调用移动赋值是因为编译器是会进行优化的,在VS2022当中优化的程度是很高的,所以我们要怎么样才能看到优化之前的结果呢?其实在Linux就可以实现,只需要我们在使用g++进行编译的时候带上-fno-elide-constructors,即可将编译器的优化关闭。
关闭优化之后就可以看到以上确实是会调用我们实现的移动语义的。
那么接下来再来对以下的代码分析存不存在移动语义的时候以及编译器是否进行优化时的差别。
zhz::string addStrings(string num1, string num2) {
zhz::string str;
int end1 = num1.size() - 1, end2 = num2.size() - 1;
// 进位
int next = 0;
while (end1 >= 0 || end2 >= 0)
{
int val1 = end1 >= 0 ? num1[end1--] - '0' : 0;
int val2 = end2 >= 0 ? num2[end2--] - '0' : 0;
int ret = val1 + val2 + next;
next = ret / 10;
ret = ret % 10;
str += ('0' + ret);
}
if (next == 1)
str += '1';
reverse(str.begin(), str.end());
return str;
}
// 场景1
int main()
{
zhz::string ret = addStrings("11111", "2222");
cout << (void*)ret.c_str() << endl;
return 0;
}
在以上的场景当中如果当我们没有实现对应的移动构造的话,那么这时就会先调用对应的构造来将函数内的临时对象值带出来,之后再调用对应的拷贝构造来创建出ret对象。
但在VS2022当中就会进行优化将拷贝构造和构造优化为一次构造
以上是没有移动构造的情况在拷贝的时候是会调用拷贝构造的,那么若存在对应的移动构造输出的结果又如下所示:
在VS2022当中依旧会直接优化为构造
接下来再来看以下的场景2:
// 场景2
int main()
{
zhz::string ret;
ret = addStrings("11111", "2222");
cout << (void*)ret.c_str() << endl;
return 0;
}
以上的场景当中如果不存在移动构造和移动赋值,那么输出的结果就如下所示:
在VS2022当中会进行优化
若存在移动构造和移动赋值在Linux当中去掉优化之后输出的结果如下所示:
在VS2022当中会进行以下的优化:
3.5 右值引用和移动语义应用
在C++11之后就给vector、list等的list、push_back等的函数当中提供了右值引用的接口,那么这就可以让当用户传的参数是右值的时候调用该接口,从而避免调用原来的左值版本以提升效率。
以上就是本篇的全部内容了,接下来在C++11(下)当中我们将继续来学习C++11当中更多的知识,未完待续……