Skip to content

Latest commit

 

History

History
672 lines (535 loc) · 24.8 KB

Chapter07.md

File metadata and controls

672 lines (535 loc) · 24.8 KB

Chapter7. Modern C++ 引用

关于引用的更详细说明 知乎链接,本文部分参考该文

7.1 左值&右值

C++11中将右值拓展为 纯右值 (prvalue)将亡值 (xvalue)

  • 纯右值:非引用返回的临时变量,运算表达式的结果,字面常量
  • 字符串字面量是左值,而且是不可被更改的左值。字符串字面量并不具名,但是可以用&取地址所以也是左值。
  • 将亡值:与右值引用相关的表达式 如:将要被移动的对象,T&&函数返回的值,std::move()的返回值,转换成T&&的类型的转换函数的返回值
    int&& f() {
      return 3;
    }
    
    int main() {
      f(); 
      // The expression f() belongs to the xvalue category,
      // because f() return type is an rvalue reference to object type.
    }
    static_cast<int&&>(7); 
    // The expression static_cast<int&&>(7) belongs to the xvalue 
    // category, because it is a cast to an rvalue reference to object type.
    std::move(7); 
    // std::move(7) is equivalent to static_cast<int&&>(7).

左值右值判断方法精简:

  • 如果你可以对一个表达式取地址,那这个表达式就是个lvalue。
  • 如果一个表达式的类型是一个lvalue reference (例如, T&const T&, 等.),那这个表达式就是一个lvalue。
  • 其它情况,这个表达式就是一个rvalue。从概念上来讲(通常实际上也是这样),rvalue对应于临时对象,例如函数返回值或者通过隐式类型转换得到的对象,大部分字面值(e.g., 10 and 5.3)也是rvalue。
class A() {
  public:
   int m_a;
};

A&& getTemp() {
  return A();
}

int i = 3;        // 纯右值
int j = i + 8;    // 纯右值
A aa = getTemp(); // 将亡值
getTemp().m_a;    // 将亡值

7.2 右值引用 (rvalue reference)

  • C++11中增加右值引用,在C++98中的引用都称为左值引用 (lvalue reference)

右值引用就是给右值取别名,新名字就是左值。如果一个prvalue被绑定到一个引用上,它的生命周期则会延长到跟这个引用变量一样长。

C++11的右值引用为了实现移动语义

语法:Type&& varName = right_value;

class A() {
  public:
   int m_a = 9;
};

A getTemp() {
  return A();
}

int main() {
  int&& a = 3;     // 3是右值
  int b = 8;       // b是左值
  int&& c = b + 5; // b + 5 是右值

  A&& aa = getTemp(); // getTemp()返回值是右值

  std::cout << "a = " << a << std::endl; // 3
  std::cout << "c = " << c << std::endl; // 8
  std::cout << "aa.m_a = " << aa.m_a << std::endl; // 9

  a++;
  c++;
  aa.m_a++;

  std::cout << "a = " << a << std::endl; // 4
  std::cout << "c = " << c << std::endl; // 9
  std::cout << "aa.m_a = " << aa.m_a << std::endl; // 10
}

生命周期延长可以被应用在析构函数上,当我们想要去继承某个基类的时候,这个基类往往会被声明为virtual,当不声明的话,子类便不会得到析构。如果想让这个子类对象的析构仍然是完全正常,你可以把一个没有虚析构函数的子类对象绑定到基类的引用变量上。

class shape {
 public:
  shape() { std::cout << "shape" << std::endl; }

  // 基类没有虚析构
  ~shape() { std::cout << "~shape" << std::endl; }
};

class circle : public shape {
 public:
  circle() { std::cout << "circle" << std::endl; }

  ~circle() { std::cout << "~circle" << std::endl; }
};

class triangle : public shape {
 public:
  triangle() { std::cout << "triangle" << std::endl; }

  ~triangle() { std::cout << "~triangle" << std::endl; }
};

class rectangle : public shape {
 public:
  rectangle() { std::cout << "rectangle" << std::endl; }

