左值、右值、右值引用、std::move、完美转发问题

左值、右值、右值引用、std::move、完美转发

这题是 C++ 面试高频,而且特别容易被一路追到源码和模板推导。

面试不要只提到“左值有名字,右值没名字”,要你往深了答:

  1. 左值和右值本质区别是什么
    • 是不是“能不能取地址”这么简单?
  2. 什么是将亡值(xvalue),什么是纯右值(prvalue)
  3. 为什么要引入右值引用
  4. T&& 在什么场景下一定是右值引用,什么场景下又不一定
  5. std::move到底做了什么
    • 它会不会真的移动对象?
    • 它的本质是不是一次强制类型转换?
  6. 一个对象被 move 之后,处于什么状态
  7. 什么是转发引用(万能引用)
  8. std::forwardstd::move 的区别
  9. 完美转发是怎么做到“保持实参原本的值类别”的
  10. 为什么下面这个模板里必须用 forward,不能无脑用 move
1
2
3
4
template<class T, class... Args>
shared_ptr<T> my_make_shared(Args&&... args) {
return shared_ptr<T>(new T(std::forward<Args>(args)...));
}

追问 1

看下面代码,分别会调用哪个构造函数?为什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A {
public:
A() {}
A(const A&) { cout << "copy\n"; }
A(A&&) { cout << "move\n"; }
};

A getObj() {
A a;
return a;
}

int main() {
A a1;
A a2 = std::move(a1);
A a3 = getObj();
}

你要把:

  • a2 为什么是 move
  • a3 到底是 move、copy,还是直接省略构造
    讲清楚。

追问 2

为什么“一个有名字的右值引用变量,本身是左值”?

比如:

1
2
3
A&& x = A();
foo(x); // 这里 x 是左值还是右值?
foo(std::move(x)); // 又是什么?

追问 3

下面这段代码有问题吗?

1
2
3
4
template<class T>
void wrapper(T&& arg) {
process(arg);
}

问题出在哪?如果 arg 原来传进来的是右值,会发生什么?

问题回答

左值和右值更本质的区别,其实是表达式的值类别不同,也就是这个表达式求值以后,代表的是一个可持续存在、可被定位的对象身份,还是一个更偏向临时结果、可被拿来搬资源的值。所以它不是简单的“能不能取地址”。比如一个有名字的对象通常是左值,但“有名字”不是定义本身;同样,右值里也不只是“字面量”这么简单。

如果按现代 C++ 更准确地分,值类别可以分成三类你最该记住的:
lvalue,也就是左值;
xvalue,叫将亡值;
prvalue,叫纯右值。

其中右值其实是个大类,xvalue 和 prvalue 都属于右值

左值你可以理解成:有明确对象身份、生命周期比较稳定、可以被反复使用的表达式。
比如:

1
int a = 10;

这里 a 就是左值。

prvalue 是纯右值,通常表示一个纯粹的值结果,很多时候是个临时值,比如:

1
2
3
10
a + b
std::string("abc")

这些更像“算出来一个值”。

xvalue 是将亡值,它很关键,因为它表示:这个对象还有身份,但编译器知道你马上可以把它的资源拿走了。最典型的就是:

1
std::move(a)

它返回出来的那个表达式就是 xvalue。
所以很多人说“move 以后变成右值”,更精确一点应该说:变成将亡值


为什么要引入右值引用

右值引用最核心的意义,是为了支持移动语义完美转发

在没有右值引用之前,临时对象也只能走拷贝。
比如一个大字符串、一个大 vector,临时对象本来生命周期都快结束了,理论上可以直接把内部资源“偷”过来,但没有语言层支持,就只能老老实实拷贝,性能很差。

右值引用出现以后,像下面这样:

1
2
std::string s1 = "hello";
std::string s2 = std::move(s1);

这里就有机会走移动构造,而不是深拷贝。

也就是说,右值引用的本质目的不是“多一种引用写法”,而是:

给“可以安全搬资源的对象”一个语言层标记。


T&& 什么时候一定是右值引用,什么时候又不一定

这个是面试特别爱追的点。

如果你写的是一个确定类型,那 T&& 就是右值引用,比如:

