-
左值和右值
在 C++ 中,左值(lvalue)和右值(rvalue)是用于描述表达式的值类别(value category)的术语。
-
左值(lvalue):
- 左值是指可以标识一个内存位置的表达式,通常是具有名称的变量或者对象。左值可以出现在赋值操作符的左边或者右边。
- 具有左值属性的表达式通常可以取地址,因为它们在内存中有确切的位置。例如,变量、数组元素、返回引用的函数调用等都是左值。
- 左值表达式的特点是具有持久性,即它们的生命周期可以比单个表达式更长,可以作为赋值的目标等。
- 常见的左值如下:
int i = 42; i = 43; // ok, i is an lvalue int* p = &i; // ok, i is an lvalue int& foo(); foo() = 42; // ok, foo() is an lvalue int* p1 = &foo(); // ok, foo() is an lvalue
-
右值(rvalue):
- 右值是指临时创建的、没有具体内存位置的表达式,通常是无法取地址的临时对象或者字面量。右值通常出现在赋值操作符的右边。
- 具有右值属性的表达式通常没有持久性,它们的值只在表达式被求值的时候存在,求值结束后就会被销毁。
- 右值表达式的特点是临时性和短暂性,它们通常在表达式求值结束后就立即销毁。
-
常见的右值:
int foobar(); int j = 0; j = foobar(); // ok, foobar() is an rvalue int* p2 = &foobar(); // error, cannot take the address of an rvalue j = 42; // ok, 42 is an rvalue
在 C++11 中引入了右值引用(rvalue reference),通过 &&
语法来表示。右值引用的出现使得我们可以对右值进行特殊处理,例如移动语义(move semantics),这样可以提高对临时对象的效率。
总之,左值是可以标识内存位置的表达式,而右值则是临时的、无法取地址的表达式。右值引用的引入使得我们能够更好地管理临时对象,提高程序的效率。
-
移动语义
为何需要移动语义?
下面用一个例子说明:
#include <vector>
#include <string>
#include <cctype> // 包含 toupper 函数
std::vector<std::string> allcaps(const std::vector<std::string> &vs) {
std::vector<std::string> temp;
for (const std::string &str : vs) {
std::string uppercase_str;
for (char ch : str) {
uppercase_str += std::toupper(ch); // 将字符转换为大写形式
}
temp.push_back(uppercase_str); // 将大写形式的字符串存储到临时向量中
}
return temp;
}
int main() {
std::vector<std::string> vstr;
// 构建一个包含 20,000 个字符串,每个字符串都有 1000 个字符
for (int i = 0; i < 20000; ++i) {
std::string str(1000, 'A');
vstr.push_back(str);
}
// 复制 vstr 到 vstr_copy1
std::vector<std::string> vstr_copy1(vstr); //#1
// 生成 vstr 中所有字符串的大写副本,并复制到 vstr_copy2
std::vector<std::string> vstr_copy2(allcaps(vstr)); //#2
return 0;
}
从表面上看,语句#1和#2类似(最后两行),它们都使用一个现有的对象初始化 一个vector对象。如果深入探索这些代码,将发现allcaps( )创建 了对象temp,该对象管理着20000000个字符;当函数返回的时候,vector和string的复制构造函数创建这20000000个字符的副本,然后程序删除allcaps( )返回的临时对象(迟钝的编译器甚至可能将temp复制给一个临时返回对象,删除 temp,再删除临时返回对象)。这里的要点是,做了大量的无用功。考虑到临时对象被删除了,如果编译器将对数据的所有权直接转让给 vstr_copy2,不是更好吗?也就是说,不将20000000个字符复制到新地方,再删除原来的字符,而将字符留在原来的地方,并将vstr_copy2与 之相关联。这类似于在计算机中移动文件的情形:实际文件还留在原来的地方,而只修改记录。这种方法被称为移动语义(move semantics)。有点悖论的是,移动语义实际上避免了移动原始数据,而 只是修改了记录。 要实现移动语义,需要采取某种方式,让编译器知道什么时候需要 复制,什么时候不需要。这就是右值引用发挥作用的地方。可定义两个 构造函数。其中一个是常规复制构造函数,它使用const左值引用作为参 数,这个引用关联到左值实参,如语句#1中的vstr;另一个是移动构造 函数,它使用右值引用作为参数,该引用关联到右值实参,如语句#2中 allcaps(vstr)的返回值。复制构造函数可执行深复制,而移动构造函数只调整记录。在将所有权转移给新对象的过程中,移动构造函数可能修改其实参,这意味着右值引用参数不应是const。
具有”移动语义“的对象自然而然具备的特点是,它同时是一个临时对象。因为当这个对象被移动后,其原有资源被移动到另一个对象中,此时对该临时对象任何访问或使用行为都是不合理的。在 C++ 中,我们可以粗浅认为区分左值和右值就是为了区分对象是否是临时的,对于右值,其本身就是临时的,可以直接赋予”移动语义“;对于左值,必须先通过std::move()
将其转化为一个右值,再赋予移动语义。
std::move()
这个函数名或许有点误导性,虽然他函数名是“移动”,但这个函数唯一做的一件事就是把值转换成右值。std::move()并没有实现“移动”操作。“
移动”的操作通常需要定义移动构造函数和移动赋值运算符,移动构造函数通常会接受一个右值引用参数,并将其指向的资源"窃取"到新对象中,而移动赋值运算符则会释放自身对象中旧的资源,并将右值的资源"移动"过来。
示例:
#include <iostream>
#include <cstring> // 用于 strcpy 函数
class MyClass {
private:
char *data; // 内部资源
public:
// 默认构造函数
MyClass() : data(nullptr) {}
// 析构函数
~MyClass() {
delete[] data;
}
// 移动构造函数 函数内修改了other对象,这要求不能在参数声明中使用const。
MyClass(MyClass&& other) noexcept : data(other.data) {
other.data = nullptr; // 置空源对象的指针,避免资源重复释放
}
// 拷贝构造函数(深拷贝)
MyClass(const MyClass& other) : data(nullptr) {
if (other.data) {
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
}
}
// 移动赋值运算符
MyClass& operator=(MyClass&& other) noexcept {
if (this != &other) {
delete[] data; // 释放当前对象的资源
data = other.data; // 转移资源所有权
other.data = nullptr; // 置空源对象的指针,避免资源重复释放
}
return *this;
}
// 拷贝赋值运算符(深拷贝)
MyClass& operator=(const MyClass& other) {
if (this != &other) {
delete[] data; // 释放当前对象的资源
if (other.data) {
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
} else {
data = nullptr;
}
}
return *this;
}
// 示例函数:输出数据
void printData() const {
std::cout << "Data: " << (data ? data : "nullptr") << std::endl;
}
};
int main() {
// 移动构造函数的用法
MyClass obj1;
MyClass obj2(std::move(obj1)); // 使用 std::move 将 obj1 转为右值引用
// 拷贝构造函数的用法
MyClass obj3(obj2);
// 移动赋值运算符的用法
MyClass obj4;
obj4 = std::move(obj3); // 使用 std::move 将 obj3 转为右值引用
// 拷贝赋值运算符的用法
MyClass obj5;
obj5 = obj4;
return 0;
}
-
右值引用
移动语义想要解决的问题是,能否可以做到:对于临时对象(右值)进行移动拷贝,对于非临时对象(左值)进行深拷贝。这样可以避免不必要的深拷贝,从而提高效率。 右值引用则可以很好地解决这个问题:
要做的事情也很简单,需要实现两个拷贝赋值函数,一个负责深拷贝,一个负责移动拷贝,其中深拷贝函数对应的参数是左值(当参数是左值时,调用深拷贝函数),移动拷贝函数对应的参数是右值(当参数是右值时,调用移动拷贝函数)。
TestClass& operator=(const TestClass &rhs) {
// deep copy resource
if (resource != nullptr) {
delete resource;
}
resource = new std::vector<int>(*rhs.resource);
std::cout << "Copy Assignment." << std::endl;
return *this;
}
TestClass& operator=(TestClass && rhs) {
// swap resource pointer
swap(resource, rhs.resource);
std::cout << "Move Assignment." << std::endl;
return *this;
}
TestClass GetTestClass() {
TestClass tc(new std::vector<int>);
return tc;
}
int main() {
TestClass tc;
TestClass tc2(new std::vector<int>);
tc = tc2;
std::cout << "==================" << std::endl;
tc = GetTestClass();
std::cout << "==================" << std::endl;
TestClass tc3(new std::vector<int>);
tc = std::move(tc3);
return 0;
}
---------output----------
Copy Assignment. // tc = tc2, 拷贝
==================
Move Assignment. // tc = GetTestClass(), 由于 GetTestClass() 是一个右值,进行移动拷贝
==================
Move Assignment. // tc = std::move(tc3), 由于 std::move(tc3) 是一个右值,进行移动拷贝
-
如何使用 std::move
之前说到,std::move
唯一的作用是将一个值转化为右值。如果从抽象的角度理解,当然可以把std::move
作为一种移动语义的实现,但我还是认为应当把整个实现逻辑理解为:std::move
将值转化为右值 -> 右值引用使编译器能够分辨出右值并调用移动拷贝函数 -> 赋予对象移动语义。
从使用的角度来说,典型的例子是swap
函数,在不使用std::move
时, swap
可以实现如下:
template<class T>
void swap(T& a, T& b) {
T tmp(a);
a = b;
b = tmp;
}
这样的实现当然是正确的,但也是效率低下的。此处的tmp
、a
、b
都是左值,左值对应的拷贝赋值函数可能是深拷贝,但在swap
这个例子中,深拷贝是不需要的,因为可以发现每次进行赋值后,被赋值的对象(参数a,b)就再也不会被使用了。在这个前提下,可以在每次进行拷贝赋值前先将参数转化为右值,也就是:
template<class T>
void swap(T& a, T& b) {
T tmp(std::move(a));
a = std::move(b);
b = std::move(tmp);
}
而这也是标准库中std::swap
的实现。通过std::move
转化为右值后,会调用参数为右值的拷贝赋值函数,而这个函数的实现通常是移动拷贝。不仅仅是std::swap
,标准库中很多其他的算法也会尽可能使用std::move
来提升效率。同时一些 STL 原本要求容器具有可复制的特性,有了移动语义之后则可以把要求降低为只要具有可移动的特性即可,比如std::auto_ptr
只能满足可移动而不能满足可复制,但仍然可以作为许多 STL 的容器,而在 C++11 之前,由于没有移动语义的概念,编译器则会禁止将std::auto_ptr
作为容器的行为。
-
右值引用的特性
这里要讨论的问题是,右值引用是左值还是右值?对于这个问题的判断原则是这样的:如果右值引用有名字则是左值,如果没有名字则是右值。
回顾之前我们对左值和右值的认识,右值表示一个临时值,左值表示一个非临时值。这种设计的合理性在于,“移动语义”和可访问性是不能共存的。
试想这个情形,假设x
是一个右值,那么下面这句赋值很可能调用的是移动拷贝:
X anotherX = x; // x is still in scope!
// do something about x...
移动拷贝后,对象x
的状态是不确定的,无论是置空还是与anotherX
互换。但x
仍然在作用域内,再对x
的任何访问操作都非常危险。
而且这种移动可能会发生地非常隐蔽,导致错误更难发现。总之可以从中总结出一个新的原则:有名字的值一定是左值。
这会导致的一个常见错误是,如果希望在派生类和基类中都使用移动语义,那么需要在中间步骤使用std::move
:
Derived(Derived&& rhs)
: Base(rhs) {} // wrong: rhs is an lvalue
Derived(Derived&& rhs)
: Base(std::move(rhs)) {} // good, calls Base(Base&& rhs)
根据是否有名字判断左右值的原则对于std::move
也是适用的,虽然std::move
可以将一个值转换成右值,但一旦用一个有名字的对象来接这个右值,它就会立刻转变为左值。从这个意义讲,std::move
正是通过隐藏名字的方式将值转化为右值的。
#include <iostream>
void foo(int & i) {
std::cout << "lvalue ref" << std::endl;
}
void foo(int && i) {
std::cout << "rvalue ref" << std::endl;
}
int main() {
auto a = std::move(1);
foo(a);
foo(std::move(1));
return 0;
}
===output===
lvalue ref
rvalue ref
-
编译器返回值优化
在理解了移动语义是如何与移动赋值/构造函数结合来提高性能后,你可能会有一种优化函数返回值的想法,比如下面这个函数:
class A {
public:
A() {
std::cout << "Default Constructor." << std::endl;
}
A(const A &) {
std::cout << "Copy Constructor." << std::endl;
}
A(A &&) {
std::cout << "Move Constructor." << std::endl;
}
};
A GetA() {
A a;
return a;
}
看起来函数会先构造一个局部变量,然后进行一次深拷贝,将局部变量拷贝到返回值上。变量a
是一个局部变量,从这个角度讲其实使用std::move
将其转化为右值,并利用移动语义优化拷贝,也就是改成:
A GetA() {
A a;
return std::move(a);
}
这个想法当然是好的,但是在实际情况下并不会产生优化,反而会使效率下降。其原因在于编译器会使用 return value optimization (ROV)
对这种情况进行优化。当返回值是一个局部变量,并与返回值数据类型一致时,编译器不会通过拷贝的方式构造返回值,而是直接选择在构造局部变量时,直接在返回值的位置上进行构造,从而减少了一次拷贝的代价(无论是深拷贝还是浅拷贝)。当我们将返回值改成std::move(a)
时,反而破坏了达成这种优化的前提条件,额外多了一次移动拷贝行为。