跳转至

C++ 核心概念

Abstract

jyy os

右值引用:Rvalue References

Rvalue 和 Lvalue

为什么需要右值引用,其引入解决了两个问题: - 移动语义: - 完美转发:

首先需要区别什么是 lvalues 以及 rvalues,这两个概念最开始是从C中引入的,但是C++有些不同,但是需要注意:

  1. lvalue & rvalueexpression的属性,即value category,表达式是运算符和操作数的序列,用来指明计算,计算的结果是产生值,也可以导致副作用,比如: std::cout << 1

  2. primary expression:

    • this
    • 字面量,例如 2 或者 "Hello, world!"
    • id-expressions,例如 n 或者 std::cout
    • lambda 表达式,例如 { n *= 2; }
  3. 为什么int m; m + 1 = 2;int foo(); foo() = 2这些表达式是违法的,其实每个表达式其实都有两个性质,一个是我们熟知的 类型 (type),而另一个就是我们刚刚发现的「新区别」:value category

lvalue

An lvalue is an expression that refers to a memory location and allows us to take the address of that memory location via the & operator. - 任何有名字的表达式都是左值,比如int n = 1,唯一的例外是枚举

  • 内置 的 ++a, a = b, a += b, p, p->m 是 lvalue,因为a = b返回的是a的引用,p和p->m体现占内存的操作。

  • 返回值为引用类型的函数调用表达式是 lvalue,因为返回值表征的是一个对象,比如int& foo()

  • 如果 a 是 lvalue,m 是成员变量,则 a.mlvalue;如果 m 是成员变量,p->m 是 lvalue。

  • 除了字符串字面量是 lvalue(因为字符串字面量的类型是 const char[N]lex.string#5 以外,其他字面量都是 rvalue

1
2
3
4
5
6
7
// lvalue
int i = 42;
i = 43; // ok, i is an lvalue
int* p = &i; // ok, i is an lvalue
int& foo();
foo() = 42; // ok, foo() is an lvalue
int* p1 = &foo(); // ok, foo() is an lvalue

rvalue:

右值不表征对象,非左值表达式就是右值表达式,右值常用来完成以下两件事:

  1. 计算内置操作符(非重载运算符)的一个操作数,比如1 + 2 + 3,即1 + 2是rvalue,作为第二个+的操作数。

  2. 初始化对象

    • 初始化出来的这个对象称为这个表达式的result object
    • int i = 1 + 1; void f(int x); f(1),用来初始化i和参数x

右值包括: - 枚举数 (enumerator) 和除了字符串字面量以外的字面量是 rvalue,比如int i = 100,100是右值

  • a++,因为a++返回的是原来的a,a + b, a || b, a < b, &a 等表达式是 rvalue

  • 目标为非引用类型的 cast expression 是 rvalue,如 int(3.0)

  • this 是 rvalue,因为this不能赋值

  • lambda 表达式是 rvalue

1
2
3
4
5
int foobar();
int j = 0;
j = foobar(); // ok, foobar() is an rvalue
int* p2 = &foobar(); // error, cannot take the address of an rvalue
j = 42; // ok, 42 is an rvalue

Move Semantics

假设类X拥有某些资源的pointer或者handler,所以当类X发生拷贝的时候,需要自定义拷贝构造函数。

1
2
3
4
5
6
7
8
X& X::operator=(X const & rhs)
{
  // [...]
  // Make a clone of what rhs.m_pResource refers to.
  // Destruct the resource that this.m_pResource refers to. 
  // Attach the clone to this.m_pResource.
  // [...]
}

拷贝赋值可能发生的情况

1
2
3
4
X foo();
X x;
// perhaps use x in various ways
x = foo();
对于x = foo()这一语句,发生以下事情:

  • clones the resource from the temporary returned by foo
  • destructs the resource held by x and replaces it with the clone
  • destructs the temporary and thereby releases its resource

用图表示为:

很明显,对于这种声明周期快要结束的temporary object而言重复这种拷贝和析构是低效的,能否提供一种机制让x直接交换temporary object的资源,然后让temporary object在析构的时候将x原来的资源释放掉。

即我们需要一个特殊的copy assignment operator,在等式右边是右值的时候,做到:

1
2
3
// [...]
// swap m_pResource and rhs.m_pResource
// [...]  
这个想法就是C++11提出的move semantics,考虑如何实现函数重载,对于overload of the copy assignment operator,这个mystery type需要有以下特点:

  • 必须是reference
  • 如果传入rvalue则选择mystery type,lvalue则选择ordinary reference

1
2
3
4
5
6
X& X::operator=(<mystery type> rhs)
{
  // [...]
  // swap this->m_pResource and rhs.m_pResource
  // [...]  
}
这种特殊的referenceRvalue Reference,效果如下:

1
2
3
4
5
6
7
8
void foo(X& x); // lvalue reference overload
void foo(X&& x); // rvalue reference overload

X x;
X foobar();

foo(x); // argument is lvalue: calls foo(X&)
foo(foobar()); // argument is rvalue: calls foo(X&&)
有了右值引用后,可以重载任何函数,

1
2
3
4
5
6
X& X::operator=(X const & rhs); // classical implementation
X& X::operator=(X&& rhs)
{
  // Move semantics: exchange content between this and rhs
  return *this;
}

Forcing Move Semantics(std::move)

C++设计右值引用最开始是为了实现move semantics以提高性能,但是如果程序员同样需要在lvalue上实现move semantics,C++也提供了对应的机制,比如下面这个模板函数:

template<class T>
void swap(T& a, T& b)
{
    T tmp(a);
    a = b;
    b = tmp;
}

X a , b;
swap(a, b)

这里并没有右值,但是这里的swap如果使用移动语义,性能更好,省区拷贝析构的代价,所以C++11提供了一个std::move(),它将其参数转换为右值,而不执行任何其他操作,即程序员如果意思到当前的对象可能发生资源转换,可以通过std::move将其转换为右值,使用过std::move后的object里面资源状态是未确定的,Moved-from objects exist in an unspecified, but valid, state,即做下面事情是合法的

  • destruction
  • assignment
  • const observers such as get, empty, size

使用std::move后的swap函数如下:

1
2
3
4
5
6
7
8
// T类需要实现移动构造和移动赋值
template<class T>
void swap(T& a, T& b)
{
   T c = std::move(a);
   a = std::move(b);
   b = std::move(c);
}

注意如果我们写下a = std::move(b),我们希望ab的资源发生交换,但是如果仅仅是发生交换,被交换后a拥有的资源何时被析构暂时没做定义,所以需要在交换后要保证b处于可以被析构以及赋值的状态。

X& X::operator=(X&& rhs)
{

  // Perform a cleanup that takes care of at least those parts of the
  // destructor that have side effects. Be sure to leave the object
  // in a destructible and assignable state.

  // Move semantics: exchange content between this and rhs

  return *this;
}

Is an Rvalue Reference an Rvalue

考虑下面这个函数,假设X已经实现移动语义。 一般如果考虑传入的是右值引用,那么下面的代码应该调用X(X&& rhs),但是正确的判断的准则为:

if it has a name, then it is an lvalue. Otherwise, it is an rvalue.

移动语义学的全部意义在于只在“无关紧要”的地方应用它,即我们移动的对象在移动之后立即死亡和消失。因此有了这样的规则,比如:

  • 对于x = foo()是在「不影响结果的地方」使用,因为这样一定不会带来问题

  • 对于std::move,则是「程序员明确知道这里使用移动不影响结果」,程序员对这样的移动语义负责

但是对于X anotherX = x;,由于后续的操作可能使用x,并且仍在作用域中,所以窃取是危险的行为。

1
2
3
4
5
void foo(X&& x)
{
  X anotherX = x; // 这里实际上调用 copy constructor
  // ...
}

应用if-it-has-a-name rule可以有以下判断,goo()的返回值没有名字,但是我们通过右值引用延长了其声明周期,所以为xvalue

1
2
3
4
5
6
7
8
void foo(X&& x)
{
  X anotherX = x; // calls X(X const & rhs)
}

X&& goo(); // goo的返回值没有名字,但是我们通过右值引用延长了其声明周期
X x = goo(); // calls X(X&& rhs) because the thing on
             // the right hand side has no name

上面的这一点要在继承类的构造函数中特别注意,比如Base类实现了移动语义,Derived继承了Base,对于Derived的构造函数:

// wrong
Derived(Derived&& rhs) 
  : Base(rhs) // wrong: rhs is an lvalue
{
  // Derived-specific stuff
}

// ok
Derived(Derived&& rhs) 
  : Base(std::move(rhs)) // good, calls Base(Base&& rhs)
{
  // Derived-specific stuff
}

Move Semantics and Compiler Optimizations

考虑下面的代码片段:如果禁用Return Value Optimization,可能会天真的以为这个函数会调用copy constructor来用x初始化返回值临时对象,然后局部变量x会被析构,既然已知x的生命周期,能够采用move-constructor来构造返回值临时对象

1
2
3
4
5
6
X foo()
{
  X x;
  // perhaps do something to x
  return x;
}
即把代码修改为:
1
2
3
4
5
6
X foo()
{
  X x;
  // perhaps do something to x
  return x;
}
但是,相比在constructing an X locally然后copying it out, 现代编译器通常采用会construct the X object directly at the location of foo's return value,即称为return value optimization,这优于move semantics

Perfect Forwarding

设计move semantics的目的之一是为了解决perfect forwarding problem,那到底什么是完美转发:

  • 完美转发:接收任意数量实参的函数模板,它可以将实参转发到其他的函数,使目标函数接收到的实参与被传递给转发函数的实参保持一致

比如考虑下面的函数模板:想让factory function的arg转发到 T's constructor.

1
2
3
4
5
template<typename T, typename Arg>
std::shared_ptr<T> factory(Arg arg)
{
    return std::shared_ptr<T>(new T(arg));
}
但是按照模板的推导规则,上面的代码会导致call by value,这导致采用引用为参数的构造函数失败,是否对ParamType变为引用即解决问题呢,即

1
2
3
4
5
template<typename T, typename Arg>
std::shared_ptr<T> factory(Arg& arg)
{
    return std::shared_ptr<T>(new T(arg));
}
按照模板的推导规则,这个函数无法传入右值:

factory<X>(41) // error
factory<X>(hoo()); // error if hoo returns by value

这个错误可以通过修改为const解决:但是如果factory函数参数的个数变多,这个方法变得不可扩展,你需要提供各种const&non-const变体,而且这种方法这种方法没法实现移动语义,因为这里全是左值,但是右值引用可以解决这个问题。

1
2
3
4
5
template<typename T, typename Arg>
std::shared_ptr<T> factory(Arg const & arg)
{
    return std::shared_ptr<T>(new T(arg));
}
这里涉及reference collapsing的规则:这个规则还可以应用在模板的ParamType的推导上, - T& & becomes T& - T& && becomes T& - T&& & becomes T& - T&& && becomes T&&

对于ParamType是一个通用引用的情景,规则是:

  • 如果expr是右值,忽略引用,比如对于右值AT被推导为AParamType类型即为A&&
  • 如果expr是左值,TParamType都会被推导为左值引用,比如对于左值A,T被推导为T&,然后T& && -> T&,ParamType类型也为A&
template<typename T>
void foo(T&&);

考虑上面的规则,之前的函数形式修改为:

1
2
3
4
5
template<typename T, typename Arg>
std::shared_ptr<T> factory(Arg&& arg)
{
    return std::shared_ptr<T>(new T(std::forward<Arg>(arg)));
}
注意由于arg是万能引用,对通用引用使用std::forward

这里的std::forward的形式如下: