IV · C++ 与 Linux

左值、右值和移动语义:std::move、完美转发

拷贝一个持有堆内存的对象要把整块内存复制一遍,可很多源对象马上就要销毁,复制完就扔。移动语义让编译器分清“之后还要用的对象”和“马上没人要的对象”,对后者直接把资源交过去。分辨的依据是值类别:左值、将亡值、纯右值。再讲三种引用各绑定什么、移动构造怎么写、std::move 为什么什么都不移动、vector 扩容为什么只认 noexcept 的移动、返回局部变量时发生了什么,以及模板里的 T&&、引用折叠和 std::forward。

本页目录

拷贝一个 std::vector<int> 或 std::string,要新开一块堆内存,再把元素一个个复制过去。如果源对象之后还要用,这一步省不掉;可很多时候源对象马上就要销毁:函数返回的临时对象、表达式算出来的中间结果,复制完就被扔掉。这时与其复制,不如把它那块内存直接交给新对象。移动语义要解决的就是让编译器分清两种对象:之后还会被用到的,和马上没人要的,后一种可以放心地把资源拿走。分辨的依据,是每个表达式的值类别。

1. 值类别:每个表达式都属于一类

1.1 两个问题:有没有身份,能不能被移动

值类别描述的是表达式,不是变量,也不是类型。判断它只要问两个问题:这个表达式是否指着一个能说出是“哪一个”的对象(有身份);它的资源能不能被拿走(能被移动)。

glvalue(泛左值):有身份,能说出是哪一个对象lvalue 左值有名字的变量 x、s返回引用的调用 ref(s)字符串字面量 "abc"xvalue 将亡值std::move(s)返回 T&& 的调用指着一个对象,但可以搬空它prvalue 纯右值字面量 42、x + 1按值返回的调用 make()std::string("a")rvalue(右值):能被移动,资源可以拿走有身份、不能移动 = 左值;两样都有 = 将亡值;没有身份、能移动 = 纯右值。纯右值用来初始化一个对象,需要时编译器才把它落成一个临时对象。

用 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_;
};
拷贝:Buf c = b;b:p,n = 1024c:p,n = 1024堆上 1 KiB新开 1 KiB,逐字节复制过来两块内存,复制了 1 KiB移动:Buf b = std::move(a);a:nullptr,n = 0b:p,n = 1024原来那 1 KiB断开一块内存,只拷了指针和大小,共 16 字节

移动构造做了两件事:把 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 的对象从声明起就存在,编译器能不能提前决定它的位置要看函数怎么写,所以标准只允许、不保证。两者的差别:

RVONRVO
返回的是什么纯右值,比如 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::stringstd::string&&将亡值,能被移动

std::move 和它的区别:std::move 无条件转成右值,std::forward 按调用者传进来的类别原样还原。

本页代码都在 GCC 16.2 -std=c++20 -O2 -Wall -Wextra 下编译运行过,输出是实际结果。