C++11新特性——移动语义,右值引用

移动语义

有一些类的资源是__不可共享__的,这种类型的对象可以被移动但不能被拷贝,如:IOunique_ptr
库容器、stringshared_ptr 支持拷贝和移动,IOunique_ptr 则只能移动不能拷贝。。

右值引用

右值引用是必须绑定到右值的引用,右值引用使用 && 符号,相较于左值引用的& 。右值引用有一个特性就是其只能绑定到即将销毁的对象上,因而,可以自由的移动右值引用对象中的资源。

左值表示对象的身份,而右值表示对象的值。不能将左值引用(lvalue reference)绑定到需要转型的值、字面量或者返回右值的表达式上。右值引用则刚好相反:可以将右值引用绑定到以上的值,但不能直接将右值引用绑定到左值。如:

int i = 42;
int &r = i;
int &&rr = i; //错误:不能将右值引用绑定到左值上
int &r2 = i * 42; //错误:不能将左值引用绑定到右值上
const int &r3 = i * 42; //可以将 const 左值引用绑定到任何类型的值上(const/非 const 的左/右值)
int &&rr2 = i * 42; //将右值引用绑定到右值上

返回左值引用的函数和赋值、下标操作、解引用和前缀自增/自减操作符都是返回左值的表达式,可将左值引用绑定到这些表达式的结果中。

返回非引用类型的函数与算术、关系、位操作和后缀自增/自减的操作符都是返回右值的表达式,可将右值引用和 const 左值引用绑定到这种表达式上。

变量是左值

一个变量就是一个表达式,其只有一个操作数而没有操作符。变量表达式是左值。因而,不能将右值引用绑定到一个定义为右值引用的变量上。如:

int &&rr1 = 42;
int &&rr2 = rr1; //错误:rr1 是左值,因而不能这样定义

一个变量就是一个左值;不能直接将右值引用绑定到一个变量上,即使这个变量被定义为右值引用类型也不可以。

但是如果临时对象通过一个接受右值的函数传递给另一个函数时,就会变成左值,因为这个临时对象在传递过程中,变成了命名对象。

move库函数

template< class T >                                                                                                                  (C++11 起)
typename std::remove_reference::type&& move( T&& t ) noexcept;         (C++14 前)
template< class T >                                                                                                                  (C++14 起)
constexpr typename std::remove_reference::type&& move( T&& t ) noexcept;

可以显式将左值强转为对应的右值引用类型,也可以通过调用 move 库函数来获取绑定到左值的右值引用,其被定义在 utility 头文件中。如:

int &&rr3 = std::move(rr1);

调用 move 告知编译器,以右值方式对象一个左值。特别需要了解的是调用 move 将承诺:不会再次使用 rr1 ,除非是赋值或者析构。当调用了 move 之后,不能对这个对象做任何值上的假设。可以析构或赋值给移动后的对象,但在此之前不能使用其值。

使用 move 的代码应该使用 std::move ,而不是 move,这样做可以避免潜在的名字冲突。

移动构造函数和移动赋值

为了让我们自己的类可以执行移动操作,需要定义移动构造函数和移动赋值操作符。这些成员类似于对应的拷贝赋值操作,但是他们将从给定对象中偷取资源而不是复制。

  1. 参数(右值)不可以是常量,因为我们需要修改右值。
  2. 参数(右值)的资源链接和标记必须修改。否则,右值的析构函数就会释放资源。转移到新对象的资源也就无效了。

除了移动资源,移动构造函数需要保证移动后的对象的状态是析构无害的。特别是,一旦资源被移动后,原始对象就不再指向移动了的资源,这些所有权被转移给了新创建的对象。如:

StrVec::StrVec(StrVec &&s) noexcept :
    elements(s.elements), first_free(s.first_free), cap(s.cap)
{
    s.elements = s.first_free = s.cap = nullptr;
}

与拷贝构造函数不同,移动构造函数并不会分配新资源;其将攫取参数中的内存,在此之后,构造函数体将参数中的指针都设置为 nullptr,当一个对象被移动后,这个对象依然存在。最后移动后的对象将被析构,意味着析构函数将在此对象上运行。析构函数将释放其所拥有的资源,如果没有将指针设置为 nullptr 的,就会将移动了的资源给释放掉

