C++11:移动语义

为什么需要移动语义

指针成员和拷贝构造

  当一个类中含有指针成员时,由于默认的拷贝构造函数只会进行浅拷贝(memcpy),所以当我们写出一下代码时:

class Base {
public:
	Base() :data(new int(0)) {}
	//Base(const Base& base): data(base.data){}  默认的拷贝构造
	~Base() {
		cout << "~Base()" << endl;
		delete data;
	}

	void out() {
		cout << data << ":" << *data << endl;
	}
private:
	int* data;
};

int main() {
	Base base1;

	{
		Base base2(base1);
		base1.out();       // address1:0
		base2.out();       // address2:0
	}                      // ~Base(),base2析构

	base1.out();           // address1:未知
	return 0;
}

  由于 base2 只是拷贝了 base1 的指针,所以它们的指针地址是相同的,当 base2 析构之后,所指向的内存地址已经被释放,当 base1 再去调用的时候已经是野指针了,会造成未知后果。出现这种情况,我们只需要自己重新实现拷贝构造函数重新申请堆空间即可。

Base(const Base& base): data(new int(*base.data)){}

移动语义

  上一部分说到重新实现拷贝构造,但是这样还会一些问题,比如下面的例子:

class Test {
public:
	Test(int a = 0) {//普通构造函数
		d = new int(a);
		cout << "构造函数\n";
	}

	Test(const Test& tmp) {//拷贝构造函数
		d = new int;
		*d = *(tmp.d);
		cout << "拷贝构造函数\n";
	}

	~Test() {//析构函数
		if (d != NULL) {
			delete d;
			cout << "delete d\n";
		}
		cout << "析构函数\n";
	}

	int* d;
};

Test GetTmp() {
	Test h; //调用构造函数
	cout << "Resource from " << __func__ << ": " << (void*)h.d << endl;
	return h;  //调用拷贝构造,将 h 复制给一个临时变量,然后 h 被析构
}

int main() {
	Test obj = GetTmp(); //再次调用拷贝构造,将临时的对象赋值给 obj,临时对象被析构
	cout << "Resource from " << __func__ << ": " << (void*)obj.d << endl;
	return 0;
}

上述代码输出如下:

image.png

  在编译时需要添加-fno-elide-constructors, 此选项的作用是在 g++ 上编译时关闭 RVO。编译器会对返回值进行优化,简称RVO,是编译器的一项优化技术,它涉及(功能是)消除为保存函数返回值而创建的临时对象。

  通过上面的例子看到,临时对象的维护 ( 创建和销毁 ) 对性能有严重影响

  右值引用是用来支持转移语义的。转移语义可以将资源 ( 堆,系统对象等 ) 从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,能够大幅度提高 C++ 应用程序的性能。

  移动语义是和拷贝语义相对的,可以类比文件的剪切与拷贝,当我们将文件从一个目录拷贝到另一个目录时,速度比剪切慢很多。通过移动语义,临时对象中的资源能够转移其它的对象里。

移动语义定义

  在现有的 C++ 机制中,我们可以定义拷贝构造函数和赋值函数。要实现移动语义,需要定义移动构造函数,还可以定义移动赋值操作符。对于右值的拷贝和赋值会调用移动构造函数和移动赋值操作符。

  普通的函数和操作符也可以利用右值引用操作符实现转移语义。

移动构造函数

class Base {
public:
	Base():d(new int(3)) {
		cout << "Construct: " << ++n_cstr << endl;
	}
	Base(const Base& h) :d(new int(*h.d)) {
		cout << "Copy construct: " << ++n_cptr << endl;
	}

	Base(const Base&& h) :d(h.d) {
		this->d = nullptr;
		cout << "Move construct: " << ++n_mvtr << endl;
	}
	~Base() {
		delete this->d;
		cout << "Destruct: " << ++n_dstr << endl;
	}
public:
	int* d;
	static int  n_cstr;
	static int  n_dstr;
	static int  n_cptr;
	static int  n_mvtr;
};
int  Base::n_cstr = 0;
int  Base::n_dstr = 0;
int  Base::n_cptr = 0;
int  Base::n_mvtr = 0;

Base GetTemp() {
	Base temp;
	cout << "Resource from" << __func__ << ": " << hex << temp.d << endl;
	return temp;
}

int main() {
	Base a = GetTemp();
	cout << "Resource from" << __func__ << ": " << hex << a.d << endl;
	return 0;
}

上述代码输出如下

image.png

调用了两次移动构造,没有调用拷贝构造,且 GetTemp() 中的 h.dmain 中的 h.d 指向的是同一片地址空间,成功的拜托了对内存的重新拷贝赋值、析构带来的性能浪费。有了右值引用和转移语义,我们在设计和实现类时,对于需要动态申请大量资源的类,应该设计移动构造函数和移动赋值函数,以提高应用程序的效率。

移动构造和拷贝构造函数类似,有几点需要注意:

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

移动赋值函数

class Base {
public:
	Base():d(new int(3)) {
		cout << "Construct: " << ++n_cstr << endl;
	}
	Base(const Base& h) :d(new int(*h.d)) {
		cout << "Copy construct: " << ++n_cptr << endl;
	}

	Base(Base&& h) :d(h.d) {
		h.d = nullptr;
		cout << "Move construct: " << ++n_mvtr << endl;
	}

	Base& operator=(const Base& tmp) {
		if (&tmp == this)
			return *this;
		delete d;
		d = new int(*tmp.d);
		cout << "赋值运算符重载函数" << endl;
	}

	Base& operator=(Base&& tmp) {
		this->d = tmp.d;
		tmp.d = nullptr;
		cout << "移动赋值函数" << endl;
		return *this;
	}

	~Base() {
		delete this->d;
		cout << "Destruct: " << ++n_dstr << endl;
	}
public:
	int* d;
	static int  n_cstr;
	static int  n_dstr;
	static int  n_cptr;
	static int  n_mvtr;
};
int  Base::n_cstr = 0;
int  Base::n_dstr = 0;
int  Base::n_cptr = 0;
int  Base::n_mvtr = 0;

Base GetTemp() {
	Base temp;
	cout << "Resource from" << __func__ << ": " << hex << temp.d << endl;
	return temp;
}

int main() {
	Base a;
	a = GetTemp();
	cout << "Resource from" << __func__ << ": " << hex << a.d << endl;
	return 0;
}