目录
lvalue 和 rvalue
每个表达式都会得到 lvalue 或 rvalue。它们的区别是,lvalue 是一个持久存在的值,其内存地址可被用来持续存储值;rvalue 是一个暂时存储的结果。之所以称为 lvalue,是因为得到 lvalue 的表达式通常出现在赋值运算符的左边,而 rvalue 只能出现在赋值运算符的右边。表达式的结果不是 lvalue,就是 rvalue。包含一个变量名称的表达式总是 lvalue。
注意:虽然名称中带有 value,但是 lvalue 和 rvalue 是表达式分类,而不是值分类。
int a{}, b{1}, c{-2};
a = b + c;
double r = std::abs(a * c);
auto p = std::pow(r, std::abs(c));
第一条语句把 a、b 和 c 定义成 int 类型,分别初始化为 0、1 和 -2。之后,名称 a、b 和 c 都是 lvalue。
第二条语句中,会临时存储 b+c 的结果,并且复制到 a 中。执行完该语句后,就舍弃保存 b+c 结果的空间。因此,b+c 的结果是 rvalue。
涉及函数时,存在临时结果这一点就更加明显了。例如,在第三条语句中,a*c 被计算,作为临时值存储在内存中的某个位置。然后,这个临时结果作为实参被传递给 std::abs() 函数。这使得 a*c 成为 rvalue。std::abs() 自己返回的值也是临时的。它只是存在一小段时间,直到被隐式转换为一个 double 值。
对于第四条语句的两个函数调用,也是同样的道理。例如,std::abs() 返回的值显然只临时存在,以用作 std::abs() 的实参。
注意:大部分函数调用表达式都是 rvalue。只有返回引用的函数调用是 lvalue。返回引用的函数调用可出现在内置赋值运算符的左侧,说明它们是 lvalue,典型的容器的下标运算符 (operator[]()) 和 at() 函数是很好的例子。例如,如果 v 是 vector<int>,那么 v[1] = 1; 和 v.at(2) = 2; 都是完全有效的语句。显然,它们是 lvalue。( 源自:《C++17入门经典》)
观察以下实例,判断给定表达式是 lvalue 还是 rvalue:
int *x = &(a+b);
int *y = &std::abs(a*d);
int *z = &123;
int *w = &a;
int *u = &v.at(2);
当周围语句完成执行后,存储表达式 b+c 和 std::abs() 结果的内存将立即回收。如果允许它们存在,指针 x 和 y 将成为悬挂指针,没有人能够查看它们。这意味着这些表达式是 rvalue。所有数值字面量是 rvalue,而编译器不允许获取数值字面量的地址。
对于基本类型的表达式,lvalue 和 rvalue 的区别一般不重要。对于类类型的表达式,这种区别比较重要;而且即使是类类型表达式,也只有在某些情况下才重要,例如,把表达式传递给函数,且函数有专门定义为接收 rvalue 表达式结果的重载时,或者当在容器中存储对象时。区分左值和右值的一个简单办法是:看能不能对表达式取地址,如果能,则为左值,否则为右值。
#include <iostream>
#include <string>
using namespace std;
char &get_val(string &str, string::size_type ix)
{
return str[ix];
}
char *get_val1(char a[], char *b)
{
b = &a[0];
return b;
}
int main()
{
string s("a value");
cout << s << endl;
get_val(s, 0) = 'A';
cout << s << endl;
return 0;
}
输出:
a value
A value
rvalue 引用
引用是一个名称,可用作其他某个事物的别名。但是,实际上有两种类型的引用:lvalue 引用和 rvalue 引用。在多数情况下使用的引用是 lvalue 引用。通常,lvalue 引用是另外一个变量的别名;之所以叫作 lvalue 引用,是因为它通常引用一个持久的数据存储位置,可以出现在赋值运算符的左边。
rvalue 引用也可以是变量的别名,这一点和 lvalue 引用相同,但与 lvalue 引用不同,rvalue 引用能够引用一个 rvalue 表达式的结果,即使这个值一般来说是临时的。绑定到 rvalue 引用,就延长了这种临时值的生存期。只要 rvalue 引用还在作用域内,用于临时值的内存就不会被丢弃。要指定 rvalue 引用,需要在类型名的后面使用两个 & 符号,例如:
int count {5};
int && rtemp {count + 3};
std::cout << rtemp <<std::endl;
int & rcount {count};
这段代码可以编译并运行,但不是使用 rvalue 引用的正确方式,这段代码仅仅演示了 rvalue 引用的含义。
移动语义
移动语义的概念
如果想避免高开销的复制,可以使用引用或指针。不过,从 C++11 开始,有了另一种新的更强大的方法,现在不只能够复制对象,还可以移动对象。移动语义允许高效地将一个对象传输到另一个对象,不进行深层复制。在 C++11 之前的复制过程:
vector<string> vstr;
创建一个含有20000字符串的vector,每个字符串含有1000个字符。
......
vector<string> vstr_copy1(vstr);
vector 和 string 类都使用动态内存分配,因此它们必须定义使用某种 new 版本的复制构造函数。为初始化对象 vstr_copy1,复制构造函数 vector<string> 将使用 new 给 2 万个 string 对象分配内存,而每个 string 对象又将调用 string 的复制构造函数,该构造函数使用 new 为 1000 个字符分配内存。接下来,全部两千万个字符都将从 vstr 控制的内存复制到 vstr_copy1 控制的内存中,这里的工作量是很大的。
考虑以下情况:
vector(string) allcaps(const vector<string> & vs)
{
vector<string> temp;
return temp;
}
假设这样使用它:
vector<string> vstr;
vector<string> vstr_copy1(vstr); // 1
vector<string> vstr_copy2( allcaps(vstr) ); // 2
从表面上看,语句 1 和语句 2 类似,它们都使用一个现有的对象初始化一个 vector<string> 对象。如果深入探索这些代码,将发现 allcaps() 创建了对象 temp,该对象管理两千万个字符;vector 和 string 的复制构造函数创建这两千万个字符的副本,然后程序删除 allcaps() 返回的临时对象。这可不是一个小小的临时对象啊!
如果编译器将对数据的所有权直接转让给 vstr_copy2,不是更好吗?也就是说,不将两千万个字符复制到新的地方,再删除原来的字符,而是将字符留在原来的地方,并将 vstr_copy2 与之相关联。这种方法被称为移动语义(move semantics),但不要被它的名字所迷惑,移动语义实际上避免了移动原始数据,而只是修改了记录。
要实现移动语义,需要采取某种方式让编译器知道什么时候需要复制,什么时候不需要。这就是右值引用发挥作用的地方。可定义两个构造函数,其中一个是常规的复制构造函数,它使用 const 左值引用作为参数,这个引用关联到左值实参,如语句 1 中的 vstr ;另一个是移动构造函数,它使用右值引用作为参数,该引用关联到右值实参,如语句 2 中的 allcaps(vstr) 的返回值。赋值构造函数可执行深复制,而移动构造函数只调整记录。在将所有权转移给新对象的过程中,移动构造函数可能修改其实参,这意味着右值引用参数不应是 cosnt。
了解完需求及概念后,为了使用移动构造函数,并分析它与复制构造函数的不同。以下代码在各个构造函数中添加了输出信息的语句,用于区分不同构造函数的调用情况。
移动构造函数示例代码:
#include <iostream>
#include <string>
using namespace std;
class Asd
{
private:
int n; //元素的数量
char * pc; //指向数据
static int numsum; //对象的数量
void Showobject() const; //展示对象信息,元素数量及地址
public:
Asd();
explicit Asd(int k);
Asd(int k, char ch);
Asd(const Asd &f); //copy constructor 复制构造函数
Asd(Asd &&f); //move constructor 移动构造函数
~Asd();
Asd operator +(const Asd &f) const;
void Showdata() const;
};
int Asd::numsum = 0;
Asd::Asd()
{
++numsum;
n = 0;
pc = NULL;
cout << "Default constructor, number of objects:" << numsum <<endl;
Showobject();
}
Asd::Asd(int k) : n(k)
{
++numsum;
cout << "Int constructor, number of objects:" << numsum <<endl;
pc = new char[n];
Showobject();
}
Asd::Asd(int k, char ch) : n(k)
{
++numsum;
cout << "Int, char constructor, number of objects:" << numsum <<endl;
pc = new char[n];
for(int i = 0; i < n; i++)
{
pc[i] = ch;
}
Showobject();
}
Asd::Asd(const Asd &f) : n(f.n)
{
++numsum;
cout << "Copy constructor, number of objects:" << numsum <<endl;
pc = new char[n];
for (int i = 0; i < n; i++)
{
pc[i] = f.pc[i];
}
Showobject();
}
Asd::Asd(Asd &&f) : n(f.n)
{
++numsum;
cout << "Move constructor, number of objects:" << numsum <<endl;
pc = f.pc;
f.pc = NULL;
f.n = 0;
Showobject();
}
Asd::~Asd()
{
cout << "Destructor, number of objects:" << --numsum <<endl;
cout << "Delete object:\n";
Showobject();
cout << "End" <<endl;
delete [] pc;
}
Asd Asd::operator +(const Asd &f)const
{
cout << "Entering Operator+() \n";
Asd temp = Asd(n + f.n);
for (int i = 0; i < n; i++)
temp.pc[i] = pc[i];
for (int i = n; i < temp.n; i++)
temp.pc[i] = f.pc[i-n];
cout << "Leaving Operator+() \n";
return temp;
}
void Asd::Showobject() const
{
cout << "Number of elements:" << n;
cout << " Data address: " << (void *)pc <<endl;
}
void Asd::Showdata() const
{
if (n == 0)
cout << "Empty";
else
for(int i = 0; i < n; i++)
cout << pc[i];
cout <<endl;
}
int main()
{
{
Asd one(10, 'x');
Asd two = one;
Asd three(10, 'o');
Asd four(one + three); //这里调用了operator+(),移动构造函数
cout << "one:";
one.Showdata();
cout << "two:";
two.Showdata();
cout << "three:";
three.Showdata();
cout << "four:";
four.Showdata();
}
return 0;
}
对象 two 是对象 one 的副本,它们的数据是相同的,但是数据的地址不一样。另一方面,在方法 operator+() 中创建的对象的数据地址与对象 four 存储的数据地址相同,而对象 four 是由移动构造函数创建的。另外,在创建对象 four 后,为临时对象调用了析构函数。之所以是临时对象,因为它们的元素数和数据地址都是 0。
如果不关闭 RVO (返回值优化),是看不到调用移动构造函数的。一般不用太在意这一点,这是因为编译器进行优化的结果与未优化时的结果相同。
移动构造函数
虽然使用右值引用可以支持移动语义,但这并不会自己神奇的发生。要想完成相应的功能,需要两个步骤。
(1) 右值引用让编译器知道何时使用移动语义
Asd two = one; //匹配复制构造函数
Asd four(one + three); //匹配移动构造函数
对象 one 是左值,与左值引用相匹配,而表达式 one+three 是右值,与右值引用匹配。因此,右值引用让编译器使用移动构造函数来初始化对象 four。
(2) 编写移动构造函数
Asd::Asd(Asd &&f) : n(f.n)
{
++numsum;
pc = f.pc; // pc指向f.pc
f.pc = NULL; //f.pc指向空,这样就可以避免调用析构函数时对同一个空间释放两次。
f.n = 0;
Showobject();
}
赋值
适用于构造函数的移动语义也适用于赋值运算符。例如:
(1) 复制赋值运算符
深复制,申请空间后复制原数据。
Asd Asd::operator =(const Asd &f)
{
if (this == &f)
return *this;
delete [] pc;
n = f.n;
pc = new char[n];
for (int i = 0; i < n; i++)
pc[i] = f.pc[i];
return *this;
}
(2) 移动赋值运算符
修改指针指向,然后让原指针指向 nullptr
Asd Asd::operator =(Asd && f)
{
if (this == &f)
return *this;
delete [] pc;
n = f.n;
pc = f.pc;
f.n = 0;
f.pc = nullptr;
return *this;
}
强制移动
移动构造函数和移动赋值运算符使用右值。如果要让它们使用左值,该怎么办?例如,程序可能分析一个包含候选对象的数组,选择其中一个对象使用,并丢弃数组。如果可以使用移动构造函数或移动赋值运算符来保留选定的对象,那该多好啊。然而,若按以下方式做:
Asd test[10];
Asd best;
int pick;
...
best = test[pick];
由于 test[pick] 是左值,因此会使用复制赋值运算符,而不是移动赋值运算符。但如果让 test[pick] 看起来像右值,便将使用移动赋值运算符。为此,可使用运算符 static_cast<> 将对象的类型强制转换成 Asd &&,但自 C++11 提供了一种更简单的方式——使用 std::move() 函数。
代码如下:
#include <iostream>
#include <string>
using namespace std;
class Asd
{
private:
int n; //元素的数量
char * pc; //指向数据
static int numsum; //对象的数量
void Showobject() const; //展示对象信息,元素数量及地址
public:
Asd();
explicit Asd(int k);
Asd(int k, char ch);
Asd(const Asd &f); //copy constructor 复制构造函数
Asd(Asd &&f); //move constructor 移动构造函数
~Asd();
Asd operator +(const Asd &f) const;
Asd & operator =(const Asd &f);
Asd & operator =(Asd &&f);
void Showdata() const;
};
int Asd::numsum = 0;
Asd::Asd()
{
++numsum;
n = 0;
pc = NULL;
}
Asd::Asd(int k) : n(k)
{
++numsum;
pc = new char[n];
}
Asd::Asd(int k, char ch) : n(k)
{
++numsum;
pc = new char[n];
for(int i = 0; i < n; i++)
pc[i] = ch;
}
Asd::Asd(const Asd &f) : n(f.n)
{
++numsum;
pc = new char[n];
for (int i = 0; i < n; i++)
pc[i] = f.pc[i];
}
Asd::Asd(Asd &&f) : n(f.n)
{
++numsum;
pc = f.pc;
f.pc = NULL;
f.n = 0;
}
Asd::~Asd()
{
delete [] pc;
}
Asd Asd::operator +(const Asd &f)const
{
Asd temp = Asd(n + f.n);
for (int i = 0; i < n; i++)
temp.pc[i] = pc[i];
for (int i = n; i < temp.n; i++)
temp.pc[i] = f.pc[i-n];
return temp;
}
Asd & Asd::operator =(const Asd &f)
{
cout << "Copy assignment:\n";
if (this == &f)
return *this;
delete [] pc;
n = f.n;
pc = new char[n];
for (int i = 0; i < n; i++)
pc[i] = f.pc[i];
return *this;
}
Asd & Asd::operator =(Asd && f)
{
cout << "Move assignment:\n";
if (this == &f)
return *this;
delete [] pc;
n = f.n;
pc = f.pc;
f.n = 0;
f.pc = nullptr;
return *this;
}
void Asd::Showobject() const
{
cout << "Number of elements:" << n;
cout << " Data address: " << (void *)pc <<endl;
}
void Asd::Showdata() const
{
if (n == 0)
cout << "Empty";
else
for(int i = 0; i < n; i++)
cout << pc[i];
cout <<endl;
}
int main()
{
{
Asd one(10, 'x');
Asd two = one + one;
cout << "One:";
one.Showdata();
cout << "Two:";
two.Showdata();
Asd three, four;
cout << "Three = One:\n";
three = one;
cout << "Now, object three:";
three.Showdata();
cout << "and object one = ";
one.Showdata();
cout << "Four = one + two\n";
four = one + two; //移动赋值运算符
cout << "Now, object four = ";
four.Showdata();
cout << "four = move(one)\n";
four = move(one);
cout << "Now, object four = ";
four.Showdata();
cout << "and object one = ";
one.Showdata();
}
return 0;
}
从上面实验可看出,将 one 赋给 three 调用了复制赋值运算符,但将 move(one) 赋给 four 调用的是移动赋值运算符。对大多数程序员来说,右值引用带来的主要好处并非是让他们能够编写使用右值引用的代码,而是能够使用利用右值引用实现移动语义的库代码。例如,STL 类现在都有复制构造函数、移动构造函数、复制赋值运算符和移动赋值运算符。