前言

C++ 中的引用折叠十分晦涩难懂,而 moveforward 正是基于引用折叠,且难以理解。本文将从引用折叠讲起,理解为什么需要引用折叠、什么是引用折叠、引用折叠在模板中的作用,最后复现 moveforward

本文需要右值引用、移动语义、模板的前置芝士。


引用折叠

什么是引用折叠

为什么需要引用折叠

引用折叠用于保障一些 C++ 功能的正常工作,而它正是 std::move 的基础。事实上,我们之前学习的所有右值引用和移动语义的相关芝士,都需要通过 std::move 的协助。如果没有引用折叠,则 std::move 将无法实现,那么所有的右值引用都没有意义,引用折叠可以看作是编译器最初为实现右值引用做出的妥协。

在正常编程中,我们实际上极少接触引用折叠,因为它几乎仅仅被 std::movestd::forward 应用。不过在模板编程中,​引用折叠又被赋予了其新的意义 。当然,如果你并不熟悉引用折叠,这些意义可能意味着新的 “坑”。

引用折叠是多次定义引用后的类型推导方式。但引用折叠禁止被显示定义,它事实上只在 using 或模板场景中才会出现,例如下面这个示例:

1
2
3
4
int x = 0;
int &r = x;
// int & &&rr = r; // 编译错误: 直接定义引用折叠是被禁止的
decltype(r) &&rr = r; // rr: int & && -> int &

具体而言,引用折叠分为如下四种情况:

  • T & & \to T &
  • T & && \to T &
  • T && & \to T &
  • T && && \to T &&

总之,只有 T && && 的引用折叠是 T &&

引用折叠在模板

在模板的类型推导中,引用折叠是被允许的,例如下面两个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <type_traits>
using std::cin;
using std::cout;
using std::endl;

template <typename T>
void foo(T &&x) {
cout << std::is_const<typename std::remove_reference<T>::type>::value << ' '
<< std::is_lvalue_reference<T>::value << ' '
<< std::is_rvalue_reference<T>::value << endl;
cout << std::is_const<typename std::remove_reference<T &&>::type>::value << ' '
<< std::is_lvalue_reference<T &&>::value << ' '
<< std::is_rvalue_reference<T &&>::value << endl;
};

int main() {
int x;
int &lr = x;
foo(x); // void foo(int & &&)
foo(lr); // void foo(int & &&)
foo(std::move(lr)); // void foo(int &&)
return 0;
}

/* 输出:
0 1 0
0 1 0
0 1 0
0 1 0
0 0 0
0 0 1
*/

特别提醒:对于 foo(x) 的匹配,x 匹配为 void foo(int &&) 是非法的,因为 int 无法转换为 int && 类型,因而 int & && 被匹配。

具名变量是非引用,也是左值引用(解释上面);匿名变量是非引用,也是右值引用。在函数匹配中它们都是精确匹配,但 ​在模板中,无论非引用与引用谁是显式的,非引用总是被优先考虑 (例如下面)。

foo(std::move(lr)) 为例,std::move(lr) 是显式的 int && 类型,同时 int &&int && && 都可以实例化模板,但通过实验可以发现前者才是实际实例化模板的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
// #include <fstream>
// #include <functional>
// #include <iterator>
// #include <list>
// #include <memory>
// #include <queue>
#include <type_traits>
// #include <utility>
// #include <vector>
// #include <iostream>
// using namespace std;
// using namespace std::placeholders;
using std::cin;
using std::cout;
using std::endl;

template <typename T>
void foo(T &x) {
cout << std::is_const<typename std::remove_reference<T>::type>::value << ' '
<< std::is_lvalue_reference<T>::value << ' '
<< std::is_rvalue_reference<T>::value << endl;
cout << std::is_const<typename std::remove_reference<T &>::type>::value << ' '
<< std::is_lvalue_reference<T &>::value << ' '
<< std::is_rvalue_reference<T &>::value << endl;
};

