C++11相较于C++98的新特性介绍:列表初始化,右值引用与移动语义
一,列表初始化
1.1C++98中传统的{}
C++98中一般数组和结构体可以使用{}进行初始化:
struct Date
{
int _year;
int _month;
int _day;
};
int main()
{
int a[] = { 1,2,3,4,5 };
Date _date = { 2025,2,27 };
return 0;
}
1.2C++11中的{}
- C++11以后想统一初始化方式,试图实现一切对象皆可用{}初始化,{}初始化也叫做列表初始化。
- 内置类型支持,自定义类型也支持,自定义类型本质是类型转换,中间会产生临时对象,最后优化了以后变成直接构造。
- {}初始化的过程中,可以省略掉=。
- C++11列表初始化的本意是想实现一个大统一的初始化方式,其次他在有些场景下带来的不少便利,如容器push/inset多参数构造的对象时,{}初始化会方便许多。
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
{
cout << "Date(int year, int month, int day)" << endl;
}
Date(const Date& d)
:_year(d._year)
, _month(d._month)
, _day(d._day)
{
cout << "Date(const Date& d)" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
int a[] = { 1,2,3,4,5 };
Date _date = { 2025,2,27 };
const Date& d2 = { 2027,2,29 };//右边{2027,2,29}是一个临时对象,需要加上const
//也可以不加括号
Date d3{ 2028,2,27 };
//非{}初始化必须要加上等号
Date d4 2025;//编译器报错
Date d5 = 2025;
return 0;
}
1.3C++11中的std::initializer_list
上面的{}初始化已然很方便,但是如果说我们要初始化像vector这样的对像时,他的参数个数是会发生变化的,显然仅仅只有上面的{}远远无法满足这种需求。所以c++中就提供了std::initializer_list的类, auto il = { 10, 20, 30 }; // thetype of il is an initializer_list ,这个类的本质是底层开一个数组,将数据拷贝过来,std::initializer_list内部有两个指针分别指向数组的开始和结束。
这是他的文档:initializer_list - C++ Reference,std::initializer_list支持迭代器遍历。
区别是{}还是initializer_list的方法也很简单,{}传入的值个数是固定的,由需要初始化的对象类型决定,里面所有数据的类型可能不同。后者可写入的值不固定,但类型一定相同。
map<int, string> m{ {1,string("hallo")} };
vector<string> v{ "a","b","c" };
二,右值引用与移动语义
我们之前学习的是C++98中的引用,比如原本有一个int类型的对象x,那么int& b = x,此时b其实就是x的别名。C++11之后,我们之前学过的这种引用方式被叫做左值引用。需要注意的是,无论是左值引用还是右值引用,本质上都是取别名。
2.1左值与右值
左值是一个表示数据的表达式(如变量名或解引用的指针),一般是有持久状态,存储在内存中,我
们可以获取它的地址,左值可以出现赋值符号的左边,也可以出现在赋值符号右边。定义时const
修饰符后的左值,不能给他赋值,但是可以取它的地址。
右值也是一个表示数据的表达式,要么是字面值常量、要么是表达式求值过程中创建的临时对象
等,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能取地址。
值得一提的是,左值的英文简写为lvalue,右值的英文简写为rvalue。传统认为它们分别是left
value、right value 的缩写。现代C++中,lvalue 被解释为loactor value的缩写,可意为存储在内
存中、有明确存储地址可以取地址的对象,而 rvalue 被解释为 read value,指的是那些可以提供
数据值,但是不可以寻址,例如:临时变量,字面量常量,存储于寄存器中的变量等,也就是说左
值和右值的核心区别就是能否取地址。
//常见的左值比如:
int p;
const int p1;
int a[10];
char c;
a[0] = 1;
string s("111111111");
s[0] = 'b';//....
//它们都有一个共同的特性:可以取地址
//常见的右值比如:
10;
s[0] + a[0];
string("111111111111111");
min(s[0],a[0]);
//它们均是临时对象,无法取地址
2.2左值引用与右值引用
Type& x = y;//左值引用
Type&& x = y;//右值引用
同样,右值引用其实就是给右值取别名。
1.对于左值引用,不可以直接引用右值,引用右值时需要加上const。
int& x = 10;//error
const int& x = 10;//true
2.对于右值引用,当然也不可以直接引用左值,但是可以引用move移动之后的左值。
int x = 10;
int&& y = x;//error
int&& y = std::move(x);//true
//move可以将左值强制类型转化为右值,是库里面的一个函数模板,本质内部是进行强制类型转换,当然他还涉
//及一些引用折叠的知识,我们后面会详细介绍。
3.需要注意的是变量表达式都是左值属性,也就意味着一个右值被右值引用绑定后,右值引用变量变量表达式的属性是左值。
int&& y = 10;//y此时是一个左值
4.语法层面看,左值引用和右值引用都是取别名,不开空间。从汇编底层的角度看无论左值引用还是右值引用,底层都是用指针实现的,没有本质区别。
2.3引用改变生命周期
右值引用和带有const的左值引用都可以延长临时变量的声明周期,但后者无法被修改。
std::string s1 = "1111111111";
std::string&& s2 = s1 + s1;
const std::string& s3 = s1 + s1;
s2 += "Text";//true
s3 += "Text";//error
2.4左右值引用的参数匹配
在C++98中,实现一个const左值引用作为参数的函数,传入左值还是右值都可以匹配
void f(int& x)
{
std::cout << "f函数的左值引用重载(" << x << ")" << std::endl;
}
void f(const int& x)
{
std::cout << "f函数的const左值引用重载(" << x << ")" << std::endl;
}
int main()
{
int x = 10;
f(x);
f(10 + 20);
return 0;
}
C++11中,对右值进行了明确定义,此时便可以分担const左值引用对于右值的引用任务给右值引用。即实参是左值会匹配f(左值引用),实参是const左值会匹配f(const 左值引用),实参是右值会匹配f(右值引用)。 (当然没有右值引用的重载下还可以通过const左值引用来引用右值)。
void f(int& x)
{
std::cout << "f函数的左值引用重载(" << x << ")" << std::endl;
}
void f(const int& x)
{
std::cout << "f函数的const左值引用重载(" << x << ")" << std::endl;
}
void f(int&& x)
{
std::cout << "f函数的右值引用重载(" << x << ")" << std::endl;
}
int main()
{
int x = 10;
const int y = 20;
f(x);
f(y);
f(10 + 20);
return 0;
}
2.5右值引用和移动语义的使用场景
2.5.1移动构造与移动赋值
- 1.移动构造函数是一种构造函数,类似拷贝构造函数,移动构造函数要求第一个参数是该类类型的引用,但是不同的是要求这个参数是右值引用,如果还有其他参数,额外的参数必须有缺省值。
- 2.移动赋值是一个赋值运算符的重载,他跟拷贝赋值构成函数重载,类似拷贝赋值函数,移动赋值函数要求第一个参数是该类类型的引用,但是不同的是要求这个参数是右值引用。
- 3.对于像string/vector这样的深拷贝的类或者包含深拷贝的成员变量的类,移动构造和移动赋值有意义,因为移动构造和移动赋值的第一个参数都是右值引用的类型,他的本质是要“窃取”引用的右值对象的资源,而不是像拷贝构造和拷贝赋值那样去拷贝资源,从提高效率。下面ELY::string例实现了移动构造和移动赋值,我们需要结合场景理解。
之前文章中我们自己实现的string类:
namespace ELY
{
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 = "")
:_size(strlen(str))
, _capacity(_size)
{
cout << "string(char* str)-构造" << endl;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
void swap(string& s)
{
::swap(_str, s._str);
::swap(_size, s._size);
::swap(_capacity, s._capacity);
}
string(const string& s)
:_str(nullptr)
{
cout << "string(const string& s) -- 拷贝构造" << endl; reserve(s._capacity);
for (auto ch : s)
{
push_back(ch);
}
}
//移动构造
string(string&& s)
{
cout << "string(string&& s) -- 移动构造" << endl;
swap(s);//这样写的原因是因为移动构造底层实现原理与直接和临时对象交换资源类似
}
string& operator=(const string& s)
{
cout << "string& operator=(const string& s) -- 拷贝赋值" <<
endl;
if (this != &s)
{
_str[0] = '\0';
_size = 0;
reserve(s._capacity);
for (auto ch : s)
{
push_back(ch);
}
}
return *this;
}
~string()
{
cout << "~string() -- 析构" << endl;
delete[] _str;
_str = nullptr;
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
if (_str)
{
strcpy(tmp, _str);
delete[] _str;
}
_str = tmp;
_capacity = n;
}
}
void push_back(char ch)
{
if (_size >= _capacity)
{
size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(newcapacity);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
const char* c_str() const
{
return _str;
}
size_t size() const
{
return _size;
}
private:
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
};
}
2.5.2右值引用和移动语义解决传值返回问题
我们知道,在函数传参的时候使用左值引用能够减少拷贝,比如下面通过字符串实现高精度计算的函数:
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;
}
int main()
{
std::string s1 = "11111";
std::string s2 = "22222";
std::string s3 = AddStrings(s1, s2);
std::cout << s3 << std::endl;
return 0;
}
但是此时返回str时,如果str过大,那么s3在接收的时候会进行两次拷贝构造(在string没有实现移动构造以及编译器没有进行编译优化的前提下):
这样子代价特别巨大,但是有了上面的右值引用之后。因为右值是一个临时对象,我们完全可以走移动构造来完成上面的拷贝构造过程。直接把临时对象中的资源给抢过来。但是我们不能说将上面AddString的返回值改为string&&,因为str中存储的资源生命周期在函数的作用域内。无法达到预期效果,我们只有对string类实现了移动构造才能实现抢过来的那一步:
当我们把移动构造屏蔽后,s3接收str会经历以下过程,走了两次拷贝构造,等于说进行两次新的资源构建:
但是我们实现移动构造之后,全程只有移动构造,没有任何新资源的构建,极大节省了资源消耗:
2.5.3右值引用与移动语义在传参中的提效
- 查看STL文档我们发现C++11以后容器的push和insert系列的接口否增加的右值引用版本。
- 当实参是一个左值时,容器内部继续调用拷贝构造进行拷贝,将对象拷贝到容器空间中的对象。
- 当实参是一个右值,容器内部则调用移动构造,将实参的资源直接掠夺过来构造当前对象
测试代码:
int main()
{
std::list<bit::string> lt;
bit::string s1("111111111111111111111");
lt.push_back(s1);
cout << "*************************" << endl;
lt.push_back(bit::string("22222222222222222222222222222"));
cout << "*************************" << endl;
lt.push_back("3333333333333333333333333333");
cout << "*************************" << endl;
lt.push_back(move(s1));
cout << "*************************" << endl;
return 0;
}
运行结果:
2.6类型分类(了解)
- C++11以后,进一步对类型进行了划分,右值被划分纯右值(pure value,简称prvalue)和将亡值(expiring value,简称xvalue)。
- 纯右值是指那些字面值常量或求值结果相当于字面值或是一个不具名的临时对象。如: 42、true、nullptr 或者类似str.substr(1, 2)、str1 + str2 传值返回函数调用,或者整形a、b、a++,a+b 等。纯右值和将亡值C++11中提出的,C++11中的纯右值概念划分等价于C++98中的右值。
- 将亡值是指返回右值引用的函数的调用表达式和转换为右值引用的转换函数的调用表达,如move(x)、static_cast<X&&>(x)。
- 泛左值(generalized value,简称glvalue),泛左值包含将亡值和左值。
- 有名字,就是glvalue;有名字,且不能被move,就是lvalue;有名字,且可以被move,就是xvalu;没有名字,且可以被移动,则是prvalue。
官方文档:
值类别 - cppreference.com ,https://en.cppreference.com/w/cpp/language/value_category
2.7引用折叠
1.C++中不能直接定义引用的引用如int& && r = i; 这样写会直接报错,通过模板或 typedef
中的类型操作可以构成引用的引用。
using lref = int&;
using rref = int&&;
int main()
{
int y = 10;
int& &&r = y;//error
lref&& z = y;//true
return 0;
}
2.对于两种引用的四种组合,只有&& 与 && 组合时才是右值引用:
lref& x = y;//x类型是int&
lref&& x = y;//x类型是int&
rref& x = y;//x类型是int&
rref&& x = 10;//x类型是int&&
template<class T>
void f1(T& x)
{}
template<class T>
void f2(T&& x)
{}
int main()
{
int n = 0;
//无折叠->类型实例化为int&
f1<int>(n);
f1<int>(0);//报错
//折叠->类型实例化为int&
f1<int&>(n);
f1<int&>(0);//报错
//折叠->类型实例化为int&
f1<int&&>(n);
f1<int&&>(0);//报错
//折叠->示例化为const int&
f1<const int&>(n);
f1<const int&>(0);
//折叠->实例化为const int&
f1<const int&&>(n);
f1<const int&&>(0);
//无折叠->类型实例化为int&&
f2<int>(n);//报错
f2<int>(0);
//折叠->类型实例化为int&
f2<int&>(n);
f2<int&>(0);//报错
//折叠->类型实例化为int&&
f2<int&&>(n);//报错
f2<int&&>(0);
//折叠->示例化为const int&
f2<const int&>(n);
f2<const int&>(0);
//折叠->实例化为const int&&
f2<const int&&>(n);//报错
f2<const int&&>(0);
return 0;
}
template<class T>
void Function(T&& t)
{
int a = 0;
T x = a;
x++;
cout << &a << endl;
cout << &x << endl << endl;
}
int main()
{
//无折叠-模板类型实例化为int&&
Function(10);//右值
int a = 0;
//折叠,因为a为左值,模板类型实例化为int&
Function(a);//左值
// std::move(a)是右值,推导出T为int,模板实例化为void Function(int&& t)
Function(std::move(a)); // 右值
const int b = 8;
// b是左值,推导出T为const int&,引用折叠,模板实例化为void Function(const int&
// 所以Function内部会编译报错,x不能++
Function(b); // const 左值
// std::move(b)右值,推导出T为const int,模板实例化为void Function(const int&&t)
// 所以Function内部会编译报错,x不能++
Function(std::move(b)); // const 右值
return 0;
}
我们也称Function(T&& t)这种为万能引用,传左值为左值,而传右值则为右值。
2.8完美转发
我们在实际过程中,可能在函数中再去调用别的函数,比如如下这种情况:
void f1(int& x)
{}
void f1(int&& x)
{}
template<class T>
void fc(T&& t)
{
f1(t);
}
int main()
{
int n = 0;
fc(n);
fc(0);
return 0;
}
我们上面也说过,对于int&& x = y;此时的x为左值属性。我们发现对于上图中的两次fc调用,n是左值直到传到f1时也可以保持其左值属性不变。但0在传入fc之后,0是一个右值,一个右值被右值引用绑定后,右值引用变量表达式的属性是左值,也就是说fc函数中t的属性是左值。此时便会调用f1的左值引用版本,换言之0在函数传递中失去了其本身的右值属性。如果我们想要保持0自身的右值属性在传递中不丢失,就需要使用完美转发。
完美转发:
template <class _Ty>
_Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept
{ // forward an lvalue as either an lvalue or an rvalue
return static_cast<_Ty&&>(_Arg);
}
完美转发forward本质是一个函数模板,他主要还是通过引用折叠的方式实现。此时我们只需要将上面的代码改为如下格式:
void f1(int& x)
{}
void f1(int&& x)
{}
template<class T>
void fc(T&& t)
{
f1(forward<T>(t));
}
int main()
{
int n = 0;
fc(n);
fc(0);
return 0;
}
这样0在经fc传入f1时便会调用右值引用版本的重载。保留了其本身的右值属性。