目录       

什么是左右值

左右值关系

右值引用的应用场景

场景一:实现移动语义 (Move Semantics) - 最重要

场景二:实现完美转发 (Perfect Forwarding)

引用折叠 (Reference Collapsing)

场景三:优化容器和标准库


什么是左右值

         C++11之后,表达式按值类别分为三个主类别:左值 (lvalue)将亡值 (xvalue) 和 纯右值 (prvalue)。后两者合称为右值 (rvalue)。我们有时候会弄混淆这个值类别到底是左值还是右值,那么本文来捋一下到底什么是lvalue,什么是prvalue,以及常介于这两者之间的xvalue。

        expression
           /           \
       glvalue  rvalue
        /      \     /       \
   lvalue   xvalue   prvalue

  • 左值 (lvalue): 具有身份(可以获取地址)且不能被移动的表达式。

    • eg:变量名、函数名、返回左值引用的函数调用、前置自增/减表达式等。

      • int a = 10;         a 是左值。

      • std::cout << "Hello CSDN";      std::cout 是左值。

  • 纯右值 (prvalue): 不具有身份(通常不能获取其地址)且可以被移动的表达式。也就是常说的“临时对象”。

    • eg:字面量(如 24, truenullptr)、返回非引用类型的函数调用、算术/逻辑表达式的结果、后置自增/减表达式、this 指针、Lambda表达式等。

      • int x = 24;                    24 是纯右值。

      • std::string s = get_str(); 如果 get_str() 返回 std::string(而非引用),则其返回值是纯右值。

      • int res = a + b;          a + b 这个表达式的结果是纯右值。

  • 将亡值 (xvalue): 具有身份但可以被移动的表达式。可以理解为“即将被销毁的左值”。

    • eg:返回右值引用的函数调用(最典型的就是 std::move 的结果)。

      • std::string str = std::move(other_str); 中的 std::move(other_str) 表达式的结果就是将亡值。

        有一个关键的规则常常导致混淆:当一个纯右值被用来初始化一个对象时(具体来说,是作为初始化器),会发生“临时量实质化”(Temporary Materialization)。此时,这个纯右值会转换成一个将亡值(xvalue)

        这个将亡值是一个临时对象,它拥有身份(可以取地址),并且是右值。

#include <iostream>

void foo(const std::string& s) { // 接受常左值引用
    std::cout << s << std::endl;
}

void foo(std::string&& s) { // 接受右值引用
    std::cout << s << " (rvalue)" << std::endl;
}

int main() {
    foo("Hello"); // 调用哪个 foo?
    
    // 1. “Hello” 是 const char[6] 类型的字面量,它是一个纯右值 (prvalue)。
    // 2. 为了匹配 foo 的参数(需要 std::string),需要构造一个临时的 std::string 对象。
    // 3. 此时发生“临时量实质化”:纯右值 “Hello” 转换成了一个 std::string 类型的临时对象。
    // 4. 这个临时对象是一个将亡值 (xvalue),属于右值 (rvalue)。
    // 5. 因此,重载决议会选择 void foo(std::string&& s) 这个版本。
    
    return 0;
}

输出结果将是:Hello (rvalue)

        在这个例子中,我们通常会说“传递了一个临时对象给 foo”,这个临时对象是右值。它的前身是纯右值 "Hello",但在函数传参这个“初始化”的语境下,它被实质化为了一个右值(将亡值)临时对象。

        那么有没有一些方式来判断它是什么类型的值呢?

  • 它能否放在赋值运算符左边?

    • 左值可以:a = 10; (正确)

    • 右值不行:get_str() = “new value”; (错误,get_str() 返回临时对象,是右值)

  • 它能否取地址?

    • 左值可以:int* p = &a; (正确)

    • 实质化后的临时对象也可以取地址,但它是右值。这是一个特例。

    • 纯右值不行:int* p = &(a + b); (错误,a + b 的结果是纯右值,无地址)

  • 所以怎么判断是什么?

    • 如果有名字(变量、函数名),通常是左值。

    • 如果是字面量(10nullptr)、匿名对象(MyValue{})或运算结果(a+b),通常是纯右值。

    • 如果是 std::move() 的结果,则是将亡值。

