C++ 移动语义

C++ 移动语义

1. 左值 vs 右值

1. 从一段代码开始

我们从最底层开始,看这几行代码:

1
2
3
int a = 10;
int b = a;
int c = 20;

问一个问题:哪些是左值?哪些是右值? 可能大多数人的第一反应是:等号左边的是左值,等号右边的是右值。

这是最常见的误区,必须纠正:左值右值跟等号左右没关系。

2. 本质判断标准

我们换个角度问问题:

  • a 能不能取地址?比如 &a 合法吗?
  • 10 能不能取地址?比如 &10 合法吗?

答案是:a 合法,10 不合法。现在给一个 更本质的判断标准

能不能被持续 “占有” 一块内存,并且有名字,可以反复用?

回来看刚才那几个:

1
2
3
int a = 10;
int b = a;
int c = 20;

思考:

  • a 赋值给 b 之后,a 还在吗?还能继续用吗?
  • 10 赋值结束之后,它还能被你再访问吗?

答案:

  • a 是个 有名字、有固定位置 的东西(能一直拿它用,能取地址)
  • 10 只是个 临时值,用完就没了(不能对它取地址)
  • 注意:10 不是 “旧对象新对象”,它压根就不是 “一个我们能抓住的对象”

3. 第一个判断题

1
2
3
int x = 10;
int& r1 = x;
int& r2 = 10;
  • r1 这行能编译。对/错?
  • r2 这行能编译。对/错?

先纠正一个概念:int& 不是指针,它是 引用,引用必须绑定到 “一个真实存在、可持续存在的对象”。 逐行看:

1
2
3
int x = 10;
int& r1 = x; // ✅ 能编译
int& r2 = 10; // ❌ 不能编译

原因一句话:左值引用(T&)只能绑定左值10 是右值(临时值)。

4. 正式定义

  • 左值 = 有身份、有地址、能反复使用
  • 右值 = 临时值、即将消失、不能取地址

5. 函数返回值的情况

下一个思考题:

1
2
int foo() { return 10; }
int& r = foo();

foo() 返回的是 10,那这里:

  • foo() 是左值还是右值?
  • 这行代码能不能编译?

答案: foo() 这种 “按值返回” 的结果,是个 临时值,表达式用完就没了。所以:

  • foo() 这个表达式是 右值
  • int& r = foo(); 这行 不能编译(左值引用绑不了右值)

但有个 “转折” 很好玩:

1
const int& r = foo();   // ✅ 这个能编译

因为 const 左值引用可以绑定右值,并且会把临时值 “续命” 到 r 的生命周期结束。

6. 为什么 const int& 可以绑右值?

问题来了:为啥 const int& 可以绑右值,但 int& 不行?

核心原因就一句话:

  • 右值是临时的,马上就要销毁
  • 如果允许 int& 绑定它,你就能修改一个 “马上要消失” 的东西
  • 这会制造悬空引用和未定义行为

而:

  • const int& 只能读,不能改
  • 编译器就可以安全地 “延长这个临时值的生命周期”

所以它其实是在做一件事:给临时值续命,并且保证你不能乱改它。 对比:

1
2
int& r = 10;          // ❌
const int& r = 10; // ✅

如果第一行合法,就可以:r = 20;,这其实是在改一个 “本来不存在实体” 的临时值,这就炸了!

7. 右值引用登场

下一个判断题(关键):

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

这行能不能编译?想想:

  • 10 是右值
  • && 是什么引用?
  • 它是专门为谁准备的?

答案:能编译! && 是右值引用,& 是左值引用,&&& 分别用于相反的情况。所以我们只需要记住:

  • T&:专绑左值(有名字、能取地址)
  • T&&:专绑右值(临时值)

8. 小练习总结

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

int& a = x; // ✅
int& b = 10; // ❌ 左值引用不能绑右值

const int& c = 10; // ✅ const 左值引用可以绑右值

int&& d = 10; // ✅ 右值引用专绑右值
int&& e = x; // ❌ 右值引用不能绑左值

能编译的是:a、c、d

