左值、右值、`std::forward` 与完美转发笔记

左值、右值、std::forward 与完美转发笔记

时间:2026/04/09

关键词:左值、右值、移动语义、std::move、转发引用、引用折叠、std::forward、完美转发
核心目标:搞清楚“一个参数到底该拷贝、移动,还是原样转发”。


1. 这篇笔记解决什么问题

C++ 模板和现代泛型代码里,最容易混乱的一组概念就是:

  • 左值 / 右值
  • 左值引用 / 右值引用
  • std::move
  • std::forward
  • 完美转发
  • 引用折叠

如果这几个点没有打通,就很容易出现这些问题:

  • 以为 T&& 一定是右值引用
  • 以为 std::move 真的会“立即移动对象”
  • 写了包装函数,却把右值错误地当成左值传下去了
  • 用错 std::forward,导致重复移动或行为退化

这篇笔记的主线是:

  1. 先分清值类别
  2. 再理解引用类型
  3. 再理解移动语义
  4. 最后看 std::forward 和完美转发为什么成立

2. 什么是左值和右值

2.1 先用直觉理解

可以先粗略理解为:

  • 左值:有身份、可定位、可被多次使用的对象
  • 右值:通常是临时结果,生命周期短,更适合被移动

例子:

1
2
3
int a = 10;
int b = a; // a 是左值
int c = a + 1; // a + 1 是右值

这里:

  • a 有名字、有地址,通常是左值
  • a + 1 是表达式求值结果,通常是右值

2.2 常见例子

1
2
3
4
5
6
7
8
int x = 1;

x // 左值
(x) // 左值
std::move(x) // 右值中的 xvalue
1 // 右值
x + 1 // 右值
"hello" // 字符串字面量是左值

注意:很多入门资料喜欢说“能放在赋值号左边的是左值”,这个说法只能帮助建立最初直觉,不够严格

2.3 更准确的现代分类

C++11 以后,值类别更完整地分成:

  • lvalue
  • xvalue
  • prvalue

再往上组合成:

  • glvalue = lvalue + xvalue
  • rvalue = prvalue + xvalue

复习时可以先抓住最重要的:

  • 普通有名字对象,大多是 lvalue
  • 临时值大多是 prvalue
  • std::move(x) 产生的是 xvalue

3. 左值引用与右值引用

3.1 左值引用 T&

1
2
int x = 10;
int& ref = x;

特点:

  • 必须绑定到左值
  • 绑定后就是原对象的别名
1
2
int& a = x;   // 对
int& b = 10; // 错,普通左值引用不能绑定右值

3.2 const T& 很特殊

1
const int& a = 10; // 对

const T& 可以绑定:

  • 左值
  • 右值
  • 临时对象

这也是它经常被用来做“只读传参”的原因。

3.3 右值引用 T&&

1
int&& r = 10; // 对

特点:

  • 通常绑定右值
  • 常用于移动语义
  • 常用于表达“这个对象可以被偷资源”
1
2
3
4
int x = 10;
int&& a = 20; // 对
int&& b = std::move(x); // 对
int&& c = x; // 错,x 是左值

4. 一个容易忽略但极其重要的事实

任何“有名字的变量”,即使它的类型是 T&&,表达式本身依然是左值。

例子:

1
2
3
4
void f(int&& x) {
// x 的类型是 int&&
// 但表达式 x 是左值
}

这句话是理解 std::forwardstd::move 的关键前提。
因为只要参数进入函数体,它就有名字了,于是直接写 x 时,它就是左值表达式。


5. 为什么需要移动语义

在 C++11 之前,临时对象也只能拷贝,很多场景成本很高。

例如一个大对象:

1
2
3
4
std::vector<int> make_big_vector() {
std::vector<int> v(1'000'000, 42);
return v;
}

如果只能拷贝,那么返回 v 的代价很大。
移动语义的核心思想是:

  • 对于即将销毁的对象,不一定要深拷贝
  • 可以“偷走”它内部持有的资源

这就引出了移动构造和移动赋值。


6. std::move 到底做了什么

6.1 std::move 不是“移动”

