Amethyst Studio
2633 words
13 minutes
C++完美转发的细节
2024-06-23

左值引用和右值引用#

为了避免值传递的开销,我们可能希望对一个对象使用引用。在C++中,在向函数传引用的时候,有两种方式,一种是传左值引用,像这样:

void foo(int&);

对于上面的foo函数,传值的时候可以传任意的左值,例如假设有int y=1;foo(y)是OK的。

但是如果仅仅有左值引用,在一些情况下是有问题的,对于foo(42)来说,无法通过编译,因为42是一个字面量,它没有一个变量名去绑定(或者说,你没办法对它取指针),因此它没法传值给foo(int&)这个函数。

于是C++中就诞生了右值引用,规定42,或者3+29这样由表达式得到的值,为类型的右值引用,可以调用下面的函数:

void foo(int &&);

这样的话,foo(42),或者foo(3+29)都是合法的了。

常量引用#

但是为了做到传引用这件事,C++要分别处理左值引用和右值引用的办法明显会显得非常麻烦。C++中提供了一个常量引用的机制,就是说,如果你对传进来的引用类型不进行修改的话,那么就允许右值进行绑定,也就是说,假设代码中只有一个接收常量引用的函数:

void foo(const int&);

对于上面的函数而言,foo(42)int y=29; foo(y)都是合法的。

但是这种方式要求的是,foo函数中是不能修改x的值的,因为它是const引用。

右值引用于常量引用的使用关系#

不过这样带来一个问题,假设代码中同时存在foo(int&&)foo(const int&)时,在调用foo(42)时,会走哪一个呢?

答案是,会走foo(int&&),因为42本身更加符合int&&的定义,如果代码中是存在foo(int&&),那么就会优先走这个接收右值引用的函数。

但是,假设我们的代码是这样:

void foo(int&&);
void foo(const int&);

int && t = 42;
foo(t);

这个时候,会调用哪一个foo呢?

答案是,会调用foo(const int&),而不是foo(int&&)

引用折叠#

为了要解释清楚上面的代码中,为什么foo(t)调用的是foo(const int &)而不是foo(int&&),我们就需要弄清楚C++在传引用的时候到底做了什么。这里的规则是,传值时,任何的具名变量都是左值,任何的非具名变量都是右值。

int x;
func(x);   // func(int&);
func(x+1); // func(int&&);

但是有意思的问题是,如果上面的x是一个引用类型呢?也就是说,如果x本身是一个int&,那么func(x)传入的参数类型到底是什么?

这个问题非常地细节,大多数人都会忽略掉,但是要理解C++的转发机制,就必须知道这个细节。实际上,func(x)的参数类型是int&的引用,也就是说,它是一个左值引用的左值引用。

而C++中有一个引用折叠机制,这种左值引用的左值引用会被折叠成左值引用,正因为这种折叠机制的存在,导致这个问题被很多人忽略掉了,人们想当然地认为x是一个int&,那么func(x)调用的是func(int&)。实际上这里多了一个引用折叠机制。

然后进一步讨论,如果像先前的代码那样,x的类型是一个int&&,右值引用,那么func(x)调用的是什么?

要讨论的是func(x)里面的x的类型是什么,跟前面一样,这里面的x的类型是int&&的引用,也就是说,它是右值引用的左值引用。

接着根据C++中的引用折叠机制,这种int&&的左值引用实际上会被折叠成int&

所以即使x本身的类型是int&&,在func(x)的时候,依然调用的是func(int&),而并非func(int&&)

以下是C++中的折叠规则:

  1. && 折叠为 &
  2. &&& 折叠为 &
  3. &&& 折叠为 &
  4. &&&& 折叠为 &&

所以,回到前面一小节的问题,这里面,尽管t的类型是一个右值引用,这意味着int && t = y;这样的语句是完全不合法的,但是t是具名的,因此在函数调用的是否,调用的是左值引用的函数,调用的是foo(const int&),而不是foo(int&&)

移动语义#

如果仅仅是针对前面的例子,假设我需要去调用foo(int&&),那么是需要对这个t使用std::move的。

void foo(int&&);
void foo(const int&);

int && t = 42;
foo(std::move(t));

std::move本身会强制类型转换为右值,而在传参的时候,由于std::move得到的结果本身不具名,因此传参的时候的参数类型是一个右值引用的右值引用,通过引用折叠之后得到一个右值引用,因此对于上面的代码来说,就会使用foo(int&&)了。

forward reference#

对于前面的讨论,可能会觉得很无聊,因为日常的开发过程中,很少有人会使用int && t = 42;这样的语句,但是实际上上面的代码仅仅是一种简化的讨论,真正的问题在于函数的调用链。

从上面的代码中,我们已经知道,如果要写一个函数foo,既能够接受左值int&,又能够接受右值int&&,我们就得写两个函数,一个接收左值foo(int&),一个接收右值foo(int&&)。如果我们只想写一个函数,如果这个值保证在函数内不变的话,可以使用常量引用foo(const int&),这里的问题是,如果我们没法保证这个值在函数内是不变的,又想只写一个函数,怎么办?

