1. 为什么需要移动语义
1.1 拷贝贵
std::string 里可能有 1 MB 数据,按值传递就是 1 MB 的 memcpy:
// 传参需要一次拷贝
std::string process(std::string data) {
data += " suffix";
return data; // 又一个拷贝
}
std::string s = load_big_string(); // 1 MB
s = process(s); // 两次拷贝(传参一次,返回一次)
1.2 手动"转移"笨办法——用指针
// C 风格:把内存"送"过去,自己不持有
char * take_buf(char * p) {
char * old = my_buf;
my_buf = p; // 手动交换
return old; // 返回旧的
}
容易忘、容易错、资源归属模糊。
1.3 一句话引入
std::move + 移动语义让 C++ 可以零拷贝转移所有权:数据还是那块数据,只是主人换了。在智能指针文档里你看到的"unique_ptr 不能拷贝只能移动"就是依赖这个机制。
2. std::move 到底做了什么
2.1 关键澄清
std::move 本身什么都没搬。
它只是把对象标记成"可以掏空"。真正的搬运,发生在接收方的移动构造函数或移动赋值运算符里。
2.2 “贴纸条"类比
- 你有一箱书(一个
std::string),上面贴着「张三的」 std::move(str)→ 在箱子上加一张纸条:「这箱书不要了,可以随便拿」- 谁看到了纸条,谁就可以把书搬走——搬走的是看到纸条的那个人,不是贴纸条的
- 搬完之后,原箱子里是空的(
str变成"")
std::string s = "hello";
std::string t = std::move(s); // t 的移动构造看到纸条,把数据从 s 搬走了
// s 现在是空的,t 里有 "hello"
第 2 行 std::move(s) 贴纸条。第 2 行 = 右边的 t 的移动构造函数看到了纸条,搬走了数据。
2.3 不提实现,但记住这点
移动构造函数在 std::string、std::unique_ptr、std::vector 等标准库里都已经帮你写好了。你只需要用 std::move 告诉编译器"可以搬”,剩下的库帮你做。初学阶段暂时不用自己写移动构造函数。
3. 什么对象可以被 move,move 之后怎样
3.1 move 有收益的对象(内部持有堆资源的类型)
| 类型 | move 的效果 |
|---|---|
std::unique_ptr<T> | 原指针变 nullptr,所有权转移 |
std::string | 原字符串变 "",内部动态分配的内存被转移 |
std::vector<T> | 原 vector 变空,数组被转移 |
std::function | 原 function 变 nullptr,内部捕获的对象被转移 |
3.2 move 没收益(也没害)的类型
int a = 42;
double b = 3.14;
int * p = &a;
auto a2 = std::move(a); // 等同于 auto a2 = a; 没有收益
auto b2 = std::move(b); // 同上
auto p2 = std::move(p); // 同上
int、double、裸指针本身就是栈上的小数据,“搬"和"拷贝"完全一样。编译器会退化为拷贝,不会出错,但写了也是白写。
3.3 move 之后的对象
move 之后,原对象变空壳。只能做两件事:
- 再赋值(给它新数据)
- 让它析构(没事,空壳析构没问题)
不要再读它的值。编译器不会报警告,拿到的是空/零/未指定。
4. llama.cpp 中的典型用法
项目中 std::move 出现约 500 次(排除 vendor 和 test)。以下是四种最有代表性的场景。
4.1 move 进容器(最高频)
// src/llama-adapter.cpp:390
adapter.bufs.emplace_back(std::move(buf));
buf 是 ggml_backend_buffer_ptr(即 unique_ptr),move 进 emplace_back 把所有权交给了容器。以后通过容器访问,buf 不再持有。
4.2 构造函数按值接参 → move 到成员(最经典范式)
// common/common.h:187
common_grammar(common_grammar_type t, std::string g)
: type(t), grammar(std::move(g)) {}
这是 C++ 的标准成语:
- 调用方传入
std::string时,如果也是std::move过来的,全程零拷贝 - 如果调用方传的是左值(还需要保留原字符串),参数处会有一次拷贝,但构造函数内部只是 move
类内统一用 std::move(arg) 收尾,交给编译器决定路径。
4.3 move std::function 到成员字段
// tools/server/server-queue.h:89-90
void on_new_task(std::function<void(server_task &&)> callback) {
callback_new_task = std::move(callback);
}
std::function 内部可能捕获大对象(lambda 捕获列表里的大字符串、大 vector)。拷贝 std::function 代价高昂,move 是最正确的传递方式。
4.4 移动语义价值的最佳展示
// tools/server/server-queue.cpp:152-157
server_task task = std::move(queue_tasks.front()); // 从队列头部 move 出来
queue_tasks.pop_front();
lock.unlock();
QUE_DBG("processing task, id = %d\n", task.id);
callback_new_task(std::move(task)); // 再 move 进回调
server_task 包含多个 std::string、std::vector<llama_token>、prompt 数据等。这段代码在「出队 → 解锁 → 回调」链上连续两次 move,没有发生任何深拷贝。
不需要理解锁的含义。抓住一点:一个大对象在多步传递中一直用 move,拷贝量为零。这是移动语义在真实项目中的威力。
5. 三个常见误解
误解一:"std::move 会把数据移走”
错。 std::move 只是贴纸条。真正搬走数据的是接收方的移动构造或移动赋值。
误解二:“move 之后原对象会被销毁”
错。 原对象只是变空了(""、nullptr、vector{}),它本身还活着。它的析构函数在离开自己作用域时正常调用。空壳析构没问题。
误解三:“对 int 用 move 有性能收益”
错。 int 是栈上数据,“搬"和"拷贝"完全一样。编译器退化为拷贝,写 std::move 只是浪费眼睛。
6. 初学者的三条规则
规则 1:看到 std::move(x) 就想"x 马上要失效了”
你心里要把 x 标为"已废,不要读"。之后就算读到了东西(编译器不拦你),也不应该依赖。
规则 2:std::move 之后别再用那个变量
除非你明确重新赋值了它:
auto a = std::make_unique<int>(42);
auto b = std::move(a);
// a 是 nullptr,不要读
a = std::make_unique<int>(100); // 重新赋值,可以用了
规则 3:大对象按值接受,用 std::move 收尾
// 好:大对象按值传入,move 到成员
struct Parser {
std::string source;
Parser(std::string s) : source(std::move(s)) {}
};
// 不必要:基本类型也用 move
struct Point {
float x, y;
Point(float x_, float y_) : x(std::move(x_)), y(std::move(y_)) {} // 写了也白写
};
float、int、bool、裸指针在这些初始化列表里有 std::move,效果等同于不加。写了不坏但没必要。
7. llama.cpp 中相关代码位置
| 场景 | 文件:行号 | 说明 |
|---|---|---|
move unique_ptr 进容器 | src/llama-adapter.cpp:390 | emplace_back(std::move(buf)) |
构造器 std::move 到成员 | common/common.h:187 | : grammar(std::move(g)) |
move std::function 到成员 | tools/server/server-queue.h:89-90 | 回调注册 |
| move 出队 + move 回调 | tools/server/server-queue.cpp:152-157 | 连续两次 move,零深拷贝 |
| AST 节点(范式级) | common/jinja/runtime.h:150-563 | AST 节点类大量使用 move 构造 |
move std::string 到容器 | common/hf-cache.cpp:454 | files.push_back(std::move(file)) |
8. 总结
| 你学到了 | 一句话 |
|---|---|
std::move 是什么 | 把对象标记为 “可以掏空” 的类型转换,本身不搬数据 |
| 什么对象能受益 | 持有堆资源的大对象(unique_ptr、string、vector、function) |
| 什么对象没意义 | 基本类型(int、float、裸指针),move 等同于拷贝 |
| move 之后的对象 | 变空壳,只能再赋值或析构,不要读 |
| 三条规则 | 看到 move 知变量失效;move 后别用;大对象按值接 + move 收尾 |