它本质上只是一个类型转换,把表达式转换成右值形式,告诉编译器:

这个对象后面我不打算保留原语义了,可以把它当成可移动对象处理。

例子:

1
2
3
4
5
#include <utility>
#include <string>

std::string s = "hello";
std::string t = std::move(s);

这里真正发生移动的是:

  • std::string 的移动构造函数

而不是 std::move 本身。

6.2 可以把它理解成

1
std::move(x)

约等于:

1
static_cast<std::remove_reference_t<decltype(x)>&&>(x)

也就是把 x 强制转成右值。

6.3 被移动后的对象

被移动后的对象通常满足:

  • 仍然处于有效状态
  • 但值是未指定的

这意味着:

  • 可以析构
  • 可以重新赋值
  • 不应该假设它仍然保持原值

例如:

1
2
3
4
5
std::string s = "hello";
std::string t = std::move(s);

// s 依然有效,但内容不要依赖
s = "new value"; // 可以

7. std::move 和拷贝 / 移动构造的关系

看一个最典型的重载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <utility>

void consume(const std::string& s) {
std::cout << "copy-style view: " << s << '\n';
}

void consume(std::string&& s) {
std::cout << "move-style view: " << s << '\n';
}

int main() {
std::string s = "hello";

consume(s); // 传左值
consume(std::move(s)); // 传右值
}

这里:

  • consume(s) 选中左值版本
  • consume(std::move(s)) 选中右值版本

所以 std::move 更准确的作用是:

  • 改变重载决议和构造/赋值选择

8. 转发引用是什么

8.1 最经典定义

当且仅当满足下面条件时,T&& 才是转发引用

  • 形式是 T&&
  • T 需要通过模板参数推导得到

例如:

1
2
3
template <class T>
void wrapper(T&& x) {
}

这里的 x 是转发引用。

8.2 为什么它特殊

因为它既能接左值,也能接右值:

1
2
3
4
int a = 10;

wrapper(a); // T 推导为 int&
wrapper(20); // T 推导为 int

于是:

  • 传左值时,T = int&
  • 传右值时,T = int

这正是完美转发的前提。

8.3 不是所有 && 都是转发引用

下面这些都不是转发引用:

1
2
3
4
void f(int&& x);          // 不是,T 没有推导

template <class T>
void g(std::vector<T>&& x); // 这里参数整体不是 T&&,不是转发引用

类模板中也常见这个坑:

1
2
3
4
template <class T>
struct Box {
void set(T&& x); // 这里通常不是转发引用
};

因为这里的 T 是类模板参数,在成员函数处并不是靠这次调用来推导。

8.4 auto&& 也常常是转发引用

1
auto&& x = expr;

auto 发生推导时,auto&& 也会表现出类似转发引用的特性。


9. 引用折叠

引用折叠是解释“为什么 T&& 能同时接左值和右值”的关键规则。

9.1 四条规则

写出来的形式 折叠结果
T& & T&
T& && T&
T&& & T&
T&& && T&&

可以直接记成一句:

只要有左值引用参与折叠,结果就是左值引用;只有全是右值引用时,结果才是右值引用。

9.2 为什么 wrapper(a) 能成立

1
2
3
4
5
template <class T>
void wrapper(T&& x) {}

int a = 10;
wrapper(a);

当传入左值 a 时:

  • T 推导为 int&
  • 于是参数类型变成 int& &&
  • 根据引用折叠规则,最终变成 int&

所以左值能匹配成功。

9.3 传右值时

1
wrapper(10);

此时:

  • T 推导为 int
  • 参数类型是 int&&

于是右值版本也能成立。


10. 为什么需要 std::forward

看一个最典型的包装函数:

1
2
3
4
5
6
7
8
9
10
11
12
void process(int& x) {
std::cout << "lvalue\n";
}

void process(int&& x) {
std::cout << "rvalue\n";
}

template <class T>
void wrapper(T&& arg) {
process(arg); // 有问题
}

调用:

1
2
3
int a = 1;
wrapper(a);
wrapper(2);

结果会是:

1
2
lvalue
lvalue