9. 小结

  • 左值:有名字、能取地址、能长期存在
  • 右值:临时值、表达式结果、不能直接取地址
  • T&(左值引用)只能绑定左值
  • T&&(右值引用)只能绑定右值
  • const T&(const 左值引用)可以绑定右值(但不能修改)

2. 右值引用(&&)与移动构造函数

1. 为什么需要右值引用?

一个关键问题:为什么 C++ 要专门搞一个 “右值引用”?如果已经有 const T& 能绑右值了,那 T&& 是为了解决什么问题?

先看这段代码:

1
2
std::string s1 = "hello";
std::string s2 = s1;

这里发生的是啥?是 拷贝。那再看这个:

1
std::string s2 = std::string("hello");

右边这个 std::string("hello") 是啥?左值还是右值?

答案:

  • 是一个 匿名临时对象
  • 右值
  • 没人再用它
  • 表达式结束就要销毁

关键问题来了: 既然它马上要销毁,那我们还有必要 “拷贝” 它内部的数据吗?还是可以 “直接偷走” 它内部那块内存?

很显然:右值反正没人要了,直接 “搬走/偷走” 它的资源最划算。

2. 移动语义的核心

直接把匿名临时对象给一个 “名字” 就能延长生命周期,并且没有拷贝构造,省资源还快。需要纠正的是:不是 “给右值起名字来延长生命周期”,移动语义更核心的是:

把右值内部的资源指针/句柄挪给新对象,然后把旧对象改成 “空壳”,让它析构时不再释放那份资源。

这就叫 move(移动)

3. 为什么 const T& 不够用?

为啥 const T& 不够用?因为:

  • const T& 绑定右值以后,不能改它
  • 但 “移动” 这件事,本质上需要 改旧对象(把它置空)

所以要一个新东西:

T&&:我明确告诉你 “这东西是右值,没人要了,你可以安全地把它掏空”。

4. 移动构造函数示例

来看一个自定义类的移动构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyString
{
private:
char* data;
size_t size;

public:
// 移动构造函数
MyString(MyString&& other) noexcept
: data(other.data)
, size(other.size)
{
// 把旧对象掏空
other.data = nullptr;
other.size = 0;
}
};