移动操作,库容器和异常

移动操作通常不必自己分配资源,所以移动操作通常不抛出任何异常。当我们写移动操作时,由于其不会抛出异常,我们应当告知编译器这个事实。除非编译器知道这个事实,它将必须做额外的工作来满足移动构造操作将抛出异常。

通过在函数参数列表后加上 noexcept ,在构造函数时则,noexcept 出现在参数列表后到冒号之间,来告知编译器一个函数不会抛出异常。如:

class StrVec {
public:
    StrVec(StrVec &&) noexcept;
};
StrVec::StrVec(StrVec &&s) noexcept : { ... }

必须同时在类体内的声明处和定义处同时指定 noexcept

移动构造函数和移动赋值操作符,如果都不允许抛出异常,那么就应该被指定为 noexcept

告知移动操作不抛出异常是由于两个不相关的事实:第一,尽管移动操作通常不抛出异常,它们可以这样做。第二,有些库容器在元素是否会在构建时抛出异常有不同的表现,如:vector 只有在知道元素类型的移动构造函数不会抛出异常才使用移动构造函数,否则将必须使用拷贝构造函数

移动赋值操作符

StrVec& StrVec::operator=(StrVec &&rhs) noexcept
{
    if (this == &rhs)
        return *this;
    free();
    elements = rhs.elements;
    first_free = rhs.first_free;
    cap = rhs.cap;

    rhs.elements = rhs.first_free = rhs.cap = nullptr;
    return *this;
}

移动赋值操作符不抛出异常应当用 noexcept 修饰,与拷贝赋值操作符一样需要警惕自赋值的可能性。移动赋值操作符同时聚合了析构函数和移动构造函数的工作:其将释放左操作数的内存,并且占有右操作数的内存,并将右操作数的指针设为 nullptr

移动后的对象必须是可以析构的

移动对象并不会析构那个对象,有时在移动操作完成后,被移动的对象将被销毁。因而,当我们写移动操作时,必须保证移动后的对象的状态是可以析构的。StrVec 通过将其指针设置为 nullptr 来满足此要求。

除了让对象处于可析构状态,移动操作必须保证对象处于有效状态。通常来说,有效状态就是可以安全的赋予新值或者使用在不依赖当前值的方式下。另一方面,移动操作对于遗留在移动后的对象中的值没有什么特别要求,所以,程序不应该依赖于移动后对象的值

例如,从库 string 和容器对象中移动资源后,移动后对象的状态将保持有效。可以在移动后对象上调用 emptysize 函数,然而,并不保证得到的结果是空的。可以期望一个移动后对象是空的,但是这并不保证。

以上 StrVec 的移动操作将移动后对象留在一个与默认初始化一样的状态。因而,这个 StrVec 的所有操作将与默认初始化的 StrVec 的操作完全一样。其它类,有着更加复杂的内部结构,也许会表现的不一致。

在移动后操作,移动后对象必须保证在一个有效状态,并且可以析构,但是用户不能对其值做任何假设

*合成移动操作

编译器会为对象合成移动构造函数和移动赋值操作符。然而,在什么情况下合成移动操作与合成拷贝操作是十分不同的。

与拷贝操作不同的,对于某些类来说,编译器根本不合成任何移动操作。特别是,如果一个类定义自己的拷贝构造函数、拷贝赋值操作符或析构函数,移动构造函数和移动赋值操作符是不会合成的。作为结果,有些类是没有移动构造函数或移动赋值操作符。同样,当一个类没有移动操作时,对应的拷贝操作将通过函数匹配被用于替代移动操作。

编译器只会在类没有定义任何拷贝控制成员并且所有的非 static 数据成员都是可移动的情况下才会合成移动构造函数和移动赋值操作符。编译器可以移动内置类型的成员,亦可以移动具有对应移动操作的类类型成员。