那我们可能会想让模板来进行推断,这个就是forward reference,转发引用,或者说是万能引用。这样写:

template<typename T>
void foo(T && x);

对于这个函数来说,foo(42)foo(y)都是可以实例化的,但是在使用的过程中,会遇到一个问题。就是有时你可能需要弄清楚这个x的类型。

前面我们已经提到,在C++中,任何具名的变量在函数传递的时候都是左值,这样一来就会遇到一个问题,假设我有两个函数:

void bar(int&&);
void bar(const int&);

这两个函数接收了不同的引用类型,内部的逻辑也不太相同,然后它们被foo调用:

template<typename T>
void foo(T && x) {
  bar(x);
}

这里可能会遇到问题,根据前面的描述:任何具名的变量在函数传递的时候都是左值,无论x本身的类型是左值还是右值。换而言之:

foo(42);  // call bar(const int&);
foo(y);   // call bar(int &&);

尝试运行下面的代码,可以展示上面描述的问题:

#include <utility>
#include <iostream>

void foo(const int & x) {
  std::cout << __PRETTY_FUNCTION__ << std::endl;
}

void foo(int && x) {
  std::cout << __PRETTY_FUNCTION__ << std::endl;
}

template <typename T>
void bar(T&& x) {
  foo(x);
  return;
}

int main() {
  int t = 42;
  bar(42);
  bar(t);
  return 0;
}

但这很可能违背了我们的期望,我们有的时候可能希望函数的参数传进来的是什么类型,就要调用什么类型的函数。

对于上面的bar(42),在函数内部是int&&类型,我们希望它调用foo(int&&),但是因为具名的关系,调用的是foo(const int&)

我们很显然不能无脑使用move,因为对于第二种情况bar(t)来说,我们又确实希望它去调用foo(const int&)

此时就需要使用std::forward,完美转发机制。

(不要认为这个事情是无意义的,在某些领域的开发中,例如在一些EDSL的开发中,左值和右值可能会因为涉及到寄存器分配的问题,因此需要区别实现。)

std::forward#

对于前面的讨论

我们把前面的bar函数,修改成下面的形式,就可以了:

template <typename T>
void bar(T&& x) {
  foo(std::forward<T>(x));
  return;
}

forward的原理#

前面讲述的都是用法,要彻底弄明白完美转发到底是怎么一回事,我们就必须对前面提到的代码进行一个详细的思考。

看这个代码:

template <typename T>
void bar(T&& x);

我们有一个bar函数,使用了这种forward reference,那么问题是对于函数调用bar(42)bar(y)两个函数调用而言,Tx的类型到底是什么。

T的类型很好知道,直接运行就可以了:

template <typename T>
void bar(T&& x) {
  std::cout << __PRETTY_FUNCTION__ << std::endl;
}

运行一下就知道,bar(42)会告诉你此时T = int,而bar(y)会告诉你T = int&

但这是T的类型,问题是x的类型。我从两个方面解释这个问题。

一个方面是forward reference有的时候被翻译成“万能引用”或者说“通用引用”,也就是说,这里面,x的类型,是传入什么类型,就是是什么类型,也就是说,对于bar(42)来说,x的类型就是int&&,对于bar(y)来说,x的类型就是int&

另一个方面就是前面提到的引用折叠机制。

也就是说,当T = int时,T &&当然就是int &&,当T = int&时,T &&就是int& &&,会被折叠成int&,这个就是x的类型的由来。

回到之前代码里的问题,对于函数:

template <typename T>
void bar(T&& x) {
  foo(std::forward<T>(x));
  return;
}

然后使用了bar(42)bar(y)。对于内部的两次调用而言,foo的参数分别是std::forward<int>(x),和std::forward<int&>(x)。然后我们可以知道std::forward<int>(x)应该是把x的类型转换为了int&&,而std::forward<int&>(x)则是把x的类型转换为了int&

知道这一层之后,我们就可以来看forward的原理代码了:

template<typename T>
T&& forward(typename std::remove_reference<T>::type& x) noexcept {
    return static_cast<T&&>(x);
}

它的基本意思,就相当于利用前面所说的引用折叠的规则,forward reference的函数中对于bar(42),它的T类型是T = int,加上一个&&之后就变成了int&&,因此在调用foo的时候就会调用foo(int&&),而对于bar(x),它的T类型是T = int&,加上一个&&就变成了int& &&,通过引用折叠就变成了int&,所以调用foo的时候调用foo(const int&)

额外注意一下上面forward的参数:typename std::remove_reference<T>::type& x,这里确保即使T是引用类型,参数x也是一个普通的引用(左值引用)。

C++完美转发的细节
https://ziyue.cafe/posts/cpp-perfect-forwarding/
Author
Kaida Amethyst
Published at
2024-06-23