int main() {
int x = 9;
const int &lr = x;
foo(x); // void foo(int &)
foo(lr); // void foo(int &)
foo(std::move(lr)); // void foo(const int &)
return 0;
}

/* 输出:
0 0 0
0 1 0
1 0 0
1 1 0
1 0 0
1 1 0
*/

同样地,在这个示例中,foo(lr) 匹配的式 int & 而非 int && 也是出于相同的原因。

值得一提的是,lr 必须是 const int & 的,因为在 foo(std::move(lr)) 中,int &&int & 的转换是非法的,因为 int && 可以接受临时变量。

我想说(不知道你这么看),​引用折叠真麻烦 ,它的复杂规则甚至一直在埋坑,到现在我们依然无法体会到引用折叠在模板中的意义(当然,我们后面会讲)。不过我们确实可以尝试禁用引用折叠,这在一些时候可以避免不必要的麻烦:

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
void foo(typename std::remove_reference<T>::type &x) {
// ...
}

template <typename T>
void foo(typename std::remove_reference<T>::type &&x) {
// ...
}

// (妙啊! 我怎么没想到)

也许你会考虑将其中的所有 T 替换为 typename std::remove_reference<T>::type,但事实上这并不需要,因为自动模板类型推导是通过参数列表完成的,如果对于参数列表非引用和引用版本是相同的,那么如果匹配成功,总是以非引用匹配的。当然,这也有一个极少的例外,即你希望在函数的其他地方禁用引用折叠,但在参数列表允许。

tips: std::is_const 存在 BUG

在使用 std::is_const 时,务必首先去除引用:

1
std::is_const<typename std::remove_reference<T>::type>::value