原因不是模板坏了,而是:

  • arg 在函数体里有名字
  • 所以表达式 arg 永远是左值

这就导致即使最初传进来的是右值,传给 process 时也退化成了左值。


11. std::forward 如何保留值类别

正确写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <utility>

void process(int& x) {
std::cout << "lvalue\n";
}

void process(int&& x) {
std::cout << "rvalue\n";
}

template <class T>
void wrapper(T&& arg) {
process(std::forward<T>(arg));
}

int main() {
int a = 1;
wrapper(a); // lvalue
wrapper(2); // rvalue
}

这里:

  • 如果 T = int&,那么 std::forward<T>(arg) 最终是左值
  • 如果 T = int,那么 std::forward<T>(arg) 最终是右值

所以 std::forward 的本质是:

  • 按模板参数 T 原样恢复调用点的值类别

这就是“转发”的含义。


12. 完美转发是什么

所谓完美转发,就是:

在包装层不改变实参的左值/右值属性、不额外引入拷贝或错误移动,把参数继续传给下游函数。

标准写法几乎总是:

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

多个参数时:

1
2
3
4
template <class... Ts>
void wrapper(Ts&&... args) {
target(std::forward<Ts>(args)...);
}

这就是最经典的完美转发模板。


13. std::forwardstd::move 的区别

工具 作用 典型用途
std::move(x) 无条件把 x 转成右值 明确表示“可以移动”
std::forward<T>(x) T 的推导结果恢复原值类别 转发引用场景下继续传参

一句话记忆:

  • move 是“我决定把它当右值”
  • forward 是“我保持它原本是什么就还是什么”

13.1 什么时候用 move

当你明确知道:

  • 这个对象后面不再按原语义使用
  • 想把资源交出去

例如:

1
2
std::string s = "hello";
vec.push_back(std::move(s));

13.2 什么时候用 forward

当你写的是包装层、工厂函数、泛型接口,需要把参数继续传下去,并保留调用者传入时的左值/右值属性。

例如:

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

14. 一个最重要的工厂函数例子

14.1 错误或退化的版本

1
2
3
4
template <class T, class Arg>
std::unique_ptr<T> make_obj_bad(Arg arg) {
return std::make_unique<T>(arg);
}

问题:

  • 参数被按值接收,已经发生了一次拷贝或移动
  • 无法保留调用点的值类别

14.2 正确的完美转发版本

1
2
3
4
5
6
7
#include <memory>
#include <utility>

template <class T, class... Args>
std::unique_ptr<T> make_obj(Args&&... args) {
return std::make_unique<T>(std::forward<Args>(args)...);
}

这类写法在标准库里非常常见,例如:

  • std::make_unique
  • std::make_shared
  • 各种 emplace

15. emplace_back 为什么离不开完美转发

例如:

1
2
std::vector<std::string> v;
v.emplace_back("hello");

emplace_back 的核心价值不是“语法更短”,而是:

  • 直接在容器内部构造对象
  • 参数通过完美转发传给元素构造函数

可以把它粗略理解成:

1
2
3
4
template <class... Args>
reference emplace_back(Args&&... args) {
// 在尾部原地构造 T(std::forward<Args>(args)...)
}

也就是说:

  • 左值实参按左值传进去
  • 右值实参按右值传进去

16. std::forward 的直觉实现

可以先用一种“帮助理解”的方式记它:

1
2
3
4
template <class T>
T&& forward(std::remove_reference_t<T>& arg) {
return static_cast<T&&>(arg);
}

标准库实现会更严谨,通常还会提供多个重载并处理误用问题,但核心思想就是:

  • 先拿到一个引用
  • 再按 T&& 转回去
  • 借助引用折叠恢复正确类别

所以 forward 能工作,不是因为它“会智能判断”,而是因为:

  • 模板推导已经把信息保存在 T 里了

17. 为什么说“完美转发”离不开引用折叠

还是看:

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

如果传左值:

  • T = U&
  • T&& = U& &&
  • 折叠后为 U&
  • forward<T>(arg) 返回左值

