Loading...
墨滴

阿酷尔工作室

2021/10/16  阅读:29  主题:默认主题

移动语义-右值引用-完美转发-万字长文让你一探究竟

C++ 右值引用

block://6984617523950616580?from=docs_block&id=ce31003ceb5efb1f7a7c0a5fbe6cb60191627a38

右值的引入

作为在 C++11 中引入的一个类型,容易引起误解的是,右值引用并没有说明引入是为了什么,是为了解决什么问题。

右值引用可以解决以下问题

  1. 实现移动语义
  2. 完美转发

左值和右值来自原先的 C 语言,左值可以出现在赋值左边或者右边,而右值只能出现在赋值的右边

int a = 42;
int b = 43;

// a and b are both l-values:
a = b; // ok
b = a; // ok
a = a * b; // ok

// a * b is an rvalue:
int c = a * b; // ok, rvalue on right hand side of assignment
a * b = 42// error, rvalue on left hand side of assignment

在 C++ 中,这作为第一个直观的左值和右值方法仍然很有用。但是,带有用户定义类型的 C++ 引入了一些关于可修改性和可分配性的微妙之处,导致此定义不正确。我们没有必要进一步讨论这个问题。这是一个替代定义,尽管它仍然存在争议,但它将使您能够处理右值引用:左值是一个引用内存位置的表达式,并允许我们通过&操作符取得地址,右值,不是左值的都是右值。

// lvalues:
//
int i = 42;
i = 43// ok, i is an lvalue
int* p = &i; // ok, i is an lvalue
intfoo();
foo() = 42// ok, foo() is an lvalue
int* p1 = &foo(); // ok, foo() is an lvalue

// rvalues:
//
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

移动语义

假设有一个类 X,类中的成员变量 m_pResource 是一个需要花费时间和内存取进行构造和析构的类型,比如 m_pResource 是一个vector类型,对其进行赋值时将会产生大量的析构和构造函数的调用。

X& X::operator=(X const & rhs)
{
  // [...]
  // Make a clone of what rhs.m_pResource refers to.
  // Destruct the resource that m_pResource refers to.
  // Attach the clone to m_pResource.
  // [...]
}

同样的问题会出现在copy构造函数上

foo();
X x;
// perhaps use x in various ways
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.

当赋值操作符的右边是右值的话,只是交换值的指针是比较高效的

// [...]
// swap m_pResource and rhs.m_pResource
// [...]

上述这种操作就是移动语义,可以通过操作符重载实现

X& X::operator=(<mystery type> rhs)
{
  // [...]
  // swap this->m_pResource and rhs.m_pResource
  // [...]
}

以上调用无论是赋值还是copy构造函数,都会导致大量的构造函数和析构函数调用(如当 vector 中存储很多的类对象时),因此我们当然希望能够实现对传入类型的引用,从而避免这些构造函数和析构函数的调用

block://6984620384730546178?from=docs_block&id=ce31003ceb5efb1f7a7c0a5fbe6cb60191627a38

右值引用

如果 X 是一个类型,那么 X&& 就是对 X 类型的右值引用,为了更好的区分 X&被称为左值引用

一个右值引用类型很多地方表现与左值引用相同,除了一些例外。最重要的一条就是,当进行函数重载的时候,左值当成参数传入函数,偏向调用左值引用的函数;当右值传入函数时,更加偏向调用右值重载的函数

void foo(X& x)// 左值函数重载
void foo(X&& x)// 右值函数重载

X x;
foobar();

foo(x); // argument is lvalue: calls foo(X&)
foo(foobar()); // argument is rvalue: calls foo(X&&)

Rvalue references allow a function to branch at compile time (via overload resolution) on the condition "Am I being called on an lvalue or an rvalue?"

大体意思就是,右值引用允许编译器期间通过是右值还是左值调用不同的函数

当然你可以使用上述方法重载任何函数,就像上述所示。但是通常会被用于重载拷贝构造函数和赋值构造函数,用来实现移动语义

X& X::operator=(X const & rhs); // classical implementationX& X::operator=(X&& rhs)
{
  // Move semantics: exchange content between this and rhs
  return *this;
}
Note: If you implement
void foo(X&)
;
but not
void foo(X&&)
;
then of course the behavior is unchanged: foo can be called on l-values, but not on r-values. If you implement
void foo(X const &)
;
but not
void foo(X&&)
;
then again, the behavior is unchanged: foo can be called on l-values and r-values, but it is not possible to make it distinguish between l-values and r-values. That is possible only by implementing
void foo(X&&)
;
as well. Finally, if you implement
void foo(X&&)
;
but neither one of
void foo(X&)
;
and
void foo(X const &)
;
then, according to the final version of C++11, foo can be called on r-values, but trying to call it on an l-value will trigger a compile error.

