【C++】C++11特性(上)
✨✨欢迎大家来到Celia的博客✨✨
🎉🎉创作不易,请点赞关注,多多支持哦🎉🎉
所属专栏:C++
个人主页:Celia's blog~
目录
一、列表初始化
二、std::initializer_list
三、右值引用和移动语义
3.1 左值和右值
3.2 左值引用和右值引用
3.3 引用延长生命周期
3.4 左值和右值的参数匹配
3.5 右值引用和移动语义的使用场景
3.5.1 左值引用的使用场景
3.5.2 移动构造和移动赋值
3.5.3 右值引用、移动语义解决传值返回问题
3.6 引用折叠
3.6.1 引用折叠规则
3.6.2 万能引用
3.7 完美转发
四、可变参数模板
4.1 语法和原理
4.2 包的扩展
一、列表初始化
在C++98中,数组和结构体是可以用{}初始化的:
#include<iostream>
using namespace std;
struct Point
{
int _x;
int _y;
};
int main()
{
Point p{ 1,2 };
int arr[] = { 1,2,3 };
int arr[4] = { 5,6,7,8 };
int arr[8] = { 7,8,9 };
return 0;
}
- 在C++11中,为了统一初始化的方式,出现了列表初始化的新初始化方式。
- 所有类型都可以用{}来初始化(包括内置类型、自定义类型),也叫列表初始化。
- 自定义类型的初始化本质上是类型转换,编译器会将其优化成直接构造。
- 列表初始化可以省略掉=。
- 在容器的push/insert操作时,利用列表初始化来构造自定义类型会很方便。
#include<iostream>
using namespace std;
class Date
{
public:
Date(int _year, int _month, int _day)
:year(_year)
,month(_month)
,day(_day)
{}
private:
int year;
int month;
int day;
};
int main()
{
int a{ 1 };
double b{ 2.14 };
Date d1 = { 2000,1,1 };
Date d2{ 2024,10,11 };
return 0;
}
二、std::initializer_list
- 有了列表初始化之后,对于自定义类型的初始化方便了许多。但是对于一些容器来说,初始化仍然不是那么方便。比如vector,如果我们想初始化三个不同元素的容器,就需要手动写带有三个参数的构造。那么如果想初始化五个元素,十个元素呢?由此可见,增加构造函数并不是有效的方法。
- 为了解决这个问题,C++11中引入了一个std::initializer_list的类。这个类的底层是开辟一个数组,并将数据拷贝过来。这个类中有两个指针,分别指向数组的开头和结尾。故这个类支持迭代器访问。
- 如果一个容器支持了参数为std::initializer_list的构造方法,那么就可以把std::initializer_list中的元素一个一个增添到指定的容器中,这样初始化就方便了许多。
- 需要注意的是,std::initializer_list中的元素必须是同一类型的。
int main()
{
//std::initializer_list构造
vector<int> v1({1, 4, 5, 6, 7, 8, 9}); //直接构造
vector<int> v2 = { 1,2,3,4,5,6,7 }; //构造临时对象 + 拷贝构造 → 优化成直接构造
const vector<int>& v3 = { 1,2,3,4,5 }; //std::initializer_list为临时对象,需要用const引用
return 0;
}
三、右值引用和移动语义
在C++98中就有了引用的概念,在C++11中新增了右值引用,之前所学的引用叫做左值引用。无论是左值引用还是右值引用,都是给指定对象起别名。
3.1 左值和右值
- 左值是一个表示数据的表达式,一般是持久状态,储存在内存中,我们可以获取它的地址。例如:变量名、解引用的指针等。被const修饰的左值,不能够二次赋值,但是可以取它的地址。左值可以在等号的左边,也可以在等号的右边。
- 右值是一个表示数据的表达式,但一般为字面值常量、或者运算过程中产生的临时对象。右值可以在等号的右边,但是不能在等号的左边。右值不可以取地址。
- 左值的英文简写为lvalue,右值的英文简写为rvalue。传统认为,它们是left value和right value的缩写。但是现代认为,lvalue为loactor value的缩写,意为存储在内存中、有明确存储地址可以取地址的对象。rvalue为read value的缩写,指的是那些可以提供数据值,但是不可以寻址的字面常量或临时对象。总的来说,左值和右值的核心区别在于是否可以取地址。
int main()
{
//左值
int x = 0;
cout << &x << endl;
int y = 1;
//右值
x + y;
10;
"111";
//cout << &10 << endl; //error
return 0;
}
3.2 左值引用和右值引用
- 左值是给左值取别名,右值是给右值取别名。
- 左值引用无法直接引用右值,但被const修饰的左值引用可以引用右值。
- 右值引用无法直接引用左值,但是可以引用被move过后的左值。
- move是库里面的一个函数模板,本质内部是进行强制类型转换。
- 变量表达式都是左值属性,也就是说,当一个右值被右值引用绑定后,这个右值引用的属性是左值。
- 当一个常量被右值引用引用后,可以通过右值引用来修改常量的值。并可以再次使用右值引用来访问它。
void func1(int&& x)
{
cout << "右值引用" << endl;
}
void func1(int& x)
{
cout << "左值引用" << endl;
}
int main()
{
int a = 10;
int& x1 = a; //左值
int&& x2 = 1 + 2; //右值
func1(x2); //属性是左值
func1(100); //属性是右值
cout << x1 << " " << x2 << endl;
return 0;
}
3.3 引用延长生命周期
一般来讲,临时对象的生命周期只在当前一行。但若是引用它,临时变量的生命周期就与这个引用的生命周期相同。
int main()
{
cout << 1 + 2 << endl; //生命周期只在当前一行
int&& x1 = 2 + 3; //生命周期延长
const int& x2 = 3 + 4; //生命周期延长
cout << x1 << " " << x2 << endl;
return 0;
}
3.4 左值和右值的参数匹配
在C++98中,拷贝构造函数的参数为const修饰的左值引用,左值可以匹配,右值也可以匹配。引入右值引用参数后,传入左值会匹配左值引用的构造函数,传入右值会匹配右值引用的构造函数。也就是说,左值引用和右值引用作为参数的函数可以构成函数重载。
//函数重载场景示例
void func1(int&& x)
{
cout << "右值引用" << endl;
}
void func1(int& x)
{
cout << "左值引用" << endl;
}
3.5 右值引用和移动语义的使用场景
3.5.1 左值引用的使用场景
左值引用的使用场景通常是引用传参或引用作为函数返回值来达到减少拷贝的目的。这已经解决了绝大部分的场景问题。但是还有一些场景是左值引用解决不了的。
//场景1
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;
}
};
- 场景1:
- 当前场景的返回值不能够使用引用来返回,原因是str是一个在函数内部的局部变量,当函数执行完成后,函数栈帧被销毁,str也就不存在了。尽管引用也有延长生命周期的作用,但仍然改变不了函数栈帧销毁的事实。
- C++98在这种场景下只能选择传值返回。
//场景2
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;
}
};
- 场景2:
- 但如果是场景2的情况,传值返回将是一笔巨大的开销,代价会很大很大。
3.5.2 移动构造和移动赋值
移动构造和拷贝构造很相似,唯一的不同在于:
- 移动构造的参数是右值引用,拷贝构造的参数是当前类型的左值引用。
移动赋值和赋值重载的区别:
- 移动赋值的参数是右值引用,赋值重载的参数为当前类型的左值引用。
对于像string/vector这样的深拷贝的类或者包含深拷贝的成员变量的类,移动构造和移动赋值才有意义,因为移动构造和移动赋值的第⼀个参数都是右值引用的类型,他的本质是要“窃取”引用的右值对象的资源,而不是像拷贝构造和拷贝赋值那样去拷贝资源,从而提高效率。
//string类部分代码举例
string(const string& s)
:_str(nullptr)
{
reserve(s._capacity);
for (auto ch : s)
{
push_back(ch);
}
}
//移动构造
string(string && s)
{
swap(s);
}
string& operator=(const string& s)
{
if (this != &s)
{
_str[0] = '\0';
_size = 0;
reserve(s._capacity);
for (auto ch : s)
{
push_back(ch);
}
}
return* this;
}
// 移动赋值
string& operator=(string && s)
{
swap(s);
return *this;
}
3.5.3 右值引用、移动语义解决传值返回问题
移动语义(Move Semantics)是 C++11 引入的一项重要特性,它允许对象的资源(如堆上分配的内存)在不进行深度复制的情况下进行转移。通过移动语义,可以将对象的资源从一个对象转移到另一个对象,从而避免不必要的内存拷贝,提高程序性能和效率。
在之前的代码中不难观察到,移动赋值、移动构造的独特之处在于:直接交换了右值引用对象和当前对象的数据。由于右值引用的对象是右值,比如常量字符串、匿名对象、临时变量等。这些右值原本的生命周期只有一行,在其他代码段也没有机会使用这些右值,故可以直接掠夺它们的资源,也就是把它们的资源交换过来。
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;
}
这段代码在返回str时,返回值会被当做右值来处理。故该返回值的接收必然会调用移动赋值/移动构造。这会直接导致资源的交换,对于string类来说,交换只不过是一个指针和一些其他成员变量。相比于之前的值拷贝,需要一个一个的拷贝指针指向的资源(比如遍历数组元素),移动赋值/移动构造的效率要高很多很多。这就很好的解决了拷贝时间开销巨大的问题。
3.6 引用折叠
在C++中,不可以直接定义引用的引用,如:
int& && a; //右值引用的引用?
但如果是模板参数,则可能出现以下情况:
template<class T>
void func(T& data)
{
cout << data << endl;
}
int main()
{
int a = 1;
func<int&&>(a);
return 0;
}
这种情况下,实例化函数的模板参数为int&&,所以T为int&&,函数参数理论上为int&& &data。这种情况下,参数可以被认为是引用的引用吗?答案是不可以,参数会被折叠成左值引用。这里就涉及到引用折叠的问题。
3.6.1 引用折叠规则
- 右值引用的右值引用折叠成右值引用,其他所有情况均会折叠成左值引用。
template<class T>
void func(T&& data)
{
cout << data << endl;
}
int main()
{
int a = 1;
func<int>(a); //未折叠 参数为int&& //error
func<int>(0); //未折叠 参数为int&&
func<int&>(a); //折叠 参数为int&
func<int&>(0); //折叠 参数为int& //error
func<const int&>(a); //折叠 参数为const int&
func<const int&>(0); //折叠 参数为const int&
func<int&&>(a); //折叠 参数为int&& //error
func<int&&>(0); //折叠 参数为int&&
return 0;
}
3.6.2 万能引用
在了解引用折叠的规则后,我们会发现:
- 如果函数模板参数给定T类型的右值引用(T&&),那么传入右值引用时,会折叠成右值引用;传入左值引用时,会折叠成左值引用。
这种写法的结果与实际传入的参数结果相同,这种写法也叫做万能引用。
3.7 完美转发
- func(T&& t)函数模板中,传左值实例化以后是左值引用的func函数,传右值实例化以后是右值引用的func函数。
- 右值引用的属性是左值,也就是说,当一个右值引用绑定之后,右值引用变量表达式的属性是左值。接下来传入该引用的属性都会被识别成左值。
- 如果想保持其原有的右值属性,就需要用到完美转发。
-
template <class T> T&& forward (typename remove_reference<T>::type& arg) noexcept;
- 完美转发实际上也是通过函数模板的引用折叠来实现的。
void func1(int&& x)
{
cout << "右值引用" << endl;
}
void func1(int& x)
{
cout << "左值引用" << endl;
}
int main()
{
int&& a = 0; //假设这是折叠后的类型,折叠后类型为int&&
func1(a);
func1(forward<int>(a));
//template <class T> T&& forward (typename remove_reference<T>::type& arg) noexcept;
//大致原理:
//假设T为int,说明没有折叠/折叠后为int&&,说明期望a是右值,将a强转为右值。
//若T为int&,说明折叠后为int&,说明期望a是左值,将a强转为左值。
return 0;
}
四、可变参数模板
4.1 语法和原理
C++11支持可变参数模板,也就是支持可变参数数量的函数模板和类模板。可变数目的参数被称作参数包。共有两种参数包:
- 函数参数包,表示零或多个函数参数。
- 模板参数包,表示零或多个模板参数。
- 我们用省略号来指出一个模板参数或函数参数的一个参数包,在模板参数列表中,class...或typename...指出接下来的参数表示零或多个类型列表;在函数参数列表中,类型名后面跟...指出接下来表示零或多个形参对象列表;函数参数包可以用左值引用或右值引用表示,跟前面普通模板一样,每个参数实例化时遵循引用折叠规则。
- 可变参数模板的原理跟模板类似,本质还是去实例化对应类型和个数的多个函数。
- 这⾥我们可以使用sizeof...运算符去计算参数包中参数的个数。
template<class ...Args>
void func2(Args&&... args)
{
cout << sizeof...(args) << endl;
}
int main()
{
func2(1, 1.1, "123"); //包中有三个参数
return 0;
}
4.2 包的扩展
对于参数包,除了能计算参数的个数,更重要的是能使用包中的不同类型的参数。扩展一个包就是将它分解为构成的元素,对每个元素应用模式,获得扩展后的列表。
扩展方式之一如下:
void show()
{
//最后一个调用传入空参数,需要准备空参数的函数
cout << endl;
}
template<class T, class ...Args>
void show(T x, Args ...args)
{
cout << x << " ";
//第一个参数传给x,剩下的N - 1个参数传给参数包
//递归调用
show(args...);
}
template<class ...Args>
void func2(Args&&... args)
{
cout << sizeof...(args) << endl; //输出参数个数
//args是N个参数的参数包
//第一个参数传给x,剩下的N - 1个参数传给参数包
show(args...);
}
int main()
{
func2(1, 1.1, string("hello"));
return 0;
}
实际上,编译器在编译时会自动推导以下函数(同时也是递归的执行过程):