左值、右值、右值引用、std::move、完美转发
这题是 C++ 面试高频,而且特别容易被一路追到源码和模板推导。
面试不要只提到“左值有名字,右值没名字”,要你往深了答:
- 左值和右值本质区别是什么
- 是不是“能不能取地址”这么简单?
- 什么是将亡值(xvalue),什么是纯右值(prvalue)
- 为什么要引入右值引用
T&&在什么场景下一定是右值引用,什么场景下又不一定- std::move到底做了什么
- 它会不会真的移动对象?
- 它的本质是不是一次强制类型转换?
- 一个对象被
move之后,处于什么状态 - 什么是转发引用(万能引用)
std::forward和std::move的区别- 完美转发是怎么做到“保持实参原本的值类别”的
- 为什么下面这个模板里必须用
forward,不能无脑用move
1 | template<class T, class... Args> |
追问 1
看下面代码,分别会调用哪个构造函数?为什么?
1 | class A { |
你要把:
a2为什么是 movea3到底是 move、copy,还是直接省略构造
讲清楚。
追问 2
为什么“一个有名字的右值引用变量,本身是左值”?
比如:
1 | A&& x = A(); |
追问 3
下面这段代码有问题吗?
1 | template<class T> |
问题出在哪?如果 arg 原来传进来的是右值,会发生什么?
问题回答
左值和右值更本质的区别,其实是表达式的值类别不同,也就是这个表达式求值以后,代表的是一个可持续存在、可被定位的对象身份,还是一个更偏向临时结果、可被拿来搬资源的值。所以它不是简单的“能不能取地址”。比如一个有名字的对象通常是左值,但“有名字”不是定义本身;同样,右值里也不只是“字面量”这么简单。
如果按现代 C++ 更准确地分,值类别可以分成三类你最该记住的:
lvalue,也就是左值;
xvalue,叫将亡值;
prvalue,叫纯右值。
其中右值其实是个大类,xvalue 和 prvalue 都属于右值。
左值你可以理解成:有明确对象身份、生命周期比较稳定、可以被反复使用的表达式。
比如:
1 | int a = 10; |
这里 a 就是左值。
prvalue 是纯右值,通常表示一个纯粹的值结果,很多时候是个临时值,比如:
1 | 10 |
这些更像“算出来一个值”。
xvalue 是将亡值,它很关键,因为它表示:这个对象还有身份,但编译器知道你马上可以把它的资源拿走了。最典型的就是:
1 | std::move(a) |
它返回出来的那个表达式就是 xvalue。
所以很多人说“move 以后变成右值”,更精确一点应该说:变成将亡值。
为什么要引入右值引用
右值引用最核心的意义,是为了支持移动语义和完美转发。
在没有右值引用之前,临时对象也只能走拷贝。
比如一个大字符串、一个大 vector,临时对象本来生命周期都快结束了,理论上可以直接把内部资源“偷”过来,但没有语言层支持,就只能老老实实拷贝,性能很差。
右值引用出现以后,像下面这样:
1 | std::string s1 = "hello"; |
这里就有机会走移动构造,而不是深拷贝。
也就是说,右值引用的本质目的不是“多一种引用写法”,而是:
给“可以安全搬资源的对象”一个语言层标记。
T&& 什么时候一定是右值引用,什么时候又不一定
这个是面试特别爱追的点。
如果你写的是一个确定类型,那 T&& 就是右值引用,比如:
1 | std::string&& r = std::string("abc"); |
这里 std::string&& 就是标准的右值引用。
但是如果它出现在模板参数推导里,比如:
1 | template<class T> |
这里的 T&& 就不一定是右值引用,它可能是所谓的转发引用,以前也常叫万能引用。
它的规则是:
- 如果传进来的是左值,那么
T会被推导成左值引用类型,最后参数类型会折叠成左值引用 - 如果传进来的是右值,那么
T才会被推导成普通类型,参数类型是右值引用
比如:
1 | int a = 10; |
所以你不能一看到 T&& 就条件反射说“右值引用”,得先看它是不是处在类型推导语境里。
std::move 到底做了什么
std::move 这个名字特别容易误导。
它本身不会真的移动任何东西。
它做的事情本质上就是一次强制类型转换,把一个对象显式地转成“可以被当成右值使用”的形式。更准确地说,是转成对应的右值引用类型。
你可以把它近似理解成:
1 | template<class T> |
所以 move 本质就是:
把一个表达式强转成 xvalue,让后续重载解析优先匹配移动构造/移动赋值。
真正发生移动的是后面那个构造函数或者赋值运算符,不是 move 本身。
比如:
1 | std::string s1 = "hello"; |
这里不是 move 把资源搬走了,而是 string 的移动构造函数看见你给的是右值,于是决定把资源接管过来。
一个对象被 move 之后处于什么状态
这个也特别高频。
标准上通常只能保证:
被移动后的对象仍然处于有效但未指定的状态。
“有效”表示它还能析构、还能赋新值、很多类型还能安全调用某些成员函数。
“未指定”表示你不能假设它原来内容还在,也不能假设它一定变空,除非这个类型文档明确承诺。
比如很多标准库实现里,被 move 的 std::string 或 std::vector 往往会变空,但你面试里最好别说死。更稳的说法是:
可以销毁、可以重新赋值,但不要依赖它原先的值。
什么是转发引用
转发引用就是刚才说的这种:
1 | template<class T> |
只要满足两个条件:
- 形式上是
T&& T是需要推导出来的
那它就是转发引用。
它特殊的地方就在于:它能根据传入实参的值类别,自己变成左值引用或者右值引用。
这个能力是完美转发的基础。
std::forward 和 std::move 的区别
这个一定要分清。
std::move 是无条件地把对象转成右值。
也就是说,不管你原来传进来的是左值还是右值,它都告诉编译器:“你把它当成可移动对象处理吧。”
而 std::forward 是有条件地转发。
它会根据模板参数 T 的推导结果,决定最后把它转成左值还是右值。
所以:
move的语义是“我明确要把它当右值用”forward的语义是“我想把它原本是什么类别,就继续保持什么类别传下去”
看这个代码:
1 | void foo(std::string&) { std::cout << "左值\n"; } |
用 move
1 | template<class T> |
无论你传什么:
1 | std::string s = "abc"; |
都会调用右值版本。
因为 move 是无脑强转右值。
用 forward
1 | template<class T> |
这时:
1 | std::string s = "abc"; |
这才叫保持原始值类别。
为什么不能用 move 代替 forward
因为 move 太粗暴了。
比如用户传的是左值:
1 | std::string s = "hello"; |
如果你内部写:
1 | foo(std::move(x)); |
那你就等于:
把用户本来还想继续用的 s,强行当成可搬走对象传走了。
这很危险。
因为调用者传左值,通常表示:
“这个对象我后面可能还要用,你别擅自偷我资源。”
所以:
- 只要你是“转交参数”,就该优先想
forward - 只有你明确要“消费这个对象”,才该用
move
最经典的理解方式
你可以把这两个想成:
std::move
“别管原来怎样,现在开始它是右值了。”
std::forward<T>
“按照调用者当初传进来的方式,原样转交。”
放到之前那个例子里
1 | template<class T, class... Args> |
这里 my_make_shared 的职责不是消费 args,
而是把 args 转交给 T 的构造函数。
所以:
- 如果调用者传左值,构造
T时也该收到左值 - 如果调用者传右值,构造
T时也该收到右值
这就是 forward。
如果你写成:
1 | new T(std::move(args)...) |
那就变成:
- 左值也被你强行转成右值
- 右值当然也是右值
这就不叫“转发”,这叫“全部都当成可搬走对象处理”
完美转发为什么能保持原本值类别
核心就在于两件事配合:
第一,参数写成转发引用:
1 | template<class T> |
第二,转发时用:
1 | std::forward<T>(x) |
为什么这样就能保持值类别?因为:
- 如果实参原来是左值,那么
T会推导成U& - 这时
forward<T>(x)本质上会转回U& - 所以继续作为左值传下去
- 如果实参原来是右值,那么
T会推导成U - 这时
forward<T>(x)才会转成U&& - 所以继续作为右值传下去
也就是说,forward 不是“神奇保留”,而是利用了模板推导 + 引用折叠。
引用折叠规则你可以顺口提一句:
& + & -> && + && -> &&& + & -> &&& + && -> &&
本质上就是:只要里面有左值引用,最后通常折叠成左值引用;只有全是右值,才是右值引用。
为什么这个模板里必须用 forward,不能无脑用 move
你给的这个例子非常典型:
1 | template<class T, class... Args> |
这里 Args&&... args 里的 args 虽然类型可能是右值引用,但要注意一件非常重要的事:
只要一个变量有名字,它在表达式里就是左值。
所以在函数体里,args 本身全都是左值表达式。
这时候如果你直接写:
1 | new T(args...) |
那你会把所有参数都当左值传给 T 的构造函数,右值信息全丢了。
所以必须用 forward<Args>(args)...,把每个参数原本的值类别恢复出来。
如果你无脑改成 move:
1 | new T(std::move(args)...) |
问题是:它会把所有参数都强行转成右值。
这样如果调用者本来传进来的是左值,比如:
1 | std::string s = "hello"; |
你这里却把 s 强行 move 掉了,那就把调用者的左值资源偷偷搬走了。这通常不是调用者预期,也破坏了接口语义。
所以这里必须用 forward,不能无脑 move。因为这个函数的职责不是“消费参数”,而是“把参数原样转交给 T 的构造函数”。
你可以把这句背下来:
move 是单方面声明“我现在就要把它当右值处理”;forward 是忠实转交“调用者当初传进来是什么,我就继续保持什么”。
收尾
左值右值本质上不是“有没有名字”这么简单,而是表达式的值类别问题。左值强调对象身份和可定位性,右值更强调可被移动的值,其中右值又分纯右值和将亡值。右值引用的引入主要是为了支持移动语义和完美转发。std::move 本质是把表达式强制转成右值引用,它自己不移动对象,真正移动的是后续匹配到的移动构造或移动赋值。被 move 之后的对象一般是有效但未指定状态。模板里的 T&& 在类型推导场景下是转发引用,不一定真的是右值引用。std::forward 和 std::move 最大的区别在于,forward 会保留实参原本的值类别,而 move 是无条件转成右值,所以像 my_make_shared 这种转发参数的模板必须用 forward,不能无脑 move。