  ~rectangle() { std::cout << "~rectangle" << std::endl; }
};

class result {
 public:
  result() { puts("result()"); }

  ~result() { puts("~result()"); }
};

result process_shape(const shape& shape1, const shape& shape2) {
  puts("process_shape()");
  return result();
}

int main() {
  result&& r = process_shape(circle(), triangle());
}

output:

shape
triangle
shape
circle
process_shape()
result()
~circle
~shape
~triangle
~shape
~result()

7.3 常量左值引用 (const lvalue reference)

  • 常量左值引用,可以绑定左值和右值,但不能更改引用的值
  • 非常量左值引用只能绑定左值,右值引用只能绑定右值
int a = 0;
const int& b = a; // 绑定左值

const int& c = 1; // 绑定右值

常量左值引用也可以延长右值的生命周期,这里不展开说明

7.4 万能引用 (universal reference)

也称转发引用或通用引用

If a variable or parameter is declared to have type T&& for some deduced type T, that variable or parameter is a universal reference. 如果一个变量或者参数被声明为T&&,其中T是被推导的类型,那这个变量或者参数就是一个universal reference。

在实践当中,几乎所有的universal references都是函数模板的参数。因为auto声明的变量的类型推导规则本质上和模板是一样的,所以使用auto的时候你也可能得到一个universal references。

  • 如果用来初始化universal reference的表达式是一个左值,那么universal reference就变成lvalue reference。
  • 如果用来初始化universal reference的表达式是一个右值,那么universal reference就变成rvalue reference。
template<typename T>
void f(T&& param); 
int a;
f(a);   // 传入左值,那么上述的T&& 就是lvalue reference,也就是左值引用绑定到了左值
f(1);   // 传入右值,那么上述的T&& 就是rvalue reference,也就是右值引用绑定到了左值
std::vector<int> v;
...
auto&& val = v[0];    // val becomes an lvalue reference

声明引用的时候必须用 T&& 的形式才能获得一个universal reference。这个一个很重要的信息。再看看这段代码:

template<typename T>
void f(std::vector<T>&& param);       // “&&” means rvalue reference

这里,我们同时有类型推导和一个带 && 的参数,但是参数确不具有 T&& 的形式,而是 std::vector<T>&&。其结果就是,参数就只是一个普通的rvalue reference,而不是universal reference。 Universal references只以 T&& 的形式出现!即便是仅仅加一个const限定符都会使得 && 不再被解释为universal reference:

template<typename T>
void f(const T&& param);               // “&&” means rvalue reference

来点有意思的东西

有的时候你可以在函数模板的声明中看到T&&,但却没有发生类型推导。来看下std::vectorpush_back 函数

template <class T, class Allocator = allocator<T> >
class vector {
 public:
  ...
  void push_back(T&& x);       // fully specified parameter type ⇒ no type deduction;
  ...                          // && ≡ rvalue reference
};

这里, T 是模板参数, 并且push_back接受一个 T&&, 但是这个参数却不是universal reference! 这怎么可能?

如果我们看看push_back在类外部是如何声明的,这个问题的答案就很清楚了。我会假装std::vectorAllocator 参数不存在,因为它和我们的讨论无关。我们来看看没Allocator参数的std::vector::push_back:

template <class T>
void vector<T>::push_back(T&& x);
push_back不能离开std::vector<T>这个类而独立存在。但如果我们有了一个叫做std::vector<T>的类,我们就已经知道了T是什么东西,那就没必要推导T。

举个例子可能会更好。如果我这么写:

Widget makeWidget();             // factory function for Widget
std::vector<Widget> vw;
...
Widget w;
vw.push_back(makeWidget());      // create Widget from factory, add it to vw

代码中对 push_back 的使用会让编译器实例化类 std::vector<Widget> 相应的函数。这个 push_back 的声明看起来像这样:

void std::vector<Widget>::push_back(Widget&& x);

看到了没? 一旦我们知道了类是 std::vector<Widget>push_back的参数类型就完全确定了: 就是Widget&&。这里完全不需要进行任何的类型推导。

