C++右值引用和移动语义

  • 左值和右值

在 C++ 中,左值(lvalue)和右值(rvalue)是用于描述表达式的值类别(value category)的术语。

  1. 左值(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
  1. 右值(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; 
} 

这样的实现当然是正确的,但也是效率低下的。此处的tmpab都是左值,左值对应的拷贝赋值函数可能是深拷贝,但在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)时,反而破坏了达成这种优化的前提条件,额外多了一次移动拷贝行为。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值