左右值关系

        在了解完左右值到底是什么后,再来探讨一下左值和右值之间的关系,以及C++11引入的一个关键特性--右值引用的核心应用场景。

        对左右值来说,最核心的区别就在于“资源的所有权”。

特性 左值 (lvalue) 右值 (rvalue - prvalue/xvalue)
身份 (Identity) :可以获取地址 prvalue无,xvalue有
可移动性 (Movable) :通常不应被“窃取”资源 :核心特性,资源可以被安全地“移动”
比喻 一个有名有姓的人,他有稳定的工作和住所(内存地址)。你不能随便拿走他的东西。 一个匿名旅客std::move)。他的行李可以被打包带走。
生命周期 通常由作用域决定(如局部变量)或手动管理(如堆内存)。 通常是短暂的,表达式结束后就销毁。
主要用途 作为数据源,提供数据。 作为数据源,并且可以交出资源的所有权

左值可以“变身”为右值

        通过 std::move,它不做任何实际的移动操作,只是将一个左值无条件地转换为一个右值引用(xvalue)。这说明了:“我不再需要这个对象里的资源了,你可以拿走”。       

         比如std::string new_str = std::move(old_str)之后,old_str 处于有效但未定义的状态(通常为空),它的内容被“移动”到了 new_str

所有右值都可以初始化给 const左值引用

        这是C++98就有的规则。常左值引用是个“万能”的引用类型,能接收左值、常左值和右值。它延长了右值的生命周期,但无法修改其值。比如void test(const std::string& s)可以接受变量(左值)和临时字符串(右值)。

右值引用的应用场景

        右值引用(T&&)的引入,主要是为了解决不必要的深拷贝性能问题,实现了“移动语义”和“完美转发”。

场景一:实现移动语义 (Move Semantics) - 最重要

目的: 避免代价过大的深拷贝,通过“资源窃取”来高效地初始化或赋值。

class MyString {
private:
    char* m_data;
    size_t m_size;
public:
    // 移动构造函数 (参数是右值引用)
    MyString(MyString&& other) noexcept 
        : m_data(other.m_data), m_size(other.m_size) // 1. 窃取资源
    {
        other.m_data = nullptr; // 2. 置空源对象,防止其析构时释放资源
        other.m_size = 0;
        std::cout << "Move Constructor called\n";
    }

    // 移动赋值运算符
    MyString& operator=(MyString&& other) noexcept {
        if (this != &other) {
            delete[] m_data;        // 释放自己的旧资源
            m_data = other.m_data;  // 窃取新资源
            m_size = other.m_size;
            other.m_data = nullptr; // 置空源对象
            other.m_size = 0;
        }
        std::cout << "Move Assignment called\n";
        return *this;
    }

    // ...
};

int main() {
    MyString s1("Hello"); // 普通构造函数

    // 关键场景1:用函数返回的临时对象(右值)初始化新对象
    MyString s2 = create_string(); // 调用移动构造,而非拷贝构造

    // 关键场景2:明确表示不再需要s1,将其资源转移给s3
    MyString s3 = std::move(s1);   // 调用移动构造,s1现在为空

    // 关键场景3:在容器操作中
    std::vector<MyString> vec;
    vec.push_back(MyString("World")); //  push_back的重载版本接受右值,调用移动构造

    return 0;
}
场景二:实现完美转发 (Perfect Forwarding)

目的: 在模板函数中,将一个参数的原生值类别(左值性或右值性)毫发无损地转发给另一个函数。

技术: 使用通用引用 (Universal Reference) T&& 和 std::forward

   std::forward 在转发时判断:如果原始参数是左值,就转发为左值;如果是右值,就转发为右值。

// 一个工厂函数模板,它将参数完美地转发给T的构造函数
template<typename T, typename... Args>
T create(Args&&... args) { // ...args 是通用引用包,能保持每个参数的值类别
    return T(std::forward<Args>(args)...); // std::forward 有条件地转换回左值或右值
}

class Widget {
public:
    Widget(int& i)  { std::cout << "ctor (lvalue)\n"; }
    Widget(int&& i) { std::cout << "ctor (rvalue)\n"; }
};