关键点:

  1. 参数是 MyString&&(右值引用)
  2. 直接 “偷走” other.data 指针
  3. other 置为空壳(data = nullptr
  4. noexcept 标记(移动操作不应该抛出异常)

5. 返回值优化示例

1
2
3
4
5
6
7
std::string make()
{
std::string result = "hello";
return result; // 这里会发生什么?
}

std::string a = make();

这里 make() 的返回值,适合被拷贝还是被移动?

答案是:移动!

  • 返回值是右值
  • 右值没人再用
  • 可以安全 “掏空”
  • 所以优先走移动构造

这就是移动语义存在的根本原因:减少不必要的深拷贝。


3. 移动赋值运算符

1. 移动构造 vs 移动赋值

你觉得 “移动构造” 和 “移动赋值” 最大的区别是啥?

核心区别一句话:

  • 移动构造:对象还没出生
  • 移动赋值:对象已经存在了

用例子看:

1. 移动构造(新对象刚创建)

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

这里 s2 是第一次被创建,调用的是:string(string&&)移动构造

2. 移动赋值(对象已经存在)

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

这里 s2 早就存在了,调用的是:operator=(string&&)移动赋值

所以移动构造和移动赋值的关键区别不在于 “等号和圆括号”,而是:是初始化阶段,还是已存在对象的替换阶段。

2. 移动赋值运算符的实现

实现移动赋值运算符,需要注意以下几点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class MyString
{
private:
char* data;
size_t size;

public:
// 移动赋值运算符
MyString& operator=(MyString&& other) noexcept
{
// 1. 自赋值检查(虽然移动后 other 会是空壳,但检查是好习惯)
if (this != &other)
{
// 2. 释放自己原有的资源
delete[] data;

// 3. 偷走 other 的资源
data = other.data;
size = other.size;

// 4. 把 other 置为空壳
other.data = nullptr;
other.size = 0;
}

return *this;
}
};

实现要点:

  1. 自赋值检查if (this != &other)(虽然移动后 other 是空壳,但这是好习惯)
  2. 先释放自己原有的资源(避免内存泄漏)
  3. 偷走对方的资源(指针直接赋值)
  4. 把对方置为空壳(避免析构时重复释放)
  5. 返回 *this(支持链式赋值)
  6. 标记 noexcept(移动操作不应抛异常)

3. 完整的规则类示例

一个遵循 “三/五法则” 的完整类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class MyString
{
private:
char* data;
size_t size;

public:
// 1. 构造函数
MyString(const char* str)
: size(strlen(str))
{
data = new char[size + 1];
strcpy(data, str);
}

// 2. 拷贝构造函数
MyString(const MyString& other)
: size(other.size)
{
data = new char[size + 1];
strcpy(data, other.data);
}

// 3. 拷贝赋值运算符
MyString& operator=(const MyString& other)
{
if (this != &other)
{
delete[] data;
size = other.size;
data = new char[size + 1];
strcpy(data, other.data);
}
return *this;
}

// 4. 移动构造函数
MyString(MyString&& other) noexcept
: data(other.data)
, size(other.size)
{
other.data = nullptr;
other.size = 0;
}

// 5. 移动赋值运算符
MyString& operator=(MyString&& other) noexcept
{
if (this != &other)
{
delete[] data;
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
}
return *this;
}

// 6. 析构函数
~MyString()
{
delete[] data;
}
};

4. 小测试

1
2
3
4
5
6
7
8
std::string s1 = "hello";
std::string s2(s1); // 拷贝构造
std::string s3(std::move(s1)); // 移动构造

std::string s4 = "world";
std::string s5;
s5 = s4; // 拷贝赋值
s5 = std::move(s4); // 移动赋值

记住口诀:有名字 = 左值 = 默认拷贝;想移动就 move。


4. std:: move 的原理与使用场景

1. std:: move 的本质

std::move(x) 本质就是把 x 强制转换成右值引用,它干的事接近于:

1
static_cast<T&&>(x);

它不移动数据! 它只是告诉编译器:这个对象你可以当右值看待了。 真正发生移动的是 —— 移动构造函数 / 移动赋值函数

2. 哪一行在 “偷资源”?

一个关键的问题:

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

到底是哪一行代码 “偷走” 了 s 里的资源?是 std::move 偷的?还是 std::string 的某个函数偷的?

答案:

  • std::move(s):只做 “把 s 变成右值” 的动作
  • 真正 “偷资源” 的,是这句在 初始化 t 时触发的 std::string 的移动构造函数

所以 “偷” 的是 std::string(std::string&&)(移动构造)。

3. move 之后对象还能用吗?

一个小检查(很重要,跟踩坑有关):用了 std::move(s) 之后,s 还能不能用?注意问题:不是问 “还能不能读出原来的内容”,而是问:还能不能继续当一个正常对象用(比如再赋值、再 push_back)?

答案: std::move(s) 之后:

  • s 还活着,没被析构
  • 只是变成了 “被移动过的状态”
  • 标准要求:它必须是 valid but unspecified,也就是:对象还能用,但里面是啥不保证

所以可以对 s 做这些:

  • 再赋值:s = "abc";
  • s.clear()s.size()
  • 继续 push_back(一般都行)

但你不能依赖这些:

  • 不能指望 s 还是 "hello"
  • 不能写业务逻辑基于 s 的内容

总之:move 之后的对象能 “重新用”,但别指望它 “还保持原样”。

4. 常见使用场景

1. 从函数返回大对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::vector<int> createVector()
{
std::vector<int> result(1000000);
// ... 填充数据
return result; // 现代编译器会自动优化(RVO/NRVO)
}

// 老式写法(不推荐,但能工作):
std::vector<int> createVector()
{
std::vector<int> result(1000000);
// ... 填充数据
return std::move(result); // 强制移动
}

建议:让编译器自动优化,不要手动 std::move 返回值。

2. 转移容器内容

1
2
3
4
5
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = std::move(v1);

// v1 现在是空壳,v2 拥有原来的数据
// 没有发生元素拷贝,只是指针转移

3. unique_ptr 所有权转移

1
2
3
4
5
std::unique_ptr<MyClass> ptr1 = std::make_unique<MyClass>();
std::unique_ptr<MyClass> ptr2 = std::move(ptr1);

// ptr1 变为 nullptr
// ptr2 获得所有权

4. 函数参数传递

1
2
3
4
5
void process(std::string data);  // 按值传递

std::string s = "hello";
process(s); // 拷贝
process(std::move(s)); // 移动(s 之后还能用,但内容不保证)

5. 常见陷阱

陷阱 1:对 const 对象使用 move

1
2
const std::string s = "hello";
std::string t = std::move(s); // 实际发生的是拷贝!

原因std::move(s)s 转成 const string&&,但移动构造函数需要 string&&(非 const),所以只能匹配到拷贝构造函数。

结论:别对 const 对象用 move,没用。

陷阱 2:move 后继续使用原对象的内容

1
2
3
std::string s = "hello";
std::string t = std::move(s);
std::cout << s.length(); // 合法,但 s 的内容不保证

正确做法:move 之后要么重新赋值,要么别再依赖它的内容。

陷阱 3:有名字的变量永远是左值

1
2
3
std::string s1 = "hello";
std::string s2 = s1; // 拷贝构造(s1 是左值)
std::string s3 = std::move(s1); // 移动构造

口诀:变量默认是左值;想移动就 move。


5. 拷贝 vs 移动的选择

1. 何时发生拷贝?

场景示例发生什么
左值初始化T t1 = t2;(t2 是左值)拷贝构造
左值赋值t1 = t2;拷贝赋值
函数按值传左值func(obj);拷贝构造
函数返回左值引用return obj;(obj 是局部变量)拷贝构造(或移动)

2. 何时发生移动?

场景示例发生什么
右值初始化T t1 = T();移动构造
std:: move 左值T t1 = std::move(t2);移动构造
函数返回临时对象return T();移动构造(或 RVO)
右值赋值t1 = T();移动赋值

3. 性能考量

拷贝的成本

  • 深拷贝:分配新内存 + 复制所有数据
  • 时间复杂度:O(n)
  • 可能抛异常(内存分配失败)

移动的成本

  • 指针/句柄转移
  • 时间复杂度:O(1)
  • 通常不抛异常(应标记 noexcept)

性能对比示例

1
2
3
4
5
6
// 拷贝:可能分配几 MB 内存,复制大量数据
std::vector<int> v1(1000000, 42);
std::vector<int> v2 = v1; // 拷贝

// 移动:只是转移指针,几乎零成本
std::vector<int> v3 = std::move(v1); // 移动

4. 选择策略

策略 1:默认让编译器决定

1
2
3
4
5
std::vector<int> create()
{
std::vector<int> result(1000000);
return result; // 编译器会自动优化(RVO/NRVO 或移动)
}

策略 2:明确要转移所有权时用 move

1
2
3
4
5
6
7
std::unique_ptr<Resource> getResource();

void process()
{
auto ptr = getResource();
use(std::move(ptr)); // 明确转移所有权
}

策略 3:函数内部对局部变量不用 move

1
2
3
4
5
6
std::vector<int> create()
{
std::vector<int> result(1000000);
return result; // ✅ 不用 move,编译器优化更好
// return std:: move(result); // ❌ 可能阻止 RVO 优化
}

策略 4:按值传递 + 内部 move

1
2
3
4
5
6
7
8
9
10
11
class MyClass
{
std::string name;

public:
// 推荐写法:按值传递 + 内部 move
void setName(std::string name)
{
this->name = std::move(name); // 避免一次拷贝
}
};

为什么这样更好?

  • 调用方传左值:拷贝一次(在参数传递时)
  • 调用方传右值:移动一次(在参数传递时)
  • 内部再 move 到成员变量:移动一次
  • 总共:左值调用 = 1 拷贝 +1 移动;右值调用 = 2 移动

对比传统写法(const 引用 + 拷贝):

  • 左值调用:1 拷贝
  • 右值调用:1 拷贝(多了一次不必要的深拷贝)

5. 决策流程图

1
2
3
4
5
6
7
要复制一个对象吗?

是临时对象/右值吗?
├─ 是 → 移动(通常自动发生)
└─ 否 → 还要用原对象吗?
├─ 是 → 拷贝
└─ 否 → std::move + 移动

6. 完美转发(std:: forward)

1. 从一个问题开始

先问一个铺垫问题:void foo(std::string&& s);,如果在函数里这样写:foo(s);,能不能调用成功?(注意:s 是函数参数,类型是 std::string&&

答案是:不能直接 foo(s),必须 foo(std::move(s))

1
2
3
4
5
void bar(std::string&& s)
{
foo(s); // ❌ s 是左值
foo(std::move(s)); // ✅ 转成右值
}

为什么第一行不行?

  • 因为 s 有名字
  • 有名字就是左值
  • 而 foo 需要的是右值引用

所以即便 s 的类型是 string&&,只要它有名字,它就是左值。

2. 模板函数的困境

现在关键来了,如果我们写模板函数:

1
2
3
4
5
template<typename T>
void wrapper(T&& arg)
{
foo(arg); // 这里就出问题
}

我们根本不知道:

  • 传进来的原本是左值?
  • 还是右值?

arg 一旦有名字,就变成左值了。这时候就需要:std::forward<T>(arg)

3. 转发引用(万能引用)

紧接着先不讲定义,来一个思考题:如果调用:

1
2
3
4
std::string s = "hello";
wrapper(s); // 传左值

wrapper(std::string("hi")); // 传右值

wrapper 里应该怎么 “原样转发” 给 foo 才是正确行为?首先声明不会报错,它能推导出来,而且这是模板里最经典的规则之一。

答案:当参数写成 T&&,并且 T 需要推导时,它不是普通的 “右值引用”,而是:转发引用(forwarding reference,也叫万能引用),推导规则是:

  • 传进来是左值:T 推导成 T = std::string&
  • 传进来是右值:T 推导成 T = std::string

4. 引用折叠规则

紧接的一个问题(接上面):如果 T = std::string&,那 T&& 变成什么类型?

答案是:std::string& && 会折叠成 std::string& 这就是 引用折叠规则

组合结果
& &&
& &&&
&& &&
&& &&&&

所以我们只需要记住:只要出现一个 &,结果就是左值引用。

5. 完整推导过程

我们把整个过程串起来,当写出:

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

推导过程是:

  1. s 是左值
  2. T 推导成 std::string&
  3. T&& 变成 std::string& &&
  4. 折叠后是 std::string&

所以 arg 在函数里是:左值引用。那如果调用:

1
wrapper(std::string("hi"));

这次传进来是右值。T 推导成 std::string(不是 std::string&&),然后参数类型是 T&&,所以变成:std::string&&

总结:

  • 传左值:T = std::string&,参数变 std::string&
  • 传右值:T = std::string,参数变 std::string&&

这就是 “万能引用/转发引用” 能同时接住左值和右值的原因。

6. std:: forward 的作用

wrapper 里,arg 这个变量 有名字,所以表达式 arg 永远是左值。

但我们希望:

  • 原来是左值 → 继续当左值传
  • 原来是右值 → 继续当右值传

std::forward<T>(arg) 就是干这个的。

7. 最后的判断题

在 wrapper 里,如果写:

1
foo(arg);

对于 wrapper(std::string("hi")) 这种传右值的调用,会不会错误地变成 “拷贝/绑定到左值” 那一套?

这里就是关键坑了——会!

它不会 “自己判断”,原因很简单:在 wrapper 里面,arg 是一个 有名字的变量,只要有名字,它就是左值表达式。所以:foo(arg);,哪怕最开始传进来的是右值,到这里也会变成:

  • 以左值的身份传给 foo
  • 走拷贝版本
  • 或者根本匹配不到 foo(T&&)

这就把 “移动机会” 浪费掉了。这也是为什么必须写:

1
foo(std::forward<T>(arg));

它的作用是:

  • 如果原本是左值 → 继续左值
  • 如果原本是右值 → 还原成右值

它不是 “判断”,它是利用 T 的推导结果做类型恢复。

8. 为什么不能用 std:: move 替代?

一个关键问题:为什么在模板里不能直接用 std::move(arg)?为什么必须用 std::forward<T>(arg)

答案:

  • move 会无脑把东西变右值,哪怕传进来本来是左值也会被你 “偷走”
  • forward 会按原样转发:传进来是左值就保持左值,传进来是右值才转右值

所以:

  • 写库/模板:用 forward
  • 自己明确要把某个变量 “交出去不再用”:用 move

9. 完美转发完整示例

1
2
3
4
5
6
7
8
9
10
template<typename T>
void wrapper(T&& arg)
{
foo(std::forward<T>(arg)); // 完美转发
}

// 调用
std::string s = "hello";
wrapper(s); // arg 是左值引用,forward 后还是左值
wrapper(std::string("hi")); // arg 是右值引用,forward 后是右值

10. 小测试

写一个函数:

1
void setName(std::string name);

如果调用方传右值,比如 setName("abc"),希望里面存到成员变量时更快,那在函数内部更适合用:

  • A. member = name;
  • B. member = std::move(name);

选哪个?—— B

  • name 是按值传进来的副本
  • 函数里已经没人再用它
  • 所以可以安全 std::move(name)
  • 避免一次拷贝

这就是 “值传递 + 内部 move” 的常见写法。


7. emplace vs insert/push_back

1. 基本概念

1. push_back()

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

这里会发生什么?

  • "hello" 先变成一个临时 std::string
  • 然后把这个临时对象放进 vector(拷贝或移动)

2. emplace_back()

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

它干的事是:

  • 直接在 vector 内部那块内存上构造 std::string
  • 不需要先产生一个临时 string 再搬进去

关键区别一句话:

  • push_back 是 “把一个对象塞进去”
  • emplace_back 是 “在里面直接构造对象”

2. 性能对比

问题来了:

1
2
3
std::string s = "abc";
v.push_back(s);
v.emplace_back(s);

这两行有性能差别吗?这里基本没差别。 我们慢慢推。

1
2
std::string s = "abc";
v.push_back(s);

s 是啥?是左值,所以会调用:拷贝构造,再看:

1
v.emplace_back(s);

s 还是左值。emplace_back 会把参数 原样转发 给构造函数。那最终还是:用 std::string(const std::string&) 构造,也就是——还是拷贝。所以这里两行几乎一样。

3. emplace_back 真正有优势的场景

关键问题来了(非常重要):那什么时候 emplace_back 才真的有优势? 比如下面这种:

1
v.emplace_back(10, 'a');

请问:这行如果用 push_back 要怎么写?

注意: push_back 不是不能传多个参数,而是——它 只能接收一个 “已经构造好的对象”。看这个:

1
v.emplace_back(10, 'a');

这里是调用 std::string(size_t n, char c) 构造函数。意思是:构造一个 "aaaaaaaaaa"(10 个 a)。如果用 push_back,你得先构造一个对象:

1
v.push_back(std::string(10, 'a'));

区别在哪?

  • push_back:先构造一个临时 string,再把它移动进 vector
  • emplace_back:直接在 vector 里面那块内存上构造,没有那个临时对象

因为 push_back(std::string(...)) 多出来的那一步,基本就是一次移动构造(一般很便宜)。

所以现实里常见结论是:

  • emplace_back 更通用、更顺手
  • 不一定总是更快
  • 差距常常小到测不出来(除非对象很重/没移动/有奇怪构造)

4. emplace_back 的陷阱

但还有一个更关键、更容易踩坑的点:emplace_back 可能会 “选中我们没想到的构造函数”,比如:

1
2
3
std::vector<std::vector<int>> vv;
vv.push_back({1,2,3});
vv.emplace_back({1,2,3});

一个判断题:这两行都能编译吗?还是有一行会报错?

答案是:

  • push_back({1,2,3}) ✅ 能编译
  • emplace_back({1,2,3}) ❌ 可能报错(在很多编译器下)

原因不在 vector,而在花括号的推导规则。 我们慢慢推一下。

1
vv.push_back({1,2,3});

这里 {1,2,3} 会被当成:std::vector<int>{1,2,3} 临时对象,然后 push_back 接收一个对象,没问题。但:

1
vv.emplace_back({1,2,3});

emplace_back 是模板函数,模板遇到 {} 初始化列表时:

  • 类型推导可能失败
  • 或选不到正确构造函数

因为 {} 在模板推导里是个 “特殊怪物”。解决办法一般是:

1
2
3
vv.emplace_back(std::initializer_list<int>{1,2,3});
// 或者
vv.emplace_back(std::vector<int>{1,2,3});

所以总结一句非常实用的话:

  • emplace_back 更灵活,但有时会因为模板推导踩坑
  • 不是所有场景都盲目用 emplace

5. 左值场景

如果写:

1
v.emplace_back(someObj);

someObj 是左值,它会调用移动构造吗?

答案:不会。 原因就一句话:

  • someObj 是左值
  • emplace_back(someObj) 会把它当左值转发进去
  • 最后还是走拷贝构造

想移动,必须明确表态:

1
2
3
v.emplace_back(std::move(someObj));
// 或者
v.push_back(std::move(someObj));

两者都行。

6. 总结对比表

场景push_backemplace_back性能差异
传左值拷贝构造拷贝构造无差异
传右值移动构造移动构造无差异
传构造参数需先构造临时对象直接原地构造emplace 略优
花括号初始化通常正常可能推导失败push_back 更安全
无移动构造的类型拷贝构造原地构造emplace 明显优

7. 使用建议

什么时候两者几乎一样?

  • 传左值
  • 或传右值但类型有高效移动构造

什么时候 emplace 更好?

  • 直接传构造参数(如 emplace_back(10, 'a')
  • 类型没有移动构造
  • 想避免多一次临时对象

什么时候别乱用 emplace?

  • 花括号推导复杂场景
  • 构造函数很多、可能选错构造函数

8. 记忆口诀

  • emplace_back(参数…):在里面直接构造
  • push_back(对象):把对象塞进去
  • 传左值 = 拷贝
  • 想移动就 move

8. 总结

到这一步,我们已经打通了一整条现代 C++ 主线:

1. 概念回顾

  1. RAII —— 资源跟对象走
  2. 左值/右值 —— 谁能被反复使用
  3. 右值引用 —— 专门为移动准备
  4. std:: move —— 只是类型转换
  5. 移动构造/赋值 —— 真正偷资源
  6. std:: forward —— 模板里保留值类别
  7. emplace_back —— 原地构造对象

2. 速查表

1. 左值 vs 右值判断

1
2
有名字 + 能取地址 + 能反复用 = 左值
临时值 + 不能取地址 + 用完就没了 = 右值

2. 引用绑定规则

1
2
3
T&        → 只能绑左值
const T& → 可以绑右值(续命)
T&& → 只能绑右值

3. 拷贝 vs 移动

1
2
有名字的变量 = 左值 = 默认拷贝
想移动 = 加 std::move()

4. move vs forward

1
2
自己用 = std::move()
模板转发 = std::forward<T>()

5. push_back vs emplace_back

1
2
3
已有对象 = push_back
传构造参数 = emplace_back
花括号小心 = 优先 push_back

3. 注意事项

  1. 不要对 const 对象用 move(没用,还是拷贝)
  2. move 之后的对象别乱用(内容不保证)
  3. 模板里用 forward,别用 move(会破坏左值)
  4. 返回值不要手动 move(让编译器优化)
  5. emplace_back 不是银弹(传左值时没优势)
  6. 移动操作标记 noexcept(让容器更高效)

4. 最后的话

  • 移动语义是现代 C++ 的基石之一。
  • 记住核心思想:右值反正没人要了,直接 “偷走” 它的资源最划算。
  • 记住行动口诀:变量默认是左值;想移动就 move;模板转发用 forward。