强制移动语义

我们都知道,在给予更多控制权和避免粗心大意犯错方面 C++选择给予更多的控制权,你不但可以在右值上实现移动语义,而且你可以自行决定在左值上实现移动语义,一个很好的例子就是 std::swap 函数

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

X a, b;
swap(a, b);

这里没有使用右值,因此有没有实现移动语义,但是我们知道实现移动语义会更好,只要变量作为复制构造或者赋值的源出现,该变量要么根本就不再使用,要么就作为赋值的目标。

C++11 中与一个被调用的库函数std::move可以将其参数转换 右值, 不做其他事情

void swap(T& a, T& b)
{
  tmp(std::move(a));
  a = std::move(b);
  b = std::move(tmp);
}

X a, b;
swap(a, b);

修改之后上述三行实现了移动语义,需要注意的是,对于那些没有实现移动语义的类型(即:没有使用右值引用版本重载它们的复制构造函数和赋值运算符),对于这些类型新的 swap 就和旧的一样

既然、知道了移动语义std::move,如下:

a = b;

你期望在这里发生什么?你期望 a 持有的对象被 b 的复制出来的副本替换,并且希望 a 先前持有的对象析构,现在我们考虑一下语义:

a = std::move(b);

如果实现了移动语义,会交换 a 和 b 持有的对象,不会有任何对象进行析构。当然结束之后 a 原先持有的对象的生命周期将和 b 的作用范围绑定,b 超出范围 a 原先持有的对象将会被销毁。

所以从某种意义上说,我们在这里陷入了非确定性破坏的阴暗世界:一个变量已被分配,但该变量以前持有的对象仍在某处。只要该对象的销毁不会产生任何外界可见的副作用,就可以了。但有时析构函数确实有这样的副作用。一个例子是释放析构函数内的锁。因此,具有副作用的对象销毁的任何部分都应该在复制赋值运算符的右值引用重载中显式执行:

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;
}

右值引用就是右值吗?

像以前一样,我们为 X 实现复制构造函数和赋值操作符重载来实现移动语义。

假如:

void foo(X&& x)
{
    // x是右值引用,但是x本身是一个左值,以为x是有命名的
    X anotherX = x; //  调用右值引用赋值重载函数还是左值???
    // ...
}

代码中函数内 x 是一个左值引用,然而我们期望让右值引用就是本身就是右值。右值引用的设计者提供了一个更好的思路:

Things that are declared as rvalue reference can be lvalues or rvalues. The distinguishing criterion is: if it has a name, then it is an lvalue. Otherwise, it is an rvalue.

大意就是,右值引用可以是左值也可以是右值,评判的标准是,如果这个值有命名就是左值,如果没有就是右值。

那么上述代码中,虽然参数传进的是右值,但是进入函数的时候,因为 x 已经有命名了,所以函数内部的 x 是左值,那么函数内部调用的也是左值的赋值函数

void foo(X&& x)
{
  X anotherX = x; // calls X(X const & rhs)
}

如下是一个没有名字的右值,因此会调用右值赋值函数

X&& goo();
X x = goo(); // calls X(X&& rhs) because the thing on
             // the right hand side has no name

这种设计的背后思路就是:允许移动语义应用于一些有名字的对象

X anotherX = x;
  // x is still in scope!

以上语句是非常危险的,移动的食物应该在移动后立即死亡并消失,因此有一条规则,如果它有一个名字,那么它就是左值

如果没有名字,那么他就是个右值,如果有名字需要使用 std::move()进行转换,std::move()通过将其参数转换为右值,即使这个这个参数不是右值。

在编程的过程中,时刻注意变量是否有一个名字,也就是注意变量是否是右值非常的重要。

假设你实现了一个基类,当然为了实现移动语义你要给基类实现复制构造函数和复制操作符重载

Base(Base const & rhs); // non-move semantics
Base(Base&& rhs); // move semantics

现在假设已实现了一个类 Derived 继承了 Base 基类,为了确保 Derived 类中继承的 Base 也实现了移动语义,你必须实现 Derived 的复制构造函数和赋值操作符,我们先看下复制构造函数的重载

Derived(Derived const & rhs)
  : Base(rhs)
{
  // Derived-specific stuff
}

可以看到很简洁,只需要将传递个 Derived 的参数,复制构造的时候传递给 Base 就可以了,那我们来看下移动复制构造函数的实现:

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