对比下 std::vectoremplace_back,它看起来是这样的:

template <class T, class Allocator = allocator<T> >
class vector {
public:
    ...
    template <class... Args>
    void emplace_back(Args&&... args); // deduced parameter types ⇒ type deduction;
    ...                                // && ≡ universal references
};

emplace_back 看起来需要多个参数(Argsargs的声明当中都有...),但重点是每一个参数的类型都需要进行推导。函数的模板参数 Args 和类的模板参数T无关,所以即使我知道这个类具体是什么,比如说,std::vector<Widget>,但我们还是不知道emplace_back的参数类型是什么。

我们看下在类std::vector<Widget>外面声明的 emplace_back 会更清楚的表明这一点 (我会继续忽略 Allocator 参数):

template<class... Args>
void std::vector<Widget>::emplace_back(Args&&... args);

  • 一个表达式的 左值性 (lvalueness) 或者 右值性 (rvalueness) 和它的类型无关。

来看下 int。可以有lvalue的int (e.g., 声明为int的变量),还有rvalue的int (e.g., 字面值10)。用户定义类型Widget等等也是一样的。

一个Widget对象可以是lvalue(e.g., Widget 变量) 或者是rvalue (e.g., 创建Widget的工程函数的返回值)。

表达式的类型不会告诉你它到底是个lvalue还是rvalue。因为表达式的 lvalueness 或 rvalueness 独立于它的类型,我们就可以有一个 lvalue,但它的类型确是 rvalue reference,也可以有一个 rvalue reference 类型的 rvalue :

Widget makeWidget();                       
// factory function for Widget
 
Widget&& var1 = makeWidget()               
// var1 is an lvalue, but its type is rvalue reference (to Widget)
 
Widget var2 = static_cast<Widget&&>(var1); 
// the cast expression yields an rvalue, but its type is rvalue reference  (to Widget)

var1类别是左值,但它的类型是右值引用。static_cast<Widget&&>(var1) 表达式是个右值,但它的类型是右值引用。

把 lvalue (例如 var1) 转换成 rvalue 比较常规的方式是对它们调用std::move,所以 var2 可以像这样定义:

Widget var2 = std::move(var1);             // equivalent to above

我最初的代码里使用 static_cast 仅仅是为了显示的说明这个表达式的类型是个rvalue reference (Widget&&)。rvalue reference 类型的具名变量和参数是 lvalue。(你可以对他们取地址。)

我们再来看下前面提到的 WidgetGadget 模板:

template<typename T>
class Widget {
    ...
    Widget(Widget&& rhs);        // rhs’s type is rvalue reference,
    ...                          // but rhs itself is an lvalue
};
 
template<typename T1>
class Gadget {
    ...
    template <typename T2>
    Gadget(T2&& rhs);            
    // rhs is a universal reference whose type will ventually become an
    // rvalue reference or an lvalue reference, but rhs itself is an lvalue
    ...
};

在 Widget 的构造函数当中, rhs 是一个rvalue reference,前面提到,右值引用只能被绑定到右值上,所以我们知道它被绑定到了一个rvalue上面(i.e., 因此我们需要传递了一个rvalue给它), 但是 rhs 本身是一个 lvalue,所以,当我们想要用到这个被绑定在 rhs 上的rvalue 的 rvalueness 的时候,我们就需要把 rhs 转换回一个rvalue。之所以我们想要这么做,是因为我们想将它作为一个移动操作的source,这就是为什么我们用 std::move 将它转换回一个 rvalue。

类似地,Gadget 构造函数当中的rhs 是一个 universal reference,,所以它可能绑定到一个 lvalue 或者 rvalue 上,但是无论它被绑定到什么东西上,rhs 本身还是一个 lvalue。

如果它被绑定到一个 rvalue 并且我们想利用这个rvalue 的 rvalueness, 我们就要重新将 rhs 转换回一个rvalue。如果它被绑定到一个lvalue上,当然我们就不想把它当做 rvalue。