移动操作不会隐式被定义为删除的,而是根本不定义,当没有移动构造函数时,重载将选择拷贝构造函数。当用 =default 要求编译器生成时,如果编译器无法移动所有成员,将会生成一个删除的移动操作。被删除的函数不是说不能被用于函数重载,而是说当其是重载解析时最合适的候选函数时,将是编译错误。

  • 与拷贝构造函数不同,当类有一个成员定义了自己的拷贝构造函数,但是没有定义移动构造函数时使用拷贝构造函数。当成员没有定义自己的拷贝操作但是编译器无法为其合成移动构造函数时,其移动构造函数被定义为被删除的。对于移动赋值操作符是一样的;
  • 如果类有一个成员其移动构造函数或移动赋值操作符是被删除的或不可访问的,其移动构造函数或移动赋值操作符被定义为被删除的;
  • 与拷贝构造函数一样,如果其析构函数是被删除的或不可访问的,移动构造函数被定义为被删除的;
  • 与拷贝赋值操作符一样,如果其有一个 const 或引用成员,移动赋值操作被定义为删除的;

如果一个类定义自己的移动构造函数或移动赋值操作符,那么合成的拷贝构造函数和拷贝赋值操作符都将被定义为被删除的。

右值移动,左值拷贝

当一个类既有移动构造函数又有拷贝构造函数,编译器使用常规的函数匹配来决定使用哪个构造函数。拷贝构造函数通常使用 const StrVec 引用类型作为参数,因而,可以匹配可以转为 StrVec 类型的对象参数。而移动构造函数则使用 StrVec && 作为参数,因而,只能使用非 const 的右值。如果调用拷贝形式的,需要将参数转为 const 的,而移动形式的却是精确匹配,因而,右值将调用移动形式的

右值在无法被移动时进行拷贝

如果一个类有拷贝构造函数,但是没有定义移动构造函数,在这种情况下编译不会合成移动构造函数,意味着类只有拷贝构造函数而没有移动构造函数。如果一个类没有移动构造函数,函数匹配保证即便是尝试使用 move 来移动对象时,它们依然会被拷贝。

class Foo {
public:
    Foo() = default;
    Foo(const Foo&); //拷贝构造函数
};
Foo x;
Foo y(x);  //拷贝构造函数;x 是左值
Foo z(std::move(x)); //拷贝构造函数;因为没有移动构造函数

调用 move(x) 时返回 Foo&&Foo 的拷贝构造函数是可行的,因为可以将 Foo&& 转为 const Foo& ,因而,使用拷贝构造函数来初始化 z

使用拷贝构造函数来替换移动构造函数通常是安全的,对于赋值操作符来说是一样的。拷贝构造符合移动构造函数的先决条件:它将拷贝给定的对象,并且不会改变其状态,这样原始对象将保持在有效状态内。

拷贝和交换赋值操作与移动

class HasPtr {
public:
    HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) {
        p.ps = 0;
    }
    HasPtr& operator=(HasPtr rhs)
    {
        swap(*this, rhs);
        return *this;
    }
};

赋值操作符的参数是非引用类型的,所以参数是拷贝初始化的。根据参数的类型,拷贝初始化可能使用拷贝构造函数也可能使用移动构造函数。左值将被拷贝,右值将被移动。因而,这个移动操作符既是拷贝赋值操作符又是移动赋值操作符。如:

hp = hp2;
hp = std::move(hp2);

所有五个拷贝控制成员应该被当做一个整体:通常,如果一个类定义了其中任何一个操作,它通常需要定义所有成员。有些类必须定义拷贝构造函数,拷贝赋值操作符和析构函数才能正确工作。这种类通常有一个资源是拷贝成员必须拷贝的,通常拷贝资源需要做很多额外的工作,定义移动构造函数和移动赋值操作符可以避免在不需要拷贝的情况的额外工作。

移动迭代器

在新标准中,定义了移动迭代器(move iterator)适配器。移动迭代器通过改变迭代器的解引用操作来适配给定的迭代器。通常,迭代器解引用返回元素的左值引用,与其它迭代器不同,解引用移动迭代器返回右值引用。调用函数 make_move_iterator 将常规迭代器变成移动迭代器,移动迭代器的操作与原始迭代器操作基本一样,因而可以将移动迭代器传给 uninitialized_copy 函数。如:

uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()));

值得一提的是标准库没有说哪些算法可以使用移动迭代器,哪些不可以。因为移动对象会破坏原始对象,所以将移动迭代器传给那些不会在移动后访问其值的算法才合适。

慎用移动操作:由于移动后的对象处于中间状态,在对象上调用 std::move 是很危险的。当调用 move 后,必须保证没有别的用户使用移动后对象。

谨慎克制的在类内使用 move 可以提供重大的性能提升,在用户代码中使用 move 则更可能导致难以定位的 bug,相比较得到的性能提升是不值得的。

在类实现代码外使用 std::move,必须是在确实需要移动操作,并且保证移动是安全的。

右值引用和成员函数

除了构造函数和赋值操作符外提供拷贝和移动版本亦会受益。这种可以移动的成员函数中一个使用 const 左值引用,另一个使用非 const 右值引用。如:

void push_back(const X&); //拷贝:绑定到任何类型的 X
void push_back(X&&); //移动:绑定到可修改的右值 X

可以传递任何可以转换为类型 X 的对象给拷贝版本,这个版本从参数中拷贝数据。只能将非 const 右值传递给移动版本。此版本比拷贝版本更好的匹配非 const 右值(精确匹配),因而,在函数匹配中将是更优的,并且可以自由的从参数中移动资源。

通常上面这种重载方式不会使用 const X&&X& 类型的参数,原因在于移动数据要求对象是非 const 的,而拷贝数据则应该是 const 的。

以拷贝或移动的方式对函数进行重载,常用的做法是一个版本使用 const T& 为参数,另外一个版本使用 T&& 为参数。

右值与左值引用的成员函数

有些成员函数是只允许左值调用的,右值是不能调用的,如:在新标准前可以给两个字符串拼接的结果赋值:s1 + s2 = "wow!"; ,在新标准中可以强制要求赋值操作符的左操作数是左值,通过在参数列表后放置引用修饰符(reference qualifier)可以指示 this 的左值/右值特性。如:

class Foo {
public:
    Foo& operator=(const Foo&) &;
};
Foo& Foo::operator=(const Foo& rhs) &
{ return *this; }

引用修饰符可以是 & 或者 && 用于表示 this 指向左值或右值。与 const 修饰符一样,引用修饰符必须出现在非 static 成员函数的声明和定义处。被 & 修饰的函数只能被左值调用,被 && 修饰的函数只能被右值调用。

一个函数既可以有 const 也可以有引用修饰符,在这种情况下,引用修饰符在 const 修复符的后面。如:

class Foo {
public:
    Foo someMem() const &;
};

重载带引用修饰符的成员函数

可以通过函数的引用修饰符进行重载,这与常规的函数重载是一样的,&& 可以在可修改的右值上调用,const & 可以在任何类型的对象上调用。如:

class Foo {
public:
    Foo sorted() &&; //可以在可修改的右值上调用
    Foo sorted() const &; //可以在任何类型的 Foo 上调用
};

当定义具有相同名字和相同参数列表的成员函数时,必须同时提供引用修饰符或者都不提供引用修饰符,如果只在其中一些提供,而另外一些不提供就是编译错误。如:

class Foo {
public:
    Foo sorted() &&;
    Foo sorted() const; //错误:必须提供引用修饰符

    //全不提供引用修饰符是合法的
    using Comp = bool(const int&, const int&);
    Foo sorted(Comp*);
    Foo sorted(Comp*) const;
};

精确传递 (Perfect Forwarding)

本文采用精确传递表达这个意思。Perfect Forwarding也被翻译成完美转发,精准转发等,说的都是一个意思。

精确传递适用于这样的场景:需要将一组参数原封不动的传递给另一个函数。

“原封不动”不仅仅是参数的值不变,在 C++ 中,除了参数值之外,还有一下两组属性:

左值/右值和 const/non-const。 精确传递就是在参数传递过程中,所有这些属性和参数值都不能改变。在泛型函数中,这样的需求非常普遍。

下面举例说明。函数 forward_value 是一个泛型函数,它将一个参数传递给另一个函数 process_value

forward_value 的定义为:

template  void forward_value(const T& val) {
 process_value(val);
}
template  void forward_value(T& val) {
 process_value(val);
}

函数 forward_value 为每一个参数必须重载两种类型,T&const T&,否则,下面四种不同类型参数的调用中就不能同时满足 :

int a = 0;
 const int &b = 1;
 forward_value(a); // int&
 forward_value(b); // const int&
forward_value(2); // int&

