2456 字
12 分钟
C++ 移动语义

如果你曾经好奇过 std::move 到底做了什么,为什么明明叫 “move” 却感觉什么都没发生——这篇文章就是为你写的。移动语义是 C++11 引入的最重要的特性之一,理解它不仅能让你写出更高效的代码,在 AI Infra 和 CUDA 开发中更是绕不开的核心概念。

左值与右值:先搞清楚两个概念#

在进入移动语义之前,必须先搞清楚左值(lvalue)和右值(rvalue)的区别。

左值(lvalue):有名字、有持久地址,可以出现在赋值的左边。

int x = 10; // x 是左值,它有名字,有地址
int* p = &x; // 可以取地址
x = 20; // 可以放在赋值左边

右值(rvalue):临时的、没有名字的值。字面量、表达式结果、函数返回的临时对象都是右值。

int y = 10 + 20; // 10+20 是右值,是临时计算结果
// int* p = &(10 + 20); // 错误!不能对右值取地址

一个简单的判断方法:能不能取地址?能取地址就是左值,不能就是右值。

函数返回的临时值也是右值:

std::string getName() {
return "Alice"; // 返回的临时 string 是右值
}
std::string s = getName(); // getName() 是右值

右值引用(&&):绑定临时对象#

C++11 引入了右值引用,用 && 表示:

int&& r = 42; // OK:右值引用绑定到右值,延长了临时值的生命周期
// int& lref = 42; // 错误:左值引用不能绑定到右值
const int& clref = 42; // OK:const 左值引用是个例外,可以绑定右值

右值引用最重要的用途是函数重载——让编译器知道”这个参数是个临时对象,可以把它的资源偷走”:

void process(std::string& s) { std::cout << "左值版本\n"; }
void process(std::string&& s) { std::cout << "右值版本\n"; }
std::string name = "Alice";
process(name); // 调用左值版本
process("Bob"); // 调用右值版本(字符串字面量是右值)
process(std::move(name)); // 调用右值版本(std::move 把左值转成右值引用)

移动构造函数:把资源”偷走”#

明白了右值引用,就可以理解移动构造函数了。来看一个管理堆内存的 String 类,对比深拷贝和移动的行为:

#include <iostream>
#include <cstring>
class String {
public:
// 构造函数
String(const char* str) {
m_Size = strlen(str);
m_Data = new char[m_Size + 1];
memcpy(m_Data, str, m_Size + 1);
std::cout << "[构造] " << m_Data << "\n";
}
// 深拷贝构造函数:重新分配内存,完整复制数据
String(const String& other) {
m_Size = other.m_Size;
m_Data = new char[m_Size + 1]; // 新分配内存
memcpy(m_Data, other.m_Data, m_Size + 1);
std::cout << "[拷贝] " << m_Data << "\n";
}
// 移动构造函数:转移指针所有权,不分配新内存
String(String&& other) noexcept {
m_Size = other.m_Size;
m_Data = other.m_Data; // 直接偷走指针
other.m_Data = nullptr; // 把 other 置为空,避免双重释放
other.m_Size = 0;
std::cout << "[移动] \n";
}
// 析构函数
~String() {
delete[] m_Data;
}
void print() const {
if (m_Data) std::cout << m_Data;
else std::cout << "(空)";
}
private:
char* m_Data = nullptr;
size_t m_Size = 0;
};

现在来看实际行为:

int main() {
String a("Hello"); // [构造] Hello
String b = a; // [拷贝] Hello — 深拷贝,重新分配内存
String c = std::move(a); // [移动] — 转移指针,不分配内存
std::cout << "a: "; a.print(); std::cout << "\n"; // a: (空)
std::cout << "b: "; b.print(); std::cout << "\n"; // b: Hello
std::cout << "c: "; c.print(); std::cout << "\n"; // c: Hello
}

输出:

[构造] Hello
[拷贝] Hello
[移动]
a: (空)
b: Hello
c: Hello

移动之后 a 进入了”有效但未指定”的状态——析构函数仍然能安全运行(delete nullptr 是合法的),但你不应该再读取它的数据。

std::move 的本质:什么都不做,只是改变类型#

这是很多人容易误解的地方。std::move 不移动任何东西。 它的实现本质上就是一个 static_cast

// 标准库中 std::move 的简化实现
template<typename T>
typename std::remove_reference<T>::type&& move(T&& t) noexcept {
return static_cast<typename std::remove_reference<T>::type&&>(t);
}

用大白话说:std::move(x) 就是把 x 强制转换成右值引用类型,这样编译器在函数重载决议时就会去找接受右值引用的重载版本(也就是移动构造函数/移动赋值运算符)。

真正的移动操作发生在移动构造函数里,而不是 std::move 里。

// 这两行效果完全相同
String c = std::move(a);
String c = static_cast<String&&>(a);

所以”移动”这个名字有点误导——更准确的说法是”转换成右值引用,允许移动”。

移动赋值运算符#

除了移动构造,还需要定义移动赋值运算符:

String& operator=(String&& other) noexcept {
if (this == &other) return *this; // 自赋值检查
// 释放自己当前持有的资源
delete[] m_Data;
// 偷走 other 的资源
m_Data = other.m_Data;
m_Size = other.m_Size;
// 把 other 置为空
other.m_Data = nullptr;
other.m_Size = 0;
return *this;
}

使用:

