什么是左值?什么是右值?如何区分它们?

面试官: 在 C++ 中,我们经常听到“左值”和“右值”这两个术语。你能用自己的话,简单地解释一下,什么是左值,什么是右值吗?最简单的区分它们的方法是什么?

回答:我在一年多前的程序设计课上学过这两个概念,但现在我的记忆已经模糊,凭感觉回答一下。

  • 左值是被持久保存而不是临时保存的值,它通常就是我们定义的变量的值。因为出现在赋值式子的左边,所以叫做“左值”。
  • 右值是通过某些代数计算式得到的临时值,它通常出现在赋值式子的右边,因此被称为“右值”。

面试官 (补充与深化): 你说的“能否放在赋值运算符左边”是一个非常好的启发式判断方法。一个更根本的、适用于现代 C++ 的区分标准是:

我们能否对这个表达式“取地址”?

  • 左值 (lvalue):是一个表达式,它指向了一个具名的、有持久状态的对象或函数。我们可以对它取地址 (&)。它在表达式结束后,依然存在。
    • 例子:变量名 int x; x 是一个左值,我们可以 &xstd::string s; s 是一个左值。
  • 右值 (rvalue):是一个表达式,它通常代表一个匿名的、即将被销毁的临时值。我们无法对它取地址。它在表达式结束后,就不复存在了。
    • 例子:字面量 10 是一个右值,你不能 &10。函数返回值 get_string()(如果返回的是一个值而非引用)是一个右值。x + y 这个表达式的结果也是一个右值。

总结一下:

  • 左值:有家(内存地址),能活得久。
  • 右值:没家(没法取地址),是“过客”(马上就消失)。

右值引用

面试官 (引入新概念): 好的,既然我们知道了左值和右值的区别。C++11 为了配合移动语义,引入了一种新的引用类型,叫做右值引用 (rvalue reference),用 && 表示。

1
2
3
4
5
int x = 10;         // x 是一个左值
int& l_ref = x;     // 左值引用 l_ref 绑定到左值 x (OK)

int&& r_ref = 20;   // 右值引用 r_ref 绑定到右值 20 (OK)
// int&& r_ref2 = x; // 错误!右值引用不能直接绑定到左值

请问,&& 这个右值引用的主要设计目的是什么?它和我们之前讨论的移动语义 (Move Semantics) 是如何关联起来的?

回答:

  • “移动语义”就是我们之前提过的,“移动资源,而不是重新申请资源,以降低开销的方法”。使用移动语义的前提是这份资源后续不再被人占用。
  • 右值引用能让编译器识别到该引用的对象为右值,它占用的资源即将被销毁,之后没人再用了。
  • 这就是在告诉编译器,如果需要针对这个对象进行赋值操作,就可以放心大胆地用“移动语义”来直接转移资源,降低开销。

面试官 (总结与深化): 你说得完全正确。我来帮你把这个逻辑和具体的函数联系起来。

右值引用的出现,使得我们能够重载 (overload) 函数,为左值右值参数提供不同的实现版本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MyString {
public:
    // ... 构造函数等 ...

    // 拷贝赋值运算符 (参数是左值引用)
    MyString& operator=(const MyString& other) {
        std::cout << "Copy assignment called\n";
        // ... 执行昂贵的深拷贝 ...
        return *this;
    }

    // 移动赋值运算符 (参数是右值引用)
    MyString& operator=(MyString&& other) noexcept {
        std::cout << "Move assignment called\n";
        // ... 执行廉价的资源窃取 (移动) ...
        return *this;
    }
};

MyString s1, s2;
s1 = s2; // s2 是左值,调用拷贝赋值