1
std::string&& r = std::string("abc");

这里 std::string&& 就是标准的右值引用。

但是如果它出现在模板参数推导里,比如:

1
2
template<class T>
void f(T&& x);

这里的 T&&不一定是右值引用,它可能是所谓的转发引用,以前也常叫万能引用。

它的规则是:

  • 如果传进来的是左值,那么 T 会被推导成左值引用类型,最后参数类型会折叠成左值引用
  • 如果传进来的是右值,那么 T 才会被推导成普通类型,参数类型是右值引用

比如:

1
2
3
int a = 10;
f(a); // T 推导成 int&,参数类型折叠成 int&
f(10); // T 推导成 int,参数类型是 int&&

所以你不能一看到 T&& 就条件反射说“右值引用”,得先看它是不是处在类型推导语境里。


std::move 到底做了什么

std::move 这个名字特别容易误导。
本身不会真的移动任何东西

它做的事情本质上就是一次强制类型转换,把一个对象显式地转成“可以被当成右值使用”的形式。更准确地说,是转成对应的右值引用类型。

你可以把它近似理解成:

1
2
3
4
template<class T>
typename remove_reference<T>::type&& move(T&& t) {
return static_cast<typename remove_reference<T>::type&&>(t);
}

所以 move 本质就是:

把一个表达式强转成 xvalue,让后续重载解析优先匹配移动构造/移动赋值。

真正发生移动的是后面那个构造函数或者赋值运算符,不是 move 本身。

比如:

1
2
std::string s1 = "hello";
std::string s2 = std::move(s1);

这里不是 move 把资源搬走了,而是 string 的移动构造函数看见你给的是右值,于是决定把资源接管过来。


一个对象被 move 之后处于什么状态

这个也特别高频。

标准上通常只能保证:
被移动后的对象仍然处于有效但未指定的状态。

“有效”表示它还能析构、还能赋新值、很多类型还能安全调用某些成员函数。
“未指定”表示你不能假设它原来内容还在,也不能假设它一定变空,除非这个类型文档明确承诺。

比如很多标准库实现里,被 move 的 std::stringstd::vector 往往会变空,但你面试里最好别说死。更稳的说法是:

可以销毁、可以重新赋值,但不要依赖它原先的值。


什么是转发引用

转发引用就是刚才说的这种:

1
2
template<class T>
void f(T&& x);

只要满足两个条件:

  1. 形式上是 T&&
  2. T 是需要推导出来的

那它就是转发引用。

它特殊的地方就在于:它能根据传入实参的值类别,自己变成左值引用或者右值引用。

这个能力是完美转发的基础。


std::forward 和 std::move 的区别

这个一定要分清。

std::move无条件地把对象转成右值
也就是说,不管你原来传进来的是左值还是右值,它都告诉编译器:“你把它当成可移动对象处理吧。”

std::forward有条件地转发
它会根据模板参数 T 的推导结果,决定最后把它转成左值还是右值。

所以:

  • move 的语义是“我明确要把它当右值用”
  • forward 的语义是“我想把它原本是什么类别,就继续保持什么类别传下去”

看这个代码:

1
2
void foo(std::string&)  { std::cout << "左值\n"; }
void foo(std::string&&) { std::cout << "右值\n"; }

move

1
2
3
4
template<class T>
void wrapper(T&& x) {
foo(std::move(x));
}

无论你传什么:

1
2
3
std::string s = "abc";
wrapper(s); // 右值
wrapper(std::string("abc")); // 右值

都会调用右值版本。

因为 move 是无脑强转右值。


forward

1
2
3
4
template<class T>
void wrapper(T&& x) {
foo(std::forward<T>(x));
}

这时:

1
2
3
std::string s = "abc";
wrapper(s); // 左值
wrapper(std::string("abc")); // 右值

这才叫保持原始值类别


为什么不能用 move 代替 forward

因为 move 太粗暴了。

比如用户传的是左值:

1
2
std::string s = "hello";
wrapper(s);

如果你内部写:

1
foo(std::move(x));

那你就等于:

把用户本来还想继续用的 s,强行当成可搬走对象传走了。

这很危险。

因为调用者传左值,通常表示:

“这个对象我后面可能还要用,你别擅自偷我资源。”

所以:

  • 只要你是“转交参数”,就该优先想 forward
  • 只有你明确要“消费这个对象”,才该用 move

最经典的理解方式

你可以把这两个想成:

std::move

“别管原来怎样,现在开始它是右值了。”

std::forward<T>

“按照调用者当初传进来的方式,原样转交。”


放到之前那个例子里

1
2
3
4
template<class T, class... Args>
shared_ptr<T> my_make_shared(Args&&... args) {
return shared_ptr<T>(new T(std::forward<Args>(args)...));
}

这里 my_make_shared 的职责不是消费 args
而是把 args 转交给 T 的构造函数。

所以:

  • 如果调用者传左值,构造 T 时也该收到左值
  • 如果调用者传右值,构造 T 时也该收到右值

这就是 forward

如果你写成:

1
new T(std::move(args)...)

那就变成:

  • 左值也被你强行转成右值
  • 右值当然也是右值

这就不叫“转发”,这叫“全部都当成可搬走对象处理”


完美转发为什么能保持原本值类别

核心就在于两件事配合:

第一,参数写成转发引用:

1
2
template<class T>
void f(T&& x)

第二,转发时用:

1
std::forward<T>(x)

为什么这样就能保持值类别?因为:

  • 如果实参原来是左值,那么 T 会推导成 U&
  • 这时 forward<T>(x) 本质上会转回 U&
  • 所以继续作为左值传下去
  • 如果实参原来是右值,那么 T 会推导成 U
  • 这时 forward<T>(x) 才会转成 U&&
  • 所以继续作为右值传下去

也就是说,forward 不是“神奇保留”,而是利用了模板推导 + 引用折叠

引用折叠规则你可以顺口提一句:

  • & + & -> &
  • & + && -> &
  • && + & -> &
  • && + && -> &&

本质上就是:只要里面有左值引用,最后通常折叠成左值引用;只有全是右值,才是右值引用。


为什么这个模板里必须用 forward,不能无脑用 move

你给的这个例子非常典型:

1
2
3
4
template<class T, class... Args>
shared_ptr<T> my_make_shared(Args&&... args) {
return shared_ptr<T>(new T(std::forward<Args>(args)...));
}

这里 Args&&... args 里的 args 虽然类型可能是右值引用,但要注意一件非常重要的事:

只要一个变量有名字,它在表达式里就是左值。

所以在函数体里,args 本身全都是左值表达式。

这时候如果你直接写:

1
new T(args...)

那你会把所有参数都当左值传给 T 的构造函数,右值信息全丢了。

所以必须用 forward<Args>(args)...,把每个参数原本的值类别恢复出来。

如果你无脑改成 move

1
new T(std::move(args)...)

问题是:它会把所有参数都强行转成右值

这样如果调用者本来传进来的是左值,比如:

1
2
std::string s = "hello";
my_make_shared<MyClass>(s);

你这里却把 s 强行 move 掉了,那就把调用者的左值资源偷偷搬走了。这通常不是调用者预期,也破坏了接口语义。

所以这里必须用 forward,不能无脑 move。因为这个函数的职责不是“消费参数”,而是“把参数原样转交给 T 的构造函数”。

你可以把这句背下来:

move 是单方面声明“我现在就要把它当右值处理”;forward 是忠实转交“调用者当初传进来是什么,我就继续保持什么”。


收尾

左值右值本质上不是“有没有名字”这么简单,而是表达式的值类别问题。左值强调对象身份和可定位性,右值更强调可被移动的值,其中右值又分纯右值和将亡值。右值引用的引入主要是为了支持移动语义和完美转发。std::move 本质是把表达式强制转成右值引用,它自己不移动对象,真正移动的是后续匹配到的移动构造或移动赋值。被 move 之后的对象一般是有效但未指定状态。模板里的 T&& 在类型推导场景下是转发引用,不一定真的是右值引用。std::forwardstd::move 最大的区别在于,forward 会保留实参原本的值类别,而 move 是无条件转成右值,所以像 my_make_shared 这种转发参数的模板必须用 forward,不能无脑 move