移动语义与完美转发
环境:GCC 13.1.0,
g++ -std=c++17 -O2 -Wall。下面每个计数都是给类型加上带计数器的拷贝/移动构造后实测出来的。
一、值类别:先学会问”它是谁”
值类别不是类型的属性,而是表达式的属性。判定只看两点:能不能取地址、能不能被延续使用。
int x = 1;
std::is_lvalue_reference_v<decltype(x)> // 0
std::is_lvalue_reference_v<decltype((x))> // 1
std::is_lvalue_reference_v<decltype(std::move(x))> // 0
is_lvalue_ref<decltype(x)>=0 is_lvalue_ref<decltype((x))>=1 is_lvalue_ref<decltype(std::move(x))>=0
is_rvalue_ref<int&&>=1
三条要背下来的规则:
- 具名变量永远是左值,哪怕它的类型是
int&&(void f(int &&rr)里的rr是左值!这就是转发存在的理由)。 - 加一层括号就变左值:
decltype(x)是int,但decltype((x))是int&。宏和模板里踩这个坑非常多。 std::move(x)的结果是右值引用(xvalue),不是”移动后的 x”。
二、std::move 一个字节都不搬
它的定义本质上就是:
template <class T> constexpr typename std::remove_reference<T>::type &&move(T &&v) noexcept {
return static_cast<typename std::remove_reference<T>::type &&>(v);
}
只是一个 static_cast。 真正搬东西的是移动构造函数,而移动构造只有在重载决议选中它时才会被调用。对没有移动构造的类型:
NoMove a; // 只有拷贝构造
NoMove b = std::move(a); // 看起来是移动
对没有移动构造的类型 std::move:拷贝 1 次
照样拷贝。std::move 只是把实参标成右值去”尝试”匹配移动重载,匹配不上就退回拷贝。所以:
- 看到
std::move不等于没有拷贝,要用-ftime-report、计数器或static_assert验证。 - 给自己管理的类必须真的写移动构造,否则所有
std::move都是空操作(而且写了析构函数还会隐式抑制移动生成——上一节的五法则)。
三、被移动后的对象:有效但未定
std::string s(200, 'x');
std::string t = std::move(s);
s += "abc"; // 合法
std::vector<int> v1(100), v2 = std::move(v1);
v1.push_back(7); // 合法
源 string size=3(+= 之后)
源 vector size=1,v2 size=100
标准的要求是:移动之后源对象必须处于”有效但未指定”的状态——可以析构、可以重新赋值、可以 push_back,但它的值不能假设。libstdc++ 的实现是把源置空(size=0),这是实现细节,不是承诺。
所以:
- 不要读 moved-from 对象的值(
s.size()、s[0]都是自找 bug)。 - 允许对它做的:析构、赋值、调用”对任意状态都成立”的操作(
clear()、push_back())。 - 复用 moved-from 对象前先重新赋值,别指望它是”空串”。
(顺带一个彩蛋:上面那行 printf 我写成 printf("%zu ... %zu", s.size(), (s += "abc", s.size())),本机两列都打印 3——因为函数实参求值顺序未规定,GCC 从右往左算,先执行了 +=。这正是 C 专栏第一节那条规则的现场复现。)
四、转发引用与 std::forward:值类别的保真传输
void sink(const Counted &) { /* 左值路径 */ }
void sink(Counted &&) { /* 右值路径 */ }
template <class T> void forwarder(T &&v) { sink(std::forward<T>(v)); } // ✅
template <class T> void bad_fwd(T v) { sink(v); } // ❌ 忘了转发
各调 1000 次:
forwarder(左值) :绑定 const& 1000 次,绑定 && 0 次
forwarder(右值) :绑定 const& 0 次,绑定 && 1000 次
bad_fwd(右值) :绑定 const& 1000 次,绑定 && 0 次 ← 值类别丢失
bad_fwd(T v) 的问题:参数 v 是具名变量,所以永远是左值,即使调用方传的是右值——多出来的那次绑定就是”本该移动却变成按 const& 处理”。
std::forward<T>(v) 做的事:T 推导为 Counted& 时产生左值,T 推导为 Counted 时产生右值。“完美”指的是值类别保真。
什么时候按值 + std::move 反而更好
template <class T> void byval(T v) { sink(std::move(v)); }
byval(左值):绑定 && 1000 次(外加按值传参自身的一次构造)
按值传参会无条件多一次构造,所以默认还是用 T&& + forward。只有”参数一定要存下来一份”(setter、push_back 包装)时,按值 + move 才更简单。
转发引用的四个失效场景
- 花括号初始化列表推不出
T:f({1,2,3})直接编译失败。 nullptr/ 裸数组字面量:T推导结果可能不是你要的类型。- 位域不能绑到
T&&(取不了引用)。 - 只有一处使用却写成模板:白白把实现暴露到头文件里。能用
const T&/T*解决的别上转发引用。
五、返回值:std::move(local) 是负优化
NRVO make_good() { NRVO local; return local; }
NRVO make_bad() { NRVO local; return std::move(local); }
return local; 拷贝 0 移动 0
return std::move(x); 拷贝 0 移动 1 ← 挡住了复制消除
直接 return local 时,编译器做 NRVO(具名返回值优化),对象原地构造,一次构造都没有。而 std::move(local) 强制表达式变右值,反而禁止了复制消除,退化成一次移动构造。
编译器还会直接骂你:
warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
规则:
- 返回局部对象:直接
return x;,永远不要std::move。 - 返回成员、函数内 static、基类子对象时不能 NRVO,这时才写
return std::move(member);。 - 返回按值类型的形参:
return std::move(param);是对的(形参不是调用方的对象)。 - C++17 的保证复制消除只针对 prvalue(
return T{}),不针对具名变量,别混淆。
把 -Wpessimizing-move 和 -Wuse-after-move 加进构建,这两类错误基本能一网打尽。
六、移动该不该做:判据
移动只在拷贝确实贵时才有意义。实测下来:
| 类型 | 拷贝成本 | 移动收益 |
|---|---|---|
int、double、指针、小 struct | 一条 mov | 没有,反而多一次”置空源”的操作 |
std::string(≤15 字符,SSO) | 一次栈内 memcpy | 很小,且移动要处理”缓冲在对象内”的复杂情况 |
std::string(长) / vector / unique_ptr | 堆分配 + 全量复制 | 巨大,就是指针交换 |
| 大数组成员的结构体 | memcpy 几 KB | 巨大 |
所以:
- 小对象按值传,别写
std::move(void f(Point p)比void f(Point &&p)更好)。 - 容器元素是小的平凡类型时,
std::vector的扩容搬移本来就用memmove,不需要你操心。 - 判断”值不值得移动”的机器化写法:
if constexpr (std::is_trivially_copyable_v<T>)走按值,否则走移动。
七、这一节的自查清单
- 记住”具名即左值”,转发前问自己
T推导成了什么 - 自定义类要真的写移动构造/赋值,并标
noexcept - 不读 moved-from 对象的值,用前先重新赋值
- 转发一律
std::forward<T>(x),不用std::move(x) - 返回局部对象写
return x;,不写std::move - 开
-Wpessimizing-move与-Wuse-after-move - 小平凡类型按值传,别为了”看起来现代”加移动
- 用计数器或
static_assert验证移动真的发生了 - 模板实现放手文件时,考虑用
if constexpr替代部分 SFINAE
下一节讲 RAII 与资源管理三法则:为什么”构造函数成功后资源就一定被释放”是 C++ 唯一可靠的所有权模型,以及 std::scoped_lock、gsl::finally、退出回调这些把 RAII 用到极致的写法。