std::move移动语义

std::move 概念

参考:《深入理解C++11:新特性解析与应用》
在 C++11 中,标准库在 <utility> 中提供了一个有用的函数 std::move,这个函数的名字具有迷惑性,实际上 std::move 并不能移动任何东西,唯一的功能就是将一个左值强制转换为一个右值引用,继而可以通过右值引用使用该值,以便用于移动语义。
从实现上来看,std::move 基本等同于一个类型转换:

static_cast<T&&>(lvalue);

值得一提的是,被转化的左值,其生命期并没有随着左右值的转化而改变。如果期望 std::move 转换的左值变量 lvalue 能立即被析构,那么肯定会失望了。看以下例子:

#include <iostream>
using namespace std;

class Moveable
{
public:
    Moveable() : i(new int(3)) {}
    ~Moveable() { delete i; }
    Moveable(const Moveable& m) : i(new int(*m.i)) {}
    Moveable(Moveable&& m) : i(m.i)
    {
        m.i = nullptr;
    }
    int *i;
};

int main()
{
    Moveable a;
    Moveable c(std::move(a)); //调用移动构造函数
    cout << *a.i << endl;     //运行时错误
}

我们为类型 Moveable 定义了移动构造函数。但在调用的时候使用了 Moveable c(std::move(a)); 这样的语句。将 a 转换右值。这样一来,a.i 就被 c 的移动构造函数设置为非空值。由于 a 等到 main 结束才能析构,随后对 *a.i 操作直接发生严重错误。这就是一个典型的误用 std::move 的例子。
要用该函数,必须清楚需要转换的时候。
我们要转换为右值引用的对象是一个确定生命即将结束的对象,看以下正确的例子:

#include <bits/stdc++.h>
using namespace std;

class HugeMen
{
public:
    HugeMen(int size) : sz(size > 0 ? size : 1)
    {
        cout << "HugeMen Contruct." << endl;
        c = new int[sz];
    }
    ~HugeMen() { delete[] c; }
    HugeMen(const HugeMen& hm) : sz(hm.sz), c(new int(*hm.c))
    {
        cout << "HugeMen Copy." << endl;
    }
    HugeMen(HugeMen&& hm) : sz(hm.sz), c(hm.c)
    {
        cout << "HugeMen Move." << endl;
        hm.c = nullptr;
    }
    int *c;
    int sz;
};

class Moveable
{
public:
    Moveable() : i(new int(3)), h(1024)
    {
        cout << "Moveable Contruct." << endl;
    }
    ~Moveable() { delete i; }

    Moveable(const Moveable& m) : i(new int(*m.i)), h(m.h)
    {
        cout << "Moveable Copy." << endl;
    }

    Moveable(Moveable&& m) : i(m.i), h(std::move(m.h))
    {
        cout << "Moveable Move." << endl;
        m.i = nullptr;
    }
    int *i;
    HugeMen h;
};

Moveable GetTemp()
{
    Moveable tmp = Moveable(); //构造 + 移动
    return tmp;                //移动
}

int main()
{
    Moveable a(GetTemp()); //移动
}

这个例子的运行结果是:

$ ./test 
HugeMen Contruct.
Moveable Contruct.
HugeMen Move.
Moveable Move.
HugeMen Move.
Moveable Move.
HugeMen Move.
Moveable Move.

在 Moveable 的移动构造函数中,看到了 std::move 的使用,将 m.h 强制转换为右值,迫使 Moveable 中的 h 能够实现移动构造。因为 m 将在表达式 Moveable a(GetTemp()); 结束后被析构掉,所以可以使用std::move。另外使用 std::move 使用是必要的。如果不适用 std::move(m.h) 这样的表达式,而直接使用 m.h 将会得到如下的运行结果:

$ ./test 
HugeMen Contruct.
Moveable Contruct.
HugeMen Copy.
Moveable Move.
HugeMen Copy.
Moveable Move.
HugeMen Copy.
Moveable Move.

这是 C++ 有趣的地方:可以接受右值的右值引用本身却是个左值。这里的 m.h 引用了一个确定的对象,而且 m.h 也有名字,可以使用 &m.h 取到地址,因此是个不折不扣的左值。但是它确实会很快灰飞烟灭,在 Moveable a(GetTemp()); 构造完对象 a 后就结束了。这里使用 std::move 强制其为右值就不会有问题了。若不这样做,由于 m.h 是个左值,就会调用 HugeMem 的拷贝构造函数来构造 Moveable 的成员 h。移动语义就没有想类的成员传递下去,而且会有一定的性能损失。
为了保证移动语义的传递,在编写移动构造函数时,总应该记得使用 std::move 转换拥有形如堆内存、文件句柄等资源的成员为右值。这样一来,即使没有移动构造的话,也能接受常量左值的构造函数版本也会轻松实现拷贝,因此也不会引起大的问题。