一个绑定到universal reference上的对象可能具有 lvalueness 或者 rvalueness,正是因为有这种二义性,所以催生了std::forward: 如果一个本身是 lvalue 的 universal reference 如果绑定在了一个 rvalue 上面,就把它重新转换为rvalue。函数的名字 (“forward”) 的意思就是,我们希望在传递参数的时候,可以保存参数原来的lvalueness 或 rvalueness,即是说把参数转发给另一个函数。


7.5 完美转发 std::forward

在函数模板中,可以将自己的参数“完美地”转发给其他函数,即准确转发参数的值和左右值属性

能否实现完美转发,决定了该参数在传递过程中用的是拷贝语义还是移动语义

以下实现方式中,func2()可以调用两个重载版本,func1()无法调用rvalue重载版本

void func1(int& i) {         // 参数为lvalue
  std::cout << "lvalue" << i << std::endl;
}

void func1(int&& i) {         // 参数为rvalue
  std::cout << "rvalue" << i << std::endl;
}

void func2(int& i) {          // 参数为lvalue
  func1(i);
}

void func2(int&& i) {         // 参数为rvalue
  func1(i);
}

int main() {
  int i = 3;
  func2(i);                   // 调用lvalue
  func2(8);                   // 调用rvalue
}

output

lvalue
lvalue

怎么解决func2无法调用重载版本的问题呢

func2(int&& i)中添加std::move()

void func2(int&& i) {         // 参数为rvalue
  func1(std::move(i));
}
  • func2()改成模板参数写法
template <typename T>
void func2(T& i) {            // 参数为lvalue
  func1(i);
}

template <typename T>
void func2(T&& i) {           // 参数为rvalue
  func1(std::move(i));
}

C++11支持完美转发,提供以下方案

  • 如果类模板中(包括类模板和函数模板)函数的参数为T&&类型,则为万能引用(既可以接受左值引用,又可以接受右值引用)
  • 提供模板函数std::forward<T>(),用于转发参数,转发后保留参数的左右值类型
template<typename T>
void func(T&& i) {
  func1(std::forward<T>(i));
}

int main() {
  int i = 3;
  func(i);
  func(8);
}

output

lvalue
rvalue

7.6 引用折叠 (reference collapsing)

我们即将深入探讨 universal reference 的实现原理

C++11中一些构造会弄出引用的引用,而C++不允许引用的引用。

Widget w1;
...
Widget& ref = w1;
Widget& & ref2 = ref;      // error! No such thing as "reference to reference"

在对一个 universal reference 的模板参数进行类型推导时候,同一个类型的 lvalue 和 rvalue 被推导为稍微有些不同的类型。具体来说,类型T的lvalue被推导为T&(i.e., lvalue reference to T),而类型T的 rvalue 被推导为 T。(注意,虽然 lvalue 会被推导为lvalue reference,但 rvalue 却不会被推导为 rvalue references!)

我们来看下分别用rvalue和lvalue来调用一个接受universal reference的模板函数时会发生什么:

template<typename T>
void f(T&& param);

...
 
int x;
 
...
 
f(10);                           // invoke f on rvalue
f(x);                            // invoke f on lvalue

当用rvalue 10调用 f 的时候, T 被推导为 int,实例化的 f 看起来像这样:

void f(int&& param);             // f instantiated from rvalue

这里一切都OK。但是当我们用lvalue x 来调用 f 的时候,T 被推导为 int&,而实例化的 f 就包含了一个引用的引用:

void f(int& && param);           // initial instantiation of f with lvalue

因为这里出现了引用的引用,这实例化的代码乍一看好像不合法,但是像– f(x) –这么写代码是完全合理的。为了避免编译器对这个代码报错,C++11引入了一个叫做 引用折叠 (reference collapsing) 的规则来处理某些像模板实例化这种情况下带来的"引用的引用"的问题。