如果我们这样调用,那么将会调用基类的非移动语义的复制构造函数,因为 rhs 传递给 Base 的时候,是有名字的,所以是按照左值传递的。如果我们想按照移动语义进行调用,我们可以按照如下的方式实现:

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

移动语义和编译优化

考虑到有如下函数定义:

foo()
{
  X x;
  // perhaps do something to x
  return x;
}

现在想象一下,我们通过对 X 类重载复制构造函数和赋值操作符实现了移动语义,如果你只从表面上来看,上述代码中 x 变量,在进行 return 的时候,会存在值得复制,就是局部变量 x 复制给返回值,来让我们使用移动语义优化一下吧:

foo()
{
  X x;
  // perhaps do something to x
  return std::move(x); // making it worse!
}

实际上这样写之后,会使事情变得比以前更糟。因为现代的编译器都会实行返回值优化(RVO),换句话说,比起构造一个局部的 x 对象,并将其复制出去,编译器更倾向于直接构造返回值并将其按照引用的方式在函数内部使用。

例如:

class X {

public:
    X() {
        cout << "Construct X " << endl;
    }

    ~X() {
        cout << "Destruct X" << endl;
    }
};

ReturnValueOptimization() {
    X x;

    return x;
}

int main(int argc, char* argv[]) {

    X retValue = ReturnValueOptimization();

    return 0;
}

函数执行返回

$ ./return_value_optimization
Construct X
Destruct X

如果没有执行返回值优化,那么按照代码字面的意思,正常的应该是要进行两次构造和析构,但是从执行结果可以看出,实际上只执行了一次构造和析构函数,因此在有返回值的情况下,现在的编译器都会对其进行返回值优化

所以为了正确的使用右值和移动语义,你需要充分考虑当今编译器的特殊效果。例如返回值优化和复制省略等;

完美转发的问题

另外一个需要基于右值引用实现的移动语义来进行解决的问题就是完美转发,看下如下函数:

template<typename T, typename Arg>
shared_ptr<T> factory(Arg arg)
{
  return shared_ptr<T>(new T(arg));
}

显然函数的目的是为了实现从 factory 函数将参数完美转发给 T 的构造函数,理想的情况下,应该能够实现就像外层的 factory 函数不存在一样,并且构造函数能够直接调用用户传进来的参数--这就是完美转发。上面函数的问题就是 factory 函数按照值进行参数传递,更坏的情况是如果 T 的构造函数按照引用用参数,那么将带来严重的后果。

好一点的改进就是,factory 函数按照引用来进行参数传递,如 boost::bind 函数一样

template<typename T, typename Arg>
shared_ptr<T> factory(Arg& arg)
{
  return shared_ptr<T>(new T(arg));
}

改进之后相对于按照值传递好一点,但是并不是很完美,问题在于 factory 函数不能按照右值调用。

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

当然,这样的问题能够使用增加 const 修饰符来解决:

template<typename T, typename Arg>
shared_ptr<T> factory(Arg const & arg)
{
  return shared_ptr<T>(new T(arg));
}

当然使用 const 还是会存在问题,首先如果 factory 不止有一个参数,你必须为所有参数的组合提供函数的 const 重载,因此对于多个参数的解决方案非常的有限。

其次,这种转发并不够完美,因为 factory 函数内的 arg 是一个左值,因此移动语义不可能发生,即使没有 factory 函数题也不会发生

最后,我们可以使用移动语义解决上述的两个问题,移动语义可以实现真实意义上的完美转发,为了了解如何实现,我们需要知道另外两个右值引用的规则:

完美转发的解决方式

第一条:右值引用的规则,同样也会影响左值引用。C++11 之前不允许使用引用的引用,如果 A& &将会造成编译错误,C++11 中中引入了一下折叠规则:

  • A& & --> A&
  • A& && --> A&
  • A&& & --> A&
  • A&& && --> A&

第二条: 对于使用右值引用的模板函数,有一个特殊的模板推倒规则

template<typename T>
void foo(T&&)
;
  1. 当使用类型为 A 的左值调用 foo 函数的时候,根据折叠规则,参数将变成 A&
  2. 当 foo 被一个类型为 A 的右值调用的时候,T 将会被解析成 A,因此参数类型会变成 A&&

因此定义一个模板函数,能够实现对左值和右值的同时支持。

有了以上的规则,我们就可以着手解决上述遇到的不能完美转发的问题:

template<typename T, typename Arg>
shared_ptr<T> factory(Arg&& arg)
{
  return shared_ptr<T>(new T(std::forward<Arg>(arg)));
}
where std::forward is defined as follows:template<class S>S&& forward(typename remove_reference<S>::type& a) noexcept
{
  return static_cast<S&&>(a);
}

不用关注 noexcept,它只是为了告诉编译器编译优化意图,该函数不会抛出任何异常。

假设:factory 函数被一个类型为 X 的左值调用:

X x;
factory<A>(x);

然后根据折叠规则,factory 的模板参数 Arg 将会被解析成 X&,编译器将会生成如下的 factory 函数和 std::forward

shared_ptr<A> factory(X& && arg)
{
  return shared_ptr<A>(new A(std::forward<X&>(arg)));
}
X& && forward(remove_reference<X&>::type& a) noexcept
{
  return static_cast<X& &&>(a);
}

经过折叠之后

shared_ptr<A> factory(X& arg)
{
  return shared_ptr<A>(new A(std::forward<X&>(arg)));
}
X& std::forward(X& a)
{
  return static_cast<X&>(a);
}

这样左值也实现了完美转发,工厂函数经过两次间接传递,将参数 arg 传递给构造函数,并且是通过老式的左值引用。

现在我们假设工厂函数 factory被一个类型为 X 的右值调用:

foo();
factory<A>(foo());

经过折叠规则之后 factory 函数将如下:

shared_ptr<A> factory(X&& arg)
{
  return shared_ptr<A>(new A(std::forward<X>(arg)));
}
X&& forward(X& a) noexcept
{
  return static_cast<X&&>(a);
}

该模板函数实现了对右值的完美转发,经过两次引用传递之后 A 的构造函数还是拿到了右值的 arg 参数,并且经过 forward 转发之后,A 构造函数拿到的变量没有名字。因此根据无"无名"规则,该变量就是一个右值。因此将调用 A 的右值构造函数

接下来让我们看下 std::move(),该函数知识实现了将传给其的参数转绑定成像右值一样,如下是其实现:

template<class T>
typename remove_reference<T>:
:type&&
std::move(T&& a) noexcept
{
  typedef typename remove_reference<T>::type&& RvalRef;
  return static_cast<RvalRef>(a);
}

假设我们向 std::move()传递一个类型为 X 的左值:

X x;
std::move(x);

根据新的模板推导付规则,模板参数 T 会被解析成 X&,因此编译器最终实例化是:

typename remove_reference<X&>::type&&
std::move(X& && a) noexcept
{
  typedef typename remove_reference<X&>::type&& RvalRef;
  return static_cast<RvalRef>(a);
}

经过参数折叠和 remove_reference 的作用之后,会生成如下代码:

X&& std::move(X& a) noexcept
{
  return static_cast<X&&>(a);
}

到这里 std::move()所实现的事情就一目了然了,就是接收左值引用,并将其转换为无明明的右值引用。

右值引用和异常

当你采用 C++进行代码开发时,是否使用在你的代码里使用异常处理是你决定的事情,但是右值引用比较特殊。当你为一个类实现移动语义,进行复制构造函数重载和赋值函数实现的时候,必须要遵循一下规则:

  1. 确保重载的方式不会引发异常,因为移动语义通常只是在两个资源之间交换指针和资源句柄
  2. 如果你成功的实现了不抛出异常的重载,要确保在函数上加上 noexcept 关键字

如果你没有实现上述两种规则,那么至少一种常见的场景下是不能使用你定义的移动语义的:当一个 std::vector()被调整大小的时候,你希望调整大小的时候能发生移动语义,但是除非你实现以上两条规则否则移动语义在这里不会发生-- Effective Modern C++条

隐式移动

在右值引用的复杂讨论中,标准委员会提出移动构造函数、移动赋值操作符,编译器应当自动生成-在用户没有提供的情况下。这看起来很正常,因为编译器会在用户没有提供的情况下,自动的提供构造函数和赋值操作符的默认实现。但是 Scott Meyers 向编译器提交了一个消息posted a message on comp.lang.c++,里面详细论述了如果编译器提供移动语义的构造函数和赋值操作符的实现,将会对以前已经存在的代码引入一个非常严重的问题。当然也可以参考 Scott Meyers 的 Effective Modern C++的地 17 条

推荐: C++右值引用 Implicit Move Must Go

A Brief Introduction to Rvalue References

Implicit Move Must Go C++ Rvalue references Explained

加好友,备注C++拉你进群,带你从C++入门到精通

阿酷尔工作室

2021/10/16  阅读:29  主题:默认主题

作者介绍

阿酷尔工作室

恒生研究院