s1 = MyString("temporary"); // MyString("temporary") 是右值,调用移动赋值
  1. 当编译器看到 s1 = s2; 时,它发现 s2 是一个具名的变量,是一个左值。于是,它在 MyString 的成员函数里查找,找到了最匹配的版本 operator=(const MyString& other)
  2. 当编译器看到 s1 = MyString("temporary"); 时,它发现 MyString("temporary") 是一个没有名字的、表达式结束就会被销毁的右值。于是,它找到了一个更匹配的版本 operator=(MyString&& other),并调用它。

结论: 右值引用 (&&) 是一种新的函数参数类型,它使得函数重载机制能够区分开左值和右值。这为移动语义的实现提供了语法基础,允许我们为短暂的右值对象编写专门的、高效的“资源窃取”版本,从而极大地提升了 C++ 代码的性能。


手动触发移动

面试官 (最终问题): 好的,你对右值引用和移动语义的理解已经非常深入了。最后一个小问题。

我们知道可以用 std::move 来将一个左值强制转换为一个右值引用,从而“手动”触发移动操作。

1
2
3
MyString s1 = "hello";
MyString s2 = "world";
s2 = std::move(s1); // 手动触发移动

请问,std::move(s1) 这个函数调用,它本身做了什么?它真的“移动”了 s1 吗?调用之后,s1 的状态会立刻改变吗?

回答:我不太清楚这个问题的答案,凭直觉简单回答,请你补充。

  • std::move(s1) 是强制类型转换,给了 s1 一个右值身份,让编译器将 s1 视为右值处理。
  • s1 并没有被移动。
  • s1 的状态不会立刻改变。
  • 真正的移动操作,是在移动赋值运算符的内部(也就是等号所表示的函数的内部)那里发生的。

面试官 (解释与澄清): 你说得对,std::move 本身什么也没移动

std::move 的本质,仅仅是一个类型转换 (cast)

它唯一的作用,就是无条件地将一个输入(无论左值还是右值)转换成一个右值引用 (&&)

它就像是在对编译器说:

“嘿,编译器,我知道 s1 是一个左值,但在这里,请你假装它是一个右值。我向你保证,我接下来不会再以有意义的方式使用 s1 了,你可以放心地对它进行移动操作(资源窃取)。”

我们来分解 s2 = std::move(s1); 这一行的完整过程:

  1. std::move(s1) 执行

    • s1(一个 MyString 类型的左值)被传入 std::move
    • std::move 返回一个 MyString&& 类型的右值引用,这个引用依然指向 s1
    • 在这一刻,s1 内部的数据没有任何改变std::move 只是做了一个“演员认证”,给了 s1 一个“右值”的临时身份。
  2. operator= 执行

    • 现在,编译器需要为 s2 = ... 选择一个赋值运算符的重载版本。
    • 等号右边的表达式 std::move(s1),它的类型是 MyString&&(右值引用)。
    • 因此,编译器选择了移动赋值运算符 operator=(MyString&& other)
    • s2 的移动赋值运算符被调用,参数 other 绑定到了 s1 上。
  3. 移动赋值运算符内部

    • 在这个函数内部,真正的“移动”操作才发生。
    • 它会“窃取” s1 的内部资源(比如指针),然后把 s1 的内部状态设置为“空”。
    • 函数执行完毕后,s2 拥有了原来的资源,而 s1 变成了一个空的字符串。

总结:

  • std::move 不移动,只进行类型转换,把左值伪装成右值。
  • 真正的移动操作,是由对象的移动构造函数移动赋值运算符完成的。
  • std::move 只是为后续调用这些移动成员函数创造了条件

总结

面试结束

今天我们深入探讨了 C++ 中一个既基础又深刻的话题:左值与右值。

  • 我们理解了区分它们的现代方法(能否取地址)。
  • 学习了右值引用的作用(为函数重载提供识别右值的类型)。
  • 并最终澄清了 std::move 的真正含义(仅仅是类型转换)。

这些知识是理解现代 C++ 高性能编程的关键。你今天的学习状态非常好,能通过直觉猜对很多复杂的概念,并且能快速吸收新的知识。继续保持!