因为有两种类型的引用 (lvalue references 和 rvalue references),那"引用的引用"就有四种可能的组合:

  1. lvalue reference to lvalue reference
  2. lvalue reference to rvalue reference
  3. rvalue reference to lvalue reference
  4. rvalue reference to rvalue reference

引用折叠只有两条规则:

  • 一个 rvalue reference to an rvalue reference 会变成 (“折叠为”) 一个 rvalue reference.
  • 所有其他种类的"引用的引用" (i.e., 组合当中含有lvalue reference) 都会折叠为 lvalue reference.

在用lvalue实例化 f 时,应用这两条规则,会生成下面的合法代码,编译器就是这样处理这个函数调用的:

void f(int& param);              // instantiation of f with lvalue after reference collapsing

上面的内容精确的说明了一个 universal reference 是如何在经过类型推导和引用折叠之后,可以变为一个 lvalue reference的。 实际上,universal reference 其实只是一个身处于引用折叠背景下的rvalue reference


再看点有意思的

当一个变量本身的类型是引用类型的时候,这里就有点难搞了。这种情况下,类型当中所带的引用就被忽略了。例如:

int x;
 
...
 
int&& r1 = 10;                   // r1’s type is int&&
 
int& r2 = x;                     // r2’s type is int&
  • 在调用模板函数 f 的时候 r1r2 的类型都被当做 int。这个扒掉引用的行为,和"universal references 在类型推导期间,lvalue 被推导为 T& ,rvalue 被推导为T 这条规则无关。所以,这么调用模板函数的时候:
template<typename T>
void f(T &&param) {
  static_assert(std::is_lvalue_reference<T>::value, "T& is not lvalue reference");
  std::cout << "T& is lvalue reference" << std::endl;
}

int main() {
  int x;
  int&& r1 = 10;
  int& r2 = x;
  f(r1);
  f(r2);
}

output:

T& is lvalue reference
T& is lvalue reference

r1r2 的类型都被推导为 int&。这是为啥呢?

首先,r1r2 的引用部分被去掉了(留下的只是 int)。然后,因为它们都是 lvalue 所以当调用 f,对 universal reference 参数进行类型推导的时候,得到的类型都是int&

我前面已经说过,引用折叠只发生在“像是模板实例化这样的场景当中”。 声明auto变量是另一个这样的场景。推导一个universal reference的 auto 变量的类型,在本质上和推导universal reference的函数模板参数是一样的,所以 类型 T 的lvalue被推导为 T&,类型 T 的rvalue被推导为 T 。我们再来看一下本文开头的实例代码:

Widget&& var1 = someWidget;      // var1 is of type Widget&& (no use of auto here)
 
auto&& var2 = var1;              // var2 is of type Widget& (see below)

var1 的类型是 Widget&&,但是它的 reference-ness 在推导 var2 类型的时候被忽略了;var1 这时候就被当做 Widget

因为它是个lvalue,所以初始化一个universal reference(var2)的时候,var1 的类型就被推导成Widget&。在 var2 的定义当中将 auto 替换成Widget& 会生成下面的非法代码:

Widget& && var2 = var1;          // note reference-to-reference

而在引用折叠之后,就变成了:

Widget& var2 = var1;             // var2 is of type Widget&
  • 还有第三种发生引用折叠的场景,就是形成和使用 typedef 的时候。看一下这样一个类模板,
template<typename T>
class Widget {
    typedef T& LvalueRefType;
    ...
};
int main() {
    Widget<int&> w;
}

根据引用折叠的规则:

  • 一个 rvalue reference to an rvalue reference 会变成 (“折叠为”) 一个 rvalue reference.
  • 所有其他种类的"引用的引用" (i.e., 组合当中含有lvalue reference) 都会折叠为 lvalue reference.

我们知道T会被推导为lvalue reference,因此结果肯定是lvalue reference,对应于上述规则,我们来通过代码验证。