int main() {
    int x = 10;

    Widget w1 = create<Widget>(x);  // 转发左值,调用Widget(int&)
    Widget w2 = create<Widget>(20); // 转发右值,调用Widget(int&&)
    
    // 如果没有std::forward,create内部的args都会变成左值,永远只会调用Widget(int&)
    return 0;
}

        想必一定会有疑惑?为什么没有 std::forwardargs 都会变成左值?要理解它,需要明白两点:通用引用的推导规则引用折叠

在我们的模板函数中:

template<typename T, typename... Args>
T create(Args&&... args) { // 注意:这里是 Args&&, 每个参数都是通用引用
    // ...
}

当调用 create<Widget>(x, 20) 时:

  • x 是一个左值 (int 变量),所以 Args 中的第一个类型被推导为 int&

    • Args 包的第一个元素: Arg1 = int&

    • 参数类型变为:Arg1&& => int& &&

  • 20 是一个右值 (int 字面量),所以 Args 中的第二个类型被推导为 int

    • Args 包的第二个元素: Arg2 = int

    • 参数类型变为:Arg2&& => int &&

引用折叠 (Reference Collapsing)

        C++不允许直接出现“引用的引用”(如 int& &)。为了解决这个问题,引入了引用折叠规则

  • & & -> &

  • & && -> &

  • && & -> &

  • && && -> &&

  • 那么对于第一个参数 x (左值):

    • 推导出的函数参数类型是 int& &&

    • 根据规则 & && -> &,所以最终类型是 int&。这是一个左值引用

  • 对于第二个参数 20 (右值):

    • 推导出的函数参数类型是 int &&

    • 没有发生折叠,所以最终类型是 int&&。这是一个右值引用

有意思的地方来了:在函数内部,所有具名的变量,无论其类型是左值引用还是右值引用,它本身都是一个左值

T create(Args&&... args) { // args有名字,是“args”
    // 在这里,args 是一个参数包,但包里的每个参数都有自己的名字
    // 例如,你可以通过 args... 访问到两个参数,我们称它们为 arg1 和 arg2

    // arg1 的类型是 int& (左值引用),但它有自己的标识符,所以它是一个左值。
    // arg2 的类型是 int&& (右值引用),但它同样有自己的标识符,所以它也被当作一个左值

    // 这就是问题所在:一旦你给某个东西起了名字,它就变成了左值。
}

所以这就是关键所在,如果不使用forward

那么原来的返回结果可以看成:return T(arg1, arg2); // arg1 和 arg2 现在都是左值表达式

        因此,Widget 的构造函数只会看到两个左值,会永远选择 Widget(int& i) 这个版本,即使我们传入了一个右值 20

  而std::forward 是一个有条件转换的工具。它的作用是根据原始类型信息,决定是保持当前值类别,还是将其“恢复”。

有一个形象的比喻:

  • arg2(类型 int&&)就像一个装着右值的盒子,但这个盒子本身有名字。当你把“盒子”递给别人(target(arg2)),别人看到的是这个“盒子”(左值)。

  • std::forward<int>(arg2) 就像是把盒子里的东西(右值)倒出来,直接递给别人。现在别人看到的就是里面的东西本身(右值)。

  • std::forward<int&>(arg1):

    • 因为模板参数是 int&(一个左值引用),std::forward 知道 arg1 原本应该对应一个左值。

    • std::forward 返回一个左值引用,保持了 arg1 的左值性。

  • std::forward<int>(arg2):

    • 因为模板参数是 int(注意,不是 int&&,这是 std::forward 的巧妙设计),std::forward 知道 arg2 原本应该对应一个右值。

    • std::forward 会对 arg2 进行 static_cast,产生一个类型为右值引用 (int&&)、值类别为右值(具体是将亡值)的新表达式

场景三:优化容器和标准库

标准库中的容器(vectorstringmap 等)都全面实现了移动语义。

  • vector::push_back(T&& value): 提供了接受右值引用的重载版本。

  • 容器扩容: 当 vector 需要重新分配内存时,会使用移动构造将旧元素转移到新内存,而不是拷贝构造,性能提升巨大。

        总的来说,右值和右值引用不是一种孤立的语法,而是C++为了追求效率而引入的一套完整的资源管理新方式

更多推荐