std::move以及std::forward的实现
一、std::move
实现
constexpr std::remove_reference_t<T>&&
一行 static_cast,仅此而已。它不移动任何东西,只负责把表达式的值类别改成右值,真正干活的是随后被重载决议选中的移动构造函数。
三个设计点
① 参数为什么是 T&&(转发引用)
因为 std::move 必须能接收左值和右值两种实参。传左值时 T 推成 U&,传右值时 T 推成 U。
② 返回类型为什么要 remove_reference_t
这是整个实现的关键。假设去掉它,写成 T&& move(T&& t):
std::string s;
; // T 推导为 string&
// 返回类型 T&& = string& && —— 引用折叠 —— = string&
// 返回左值引用 → 表达式是【左值】→ std::move 完全失效!
加上 remove_reference_t<T> 就先把引用剥掉:
| 实参 | T 推导为 |
remove_reference_t<T> |
返回类型 |
|---|---|---|---|
左值 s |
string& |
string |
string&& ✓ |
右值 string{} |
string |
string |
string&& ✓ |
两种情况都返回 string&&。而返回右值引用的函数调用是 xvalue,属于右值 —— 目的达成。
③ noexcept
std::move 本身绝不抛异常,标注它才不会污染调用者的 noexcept 推导(尤其是移动构造函数的 noexcept 判断)。
一个经典坑:const 对象 move 不动
const std::string s = "hi";
std::string t = ; // 编译通过,但执行的是【拷贝】
T 推成 const string&,remove_reference_t 只去引用不去 const,返回 const string&&。而 string(string&&) 无法绑定 const string&&,重载决议只好退回 string(const string&)。
静默降级、没有任何警告。所以:需要被移动的对象不要声明成 const,尤其是类成员和返回值。
二、std::forward
实现(两个重载)
// ① 接收左值
constexpr T&&
// ② 接收右值
constexpr T&&
三个设计点
① 为什么必须显式写 std::forward<T>(x)
参数类型是 remove_reference_t<T>&,T 出现在 remove_reference_t<T> 的 ::type 后面 —— 这是非推导语境(non-deduced context)。编译器无法从实参反推 T,你必须手动提供。
这不是设计缺陷,而是刻意为之:x 作为具名变量永远是左值,从它身上推不出原始调用者传的是左值还是右值。那个信息只保存在外层模板的 T 里,所以必须显式把 T 传进来。
② 核心机制:引用折叠
void
| 外层调用 | T 推导为 |
static_cast<T&&> 展开 |
折叠后 | 结果值类别 |
|---|---|---|---|---|
wrapper(lval) |
U& |
static_cast<U& &&> |
U& |
左值 |
wrapper(U{}) |
U |
static_cast<U&&> |
U&& |
右值(xvalue) |
引用折叠规则一句话:只要有一个 &,结果就是 &(& &、& &&、&& & → &;只有 && && → &&)。
std::forward 就是把这条规则当开关用:T 里是否带 &,决定了 cast 的目标是左值引用还是右值引用。
③ 第二个重载是干什么的
用来转发本身就是右值的表达式,而不是具名变量:
std::forward<T>; // 实参是右值,走重载②
里面的 static_assert 拦截 std::forward<U&>(某个右值) 这种写法 —— 那等于把一个临时对象当左值转发出去,调用结束后必然悬垂。
与 std::move 的分工
std::forward<T> // 有条件:T 带 & 就还原成左值,否则转右值
// 只用在转发引用(模板 T&& / auto&&)的转发位置
在转发引用上用 std::move 是 bug:
void
调用者写 wrapper(myVec) 后会发现 myVec 莫名其妙空了。
三、完整示例
std::unique_ptr<Widget>
std::string name = "abc";
;
// Args 推导为 {std::string&, std::string, int}
// forward 后分别以 左值 / 右值 / 右值 传给 Widget 构造函数
// → name 被拷贝(正确,调用者还要用),临时 string 被移动,42 直接传
这就是完美转发:转发过程既不改变值类别,也不改变 const 性。make_unique、emplace_back、std::bind 的内部全靠这套机制。
四、面试速答
std::move是一个无条件的static_cast<remove_reference_t<T>&&>,返回类型必须先remove_reference再加&&,否则传左值时引用折叠会让它退化成左值引用而失效。std::forward是有条件的static_cast<T&&>,靠引用折叠:T推导带&就折叠成左值引用,不带就是右值引用,从而还原调用者传入的原始值类别。它的参数写成remove_reference_t<T>&制造非推导语境,强制显式指定T—— 因为具名变量本身永远是左值,值类别信息只存在于外层的T中。 两者都是纯类型转换,编译后不产生任何指令。
不是编译器黑魔法,是纯粹的库代码,用模板偏特化实现的模式匹配。你自己十行就能写一个。
实现
; // 主模板
; // 偏特化①
; // 偏特化②
using remove_reference_t = typename remove_reference<T>::type;
原理:模板偏特化 = 类型层面的模式匹配
编译器在实例化 remove_reference<X> 时,会拿 X 去和所有特化的模式做匹配,选最特殊的那个:
| 写法 | 匹配到 | 推导出的 T |
::type |
|---|---|---|---|
remove_reference<int> |
主模板 | int |
int |
remove_reference<int&> |
偏特化① T& |
int |
int |
remove_reference<int&&> |
偏特化② T&& |
int |
int |
remove_reference<const int&> |
偏特化① T& |
const int |
const int |
注意最后一行:remove_reference 只剥引用,不剥 const。这正是前面说的"std::move 对 const 对象失效"的原因 —— const string& 剥完是 const string,加上 && 得到 const string&&,绑不上移动构造函数。
想连 cv 一起剥,C++20 提供了 std::remove_cvref_t:
using remove_cvref_t = std::remove_cv_t<std::remove_reference_t<T>>;
顺序不能反 —— 引用没有顶层 cv,必须先去引用。
为什么有 _t 后缀
C++11 只有 remove_reference<T>::type,用起来要写一堆 typename:
typename std::remove_reference<T>::type&& ; // C++11
std::remove_reference_t<T>&& ; // C++14 起
typename 是必需的,因为 remove_reference<T>::type 是依赖名(dependent name),编译器在实例化前不知道 type 是类型还是静态成员,默认按非类型解析,必须用 typename 显式声明。C++14 加的 _t 别名模板把这个噪音消掉了(别名模板自身就声明为类型,不需要 typename)。
同理还有 _v 后缀(C++17),把 is_same<A,B>::value 简化成 is_same_v<A,B>。
哪些 type_traits 才是真的编译器内建
<type_traits> 里绝大多数是纯库实现(remove_reference、add_pointer、is_same、conditional、is_pointer 等),但有一批确实做不到,必须靠编译器内建(intrinsic):
| trait | 为什么必须内建 |
|---|---|
is_enum / is_union / is_class |
语言层面无法区分 class 和 union |
is_polymorphic |
需要知道有没有虚函数 |
is_trivially_copyableis_trivially_destructible |
需要编译器对特殊成员函数的内部判定 |
is_base_of |
需要访问不可访问/歧义的基类关系 |
underlying_type |
枚举底层类型只有编译器知道 |
is_constructible / is_convertible |
需要模拟一次完整的重载决议 |
is_final / is_abstract |
语言属性 |
它们在 libstdc++/libc++ 里长这样:
; // __is_enum 是编译器内建
判断依据很简单:如果一个性质无法通过"构造一个表达式看它能否编译"来侦测,就只能内建。 remove_reference 显然不属于这类。
一个现实中的细节
有意思的是,近几年 Clang 16+ / GCC 也给 remove_reference 加了内建版本(__remove_reference_t),libc++ 会优先用它:
using remove_reference_t = __remove_reference_t<T>;
// 上面那套偏特化
但原因不是做不到,而是编译速度:模板实例化要建 AST 节点、进符号表、参与 memoization,而 remove_reference 在标准库里被实例化的次数是天文数字。内建版本直接在编译器内部做一次类型变换,零实例化开销。大型项目上这类替换能省下可观的编译时间。
顺带:自己动手实现几个
面试常让手写,这几个都是同样的偏特化套路:
// 去指针
;
;
;
// (还要 T* volatile 和 T* const volatile 两个特化)
// 判断是否同类型
;
;
// 判断是否左值引用
;
;
is_same 那两行特别能说明问题:偏特化就是在类型上做模式匹配,is_same<A,A> 这个模式只有两个实参相同时才匹配得上。整个 <type_traits> 的非内建部分,本质上都是这一个技巧的变奏。
一句话:把类型上的引用剥掉,得到"裸类型"。 T& → T,T&& → T,T → T。
它本身不做任何运行时的事,只是在编译期把类型归一化。归一化之所以必要,是因为模板推导和 decltype 经常给你带引用的类型,而你需要的往往是不带引用的那个。
具体有三类用途。
一、在 std::move 里:让 && 落到实处
这是最经典的一处。目标是"无论传进来什么,都返回右值引用",而 T 推导出来可能带引用:
constexpr std::remove_reference_t<T>&&
对比一下有没有它:
| 实参 | T |
写 T&& 的返回类型 |
写 remove_reference_t<T>&& |
|---|---|---|---|
左值 s |
string& |
string& && → string& ✗ |
string&& ✓ |
| 右值 | string |
string&& ✓ |
string&& ✓ |
没有它,传左值时引用折叠会把返回类型压成 string&,std::move 对左值完全失效 —— 而左值恰恰是它唯一的使用场景。先剥干净再加 &&,才能强制得到右值引用。
二、在 std::forward 里:制造非推导语境
constexpr T&& ;
这里用它不是为了剥引用,而是为了让 T 变得不可推导。T 藏在 remove_reference_t<T> 的 ::type 后面,编译器无法从实参反推,于是强制你写 std::forward<T>(x)。
这是刻意的:x 作为具名变量永远是左值,值类别信息只存在于外层的 T 里,必须手动传进来。
三、在泛型代码里:从"引用类型"拿到"值类型"
decltype 和转发引用给你的类型经常带引用,而你要声明变量、做类型判断时需要裸类型。
声明一个真正的副本
void
从迭代器/容器取元素类型
using V = std::remove_reference_t<decltype>; // *it 是左值,decltype 给 U&
避开 decltype((x)) 的陷阱
int x = 0;
decltype a; // int
decltype b; // int& —— 多一层括号就变成了表达式,而 x 是左值
类型比较前先归一化(实践中最常踩坑的一处)
;
Widget w1;
Widget ; // ✗ 调的是模板!因为 w1 是非 const 左值,
// 模板实例化出 Widget(Widget&) 比 Widget(const Widget&) 更匹配
修法就是用归一化后的类型把自己排除掉:
>
;
这里必须用 remove_cvref_t(= remove_cv_t<remove_reference_t<T>>),因为 T 可能是 Widget&、const Widget&、Widget 中的任何一个,不归一化就比不出相等。C++20 起也可以写成约束:
;
记住一点
它只剥引用,不剥 const:
std::remove_reference_t<const int&> // → const int,不是 int
所以做类型比较时,几乎总是应该用 remove_cvref_t 而不是 remove_reference_t。只有在 std::move/std::forward 那种"我要保留 const、只想改引用形态"的场合,才单独用 remove_reference_t。
根本原因:C++ 用 & 这一个符号,承载了两件完全不同的事
第一件:程序员声明一个别名 —— int& r = x;,"引用"是我要的类型。
第二件:编译器在模板推导和 decltype 里,把表达式的值类别偷偷编码成了引用。
void ;
; // T = U& ← 这个 & 不是"我要引用",是"我传的是左值"
; // T = U ← 没有 & 表示"我传的是右值"
int x;
decltype // int ← x 作为实体的类型
decltype // int& ← 这个 & 也不是引用,是"这个表达式是左值"
这就是问题所在:T 里同时塞了"是什么类型"和"是什么值类别"两份信息,挤在同一个符号上。 一旦你只想要前者,就必须有个操作把后者剥掉 —— 这就是 remove_reference 的存在意义。
它不是什么类型工具箱里的琐碎零件,而是解码器:把被编码进类型的值类别信息拆出去。
为什么不能"就带着引用用"
因为引用类型在 C++ 类型系统里是个残缺的公民,它无法参与组合:
| 组合 | 结果 |
|---|---|
T& & |
不存在"引用的引用",发生引用折叠 |
T&* |
不存在"指向引用的指针" |
T&[10] |
不存在"引用的数组" |
const (T&) |
顶层 const 被静默忽略,引用本来就不能重新绑定 |
sizeof(T&) |
等于 sizeof(T),引用自身没有独立的大小 |
所以任何"拿到 T 之后再往上加东西"的泛型代码,只要 T 可能带引用,构造就会走样。这不是加个 if 能绕过去的,是类型系统层面的:你必须先归一化,才能安全地做类型构造。
std::move 就是最小的例子。它想说的是"给我一个指向同一对象的右值引用",写出来是 T&&。但如果 T 已经是 U&,T&& 就不是"T 的右值引用",而是折叠成 U&。
// 你的意图: 对 T 加 &&
// 实际发生: 对 (可能已带 &) 的 T 做折叠
// 修复:先归一化,再加 remove_reference_t<T> + &&
归一化是类型构造的前提。 这就是为什么它总是成对出现:remove_reference_t<T>&&、remove_reference_t<T>*、remove_cvref_t<T> 作为容器的 value_type。
再看得深一点:它其实是在做"投影"
用一个坐标来理解:模板推导给你的 T 是一个二元组
T ≈ (裸类型, 值类别)
^^^^^^^ ^^^^^^^
你要的 编译器塞的
标准库为这两个分量各提供了一套工具:
- 取裸类型分量 →
remove_reference_t/remove_cvref_t - 用值类别分量 →
std::forward<T>(它读的正是T里有没有&) - 覆盖值类别分量 →
std::move(先剥掉,再无条件写成&&)
std::move 和 std::forward 的差别,在这个视角下一目了然:
= static_cast<remove_reference_t<T>&&> // 丢弃原值类别,写死为右值
forward<T> = static_cast<T&&> // 保留原值类别,靠折叠还原
一个剥完重写,一个原样保留。 这就是"无条件转换"和"条件转换"的全部含义。
一句话回答
因为 C++ 在模板推导和
decltype中,把表达式的值类别编码成了类型上的引用,导致T里混着两份信息。而引用类型又无法参与进一步的类型构造(不能是引用的引用/指针/数组)。所以当你想基于T构造新类型时,必须先用remove_reference_t把编码进去的值类别剥掉,拿到干净的裸类型,再做想做的构造 —— 否则引用折叠会让构造出来的东西跟你的意图不符。
暂无评论,欢迎留下第一条评论。