template<typename T>
class Widget {
  typedef T& LvalueRefType;
  typedef T&& RvalueRefType;
 public:
  void judge() {
    static_assert(std::is_lvalue_reference<LvalueRefType>::value, "LvalueRefType& is lvalue reference");
    static_assert(std::is_lvalue_reference<RvalueRefType>::value, "RvalueRefType& is lvalue reference");
    std::cout << "LvalueRefType and RvalueRefType is lvalue reference" << std::endl;
  }
};
int main() {
  Widget<int&> w;
}

output:

LvalueRefType and RvalueRefType is lvalue reference

如果我们在应用引用的上下文中使用这个typedef,例如:

void f(Widget<int&>::LvalueRefType&& param);

在对 typedef 扩展之后会产生非法代码:

void f(int& && param);

但引用折叠这时候又插了一脚进来,所以最终的声明会是这样:

void f(int& param);
  • 最后还有一种场景会有引用折叠发生,就是使用 decltype。和模板和 auto 一样,decltype 对表达式进行类型推导时候可能会返回 T 或者 T&,然后decltype 会应用 C++11 的引用折叠规则。

好吧, decltype 的类型推导规则其实和模板或者 auto 的类型推导不一样,后面我会具体说明他的用法。但是我们需要注意这样一个区别,即 decltype 对一个具名的、非引用类型的变量,会推导为类型 T (i.e., 一个非引用类型),在相同条件下,模板和 auto 却会推导出 T&

还有一个重要的区别就是decltype 进行类型推导只依赖于 decltype 的表达式; 用来对变量进行初始化的表达式的类型(如果有的话)会被忽略。因此:

Widget w1, w2;
 
auto&& v1 = w1;         
 
decltype(w1)&& v2 = w2; 

v1本身是左值,根据auto&&知道为万能引用,因此v1被推导为指向w1的左值引用。

w2是左值,decltype(w1)推导为Widget而不发生引用折叠,因此v2为右值引用,根据右值引用只能绑定到右值,这里却给了一个左值,因此不能编译!

  • 对于 template <typename T> foo(T&&)这样的代码。

如果传递过去的参数是左值,T 的推导结果是左值引用,那 T&& 的结果仍然是左值引用——即 T& && 坍缩成了T& 如果传递过去的参数是右值,T 的推导结果是参数的类型本身。那 T&& 的结果自然就是一个右值引用。 例如:

void foo(const shape&)
{
	puts("foo(const shape&)");
}
void foo(shape&&)
{
	puts("foo(shape&&)");
}
void bar(const shape& s)
{
	puts("bar(const shape&)");
	foo(s);
}
void bar(shape&& s)
{
	puts("bar(shape&&)");
	foo(s);
}
int main()
{
	bar(circle());
}

output

bar(shape&&)
foo(const shape&)

bar中传入的是右值,调用bar&&重载函数,但是对于void bar(shape&& s)来说,s本身是一个lvalue,所以在foo(s)后,仍旧调用的是&重载函数。

如果想要调用foo(shape&&),可以:

foo(std::move(s))

或者:

foo(static_cast<shape&&>(s))

再考虑下面这个例子:

void foo(const shape&) {
	puts("foo(const shape&)");
}
void foo(shape&&) {
	puts("foo(shape&&)");
}
template <typename T>
void bar(T&& s) {
	foo(std::forward<T>(s));
}
int main() {
    circle temp;
    bar(temp);
    bar(circle());
}

output

foo(const shape&)
foo(shape&&)

上面提到过一个绑定到universal reference上的对象可能具有 lvalueness 或者 rvalueness,正是因为有这种二义性,所以催生了std::forward: 如果一个本身是 lvalue 的 universal reference 如果绑定在了一个 rvalue 上面,就把它重新转换为rvalue。函数的名字 (“forward”) 的意思就是,我们希望在传递参数的时候,可以保存参数原来的lvalueness 或 rvalueness,即是说把参数转发给另一个函数。

因为在 T 是模板参数时,T&& 的作用主要是保持值类别进行转发,它有个名字就叫“转发引用”(forwarding reference)。因为既可以是左值引用,也可以是右值引用,它也曾经被叫做“万能引用”(universal reference)。