C++移动语义
1. 左值 vs 右值
1. 定义
- 左值:有名字、可反复使用、通常可取地址。
- 右值:临时值、很快就销毁、通常不能直接取地址。
2. 快速判断法
先问自己两个问题:
- 这个表达式结束后,它还会留在原地让我继续用吗?
- 我能稳定地拿到它的地址吗?
大概率能:左值。大概率不能:右值。
3. 代码示例
int makeInt()
{
return 42; // 按值返回,结果是临时值(右值)
}
void demo()
{
int x = 10; // x 是左值
int y = x; // x 仍然存在
int z = makeInt(); // makeInt() 是右值
int& lref = x; // 左值引用绑定左值
const int& cref = 10; // const 左值引用可绑定右值,并延长临时对象生命周期
int&& rref = 10; // 右值引用绑定右值
// int& bad1 = 10; // 错:左值引用不能绑定右值
// int&& bad2 = x; // 错:右值引用不能直接绑定左值
}
4. 一个关键事实
只要“有名字”,表达式就是左值,哪怕它的类型是 T&&,它本身也还是左值表达式。
int&& rr = 10;
// rr 是有名字的变量,所以 rr 这个表达式是左值
2. 右值引用(&&)与移动语义的关系
1. 为什么 const T& 不够
const T& 也能绑定右值,但它“只读”。而移动语义要做的是:把源对象内部资源“转移”走,然后把源对象置为可析构的空状态。这个过程必须修改源对象。所以需要 T&&:明确告诉编译器“这个对象可以被转移资源”。
2. 右值引用到底在干什么
右值引用本身不搬资源,它只是给“可搬运”的语义通道。真正搬资源的是:
- 移动构造函数
T(T&&) - 移动赋值运算符
T& operator=(T&&)
3. 移动构造函数
1. 作用
当“新对象正在创建”且源对象是右值时,优先调用移动构造。核心目标:不深拷贝,直接接管资源指针/句柄。
2. 代码示例
#include <algorithm>
#include <cstddef>
#include <iostream>
class Buffer
{
public:
explicit Buffer(std::size_t n)
: size_(n), data_(new int[n]{})
{
}
~Buffer()
{
delete[] data_;
}
Buffer(const Buffer& other)
: size_(other.size_), data_(new int[other.size_])
{
std::copy(other.data_, other.data_ + size_, data_);
std::cout << "copy ctor\n";
}
Buffer(Buffer&& other) noexcept
: size_(other.size_), data_(other.data_)
{
// 把源对象清空,避免双重释放
other.size_ = 0;
other.data_ = nullptr;
std::cout << "move ctor\n";
}
Buffer& operator=(const Buffer& other)
{
if (this == &other)
{
return *this;
}
int* newData = new int[other.size_];
std::copy(other.data_, other.data_ + other.size_, newData);
delete[] data_;
data_ = newData;
size_ = other.size_;
std::cout << "copy assign\n";
return *this;
}
Buffer& operator=(Buffer&& other) noexcept
{
if (this == &other)
{
return *this;
}
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
std::cout << "move assign\n";
return *this;
}
private:
std::size_t size_ = 0;
int* data_ = nullptr;
};
3. 使用示例
Buffer a(1024);
Buffer b = a; // 拷贝构造
Buffer c = std::move(a); // 移动构造
c 直接接管 a 的资源,避免深拷贝。
4. 移动赋值运算符
1. 和移动构造的区别
- 移动构造:目标对象还没创建。
- 移动赋值:目标对象已经存在,要先清理旧资源,再接管新资源。
2. 代码示例
Buffer a(256);
Buffer b(128);
b = std::move(a); // 调用移动赋值
3. 注意事项
- 先处理自赋值
if (this == &other)。 - 先释放旧资源,再接管新资源。
- 源对象要重置为“可析构状态”。
- 尽量加
noexcept,容器扩容时才更愿意走移动路径。
5. std::move 的原理与使用场景
1. 原理
std::move 本质是类型转换,不搬资源。
template <class T>
constexpr std::remove_reference_t<T>&& my_move(T&& t) noexcept
{
return static_cast<std::remove_reference_t<T>&&>(t);
}
它做的事:把表达式转成右值引用,触发后续移动构造/移动赋值。
2. 常见使用场景
#include <string>
#include <vector>
void vectorDemo()
{
std::vector<std::string> v;
std::string s = "hello";
v.push_back(std::move(s)); // 明确把 s 交给容器
// s 仍然有效,但内容不保证
}
class User
{
public:
void setName(std::string name)
{
// name 是按值传入的本地副本,这里可安全转移
name_ = std::move(name);
}
private:
std::string name_;
};
#include <memory>
void uniquePtrDemo()
{
std::unique_ptr<int> p1(new int(7));
std::unique_ptr<int> p2 = std::move(p1); // unique_ptr 只能移动,不能拷贝
}
3. 常见错误
#include <string>
std::string makeName()
{
std::string s = "cpp";
return s; // 让编译器做 RVO/NRVO,通常不要写 return std:: move(s);
}
const std::string cs = "hello";
std::string t = std::move(cs); // 这里通常还是拷贝,不是移动
说明:移动通常要求修改源对象,const 对象不能被修改,所以很难真正移动。
6. 拷贝 vs 移动
1. 决策
| 场景 | 建议 |
|---|---|
| 后面还要继续用原对象内容 | 拷贝 |
| 明确不再需要原对象内容 | std::move |
| 模板转发参数 | std::forward |
| 返回局部变量 | 直接 return obj; |
2. 代码示例
// 只读,不保存副本:用 const T&
void printName(const std::string& name);
// 需要修改调用方对象:用 T&
void normalize(std::string& name);
// 只接临时对象(较少用)
void consume(std::string&& name);
// 需要“接收并保存”参数:常用值传递 + move(简单、稳)
class Profile
{
public:
void setCity(std::string city)
{
city_ = std::move(city);
}
private:
std::string city_;
};
3. 为什么很多项目偏爱“值传递 + move”
- 调用方传左值:拷贝进来一次,再 move 到成员。
- 调用方传右值:可直接移动进来,整体成本低。
- 代码比“
const&+&&双重重载”更简洁。
4. noexcept 很重要
标准容器扩容时,为了异常安全,常常只在“移动构造不抛异常”时优先移动。所以自定义类型的移动构造/移动赋值建议写成 noexcept。
class BigObject
{
public:
BigObject(BigObject&&) noexcept = default;
BigObject& operator=(BigObject&&) noexcept = default;
};
7. 完美转发(std::forward)
1. 先看问题
#include <iostream>
#include <string>
void sink(const std::string& s)
{
std::cout << "lvalue: " << s << '\n';
}
void sink(std::string&& s)
{
std::cout << "rvalue: " << s << '\n';
}
template <typename T>
void badWrapper(T&& arg)
{
// arg 有名字,所以这里总是左值语义
sink(arg);
}
badWrapper(std::string("hi")) 也可能走左值版本,浪费移动机会。
2. 正确做法
template <typename T>
void goodWrapper(T&& arg)
{
// 保留调用方原始值类别:左值就左值,右值就右值
sink(std::forward<T>(arg));
}
3. 和 std::move 的区别
std::move:无脑转右值。std::forward<T>:按原始类别转发。
一句话记住:
- 普通业务代码:想交出对象所有权,用
move。 - 模板转发代码:想保留原语义,用
forward。
8. emplace vs insert/push_back
1. 在线性容器中(vector 为例)
#include <string>
#include <vector>
void vectorCase()
{
std::vector<std::string> v;
std::string s = "abc";
v.push_back(s); // 拷贝
v.push_back(std::move(s)); // 移动
v.emplace_back("hello"); // 在容器内直接构造 string
v.emplace_back(5, 'x'); // 构造 "xxxxx",这个场景 emplace 常更直接
}
经验规则:
- 已经有对象了:
push_back(obj)/push_back(std::move(obj))清晰。 - 只有构造参数:
emplace_back(args...)更顺手。 - 不是所有场景
emplace_back都更快,别迷信。
2. 在关联容器中(map 为例)
#include <map>
#include <string>
void mapCase()
{
std::map<int, std::string> m;
m.insert({1, "one"}); // 插入一个 pair 对象
m.emplace(2, "two"); // 原地构造 pair
m.try_emplace(2, "TWO"); // key 已存在时,不会构造新 value
m.insert_or_assign(2, "TWO"); // 有则赋值,无则插入
}
建议:
- 只插入,不覆盖:优先
try_emplace(尤其 value 构造昂贵时)。 - 要“存在则更新”:用
insert_or_assign。 insert/emplace都能用时,按可读性选,不要为“理论微优化”牺牲清晰度。
3. 一个常见坑
std::vector<std::vector<int>> vv;
vv.push_back({1, 2, 3}); // 常常没问题
// vv.emplace_back({1, 2, 3}); // 某些场景会因模板推导出问题
vv.emplace_back(std::initializer_list<int>{1, 2, 3}); // 更稳
9. 高频场景模板
1. 场景一:类成员接收字符串
class Person
{
public:
void setName(std::string name)
{
name_ = std::move(name);
}
private:
std::string name_;
};
适用:大部分业务类 setter。
2. 场景二:构造函数转移资源
class Session
{
public:
explicit Session(std::vector<int> data)
: data_(std::move(data))
{
}
private:
std::vector<int> data_;
};
适用:把传入参数保存为成员。
3. 场景三:容器里放 move-only 对象
#include <memory>
#include <vector>
void moveOnlyCase()
{
std::vector<std::unique_ptr<int>> v;
auto p = std::make_unique<int>(42);
v.push_back(std::move(p)); // 必须 move
}
适用:文件句柄、锁、socket 封装、unique_ptr 等。
4. 场景四:写通用模板包装器
template <typename F, typename... Args>
decltype(auto) call(F&& f, Args&&... args)
{
// 保留 f 和 args 的值类别
return std::forward<F>(f)(std::forward<Args>(args)...);
}
适用:日志包装器、重试框架、任务调度器。
10. 最容易踩的 10 个坑
- 把
std::move当成“真的移动动作”。 - move 之后继续依赖原对象内容。
- 在模板转发函数里用
move,误伤左值参数。 - 忘记给移动构造/赋值加
noexcept,导致容器扩容走拷贝。 - 以为“
T&&类型变量本身就是右值”。 - 对
const对象move,结果还是拷贝。 - 没有资源所有权却乱写自定义移动函数。
- 写了移动操作,但忘了把源对象置为空壳状态。
- 在
return局部变量时强行std::move,干扰编译器优化。 - 盲目把所有
push_back改成emplace_back,可读性反而变差。
11. 小结
1. 基本规则
- 变量默认是左值,想移动就
std::move。 - 模板转发参数就
std::forward<T>(arg)。 - move 后对象仍有效,但内容不保证。
- 能用编译器默认移动操作就优先
= default,并加noexcept。 - 有对象就
push_back,有构造参数就emplace_back。 map插入优先考虑try_emplace/insert_or_assign。
2. 实用的写法组合
class Config
{
public:
void setPath(std::string path)
{
path_ = std::move(path);
}
private:
std::string path_;
};
template <typename T>
void forwardToSink(T&& v)
{
sink(std::forward<T>(v));
}
std::vector<std::string> v;
v.emplace_back("log.txt");
到这里,基本上已经覆盖绝大多数的移动语义场景了。剩下就是多看标准库接口签名,遇到 && 就先判断“这是要消费对象,还是要完美转发”。