Skip to content

移动语义

约 3027 字大约 10 分钟

C++

2024-11-30

值类型(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;
};

以下是各种优化技术的示例:

RVO
// Return Value Optimization 返回值优化

A test() {
    return A(); //返回临时对象
}

int main() {
    auto ret = test();
}

// 按照 C++11 标准,应当调用三次构造函数。
// RVO技术直接在main函数栈上创建了对象,而不是在 test 函数栈上创建临时对象,
// 所以只调用了一次构造函数。

优化失效

这些优化技术在早期并没有纳入标准,但是大多数编译器都已经实现了这些优化技术。标准委员会已经逐渐将这些优化技术纳入标准,并且编译器默认开启。GCC使用-fno-elide-constructors编译选项后,可以关闭部分优化技术。

在某些复杂的情况下编译器可能无法进行优化,例如 NRVO 会进行激进的优化策略,但是在多条件分支下,编译器优化可能发生失效。

总结

  • 右值引用只能绑定右值,但是其本身是左值。
  • std::move()可以将左值转换为右值,用来标记将要亡值的对象。
  • 拷贝构造函数的形参是const T&,移动构造函数的形参是T&&
  • const T&可以绑定左值和右值,T&&只能绑定右值。
  • 值类别为右值的对象创建对象时,会优先触发移动构造函数,其次才是拷贝构造函数。
  • 移动构造函数最常用的场景是,目标对象不可拷贝(例如智能指针,线程对象,大文件对象),只能通过移动语义进行资源的转移。
  • 触发优先级:编译器优化技术 > 移动构造 > 拷贝构造