对于一个参数就要重载两次,也就是函数重载的次数和参数的个数是一个正比的关系。这个函数的定义次数对于程序员来说,是非常低效的。我们看看右值引用如何帮助我们解决这个问题 :

template  void forward_value(T&& val) {
 process_value(val);
}

只需要定义一次,接受一个右值引用的参数,就能够将所有的参数类型原封不动的传递给目标函数。四种不用类型参数的调用都能满足,参数的左右值属性和 const/non-cosnt属性完全传递给目标函数 process_value。这个解决方案不是简洁优雅吗?

int a = 0;
const int &b = 1;
forward_value(a); // int&
forward_value(b); // const int&
forward_value(2); // int&&

C++11 中定义的 T&& 的推导规则为:

右值实参为右值引用,左值实参仍然为左值引用。

一句话,就是参数的属性不变。这样也就完美的实现了参数的完整传递。

右值引用,表面上看只是增加了一个引用符号,但它对 C++ 软件设计和类库的设计有非常大的影响。它既能简化代码,又能提高程序运行效率。每一个 C++ 软件设计师和程序员都应该理解并能够应用它。我们在设计类的时候如果有动态申请的资源,也应该设计转移构造函数和转移拷贝函数。在设计类库时,还应该考虑 std::move 的使用场景并积极使用它。

关键术语

  • 拷贝-交换(copy and swap):一种书写赋值操作符的技术,先将右操作数拷贝到参数中,然后调用 swap 将其与左操作数进行交换;

  • 拷贝赋值操作符(copy-assignment operator):拷贝赋值操作符与本类的 const 引用对象作为参数,返回对象的引用。如果类不定义拷贝赋值操作符,编译器将合成一个;

  • 拷贝构造函数(copy constructor):将新对象初始化为本类的另一个对象的副本的构造函数。拷贝构造函数将在以非引用方式传递参数或从函数中返回时默认调用。如果类不定义的话,编译器将合成一个;

  • 拷贝控制(copy control):用于控制对象被拷贝、移动、赋值和销毁时应当做什么的成员函数。如果类不定这些函数,编译器将在合适的时候合成它们;

  • 拷贝初始化(copy initialization):使用 = 形式的初始化,或者当传递参数、按值形式返回值,或者初始化数组或聚合类时,将进行拷贝初始化。拷贝初始化将根据初始值是左值还是右值,使用拷贝构造函数或者移动构造函数;

  • 被删除的函数(deleted function):不被使用的函数,通过 =delete 来删除函数。使用被删除的函数是告知编译器在进行函数匹配时,如果匹配到被删除的函数就报编译器错误;

  • 析构函数(destructor):当对象离开作用域时调用的特殊成员函数来清理对象。编译器自动销毁每个数据成员,类成员通过调用其析构函数进行销毁,内置类型或符合类型将不做任何析构操作,特别是指向动态对象的指针不会被自动 delete

  • 逐个成员拷贝/赋值(memberwise copy/assign):合成的拷贝/移动构造函数和拷贝/移动赋值操作符的运作方式。依次对所有的数据成员,拷贝/移动构造函数通过从参数中拷贝/移动对应的成员进行初始化;拷贝/移动赋值操作符则依次对右操作数的各个成员进行拷贝/移动赋值;内置类型的成员是直接进行初始化或赋值的。类类型成员则调用对应的拷贝/移动构造函数或拷贝/移动赋值操作符;

  • move 函数(move function):用于将左值绑定到右值引用的库函数。调用 move 将隐式保证不会使用移动后的对象值,唯一的操作是析构或者赋予新值;

  • 移动赋值操作符(move-assignment operator):参数是右值引用的赋值操作符版本。通常移动赋值操作符将其右操作数的数据移动到左操作数。在赋值后,必须保证可以安全的析构掉右操作数;

  • 移动构造函数(move constructor):以右值引用为参数的构造函数。移动构造函数将参数中的数据移动到新创建的对象中。在移动后,必须保证可以安全地析构掉右操作数;

  • 移动迭代器(move iterator):迭代器适配器,包装一个迭代器,当其解引用时返回右值引用;

  • 右值引用(rvalue reference):对即将被销毁的对象的引用;

你可能感兴趣的:(C++)