移动语义的一些要点

如果这样声明移动构造函数:

Moveable(const Moveable&&)

或者这样声明函数:

const Moveable ReturnVal();

都会使得临时变量常量化,成为一个常量右值,临时变量的引用也就无法修改,导致无法实现移动语义。
因此,在实现移动语义时一定要排除掉不必要的 const 关键字。
C++11 中,拷贝/移动构造函数实际上有以下三个版本:

T Object(T& );
T Object(const T&); //拷贝构造
T Object(T&&);	//移动构造

默认情况下,编译器会隐式生成一个默认移动构造函数(隐式表示不被使用则不生成)。但是如果程序员声明了自定义的拷贝构造函数、赋值构造函数、移动赋值函数、析构函数中的一个或多个,编译器都不会再生成默认版本。默认的移动构造和默认的拷贝构造一样,只能做简单的拷贝工作,对于一些简单的不包含资源的的类型而言,实现移动语义无关紧要,因为移动就是拷贝。
同样声明了移动构造函数、移动赋值函数、赋值构造函数、析构函数中的一个或多个,编译器也不会再生成默认的拷贝构造函数。
所以在 C++ 11 中,拷贝构造/赋值和移动构造/赋值必须同时提供,或者同时不提供,才能保证类同时具有拷贝和移动语义。只声明其中一种的话,类仅能实现一种语义。
仅实现一种语义也比较常见。C++11 之前大多数类型的构造函数都是只是用拷贝语义。
而使用移动语义的类型很有趣,因为只有移动语义表明该类型的变量所拥有的资源只能被移动,而不能被拷贝,因此,移动语义的构造往往都是资源型的类型。比如说:智能指针,文件流等,都可以视为资源型类型。
在标准库中头文件 <type_traits> 里,可以通过一些辅助的模板类来判断一个类型是否是可移动的。比如:is_move_constructibleis_trivially_move_constructibleis_nothrow_move_constructible,使用方法仍然是其成员 value。如:

cout << is_move_constructible<UnknownType>::value << endl;

就可以打印出 UnkbowType 是否可以移动,在一些情况下是非常有用的。
有了移动语义,还有一个比较典型的应用是可以实现高性能的置换(swap)函数。看下面这段 swap 模板函数代码:

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

如果类型 T 是可以移动的,那么移动构造和移动赋值将会被用于这个置换。整个过程,代码都按照移动语义进行指针交换,不会有资源的释放与申请。如果 T 不可移动但可以拷贝,就会用拷贝语义来进行置换,但效率较低。这对于泛型编程来说,是很有意义的。
移动语义的异常处理
对于移动语义来说,抛出异常是危险的,可能移动语义没有完成,一个异常抛出来,导致一些指针成为悬挂指针。因此程序员编写时为其添加一个 noexcept 关键字,保证移动构造函数中抛出异常会直接调用 terminate 程序终止运行,而不是造成指针悬挂状态。
标准库中,可以用一个 std::move_if_noexcept 的模板函数代替 move 函数。该函数在类的移动构造函数没有 noexcept 关键字修饰时返回一个左值引用从而使变量可以使用拷贝语义,而在类的移动构造函数有 noexcept 关键字时,返回一个右值引用,从而使变量可以使用移动语义。用法如下:

struct Nothrow
{
 	Nothrow() {}
    //...
    Nothrow(const Nothrow&&) noexcept
    {
        std::cout << "Nothrow move constructor." << endl;
    }
};

int main()
{
   	Nothrow n;
    Nothrow nt = move_if_noexcept(n);
    return 0;
}

move_if_noexcept 是牺牲性能保证安全的一种做法,而且要求类的开发者对移动构造函数使用 noexcept 进行描述,否则就会损失更多性能。这是库的开发者和使用者必须协同平衡考虑的。
RVO优化与移动语义
编译器中被称为 RVO/NRVO 的优化(返回值优化),事实上很多测试代码都使用了 -fno-elide-constructors 选项在 g++ 中关闭了这个优化,编译时如果不使用该选项的话,很多构造和移动都被省略了。比如如下代码:

A ReturnRvalue() { A a(); return a; }
A b = ReturnRvalue();

代开 g++ 的 RVO,对象 a 的拷贝和移动都没有了。b 变量实际使用了 ReturnRvalue 中 a 的地址,也就是 b 直接“霸占”了 a 变量。这是编译器中一个效果非常好的优化。不过 RVO 并不是对任何情况都有效。有些情况下,一些构造是无法省略的。还有一些情况下,即使 RVO 完成了,也不能达成最好的效果。但最终的结论是:移动语义可以解决编译器无法解决的优化问题。因为总是有用的。

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

code_peak

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值