如果传右值:

  • T = U
  • T&& = U&&
  • forward<T>(arg) 返回右值

所以:

  • 转发引用负责“接住任何值类别”
  • 引用折叠负责“把最终类型算对”
  • std::forward 负责“把原值类别恢复出来”

这三者必须连起来理解。


18. 常见坑

18.1 不是所有 T&& 都要 std::forward

只有当它是转发引用,且你要把它继续传递给下游时,才需要 std::forward<T>(x)

如果你是普通右值引用参数:

1
2
3
void f(std::string&& s) {
use(std::move(s)); // 通常更合理
}

这里没有模板推导得到的 T,所以谈不上“恢复原值类别”,更常见的是显式 move

18.2 不要对同一个对象重复 forward / move

1
2
target(std::forward<T>(arg));
target(std::forward<T>(arg)); // 危险

如果第一次已经把它作为右值传出,第二次再用通常就不合理了。

18.3 const 会限制移动

1
2
const std::string s = "hello";
auto t = std::move(s); // 通常会退化成拷贝

因为移动通常需要修改源对象内部状态,而 const 对象不允许。

所以经验上:

  • 想要可移动,就不要随手把对象声明成 const

18.4 const T&& 很少用

它既不像 const T& 那样通用,也不适合作为可移动对象接口。
绝大多数场景里,它都不是你想要的东西。

18.5 命名后的右值引用变量仍然是左值

这个坑值得再强调一次:

1
2
3
4
void f(std::string&& s) {
g(s); // s 是左值
g(std::move(s)); // 才是右值
}

18.6 完美转发不是“性能银弹”

它的价值是:

  • 保留值类别
  • 避免不必要拷贝

但如果接口设计本身不合理,或者对象很小、移动和拷贝差不多,那么完美转发不一定有明显收益。


19. 面试和复习高频问题

Q1:std::move 会真的移动对象吗?

不会。
它只是把对象转换成右值形式,真正是否发生移动取决于后续是否匹配到移动构造 / 移动赋值 / 右值重载。

Q2:为什么 T&& 能同时接左值和右值?

因为当 T 通过模板推导得到时,它是转发引用;传左值时 T 会推导成引用类型,再通过引用折叠得到左值引用。

Q3:std::forwardstd::move 最大区别是什么?

  • move:无条件转右值
  • forward:按模板参数恢复原值类别

Q4:为什么包装函数里直接 target(arg) 不行?

因为 arg 有名字,所以它是左值表达式,右值信息丢了。

Q5:为什么 emplace_back 常和完美转发一起出现?

因为它需要把用户传入的构造参数原样传给元素构造函数,同时避免不必要的中间对象。


20. 一页总结

把这篇笔记压成最短记忆链,可以记成下面几句:

  1. 左值有身份,右值更像临时结果
  2. T& 绑左值,const T& 几乎都能绑,T&& 通常绑右值
  3. 命名后的 T&& 变量,表达式本身仍然是左值
  4. std::move 是把对象强制转成右值,不等于真的移动
  5. T&& 中的 T 需要推导时,它是转发引用
  6. 完美转发的标准写法是 std::forward<T>(arg)
  7. 引用折叠的记忆口诀:只要有左值引用参与,结果就是左值引用

如果只背一个代码模板,就背这个:

1
2
3
4
template <class... Args>
void wrapper(Args&&... args) {
target(std::forward<Args>(args)...);
}

21. 建议继续补充的相关主题

和这篇衔接最紧密的内容:

  1. 移动构造、移动赋值、Rule of Five
  2. noexcept move 为什么影响容器扩容策略
  3. decltype(auto)、返回值类别与转发返回
  4. std::invokestd::apply 与泛型调用包装
  5. C++23 的 std::forward_like

22. 参考资料

  1. cppreference: value categories
    https://en.cppreference.com/w/cpp/language/value_category

  2. cppreference: reference
    https://en.cppreference.com/w/cpp/language/reference

  3. cppreference: std::move
    https://en.cppreference.com/w/cpp/utility/move

  4. cppreference: std::forward
    https://en.cppreference.com/w/cpp/utility/forward