移动语义
值类型(value Type):存储数据的方式,决定了变量的内存分布和编码方式。
值类别(Value Category):表达式求值后的类别,通常与值的生命周期相关。
C++11之前:左值、右值
在 C++11 之前,值类别(Value Category)只有左值(Lvalue)和右值(Rvalue)。
- 左值(Lvalue)指的是语句结束后依然存在的持久对象。
- 右值(Rvalue)指的是语句结束后不再存在的临时对象。
最朴素的判断方式是,具名变量是左值, 匿名临时变量(字面量、函数返回值、运算表达式,类型转换等)为右值。
int a = 10;// a 是左值, 10 是右值
int b = a + 1;// b是左值,(a + 1) 是右值
int c = max(1, 2);// c是左值,max(1, 2) 是右值,(假定函数返回值的类别是右值)
double d = (double)a; // d是左值,(double)a 是右值,但注意:a 本身还是一个左值但是 C++ 是一个有历史包袱的语言,总有例外:
void test() {
cout << &"hello" << endl;
}
int main() {
cout << &"hello" << endl;
test();
cout << &"hello" << endl;
}上面代码中,"hello"是字符串常量,但是它在程序中可以被多次取地址,显然它是个左值。
根本原因是,"hello" 字符串常量在编译时就被分配到了只读数据段中,并被编译器赋予了唯一的地址,因此也不会在语句结束之后被销毁。
所以另一种稍微准确的判断方式是,一个变量可以取地址,则为左值,否则为右值。
需要注意的是:string s = "hello";这种写法中发生了隐式类型转换,"hello"(左值)产生了一个临时对象(右值),并被赋值给了左值的变量s。
C++98标准的引用
引用语法在 C++98 标准中就正式被引入。当时并没有区分左值引用和右值引用的概念。
引用能够避免对象的拷贝,但是只能绑定左值。因为右值在语句结束之后就会被销毁,因此无法被左值引用绑定。
int & a = 10; // 报错
int a = 10;
int & b = a; //正确这造成了一个很麻烦的问题,参数传递时通常会使用右值:
// void test(int& a);
test(10); // 报错
int a = 10;
test(a); // 正确为了使用引用类型的形参而区分左值和右值,这对于开发者来说很不友好。因此标准委员会提出了特殊的规则:常量引用:const T&,它既可以接收左值,也可以接收右值。
// void test(const int& a);
test(10);// 正确但是常量引用也有一个问题,它无法修改被引用的对象。
移动语义
实际上C++98的引用已经可以实现对拷贝操作的优化。常量引用更是可以接收右值,避免右值的销毁带来的性能损耗。
但是,C++98标准对资源的管理仍然不完善,C++11标准引入了移动语义。
移动语义的核心思想是:管理资源所有权,对临时对象更细致的控制。
左值引用与右值引用
C++98标准中的常量引用可以接受右值,但是无法修改。C++11标准引入了右值引用,专门用来绑定右值。
右值引用本身是左值,故可以寻址,因此也可以被修改。
int a = 1;
int b = 2;
int && c = a + b;// c == 3
c = 4; // c == 4
a = 3; // c == 4,不会被改变,因为和原来的a没有关系
int && obj2 = std::move(obj1);通常而言,右值引用是用来绑定临时对象的,绑定基础类型没有什么意义。右值引用实现了资源的移动,托管了所绑定的临时变量的资源,这种操作不涉及拷贝和销毁,能提高性能。
const T &&
常量右值引用只能绑定右值,并且不能进行修改。从语法上讲,这样的写法是合法的。但是,常量引用const T&完全覆盖了它的功能,而且还可以绑定左值,因此不必研究它的作用和意义,甚至在 C++11 标准上根本没有出现这种写法,通常都是使用const T&。
std::move()
std::move()函数是一个类型转换工具。其返回值类别为右值,作用就是将一个左值识别为将亡值,用来告诉编译器,该对象可以被移动。除此之外不做任何操作。在C++11标准中,使用了std::move()所标记的变量,在后续代码中如果再次使用,则是未定义行为。
int a = 1;
int && b = std::move(a); // 标记为右值,这样左值可以赋给右值引用。
int c = a; // 使用 a 不报错,但是这是未定义行为。将亡值 *
需要注意的是:为了更灵活的处理临时变量,C++11扩展了左右值的概念,引入了将亡值(Xvalue)。
根据ISO/IEC 14882(C++标准),xvalue的定义如下:
xvalue(将亡值)是一种表达式,它满足以下条件之一:
- 涉及右值引用的表达式(如通过std::move转换的表达式)。
- 返回类型为右值引用的函数调用表达式。
- 访问右值引用对象的非静态成员或元素的表达式。
- 通过强制类型转换(如static_cast<T&&>)生成的右值引用表达式。
xvalue的关键特性是:xvalue指向的对象处于“将亡”状态(即语句结束后对象结束其生命周期),其资源可以被移动(而非复制),从而优化性能。
非常晦涩难懂,但是可以这么理解:将亡值是个概括性的概念,如果从 C++98 的左右值的定义来看,他可以是从左值调用std::move转换过来的临时对象,也可以是右值。
关键在于,它的生命周期即将结束,可以被右值引用绑定。这样就实现了资源的移动,而不是复制。
所以,移动语义并不是拷贝语义,而是资源管理。
移动构造函数,移动赋值运算符
struct T {
T(int x):a(x) {}
// 拷贝构造函数
T(const T& t) { cout << "copy constructor" << endl; }
// 移动构造函数
T(T&& t) { cout << "move constructor" << endl; }
int a;
};
int main() {
T t1(2);
T t3(T(3)); // 调用移动构造函数(假定不开启优化技术)
}C++11 标准引入了移动构造函数,当使用右值创建对象时,优先调用移动构造函数。
拷贝构造函数的形参const T&,移动构造函数的形参是T&&。从左值引用和右值引用的角度来看,const T&作为常量引用,同样可以绑定值类别为右值的实参。
使用场景
既然拷贝构造函数可以接收右值,移动构造函数存在的意义是什么?或者说,有哪些场景是必须应用移动构造函数的呢?
拷贝构造函数实现了深拷贝
当拷贝构造函数实现了深拷贝时,如果不实现移动构造,则每一次采用临时对象构造时,一定会调用拷贝构造函数进行深拷贝,导致性能损耗。
典型的例子就是STL容器,几乎所有的STL容器的拷贝构造都是深拷贝,并且实现了移动构造。
vector<int> v1 = {1, 2, 4};
vector<int> v2(v1); //深拷贝
v2[0] = 10; //v1[0],仍然是1
vector<int> v3(vector<int> {1, 2, 3}); // 实现了移动构造,避免了深拷贝对象实现删除了拷贝构造函数
有一些对象是不可进行拷贝的,例如:文件对象、线程对象、unique_ptr智能指针等。
但是某些场景下又必须转移资源的所有权。例如使用unique_ptr托管的资源,作为参数传递时,就必须使用移动语义。因为unique_ptr删除了拷贝构造,所以必须使用移动构造。
unique_ptr<int> p1(new int(10));
unique_ptr<int> p2 = std::move(p1); // 移动构造容器中存放了不可拷贝的对象
例如 vector 容器中存放了unique_ptr智能指针,插入元素时很可能发生扩容和移动,此时就必须使用移动构造。
编译器优化技术 COPY ELISION
RVO,NRVO
移动语义是在C++11标准中引入的,但是大多数编译器早在C++98时代就已经实现了类似的拷贝省略优化技术,以减少拷贝的次数。
触发优先级:编译器优化技术 > 移动构造 > 拷贝构造
- RVO:Return Value Optimization,优化返回临时对象。
- NRVO:Named Return Value Optimization,优化返回有名对象。
struct A {
A(a = 0):_a(a) {cout << "普通构造" << endl;}
A(const A& a) {cout << "拷贝构造" << endl;}
A(A&& a) {cout << "移动构造" << endl;}
int _a;
};以下是各种优化技术的示例:
// Return Value Optimization 返回值优化
A test() {
return A(); //返回临时对象
}
int main() {
auto ret = test();
}
// 按照 C++11 标准,应当调用三次构造函数。
// RVO技术直接在main函数栈上创建了对象,而不是在 test 函数栈上创建临时对象,
// 所以只调用了一次构造函数。// Named Return Value Optimization 命名返回值优化
A test() {
auto ret = A();
return ret;
}
int main() {
auto ret = test();
}
// 按照 C++11 标准,应当调用三次构造函数。
// NRVO技术直接在main函数栈上创建了对象,所以只调用了一次构造函数。// 临时对象作为实参
void test(A a) {}
test(A());
// 直接在test函数栈上创建了对象,所以只调用了一次构造函数。优化失效
这些优化技术在早期并没有纳入标准,但是大多数编译器都已经实现了这些优化技术。标准委员会已经逐渐将这些优化技术纳入标准,并且编译器默认开启。GCC使用-fno-elide-constructors编译选项后,可以关闭部分优化技术。
在某些复杂的情况下编译器可能无法进行优化,例如 NRVO 会进行激进的优化策略,但是在多条件分支下,编译器优化可能发生失效。
总结
- 右值引用只能绑定右值,但是其本身是左值。
std::move()可以将左值转换为右值,用来标记将要亡值的对象。- 拷贝构造函数的形参是
const T&,移动构造函数的形参是T&&。 const T&可以绑定左值和右值,T&&只能绑定右值。- 值类别为右值的对象创建对象时,会优先触发移动构造函数,其次才是拷贝构造函数。
- 移动构造函数最常用的场景是,目标对象不可拷贝(例如智能指针,线程对象,大文件对象),只能通过移动语义进行资源的转移。
- 触发优先级:
编译器优化技术 > 移动构造 > 拷贝构造。