如果不去除引用,引用类型的 const 将无法检测,我认为这算得上是一个 BUG。(当然你也可以自己实现一个 is_const

总结模板引用折叠妙用

首先,模板引用折叠最大的妙用当然是在 std::movestd::forward 中,但我们现在先撇开它们,谈谈其他妙用。

模板引用折叠的妙用总结在其 “包容性” 上:

可匹配的类型 / 转换后类型或无法匹配 / 模板参数定义 T & const T & T &&
具名变量(auto)/ auto & auto & [with T = auto] const auto & [with T = auto] auto & [with T = auto &]
具名常量(const auto)/ const auto & const auto & [with T = const auto] const auto & [with T = auto] const auto & [with T = const auto &]
匿名量(auto)/ auto && 无法匹配 const auto & [with T = auto] auto && [with T = auto]
const auto && const auto & [with T = const auto] const auto & [with T = auto] const auto && [with T = const auto]

(为避免混淆,我们使用 auto 区分于 T;同时也旨在区分此处的 auto 是舍弃 const 与引用类型的)

这里我们罗列了所有可能被接受的类型,可以总结一些经验:

  • T & 可以匹配所有左值,但不接受右值。
  • const T & 可以匹配所有类型,并将它们都转化为 const T &
  • T && 也可以匹配所有类型,并且保留它们的原始类型。

(这张表也解释了之前的示例中,我们为什么要使用 std::is_const 检测)


std::move 与 std::forward

std::move 代码复现

std::move 用于将任意类型转换为右值引用,它可以接受任何类型。

标准库实现方式:T &&

1
2
3
4
template <typename T>
constexpr typename std::remove_reference<T>::type &&move(T &&x) {
return static_cast<typename std::remove_reference<T>::type &&>(x);
}

std::move 的这种实现方式其实容易理解,T && 接受任意类型的值,而 static_cast 将其转换为准确的右值引用版本。请注意,这里必须对 T 作去引用,否则会出现这样的错误:move(auto & &&) -> auto & &&,引用折叠后是 move(auto &) -> auto &

为了检验 move 的正确性,其实我们只需将上面表格的所有情况代入验证即可。

这是标准库的实现方式。

一个常见的错误版本:const T &

1
2
3
4
template <typename T>
constexpr T &&move(const T &x) {
return static_cast<T &&>(const_cast<T &>(x));
}

其实这是一个常见的网传版,那么我们 ​还是先说说这个版本为什么正确

这种写法避免了使用 std::remove_reference,但必须使用 const_cast 转换。我们知道 const T & 同样可以接受任何类型,且均接受为 const T & 类型,这样我们只需将它转为 T && 类型即可。

为什么不需要 std::remove_reference

我们知道 const T & 从来支持接受任何类型。与 T && 不同的是,T && 的包容性是被引用折叠支持的,而 const T & 的包容性并不依赖于引用折叠。因此,根据模板引用折叠的规则,非引用版本总是被使用。

通过查看罗列在表格中的情况,我们也可以验证这一点。

所以,const T & 匹配的类型 T 永远是非引用的。

为什么需要 const_cast

const T & 存在一个问题,它将非 const 类型转换为了 const 类型,const T &T && 的转换是无法直接完成的。

然而这个版本存在一个瑕疵:

在这个版本中,move(const T) -> T &&;而标准库的版本应该是,std::move(const T) -> const T &&

const T && 通常并不具有存在的意义,当然可以理解,标准库的这种行为提供一层额外的编译保障,它禁止对常量直接使用右值引用。

什么是 std::forward

std::forward 称为完美转发,它实际上是用于应对在引用折叠中,转发的 “不完美”。

转发的损耗在哪里?

完美转发即是提供一种 “无损” 的转发方式,​那么转发的 "损耗" 在哪里? 或者说转发的不完美之处是什么?我们来看下面这两个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <type_traits>
using std::cin;
using std::cout;
using std::endl;

class A {
public:
A &operator=(const A &) { cout << "Copy" << endl; return *this; }
A &operator=(A &&) { cout << "Move" << endl; return *this; }
};

template <typename T, typename U>
void foo(T &x, U &&y) {
cout << "Foo~";
x = y; // 试图自动选择移动或拷贝语义
}

int main() {
A a, b;
foo(a, b);
foo(a, std::move(b));
return 0;
}

/* 输出:
Foo~Copy
Foo~Copy
*/

在这个示例中,我们希望 foo 为我们自动选择移动或拷贝语义,现在我们调用 foo(a, b) 执行拷贝语义、 foo(a, std::move(b)) 执行移动语义,然而好像出了点问题:两个都执行了拷贝。

这就是转发的 “损耗”。在传递过程中,yint && 类型的,但到函数体内由于具名,它实际变为了 int & 类型,那么在 x = y 时实际进行了一次转发,转发后它匹配了 int & 类型。

这个问题在非模板中也存在,不过在非模板中,不存在右值引用既可以匹配左值又可以匹配右值的情况。因此即使转发不完美,我们可以使用 std::move 来不断 “修正” 转发的过程。

std::move 无法替代 std::forward 的工作

std::movestd::forward 具有高度的相似性,然而 std::move 并不能替代 std::forward 的工作。刚刚我们讲到,在非模板中,std::move 始终可以 “完美” 的 ​"修正" 转发的损耗,但在模板中它可能不是总是奏效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <type_traits>
using std::cin;
using std::cout;
using std::endl;

class A {
public:
A &operator=(const A &) { cout << "Copy" << endl; return *this; }
A &operator=(A &&) { cout << "Move" << endl; return *this; }
};

template <typename T, typename U>
void foo(T &x, U &&y) {
cout << "Foo~";
x = std::move(y); // 试图自动选择移动或拷贝语义
}

int main() {
A a, b;
foo(a, b);
foo(a, std::move(b));
return 0;
}

/* 输出:
Foo~Move
Foo~Move
*/

使用 std::move 出现了问题,现在我们总是选择移动语义。在非模板中,std::move 始终可以 "完美" 的 "修正" 转发的损耗,但请注意 std::move 在转发中始终起到的是 "修正",而非转发工作。问题的核心在于,在模板编程中,T && 既可以接受一个左值引用类型,又可以接受一个右值应用类型,我们必须在一份代码中,区分并实现真正意义上的完美转发。

std::forward 专门为这个场景设计,它为用户提供真正的完美转发。下面是一份 ​正确代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <type_traits>
using std::cin;
using std::cout;
using std::endl;

class A {
public:
A &operator=(const A &) { cout << "Copy" << endl; return *this; }
A &operator=(A &&) { cout << "Move" << endl; return *this; }
};

template <typename T, typename U>
void foo(T &x, U &&y) {
cout << "Foo~";
x = std::forward<U>(y);
}

int main() {
A a, b;
foo(a, b);
foo(a, std::move(b));
return 0;
}

/* 输出:
Foo~Copy
Foo~Move
*/

总而言之 ,完美转发提供了在接受右值引用的模板中保持左值引用依然为左值引用,右值引用依然是右值引用的方法。特别注意,在使用 std::forward 时必须显示定义模板类型,形如 std::forward<T>(x),因为 C++ 无法在未知原始类型的情况下完成完美转发。

tips: std::forward 的应用场景

std::forward 的实际应用实际非常受限,由于前面讲到 std::forward 必须显示定义模板类型,它总是被最后考虑的 “下下策”。因此只有在接受右值引用的模板中才可能用到完美转发。

1
2
3
4
template <typename T>
void foo(T &&x) {
bar(std::forward<T>(x));
}

另一个更常见的 std::forward 的场景是在参数包转发中。

1
2
3
4
5
6
7
8
9
10
template <typename T>
void print(T &&x) {
cout << x << endl;
}

template <typename T, typename... Args>
void print(T &&x, Args&&... args) {
cout << x << ' ';
print(std::forward<Args>(args)...);
}

在理解 std::forward 之后,其实它实际的应用场景就是这么僵硬,不需要有任何活用的地方。

(有时不确定使用 std::forward 是否会起作用,但在这些场景我们总会带上 std::forward 避免产生意料之外的错误)

std::forward 代码复现

一种解决方案

1
2
3
4
5
6
7
8
9
template <typename T>
constexpr T &&forward(T &x) {
return static_cast<T &&>(x);
}

template <typename T>
constexpr T &&forward(T &&x) {
return x;
}

我们依然采取一贯的使用表格的方式,来最终检验我们代码是否存在遗漏或者错误。

外部传入参数 T && 参数匹配 匹配的版本 转换后类型 匹配另版转换后类型
具名变量(auto)/ auto & auto & [with T = auto &] T & auto & auto &
具名常量(const auto)/ const auto & const auto & [with T = const auto &] T & const auto & const auto &
匿名量(auto)/ auto && auto && [with T = auto] T & auto && 无法匹配
const auto && const auto && [with T = const auto] T & const auto && 无法匹配
(用户异常调用) [with T = auto &&] T && auto && 无法匹配
(用户异常调用) [with T = const auto &&] T && const auto && const auto &&

在我们的这个实现中,所有合法情况都会匹配第一个重载(T & 可以接受所有左值,而完美转发的所有应用都接受一个左值),从而实现完美转发;另外,第二个重载用于处理用户可能产生的异常调用,如:std::forward(std::move(x)),这样的调用是无意义的,但我们允许并不改变它,因为不可否认接受右值也是完美转发的一部分。

标准库实现

事实上,我上面的 std::forward 解决方案与标准库不同,我们也可以看下标准库实现方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
constexpr T &&forward(typename std::remove_reference<T>::type &x) {
return static_cast<T &&>(x);
}

// 使用 remove_reference 可以让代码更加清晰
template <typename T>
constexpr T &&forward(typename std::remove_reference<T>::type &&x) {
static_assert(
!std::is_lvalue_reference<T>::value,
"std::forward must not be used to convert an rvalue to an lvalue"
); // 一个永远不可能有效的 static_assert ... (不知道其他编译器版本怎么样)
return static_cast<T &&>(x);
}

可以说右值引用是 C++ 为了追求效率引入的天坑,为了支持它,C++ 的开发者们修修补补提供了许多额外的机制。右值引用确实带来了效率,却也给 C++ 的使用者带来不少麻烦。(不爽别用)