std::move和std::forward是C++0x中新增的标准库函数,分别用于实现移动语义和精准转发(完美转发)。
这篇文章中,我们先只对移动语义进行阐述。
一、在进行移动语义的阐述前,我们先介绍一下右值对象的概念。
C++03 3.10/1 中说,每一个表达式,左值或右值必居其一。认识到一点很重要,即:左右值是表达式的属性,而不是物件的。
- 左值表示一个对象可以在该表达式外持续存在。例如:*ptr , ptr[index] , 和 ++x 都是左值。
- 右值表示的是一个临时对象,它将在所在的表达式结束后被销毁。例如,1729 , x + y , std::string("meow") , 和 x++ 都是右值。
注意,++x是左值,x++是右值。同时,要记住的是,可以对左值进行取地址操作,右值则不行。
我们可以使用普通引用指向一个左值,只能使用const引用指向右值。const Type& 可以指向任何变量:可修改的左值、常量左值、可修改的右值以及常量右值,并且可以通过该引用观察它们。
对于右值,我们专门又引入了一个新的符号&&,来表示这是一个右值引用。
int&& iNum = 42;
iNum++;
cout << "iNum = " << iNum << endl;
iNum输出结果为43。你可能会很惊奇,告诉你,这里iNum绑定的是一个匿名的右值整型变量。
我们知道,一个引用是一个名字,所以不管是左引用还是右值引用,就像上面的iNum一样,这个引用本身是一个左值。是的,是一个左值。
二、说句大白话,移动语义很像是浅拷贝的优化版本。既使用了浅拷贝的好处,又避免了浅拷贝的缺点。妙极!
用Effective C++中的话说,移动语义用于含有“pimpl手法”的类型。pimpl是“pointer to implementation”的缩写。也就是说,类的成员有指针,指向动态分配的堆内存。如果指针成员指向的是一块很大的内存,且源对象在拷贝后就立即被销毁了。这就是不必要的拷贝。在这些情况下,我们让目标对象的指针直接指向原始内存块,而不是再新建一个,便可以大幅度提高性能。看如下这个例子:
string s0("my mother told me that");
string s1("cute");
string s2("fluffy");
string s3("kittens");
string s4("are an essential part of a healthy diet");
接下来,你想执行下面这条语句,你猜会发生什么?
string dest = s0 + " " + s1 + " " + s2 + " " + s3 + " " + s4;
当然,上面这条语句会在几微秒内执行完毕。但我们现在关注的不是绝对的时间,而是效率。
我们知道,每一次调用operator+(),都会返回一个临时string对象。这里总共调用了8次operator+(),也就是说会产生8个临时string对象。对于每一个string临时对象,都会调用其构造函数,动态分配一块新的内存,并将字符串拷贝到新分配的内存块中。之后,当string临时对象销毁时,再动态去释放该内存块。当然,对于MSVC,当string字符串的长度不超过15个字符的时候,会静态保存在string对象中,而不会去动态分配内存。所以我们精心选了一个s0,它的长度远远超过了15个字符的限制,所以是会分配动态内存的。
事实上,因为每次调用构造函数,都会向新分配的内存中写入目前已拼接好的所有字符,所以会分配8个临时内存块,之后,向string dest的第9个新内存块中拷贝后,8个内存块再逐一销毁。这有些浪费。是的,得承认,当拼接s0 + " " 时,绝对有必要创建一个新的string临时对象,因为我们不能修改s0的值。但是,对于 (s0 + " ") + s1 ,我们可以简单地把s1的内容附加在第一个临时string对象上,而不必要去创建第二个临时对象。之后的每一个operator+() 都应该把后面的字符串的内容附加到第一个临时对象上,而不是去创建新对象,这正是移动语义的核心思想。
从技术实现的角度来讲,上面目前还是我们的美好愿望而已。在C++11中,每一次调用operator+() 仍然会创建一个新的string临时对象,不过,对于新的临时对象,是窃取了第一个临时对象的内存,并将第一个临时对象的内部管理内存的指针赋空操作,而不是再重新开辟一块。之后,将新的字符串内容附加到原来的内存块中。当然,附加的时候,可能因为原始内存块大小不够,从而引发新的物理内存分配。当第一个临时对象销毁时,由于其内部指针为nullptr,所以其析构器不会做任何事情。
三、移动语义
这种窃取资源的思想在很多地方都很有用,例如vector的reallocation。移动语义的引入是为了解决在进行大数据复制的时候,将动态申请的内存空间的所有权直接转让出去,不用进行大量的数据移动,既节省空间又提高效率。要实现移动语义,就必须让编译器知道什么时候复制,什么时候移动语义,而这就是右值引用发挥作用的地方。移动语义一定会修改右值的值,所以,右值引用参数不能是const。
通过构造复制构造函数和移动构造函数来实现复制和移动语义。复制构造使用const &引用,而移动构造函数使用普通 && 引用。被移动语义的数据交出了所有权,为了不出现析构两次同一数据区,要将交出所有权的数据的指向动态申请内存去的指针赋值位nullptr,即空指针,对空指针执行delete[]是合法的。
编译器判断构造函数中是左值还是右值,然后调用相应的复制构造函数或者移动构造函数来构造数据。强制移动,就是让左值使用移动构造函数,强制让其交出所有权。Utility文件中声明,std::move()函数。
为了下面的代码讲解,我们先引入一个类,这是用于管理内存缓冲区的 C++ 的类 MemoryBlock:
// MemoryBlock.h
#pragma once
#include <iostream>
#include <algorithm>
class MemoryBlock
{
public:
// Simple constructor that initializes the resource.
explicit MemoryBlock(size_t length)
: _length(length)
, _data(new int[length])
{
std::cout << "In MemoryBlock(size_t). length = "
<< _length << "." << std::endl;
}
// Destructor.
~MemoryBlock()
{
std::cout << "In ~MemoryBlock(). length = "
<< _length << ".";
if (_data != NULL)
{
std::cout << " Deleting resource.";
// Delete the resource.
delete[] _data;
}
std::cout << std::endl;
}
// Copy constructor.
MemoryBlock(const MemoryBlock& other)
: _length(other._length)
, _data(new int[other._length])
{
std::cout << "In MemoryBlock(const MemoryBlock&). length = "
<< other._length << ". Copying resource." << std::endl;
std::copy(other._data, other._data + _length, _data);
}
// Copy assignment operator.
MemoryBlock& operator=(const MemoryBlock& other)
{
std::cout << "In operator=(const MemoryBlock&). length = "
<< other._length << ". Copying resource." << std::endl;
if (this != &other)
{
// Free the existing resource.
delete[] _data;
_length = other._length;
_data = new int[_length];
std::copy(other._data, other._data + _length, _data);
}
return *this;
}
// Retrieves the length of the data resource.
size_t Length() const
{
return _length;
}
private:
size_t _length; // The length of the resource.
int* _data; // The resource.
};
四、移动构造函数
// Move constructor.
MemoryBlock(MemoryBlock&& other)
: _data(NULL)
, _length(0)
{
std::cout << "In MemoryBlock(MemoryBlock&&). length = "
<< other._length << ". Moving resource." << std::endl;
// Copy the data pointer and its length from the
// source object.
_data = other._data;
_length = other._length;
// Release the data pointer from the source object so that
// the destructor does not free the memory multiple times.
other._data = NULL;
other._length = 0;
}
在这段代码中,我们可以清晰地看到,我们的目标对象成功地窃取了源对象的内存块,并将源对象的指针赋空,以防止其销毁时破坏该内存块。
五、移动赋值运算符
移动赋值运算符 与 移动构造函数 有一点不同:赋值运算符 同时担任了析构函数 和 移动构造函数 的功能。首先,要释放自身的资源,以防内存泄漏。我们通过代码来看一下:
// Move assignment operator.
MemoryBlock& operator=(MemoryBlock&& other)
{
std::cout << "In operator=(MemoryBlock&&). length = "
<< other._length << "." << std::endl;
if (this != &other)
{
// Free the existing resource.
delete[] _data;
// Copy the data pointer and its length from the
// source object.
_data = other._data;
_length = other._length;
// Release the data pointer from the source object so that
// the destructor does not free the memory multiple times.
other._data = NULL;
other._length = 0;
}
return *this;
}
- 若要防止资源泄漏,请始终释放移动赋值运算符中的资源(如内存、文件句柄和套接字)。
- 若要防止不可恢复的资源损坏,请正确处理移动赋值运算符中的自我赋值。
- 如果为您的类同时提供了移动构造函数和移动赋值运算符,则可以编写移动构造函数来调用移动赋值运算符,从而消除冗余代码。 以下示例显示了调用移动赋值运算符的移动构造函数的修改后的版本:
// Move constructor.
MemoryBlock(MemoryBlock&& other)
: _data(NULL)
, _length(0)
{
*this = std::move(other);
}
我们可以看到,在初始化列表中,指针_data已经赋空,所以调用赋值运算符,先释放_data,无影响。
到此为止,我们讲完了万里长征的第一步。我们成功定义了移动构造函数和移动赋值运算符,但是何时调用,则要用到标准命名空间中的std::move()函数,下一讲,我们来着重讲解std::move()函数。
本文Done!