从拷贝说起
我们知道,C++中有拷贝构造函数和拷贝赋值运算符。那既然是拷贝,听上去就是开销很大的操作。没错,所谓拷贝,就是申请一块新的内存空间,然后将数据复制到新的内存空间中。如果一个对象中都是一些基本类型的数据的话,由于数据量很小,那执行拷贝操作没啥毛病。但如果对象中涉及其他对象或指针数据的话,那么执行拷贝操作就可能会是一个很耗时的过程。
我们来看一个例子,该类中有一个string类型的成员函数,定义如下:
class MyClass
{
public:
MyClass(const std::string& s) :str(s)
{
};
private:
std::string str;
};
MyClass A{"hello"};
当我们新建一个该类的对象A,并传递参数“hello”时,对象A的成员变量str中会存储字符“hello”。而为了存储字符串,string类型会为其分配内存空间。因此,当前内存中的数据如图所示:
现在,当我们定义一个该类的新对象B,且把对象A赋值给对象B时,会发生什么?即,我们执行如下的语句:
MyClass B = A;
当拷贝发生时,为了让B对象中的成员变量str也能够存储字符串“hello”,string类型会为其分配内存空间,并将对象A的str中的数据复制过来,因此,经过拷贝操作后,此时内存中的数据如图所示:
需要移动语义的情况
既然拷贝操作没毛病,那么为什么要新增移动语义呢。因为在一些情况下,我们可能确实不需要拷贝操作,下面的例子。
class MyClass
{
public:
MyClass(const std::string& s):str(s)
{
};
private:
std:string str;
}
std:vector<MyClass> myclasses;
MyClass tmp{"hello"};
myclasses.push_pack(tmp);
myclasses.push_pack(tmp);
在上面的例子中,我们创建了一个容器以及一个MyClass对象tmp,我们将tmp对象添加到容器中2次,每次添加时,都会发生一次拷贝操作,最终内存中的数据如图所示:
现在问题来了,tmp对象在被添加到容器2次后,就不需要了,也就是说,它的生命周期即将结束,那么聪明的你一定想到,既然tmp对象不在需要了,那么将第2次将其添加到容器中的操作是不是就可以不执行拷贝操作了,而是让容器直接取tmp对象的数据继续用,没错,这时,就需要移动语义帅气登场了。
移动语义
所谓移动语义,就像其字面意思一样,即把数据从一个对象中转移到另一个对象中,从而避免拷贝操作所带来的性能损耗。
那么在上面的例子中,我们如何触发移动语义呢?很简单,我们只需要使用std::move函数即可。有关std::move函数,就是另一个话题了,这里我们不深入探讨。我们只需要知道,通过std::move函数,我们可以告知编译器,某个对象不再需要了,可以把它的数据转移给其他需要的对象用。
class Myclass
{
public:
MyClass(const std::string& s):str(s)
{
};
//假设已经实现了移动语义
private:
std::string str;
}
std:vector<MyClass> myclasses;
MyClass tmp{"hello"};
myclasses.push_pack(tmp);
myclasses.push_pack(std::move(tmp));
由于我们还没讲到移动语义的实现,因此这里先假设MyClass类已经实现了移动语义。我们改动的是最后一行代码,由于我们不再需要tmp对象,因此通过使用std::move函数,我们让myClasses容器直接转移tmp对象的数据为已用,而不再需要执行拷贝操作了。
通过数据转移,我们避免了一次拷贝操作,最终内存中的数据如图所示:
至此,我们可以了解到,C++11引入移动语义可以在不需要拷贝函数操作的场合执行数据转移,从而极大的提升程序的运行性能。
左值引用与右值引用
在学习如何实现移动语义之前,我们需要先了解2个概念,即左值引用与右值引用。
为了支持移动语义,C++11引入了一种新的引用类型,称为“右值引用”,使用&&来声明。而我们最常用的&声明的引用,现在我们称为左值引用。
右值引用能够引用没有名称的临时对象以及使用std::move标记的对象
int val{0};
int && rRef0{ getTempValue()}; // ok 引用临时对象
int && rRef1{val}; //Error,不能引用左值
int&& rRef2{ std::move(val) }; // OK,引用使用std::move标记的对象
移动语义的实现需要用到右值引用。以下2中情况会让编译器将对象匹配右值引用:
1:一个语句执行完毕后会被自动销毁的临时对象。
2:由std::move标记的非const对象
区分拷贝操作与移动操作
我们回到上文的例子,对于myClasses容器的第一次push_back,我们期望执行的是拷贝操作,而对于myClasses容器的第二次push_back,由于之后我们不再需要tmp对象了,因此我们期望执行的是移动操作:
class MyClass
{
public:
MyClass(const std::string& s)
: str{ s }
{};
// 假设已经实现了移动语义
private:
std::string str;
};
std::vector<MyClass> myClasses;
MyClass tmp{ "hello" };
myClasses.push_back(tmp); // 这里执行拷贝操作,将tmp中的数据拷贝给容器中的元素
myClasses.push_back(std::move(tmp)); // 这里执行移动操作,容器中的元素直接将tmp的数据转移给自己
现在我们已经知道,移动操作执行的是对象数据的转移,那么它一定是与拷贝操作不一样的。因此,为了能够将拷贝操作与移动操作区分执行,就需要用到我们上一节的主题:左值引用与右值引用。
因此,对于容器的push_back函数来说,它一定针对拷贝操作和移动操作有不同的重载实现,而重载用到的即是左值引用与右值引用。伪代码如下:
class vector
{
public:
void push_back(const MyClass& value) // const MyClass& 左值引用
{
// 执行拷贝操作
}
void push_back(MyClass&& value) // MyClass&& 右值引用
{
// 执行移动操作
}
};
通过传递左值引用或右值引用,我们就能够根据需要调用不同的push_back重载函数了。那么下一个问题来了,我们知道std::vector是模板类,可以用于任意类型。所以,std::vector不可能自己去实现拷贝操作或移动操作,因为它不知道自己会用在哪些类型上。因此,std::vector真正做的,是委托具体类型自己去执行拷贝操作与移动操作。
移动构造函数
当通过push_back向容器中添加一个新的元素时,如果是通过拷贝的方式,那么对应执行的会是容器元素类型的拷贝构造函数。关于拷贝构造函数,它是C++一直以来都包含的功能,相信大家已经很熟悉了,因此在这里就不展开了。
当通过push_back向容器中添加一个新的元素时,如果是通过移动的方式,那么对应执行的会是容器元素类型的“移动构造函数”(敲黑板,划重点)。
移动构造函数是C++11引入的一种新的构造函数,它接收右值引用。以我们前文的MyClass例子来说,为其定义移动构造函数:
class MyClass
{
public:
// 移动构造函数
MyClass(MyClass&& rValue) noexcept // 关于noexcept我们稍后会介绍
: str{ std::move(rValue.str) } // 看这里,调用std::string类型的移动构造函数
{}
MyClass(const std::string& s)
: str{ s }
{}
private:
std::string str;
};
在移动构造函数中,我们要做的就是转移成员数据。我们的MyClass有一个std::string类型的成员,该类型自身实现了移动语义,因此我们可以继续调用std::string类型的移动构造函数。
在有了移动构造函数之后,我们就可以在需要时通过它来创建新的对象,从而避免拷贝操作的开销。以如下代码为例:
MyClass tmp{ "hello" };
MyClass A{ std::move(tmp) }; // 调用移动构造函数
首先我们创建了一个tmp对象,接着我们通过tmp对象来创建A对象,此时传递给构造函数的参数为std::move(tmp)。还记得我们前文提及的编译器匹配右值引用的情况之一嘛,即由std::move标记的非const对象,因此编译器会调用执行移动构造函数,我们就完成了将tmp对象的数据转移到对象A上的操作:
自己手动实现移动语义
在前文的MyClass例子中,我们将移动操作交由std::string类型去完成。那如果我们的类有成员数据需要我们自己去实现数据转移的话,通常该怎么做呢?
我们来举个例子,假设我们定义的类型中包含了一个int类型的数据以及一个char*类型的指针:
class MyClass
{
public:
MyClass()
: val{ 998 }
{
name = new char[] { "Peter" };
}
~MyClass()
{
if (nullptr != name)
{
delete[] name;
name = nullptr;
}
}
private:
int val;
char* name;
};
MyClass A{};
当我们创建一个MyClass的对象时,它在内存的布局如图所示:
现在我们来为MyClass类型实现移动构造函数,代码如下所示:
class MyClass
{
public:
MyClass()
: val{ 998 }
{
name = new char[] { "Peter" };
}
// 实现移动构造函数
MyClass(MyClass&& rValue) noexcept
: val{ std::move(rValue.val) } // 转移数据
{
rValue.val = 0; // 清除被转移对象的数据
name = rValue.name; // 转移数据
rValue.name = nullptr; // 清除被转移对象的数据
}
~MyClass()
{
if (nullptr != name)
{
delete[] name;
name = nullptr;
}
}
private:
int val;
char* name;
};
MyClass A{};
MyClass B{ std::move(A) }; // 通过移动构造函数创建新对象B
还记得移动语义的精髓嘛?数据拿过来用就完事儿了。因此,在移动构造函数中,我们将传入对象A的数据转移给新创建的对象B。同时,还需要关注的重点在于,我们需要把传入对象A的数据清除,不然就会产生多个对象共享同一份数据的问题。被转移数据的对象会处于“有效但未定义(valid but unspecified)”的状态(后文会介绍)。
通过移动构造函数创建对象B之后,内存中的布局如图所示:
移动赋值运算符
与拷贝构造函数和拷贝赋值运算符一样,除了移动构造函数之外,C++11还引入了移动赋值运算符。移动赋值运算符也是接收右值引用,它的实现和移动构造函数基本一致。在移动赋值运算符中,我们也是从传入的对象中转移数据,并将该对象的数据清除:
class MyClass
{
public:
MyClass()
: val{ 998 }
{
name = new char[] { "Peter" };
}
MyClass(MyClass&& rValue) noexcept
: val{ std::move(rValue.val) }
{
rValue.val = 0;
name = rValue.name;
rValue.name = nullptr;
}
// 移动赋值运算符
MyClass& operator=(MyClass&& myClass) noexcept
{
val = myClass.val;
myClass.val = 0;
name = myClass.name;
myClass.name = nullptr;
return *this;
}
~MyClass()
{
if (nullptr != name)
{
delete[] name;
name = nullptr;
}
}
private:
int val;
char* name;
};
MyClass A{};
MyClass B{};
B = std::move(A); // 使用移动赋值运算符将对象A赋值给对象B