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 “贴纸条"类比

  1. 你有一箱书(一个 std::string),上面贴着「张三的」
  2. std::move(str) → 在箱子上加一张纸条:「这箱书不要了,可以随便拿」
  3. 谁看到了纸条,谁就可以把书搬走——搬走的是看到纸条的那个人,不是贴纸条的
  4. 搬完之后,原箱子里是空的(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::stringstd::unique_ptrstd::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);   // 同上

intdouble、裸指针本身就是栈上的小数据,“搬"和"拷贝"完全一样。编译器会退化为拷贝,不会出错,但写了也是白写。

3.3 move 之后的对象

move 之后,原对象变空壳只能做两件事

  1. 再赋值(给它新数据)
  2. 让它析构(没事,空壳析构没问题)

不要再读它的值。编译器不会报警告,拿到的是空/零/未指定。


4. llama.cpp 中的典型用法

项目中 std::move 出现约 500 次(排除 vendor 和 test)。以下是四种最有代表性的场景。

4.1 move 进容器(最高频)

// src/llama-adapter.cpp:390
adapter.bufs.emplace_back(std::move(buf));

bufggml_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::stringstd::vector<llama_token>、prompt 数据等。这段代码在「出队 → 解锁 → 回调」链上连续两次 move,没有发生任何深拷贝。

不需要理解锁的含义。抓住一点:一个大对象在多步传递中一直用 move,拷贝量为零。这是移动语义在真实项目中的威力。


5. 三个常见误解

误解一:"std::move 会把数据移走”

错。 std::move 只是贴纸条。真正搬走数据的是接收方的移动构造或移动赋值。

误解二:“move 之后原对象会被销毁”

错。 原对象只是变空了(""nullptrvector{}),它本身还活着。它的析构函数在离开自己作用域时正常调用。空壳析构没问题。

误解三:“对 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_)) {}  // 写了也白写
};

floatintbool、裸指针在这些初始化列表里有 std::move,效果等同于不加。写了不坏但没必要。


7. llama.cpp 中相关代码位置

场景文件:行号说明
move unique_ptr 进容器src/llama-adapter.cpp:390emplace_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-563AST 节点类大量使用 move 构造
move std::string 到容器common/hf-cache.cpp:454files.push_back(std::move(file))

8. 总结

你学到了一句话
std::move 是什么把对象标记为 “可以掏空” 的类型转换,本身不搬数据
什么对象能受益持有堆资源的大对象(unique_ptrstringvectorfunction
什么对象没意义基本类型(intfloat、裸指针),move 等同于拷贝
move 之后的对象变空壳,只能再赋值或析构,不要读
三条规则看到 move 知变量失效;move 后别用;大对象按值接 + move 收尾