String a("World");
String b("Hello");
b = std::move(a); // 移动赋值:b 先释放 "Hello",再接管 "World"
// a 变成空状态

完美转发:保持左右值性质#

写模板函数时有一个常见问题:参数传进来之后,它的左右值性质会”丢失”。

template<typename T>
void wrapper(T&& arg) {
// arg 在函数体内是左值(因为它有名字)
// 即使传进来的是右值,到这里也变成左值了
process(arg); // 总是调用左值版本,不对!
}

解决方案是 std::forward

template<typename T>
void wrapper(T&& arg) {
process(std::forward<T>(arg)); // 保持原来的左值/右值性质
}
std::string s = "hello";
wrapper(s); // T 推导为 std::string&,forward 保持左值
wrapper(std::move(s)); // T 推导为 std::string,forward 保持右值
wrapper("world"); // T 推导为 const char*,forward 保持右值

背后的原理是引用折叠规则:T& && 折叠成 T&T&& && 折叠成 T&&。这让模板参数 T&& 可以同时接受左值和右值,配合 std::forward 就能完美转发。

这在写容器的 emplace 系列函数、工厂函数时非常有用。

Return Value Optimization(RVO):编译器比你聪明#

一个常见的误解:为了”优化”函数返回值,手动加上 std::move

// 错误做法:这反而阻止了 RVO!
String makeString() {
String s("Hello");
return std::move(s); // 别这么干
}
// 正确做法:直接返回,让编译器做 RVO
String makeString() {
String s("Hello");
return s; // 编译器会直接在调用者的栈帧上构造
}

RVO(Return Value Optimization)允许编译器直接在调用者提供的内存位置上构造返回值,完全省去拷贝和移动。这是 C++ 标准明确允许的优化,现代编译器几乎必然会做。

加上 std::move 反而告诉编译器”我要移动”,破坏了 RVO 的条件,得到的是移动而不是原地构造——多了一次移动操作。

记住:return local_variable; 就好,不要 return std::move(local_variable);

AI Infra 场景:为什么这很重要#

在 AI 基础设施开发中,移动语义的重要性被放大了很多倍。

大型 tensor 的传递:

// 在 CPU 端准备好一个大型 tensor(比如 512MB 的激活值)
Tensor prepare_activations(int batch_size, int seq_len, int hidden_dim) {
Tensor t(batch_size, seq_len, hidden_dim);
// ... 填充数据 ...
return t; // RVO,直接在调用者内存构造,零拷贝
}
// 把 CPU buffer 移动给 CUDA 传输队列,避免额外拷贝
void enqueue_transfer(CudaTransferQueue& queue, Tensor cpu_tensor) {
queue.push(std::move(cpu_tensor)); // 转移所有权,不复制数据
}

pipeline 中的数据流:

class DataPipeline {
public:
void feed(Tensor&& t) {
// 接受右值引用,调用者明确放弃所有权
m_queue.push(std::move(t));
}
Tensor pop() {
Tensor t = std::move(m_queue.front());
m_queue.pop();
return t; // NRVO 可以省去这次移动
}
private:
std::queue<Tensor> m_queue;
};

在一个典型的 LLM 推理引擎里,KV Cache、激活值、权重矩阵这些对象动辄几 GB,如果每次函数调用都做深拷贝,性能会直接崩掉。移动语义让数据所有权的转移变得明确且高效。

Rule of Five:五个特殊成员函数#

C++11 之前有”Rule of Three”:如果你定义了析构函数,通常也需要定义拷贝构造和拷贝赋值。C++11 之后扩展到了”Rule of Five”:

如果你定义了析构函数,就要考虑显式定义以下五个特殊成员函数:

  1. 析构函数(Destructor)
  2. 拷贝构造函数(Copy Constructor)
  3. 拷贝赋值运算符(Copy Assignment Operator)
  4. 移动构造函数(Move Constructor)
  5. 移动赋值运算符(Move Assignment Operator)

原因是:如果你手写了析构函数,说明你管理了某种资源(堆内存、文件句柄、GPU 显存……)。这种情况下编译器自动生成的拷贝和移动操作往往是错误的,必须手动实现。

如果你确认不需要某个操作,用 = delete 明确禁止:

class NonCopyable {
public:
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
NonCopyable(NonCopyable&&) noexcept = default;
NonCopyable& operator=(NonCopyable&&) noexcept = default;
};

另一个选项是”Rule of Zero”:通过组合 RAII 类型(unique_ptrvector 等)来管理资源,让编译器生成的默认版本就够用,完全不用手写这五个函数。

总结#

概念关键点
左值有名字、有地址、可取地址
右值临时值、字面量、表达式结果,不能取地址
右值引用 &&可以绑定到右值,延长其生命周期
移动构造函数接受右值引用,转移资源所有权而不分配新内存
std::move本质是 static_cast<T&&>,只改变类型,不做任何移动
std::forward完美转发,保持参数的左/右值性质
RVO编译器直接在调用者内存构造返回值,比移动更好
Rule of Five有析构函数就要考虑五个特殊成员函数

移动语义的核心思想很简单:用所有权转移代替数据复制。一旦理解了这个思想,std::move、右值引用、移动构造函数这些概念就都水到渠成了。

下一篇我们聊聊智能指针——unique_ptrshared_ptr 的内部机制,以及它们如何与移动语义配合工作。

博客桌宠