在 C++11 之前,我们把数据分为常量和变量,而在 C++11 之后,我们将数据分为左值和右值;
左值是一个表示数据的表达式 (如变量名或解引用的指针),我们可以对左值取地址,也可以对左值赋值 (const 左值不能赋值);左值既可以出现在赋值符号的左边,也可以出现在赋值符号的右边;如下:
// 以下的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;
右值也是一个表示数据的表达式,如字面常量、表达式返回值、函数返回值等等,右值可以出现在赋值符号的右边,但不能出现出现在赋值符号的左边,右值不能取地址;如下:
// 以下几个都是常见的右值
10;
x + y;
fmin(x, y);
// 右值不能被赋值,所以这里编译会报错:error C2106: “=”: 左操作数必须为左值
10 = 1;
x + y = 1;
fmin(x, y) = 1;
注意事项:
右值引用就是对右值的引用,给右值取别名;如下:
// 以下几个都是对右值的右值引用
int&& rr1 = 10;
double&& rr2 = x + y;
double&& rr3 = fmin(x, y);
注意:虽然右值不能取地址,但是给右值取别名后,会导致右值被存储到特定位置,拥有独立的内存空间,所以可以取到该位置的地址;换句话来说,虽然右值引用引用的是右值,但右值引用本身是一个左值。如下:
所以如果我们不希望改变右值引用,我们就需要将右值引用定义为 const 右值引用。
左值引用不能直接引用左值,但是 const 左值引用可以引用右值,因为 const 左值引用也是只读的,而权限可以平移:
int main()
{
// 左值引用只能引用左值,不能引用右值。
int a = 10;
int& ra1 = a;
//int& ra2 = 10; // 编译失败,因为10是右值
// const左值引用既可引用左值,也可引用右值。
const int& ra3 = 10;
const int& ra4 = a;
return 0;
}
右值引用也不可以直接引用左值,但是右值引用可以引用 move 后的左值:
int main()
{
// 右值引用只能右值,不能引用左值。
int&& r1 = 10;
// error C2440: “初始化”: 无法从“int”转换为“int &&”
int a = 10;
//int&& r2 = a;
// 右值引用可以引用move以后的左值
int&& r3 = std::move(a);
return 0;
}
我们之前接触到的引用其实都是 (const) 左值引用,左值引用主要有两个作用:
我们可以把函数形参定义为实参的引用,这样函数在传参时实参就不用拷贝构造形参了,从而提高程序的效率,特别是对于需要深拷贝的的自定义类型来说;如下:
//左值引用的使用场景
//做参数--减少一次拷贝构造
template <class T>
void func1(const T& x) {
//...
}
int main() {
vector<int> v(10, 0);
//const左值引用可以接受左值,也可以接收右值
func1(v);
func1(vector<int>(10, 1));
return 0;
}
左值引用做返回值的效果也一样,当返回的对象出了函数作用域还存在时,直接使用引用返回可以减少一次拷贝构造;如下:
//左值引用的使用场景
//做返回值--减少一次拷贝构造
template <class T>
const T& func1(const T& x) {
//...
return x; //出这个函数x仍然存在
}
int main() {
vector<int> v(10, 0);
//const左值引用可以接受左值,也可以接收右值
func1(v);
func1(vector<int>(10, 1));
return 0;
}
但是当函数返回对象是一个局部变量时,就不能使用左值引用返回,而只能传值返回了,因为局部对象出了函数作用域就不存在了,此时引用的就是一个野指针;如下:
//左值引用的短板--不能解决局部对象的返回值问题
template <class T>
T func1(const T& x) {
T tmp;
//...
return tmp; //出这个函数tmp会自动销毁
}
这种情况下下编译器会使用这个局部对象拷贝构造一个临时对象,然后再返回这个临时对象,也就是说,会比引用返回多一次拷贝构造;当局部对象是一个需要进行深拷贝的自动类型时,比如 vector
注:当局部对象较小时会将其拷贝到寄存器中,让寄存器充当临时对象;当局部对象较大寄存器放不下时,会直接将其压入到本函数栈帧和调用本函数的函数栈帧之间。
为了更好的查看左值引用和右值引用对拷贝构造的优化,这里我们给出自己实现的一个简易版的 string 类,类中如果要调用拷贝构造/赋值重载进行深拷贝我们就打印一下;如下:
#pragma once
#include
using std::cout;
using std::endl;
namespace thj {
class string {
public:
typedef char* iterator;
iterator begin() { return _str; }
iterator end() { 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);
}
// s1.swap(s2)
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
// 拷贝构造
string(const string& s)
:_str(nullptr)
{
cout << "string(const string& s) -- 深拷贝" << endl;
_size = s._size;
_capacity = s._capacity;
_str = new char[_capacity + 1];
strcpy(_str, s._str);
}
// 赋值重载
string& operator=(const string& s)
{
cout << "string& operator=(string s) -- 深拷贝" << endl;
if (this == &s)
return *this;
delete[] _str;
_size = s._size;
_capacity = s._capacity;
_str = new char[_capacity + 1];
strcpy(_str, s._str);
return *this;
}
// //移动构造
//string(string&& s)
// :_str(nullptr)
// , _size(0)
// , _capacity(0)
//{
// cout << "string(string&& s) -- 移动语义" << endl;
// swap(s);
//}
// //移动赋值
//string& operator=(string&& s)
//{
// cout << "string& operator=(string&& s) -- 移动语义" << endl;
// swap(s);
// return *this;
//}
~string()
{
delete[] _str;
_str = nullptr;
}
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
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;
}
private:
char* _str;
size_t _size;
size_t _capacity; // 不包含最后做标识的\0
};
}
现在,假设我们要实现一个 to_string 函数,如下:
thj::string to_string(int value)
{
bool flag = true;
if (value < 0)
{
flag = false;
value = 0 - value;
}
thj::string str;
while (value > 0)
{
int x = value % 10;
value /= 10;
str += ('0' + x);
}
if (flag == false)
{
str += '-';
}
std::reverse(str.begin(), str.end());
return str;
}
可以看到,由于 to_string 函数返回的 str 是一个局部对象,所以这里我们只能使用传值返回,而传值返回就需要进行深拷贝:
其实这里程序的执行结果和我们预想的并不一样,正常情况下应该是 str 先拷贝构造一个临时对象,然后由这个临时对象来拷贝构造 s,所以应该是两个拷贝构造 (多出来一次构造是 to_string 函数内部构造 str);但是我们发现这里只有一次拷贝构造。这其实是因为当遇到连续构造的场景时编译器会进行优化,直接使用 str 来拷贝构造得到 s,而不再创建临时对象,关于编译器的优化场景我们在 类和对象下 部分已经详细讲解过,这里就不再赘述。
但是优化只适用于少数场景,大部分情况下还是会拷贝构造产生临时对象,比如:
而且就算编译器进行了优化,这里还是会有一次拷贝构造,那么我们能不能想办法将 str 的资源直接转移给 s,中间不发生拷贝构造呢?这时右值引用就可以发挥作用了。
C++11 中的右值广义的来说一共分为两种:
注:上面我们说右值不能取地址其实是右值的严格定义,但其实将亡值也是可以被当作右值看待的,而将亡值有独立的内存空间,可以取地址;所以对于是否是右值我们要灵活看待。
既然将亡值的生命周期马上就要结束了,那么在拷贝构造中我们就可以直接将将亡值的资源拿过来给我自己使用,这样我就不用再去一个一个 new 节点了,将亡值也不用去一个一个释放节点了,两全其美;如下:
// s1.swap(s2)
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
//移动构造
string(string&& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(string&& s) -- 移动语义" << endl;
swap(s);
}
如上,我们重载了一个右值引用版本的构造函数 – 移动构造,这样当实参类型为右值的对象需要进行拷贝构造时就会调用此函数;在函数中,我们直接拿走将亡值的资源,从而使得深拷贝变为了浅拷贝,显著提高了程序的效率。
此时我们再调用 to_string 函数与之前的结果进行比对:
和上面一样,本来这里 str 会先拷贝构造一个临时对象,由于临时对象属于右值,所以会直接调用移动拷贝来构造 s;但是这里编译器进行了优化,直接将 str 识别为右值,让它来移动构造 s,所以通过移动构造 (右值引用) 我们成功将深拷贝变为了浅拷贝。
注意:只有当实参为右值时才会匹配 移动构造构造函数进行优化,当实参为左值时编译器在匹配参数还是会匹配形参为 const T& 的拷贝构造函数;因为编译器不知道我们是否还会对左值进行操作,所以它不敢拿走左值的资源来构造新的对象。
移动赋值和移动构造同理,只是移动赋值中将亡值还需要释放掉我之前的资源,不过这个过程是自动的:
//移动赋值
string& operator=(string&& s)
{
cout << "string& operator=(string&& s) -- 移动语义" << endl;
swap(s);
return *this;
}
注:网上有的人说右值引用延长了变量的生命周期,这种说法其实是不准确的;因为右值引用只是将该变量的资源转移给另外一个变量,让它的资源能够不随着该变量的销毁而被释放,而该变量本身的生命周期是没有变的。
总结 – 左值引用和右值引用减少拷贝的原理:
C++11 设计出右值引用之后,为 STL 的所有容器都提供了移动构造和移动赋值,包括容器适配器:
所以,以后如果我们要向容器中插入需要深拷贝的自定义类型的元素时,我们尽量构造匿名对象进行插入,这样调用的就是右值插入接口,元素会调用移动拷贝函数完成浅拷贝,从而提高程序效率。
我们也可以让我们自己实现的 list 类支持右值版本的插入接口;部分代码如下:
#pragma once
#include
#include
#include
namespace thj {
template<class T>
struct list_node //list的节点
{
list_node<T>* _next;
list_node<T>* _prev;
T _data;
//普通构造
list_node(const T& x)
:_next(nullptr)
, _prev(nullptr)
, _data(x)
{}
//移动构造
list_node(T&& x)
:_next(nullptr)
, _prev(nullptr)
, _data(move(x))
{}
};
//list的迭代器
template<class T, class Ref, class Ptr>
struct __list_iterator
{
//...
};
//list 类
template<class T>
class list
{
typedef list_node<T> node;
public:
void empty_initialize() { //初始化 -- 哨兵位头结点
_head = new node(T());
_head->_next = _head;
_head->_prev = _head;
_size = 0; //空间换时间,用于标记节点个数
}
list() {
empty_initialize();
}
void push_back(const T& x) {
insert(end(), x); //复用
}
//尾插右值版本
void push_back(T&& x) {
insert(end(), move(x)); //复用
}
void push_front(const T& x) {
insert(begin(), x); //复用
}
//头插右值版本
void push_front(T&& x) {
insert(begin(), move(x)); //复用
}
iterator insert(iterator pos, const T& x) {
node* newnode = new node(x);
node* cur = pos._pnode;
node* prev = cur->_prev;
prev->_next = newnode;
newnode->_prev = prev;
cur->_prev = newnode;
newnode->_next = cur;
++_size;
return iterator(pos);
}
//插入右值版本
iterator insert(iterator pos, T&& x) {
node* newnode = new node(move(x));
node* cur = pos._pnode;
node* prev = cur->_prev;
prev->_next = newnode;
newnode->_prev = prev;
cur->_prev = newnode;
newnode->_next = cur;
++_size;
return iterator(pos);
}
private:
node* _head;
size_t _size;
};
}
这里关键的点需要注意:
这两点其实在文章开始将右值引用的时候我们就提到过,不过还是要结合具体实例大家才能够真正的理解并且记住它。
我们上面都是单独定义一个参数为右值引用的函数,然后让编译器根据实参的类型来选择调用参数为左值引用的构造/插入接口还是参数为右值引用的构造/插入接口。那么,我们能不能让函数能够根据实参的类型自动实例化出对应不同的函数呢?万能引用可以实现这个功能。
万能引用是一个函数模板,且函数的形参类型为右值引用;对于这样的函数模板,编译器能够自动根据实参的类型 – 左值/ const 左值/ 右值/ const 右值,自动推演实例化出不同的形参类型分别为 左值引用/ const 左值引用/ 右值引用/ const 右值引用 的函数;如下:
//万能引用
template<typename T>
void PerfectForward(T&& t)
{
//fun(t);
}
int main()
{
int a;
const int b = 8;
PerfectForward(a); //左值
PerfectForward(b); //const 左值
PerfectForward(10); //右值
PerfectForward(std::move(b)); //const 右值
return 0;
}
可以看到,不管实参为什么类型,模板函数都能正确接受并实例化为对应的引用类型,所以我们把形参为右值引用的函数模板叫做万能引用。其中,当实参为左值或 const 左值时,T&& 会被实例化为 T& 或 const T&,我们称其为引用折叠,即将 && 折叠为 &。
我们上面讲解了万能引用,但是万能引用存在一个很大的问题:万能引用实例化后函数的形参的属性全部都是左值 – 如果实参为左值/ const 左值,则实例化函数的形参是左值/ const 左值;如果实参是右值/ const 右值,虽然实例化函数的形参是右值引用/ const 右值引用,但是右值引用本身是左值;所以就会出现下面这种情况:
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }
//万能引用
template<typename T>
void PerfectForward(T&& t)
{
Fun(t);
}
int main()
{
int a;
const int b = 8;
PerfectForward(a); //左值
PerfectForward(b); //const 左值
PerfectForward(10); //右值
PerfectForward(std::move(b)); //const 右值
return 0;
}
同时,这里我们也不能简单的将 t move 后传递给 Fun 函数,因为这样会让 t 全部变为右值,又满足不了实参为左值的情况。为了在传参的过程中能够保留对象原生类型属性,C++11 又设计出了完美转发 – forward。如下:
Func(forward<T>(t));
总结:C++11 的右值引用之旅:
旅程一:为了弥补左值引用局部对象返回会发生拷贝构造的问题,C++11 设计出了右值引用;右值引用可以通过移动构造和移动赋值实现资源转移,将深拷贝转化为浅拷贝,从而提高程序效率,这是 C++11 中非常重要的一个设计;
同时,C++11 还为 STL 中的容器都提供了右值版本的插入接口,但由于右值引用本身是左值,所以往下一层传递时不能保证其仍然是右值,所以C++11 又设计出了 move,但盲目的对左值进行 move 会导致错误。
旅程二:为了让模板函数能同时接受 (const) 左值和 (const) 右值并正确实例化为对应的引用类型,C++11 又设计出了万能引用,附带的又引出了引用折叠这个概念;但是这样奇怪的设计让许多学习 C++11 的人苦不堪言。
旅程三:万能引用的设计又带来了新的问题 – 不管是左值引用还是右值引用,其本身都是左值,所以往下一层传递时又要面对类型丢失的问题,但是这里使用之前的 move 已经不能解决问题了,所以 C++11 又又又设计出了完美转发,来保证传参的过程中对象原生类型属性能够保持不变。
大家不难看出,C++11 右值引用的设计是不断使用新坑来填旧坑的过程,并不是说这些东西设计的不好,只是 C++11 中这些奇怪的设计让 C++ 的学习成本变得很高,因为万能引用、引用折叠、完美转发这些语法的设计和之前的 C++ 可以说是大不相同,它们使得 C++ 越来越像一门新的语言;个人觉得,万能引用和完美转发这两个东西可以去掉,只留下右值引用和 move 即可。
在 C++11 之前,C++ 的类一共有六个默认成员函数:
由于 C++11 设计出了右值引用,所以 C++11 为每个类新增的两个默认成员函数,即移动构造函数和移动赋值运算符重载;它们的生成规则如下:
简单来说,如果你什么都没有实现,或者只实现了一个构造函数,那么编译器会自动生成移动拷贝和移动赋值;自动生成的对于内置类型完成值拷贝,对于自定义类型看自定义类型是否实现了移动构造或移动赋值,实现了就调用自定义类型的移动构造或移动赋值,没有实现就调用自定义类型拷贝构造和赋值重载。
由于 C++98 构造函数默认的初始化列表对内置类型不处理,所以 C++11 允许在类定义时给成员变量初始缺省值,这些缺省值会在构造函数的初始化列表用来初始化成员变量,如下:
强制生成默认函数的关键字default:
由于默认移动构造和移动赋值函数的生成条件十分苛刻,所以 C++11 提供了 default 关键字,它可以显示指定生成某个默认成员函数;比如我们提供了拷贝构造,就不会生成移动构造了,那么我们可以使用 default 关键字显示指定移动构造生成;如下:
class Person {
public:
Person(const char* name = "", int age = 0)
:_name(name)
, _age(age)
{}
//显式声明了拷贝构造
Person(const Person& p)
:_name(p._name)
, _age(p._age)
{}
//此时我们可以通过default关键字来让编译器默认生成移动构造
Person(Person&& p) = default;
private:
thj::string _name;
int _age;
};
int main()
{
Person s1;
Person s2 = s1;
Person s3 = std::move(s1);
return 0;
}
禁止生成默认函数的关键字delete:
假如我们要设计一个类,它不允许被拷贝,传统的做法是将拷贝构造函数定义为私有函数:
class A {
public:
A() {
_ptr = new int[10]{ 0 };
}
~A() {
delete[] _ptr;
}
private:
//将拷贝构造定义为私有,防止在类外进行拷贝
A(const A& a)
: _ptr(a._ptr)
{}
int* _ptr;
};
但是上面这种做法只防止了在类外进行拷贝,而在类内我们仍然可以调用拷贝构造函数完成拷贝,此时编译器在编译时不会发生错误,只有运行起来对同一块空间析构两次时才会报错;如下:
class A {
public:
A() {
_ptr = new int[10]{ 0 };
}
~A() {
delete[] _ptr;
}
//在类内进行拷贝
void func() {
A tmp(*this);
//...
}
private:
//将拷贝构造定义为私有,防止在类外进行拷贝
A(const A& a)
: _ptr(a._ptr)
{}
int* _ptr;
};
那么我们如何才能让一个类既不能在外部被拷贝,也不能在内部被拷贝呢?其实我们可以只给出拷贝构造函数的声明,且声明为私有;这样,只要调用了拷贝构造函数,那么在链接时一定会发生错误:
private:
A(const A& a);
上面是 C++98 中防止一个类被拷贝的做法,C++11 中提供了一种更为便捷的方法 – 在函数声明加上 =delete 即可,delete 关键字可以阻止函数的自动生成,我们称被 =delete 修饰的函数为删除函数;如下:
class A {
public:
A() {
_ptr = new int[10]{ 0 };
}
~A() {
delete[] _ptr;
}
void func() {
A tmp(*this);
//...
}
A(const A& a) = delete;
private:
int* _ptr;
};
注意:default 关键字都只能针对默认成员函数使用;而 delete 关键字既可以对默认成员函数使用,也可以对非默认成员函数和普通函数使用。