拷贝一个 std::vector<int> 或 std::string,要新开一块堆内存,再把元素一个个复制过去。如果源对象之后还要用,这一步省不掉;可很多时候源对象马上就要销毁:函数返回的临时对象、表达式算出来的中间结果,复制完就被扔掉。这时与其复制,不如把它那块内存直接交给新对象。移动语义要解决的就是让编译器分清两种对象:之后还会被用到的,和马上没人要的,后一种可以放心地把资源拿走。分辨的依据,是每个表达式的值类别。
1. 值类别:每个表达式都属于一类
1.1 两个问题:有没有身份,能不能被移动
值类别描述的是表达式,不是变量,也不是类型。判断它只要问两个问题:这个表达式是否指着一个能说出是“哪一个”的对象(有身份);它的资源能不能被拿走(能被移动)。
用 decltype((表达式)) 可以让编译器自己说出类别:结果是 T& 就是左值,T&& 是将亡值,不带引用的 T 是纯右值。下面是 GCC 16.2 的实际输出:
template <class T> struct Cat { static constexpr const char* v = "prvalue"; };
template <class T> struct Cat<T&> { static constexpr const char* v = "lvalue"; };
template <class T> struct Cat<T&&> { static constexpr const char* v = "xvalue"; };
#define CAT(e) std::printf("%-22s %s\n", #e, Cat<decltype((e))>::v)
std::string make() { return "hi"; }
std::string& ref(std::string& s) { return s; }
int x = 1; std::string s; int&& r = 2;
CAT(x); CAT(42); CAT(x + 1); CAT(s); CAT(make()); CAT(ref(s));
CAT(std::move(s)); CAT(r); CAT("abc"); CAT(std::string("a"));
x lvalue
42 prvalue
x + 1 prvalue
s lvalue
make() prvalue
ref(s) lvalue
std::move(s) xvalue
r lvalue
"abc" lvalue
std::string("a") prvalue
“能放在等号左边的是左值”这个说法只是近似:const int c = 1; 里的 c 是左值,却不能赋值;std::string() = "x" 左边是纯右值,却能编译。“右值没有地址”也不对:std::string("a") 落成临时对象以后就在栈上有地址,只是语言不让你对它写 &。真正的分界是身份和能否移动,字符串字面量 "abc" 是左值,因为它是一个 const char[4] 数组,编译器把它放在 .rodata 段里,从程序启动到退出都占着一个固定的地址(进程地图),&"abc" 能取到这个地址。
1.2 有名字的右值引用是左值
上面输出里最反直觉的一行是 r:它的类型是 int&&,表达式 r 却是左值。原因是它有名字。有名字就意味着后面的代码还能再用它,如果编译器在第一次用到 r 时就把它的资源搬走,第二次用到的就是一个空壳。所以规则是:变量的类型可以是右值引用,但只要通过名字去用它,它就是左值;想再把它当右值用,得自己写 std::move(r),明确表示“后面不再用它了”。这一条是第 5 节完美转发要解决的问题的根源。
2. 三种引用:各自能绑定什么
2.1 左值引用、const 左值引用、右值引用
| 参数类型 | 能绑定左值 | 能绑定右值 | 用来做什么 |
|---|---|---|---|
T& | 能 | 不能 | 要改调用者的对象 |
const T& | 能 | 能,临时对象的寿命延长到引用结束 | 只读,什么都收 |
T&& | 不能 | 能 | 收“马上没人要”的对象,好拿走它的资源 |
const T& 什么都能接,所以 C++11 以前一个拷贝构造函数 T(const T&) 就能处理所有情况,代价是对临时对象也只能复制。右值引用 T&& 只接右值,它和 const T& 一起出现时,重载决议会让右值优先选 T&&、左值只能选 const T&。拷贝构造和移动构造就是靠这一组重载分开的:传进来的是左值,调 T(const T&) 复制;是右值,调 T(T&&) 搬走。
3. 移动构造和移动赋值
3.1 移动:拿走指针,把源对象留成空的
一个持有堆内存的类,拷贝和移动的差别就在构造函数里:
class Buf {
public:
explicit Buf(std::size_t n) : p_(new char[n]), n_(n) {}
~Buf() { delete[] p_; }
Buf(const Buf& o) : p_(new char[o.n_]), n_(o.n_) { // 拷贝:新开一块,逐字节复制
std::memcpy(p_, o.p_, n_);
}
Buf(Buf&& o) noexcept : p_(o.p_), n_(o.n_) { // 移动:拿走指针
o.p_ = nullptr; o.n_ = 0; // 源对象留成空的,析构时 delete[] nullptr 什么也不做
}
Buf& operator=(Buf o) noexcept { // 按值传参:拷贝还是移动由调用方决定
std::swap(p_, o.p_); std::swap(n_, o.n_);
return *this; // 旧资源跟着参数 o 一起析构
}
private:
char* p_;
std::size_t n_;
};
移动构造做了两件事:把 o 的指针和大小拿过来;把 o 的指针置空。第二步不能省,否则两个对象指着同一块内存,析构时会 delete[] 两次。移动的开销和对象持有多少数据无关,Buf 管着 1 KiB 还是 1 GiB,移动都只拷 16 字节。反过来,没有堆资源的类型(int、只含几个 int 的结构体)移动和拷贝是一回事,移动语义对它们没有意义。
3.2 std::move:只是一次类型转换,什么都不移动
std::move(x) 的实现就是 static_cast<T&&>(x):把左值 x 转成将亡值,告诉重载决议“这个对象可以被搬空”。它本身不调用任何构造函数,单独写一句什么也不会发生:
Buf a(1024);
std::move(a); // 结果被丢掉:GCC 警告 ignoring return value,a 原封不动
Buf b = std::move(a); // 这一句才调用移动构造
Buf c = b; // 拷贝
a = std::move(c); // 参数 o 由移动构造得到,再和 a 交换
运行输出(构造函数里加了打印):
a.size()=1024
move
a.size()=0 b.size()=1024
copy
move
a.size()=1024 c.size()=0
第一行说明单独的 std::move(a) 没碰 a;真正的移动发生在 Buf b = ... 这次初始化里,因为右边是将亡值,重载决议挑中了 Buf(Buf&&)。std::move 只负责改类别,挑中移动构造的是重载决议。
由此还有一个容易漏掉的情况:对 const 对象调 std::move,得到的是 const T&&。它绑不到 T&&(会丢掉 const),却能绑到 const T&,于是悄悄调了拷贝构造,不报错也不警告。const 对象没法被搬空,移动对它不起作用。
3.3 被移走的对象:只能析构或重新赋值
Buf 的移动构造明确把源对象设成了“空”,所以 a.size() 是 0。标准库类型的约定更宽松:被移走的对象处于“有效但未指定”的状态,也就是它的不变量还成立、可以析构、可以重新赋值,但它现在装着什么不作保证。libstdc++ 里被移走的 std::string 实际上是空串,可代码不能依赖这一点。对被移走的对象,安全的操作只有两种:析构它,或者给它赋一个新值。
3.4 noexcept:vector 扩容只认不会抛异常的移动
std::vector 满了要扩容:分配一块更大的内存,把旧元素搬过去,释放旧内存。push_back 承诺强异常保证:如果中途抛了异常,vector 要恢复原样,像什么都没发生过。用拷贝来搬,这一点很容易做到:拷到第 5 个元素时抛了异常,旧内存里的元素都还完好,扔掉新内存就行。用移动来搬就不行了:前 4 个元素已经被搬空,第 5 个移动时抛异常,要把前 4 个搬回去,而往回搬的移动也可能抛异常,恢复不了。
所以 vector 扩容用的是 std::move_if_noexcept:元素的移动构造标了 noexcept(或者这个类型根本不能拷贝),才用移动;否则退回拷贝。同一个类型,只差一个 noexcept,往 vector 里放 1000 个元素:
int copies = 0, moves = 0;
template <bool NoExcept>
struct Item {
Item() = default;
Item(const Item&) { ++copies; }
Item(Item&&) noexcept(NoExcept) { ++moves; }
};
// 各自 emplace_back 1000 次
move is noexcept: copies=0 moves=1023 capacity=1024
move may throw: copies=1023 moves=0 capacity=1024
容量从 1 一路翻倍到 1024,扩容时一共搬了 1 + 2 + 4 + … + 512 = 1023 次元素。没标 noexcept 的移动构造,在 vector 扩容时一次都不会被用到,全部变成拷贝。所以自己写移动构造和移动赋值时要标 noexcept;= default 的移动构造,只要每个成员的移动都不抛异常,编译器会自动把它算成 noexcept。
4. 不写 std::move 也会移动的地方
4.1 返回局部变量:先尝试 NRVO,不行就隐式移动
N plain() { N n; return n; } // NRVO
N moved() { N n; return std::move(n); } // 多此一举
N two(bool f) { N a, b; if (f) return a; return b; } // 两个候选
-O2 和 -O0 输出一样:
plain:
ctor
moved:
ctor
move
two:
ctor
ctor
move
这三个函数的区别,要从返回值放在哪里说起。按值返回一个对象时,调用者先在自己的栈帧里留好一块放返回值的位置,再把这块位置的地址作为隐藏参数传给被调函数(x86-64 上放在 rdi,见函数调用栈帧)。被调函数如果能把要返回的对象直接构造在这块位置上,返回时就什么都不用拷贝或移动。三个函数的输出不同,就在于能不能做到这一点:
plain只构造了一次,连移动都省了:n是唯一会被返回的变量,编译器从声明那一行起就把它构造在返回位置上,到return n时它已经在那里了。这叫具名返回值优化(NRVO)。moved多了一次移动:return std::move(n)返回的是一个将亡值表达式,不再是“一个局部变量的名字”,不满足 NRVO 的条件。n只能先构造在函数自己的栈上,返回时再移动到返回位置,GCC 为此专门报-Wpessimizing-move。two也多了一次移动,但至少不是拷贝:a和b都可能被返回,返回位置却只有一个,编译器在声明它们时不知道该把谁放进去,NRVO 做不了。C++11 起,return 局部变量会先把它当右值试一次,所以退回来的是移动。
返回局部变量时不要写 std::move:能省的时候它挡住 NRVO,省不了的时候编译器本来就会移动。
返回纯右值(return N();)是另一种情况,叫返回值优化(RVO)。这个对象到 return 那一刻才创建,那时编译器已经知道它就是返回值,所以 C++17 起标准保证它直接构造在返回位置上。NRVO 的对象从声明起就存在,编译器能不能提前决定它的位置要看函数怎么写,所以标准只允许、不保证。两者的差别:
| RVO | NRVO | |
|---|---|---|
| 返回的是什么 | 纯右值,比如 return N(); | 有名字的局部变量,比如 N n; …; return n; |
| 标准保证吗 | C++17 起保证 | 不保证,编译器可以做也可以不做 |
| 需要移动构造吗 | 不需要,移动构造被删掉也能编译 | 需要:就算最后省掉了,移动或拷贝构造也必须可用 |
| 什么时候做不了 | 没有做不了的情况 | 多个候选(two)、返回函数参数、返回 std::move(n) |
“需要移动构造吗”这一行,GCC 16.2 实测:
struct M { M() {} M(M&&) = delete; };
M f() { return M(); } // 能编译:RVO 不需要移动构造
M g() { M m; return m; } // error: use of deleted function 'M::M(M&&)'
4.2 临时对象和“按值接收再移动”
传给函数的临时对象本来就是纯右值,v.push_back(std::string("x")) 会自动选中 push_back(T&&),不用写 std::move。只有当源对象是有名字的变量、而你确定之后不再用它时,才需要 std::move 把它转成右值。
一个常见写法是让函数按值接收参数,再把参数移动到成员里:
class User {
std::string name_;
public:
void set_name(std::string s) { name_ = std::move(s); }
};
调用者传左值,参数 s 由拷贝得到,再移动进 name_:一次拷贝加一次移动;传右值,s 由移动得到,再移动进去:两次移动,没有拷贝。一个函数同时照顾了两种调用者,代价是比分别写 const std::string& 和 std::string&& 两个重载多一次移动,对 std::string 这类移动很便宜的类型通常值得。Buf::operator=(Buf o) 用的也是这个思路。
5. 模板里的 T&&:完美转发怎么用
一个函数自己不用这些参数,只是把它们原样交给另一个函数或构造函数时,就用完美转发。 “原样”的意思是:调用者传进来的是左值,交出去还是左值,会被拷贝;传进来的是临时对象,交出去还是右值,能被移动。写法是固定的一套,套上去就行。
5.1 套用的写法
// 一个参数
template <class T>
void f(T&& t) { g(std::forward<T>(t)); }
// 任意个参数
template <class... Args>
void f(Args&&... args) { g(std::forward<Args>(args)...); }
// 泛型 lambda:参数写 auto&&,forward 的尖括号里写 decltype(参数名)
auto h = [](auto&&... a) { g(std::forward<decltype(a)>(a)...); };
三处要对上:参数写成 T&&,T 让编译器推导;交出去时写 std::forward<T>(t),尖括号里就是参数声明里的那个 T;参数包在 std::forward<Args>(args) 后面加 ...,每个参数各自转发。
5.2 例子:替别人构造对象
std::make_unique、std::make_shared、vector::emplace_back 都是这一类:它们收一串参数,原样交给 T 的构造函数。一个简化版的 make_unique:
template <class T, class... Args>
std::unique_ptr<T> my_make_unique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
auto p = my_make_unique<std::string>(3, 'z'); // *p == "zzz"
自己写容器、对象池时也一样。下面是一个定长缓冲区,emplace_back 用 placement new 在自己的内存上直接构造元素(省略了越界检查)。N 是一个在构造、拷贝、移动时各打印一行的类:
struct N {
N(int) { std::puts(" ctor"); }
N(const N&) { std::puts(" copy"); }
N(N&&) noexcept { std::puts(" move"); }
};
template <class T, std::size_t Cap>
class FixedBuf {
alignas(T) std::byte mem_[Cap * sizeof(T)];
std::size_t n_ = 0;
public:
template <class... Args>
T& emplace_back(Args&&... args) {
T* p = ::new (mem_ + n_ * sizeof(T)) T(std::forward<Args>(args)...);
++n_;
return *p;
}
void push_back(T x) { emplace_back(std::move(x)); }
~FixedBuf() {
for (std::size_t i = 0; i < n_; ++i)
std::launder(reinterpret_cast<T*>(mem_ + i * sizeof(T)))->~T();
}
};
FixedBuf<N, 4> buf;
buf.emplace_back(1);
buf.push_back(N(1));
emplace_back(1):
ctor
push_back(N(1)):
ctor
move
push_back 收到的是一个已经构造好的 N,还要再移动进缓冲区;emplace_back 收到的是构造参数,直接在缓冲区里构造。emplace_back 比 push_back 好就好在这里:连那一次移动都省了。
5.3 例子:包一层再调用
给任意函数加一层日志、计时或加锁,也要把参数原样交下去。要调的函数 f 本身也用万能引用接,这样传一个带状态的 lambda 进来时不会多拷一份;std::invoke 让普通函数、lambda、成员函数指针都能这样调:
template <class F, class... Args>
decltype(auto) logged(const char* name, F&& f, Args&&... args) {
std::printf("call %s\n", name);
return std::invoke(std::forward<F>(f), std::forward<Args>(args)...);
}
void keep(N) {}
N n(1);
logged("keep", keep, n);
logged("keep", keep, N(2));
logged(keep, n):
call keep
copy
logged(keep, N(2)):
ctor
call keep
move
n 是左值,到 keep 那里是拷贝;N(2) 是临时对象,到 keep 那里是移动。和不包这一层、直接调 keep 的结果完全一样,这就是“完美”的意思。
5.4 套的时候会错的地方
- 忘了写
std::forward,右值也会变成拷贝:参数t有名字,在函数体里是左值(1.2)。直接写g(t),调用者传进来的临时对象也会被当成左值拷贝一份。 - 只有
T要推导、写法又恰好是T&&时,才是万能引用:void f(std::vector<T>&& v)、void f(const T&& t),以及类模板里成员函数的T&&(T在类实例化时就定了),都是普通的右值引用,传左值进去编译不过。 - 同一个参数只
forward一次:转发出去的如果是右值,接收方可能已经把它的资源拿走了,再用一次就是在用被移走的对象(3.3)。 - 构造函数写成万能引用,会抢走拷贝构造:
struct Person {
std::string name;
template <class S> Person(S&& s) : name(std::forward<S>(s)) {}
};
Person a("x");
Person b(a); // error: no matching function for call to 'std::string::basic_string(Person&)'
a 是非 const 的左值,模板推导出 S = Person&,是精确匹配;拷贝构造要的是 const Person&,还得加一层 const。重载决议于是选了模板,拿一个 Person 去构造 std::string,编译报错。C++20 可以加约束把这种情况挡掉:template <class S> requires std::constructible_from<std::string, S>,这样 Person b(a); 就会回到拷贝构造。只有一个参数、目的就是把它存下来时,更简单的是 4.2 的按值接收再移动:不用写模板,也没有这个坑。
5.5 原理:forward 为什么能还原类别
std::forward<T>(t) 做的就是 static_cast<T&&>(t),而 T 里记着调用者传进来的是左值还是右值。传左值时,T 推导成 U&,T&& 就是 U& &&。C++ 的引用折叠规则是:两个都是 && 才得到 &&,只要有一个 & 就是 &,所以它折叠成 U&。传右值时,T 推导成 U,T&& 就是 U&&。以 f(T&& t) 收一个 std::string 为例:
| 调用 | T 推导成 | T&& 折叠成 | std::forward<T>(t) 是 |
|---|---|---|---|
f(s) | std::string& | std::string& | 左值,会被拷贝 |
f(std::string("x")) | std::string | std::string&& | 将亡值,能被移动 |
std::move 和它的区别:std::move 无条件转成右值,std::forward 按调用者传进来的类别原样还原。
本页代码都在 GCC 16.2 -std=c++20 -O2 -Wall -Wextra 下编译运行过,输出是实际结果。