为什么需要noexcept
为了说明为什么需要noexcept,我们还是从一个例子出发,我们定义MyClass类,并且我们先不对MyClass类的移动构造函数使用noexcept
class MyClass
{
public:
MyClass()
{}
MyClass(const MyClass& lValue)
{
std::cout << "拷贝构造函数" << std::endl;
}
MyClass(MyClass&& rValue) // 注意这里,我们没有对移动构造函数使用noexcept
{
std::cout << "移动构造函数" << std::endl;
}
private:
std::string str{ "hello" };
};
接着,我们创建一个MyClass的对象A,并且将其往classes容器中添加2次
MyClass A{};
std::vector<MyClass> classes;
classes.push_back(A);
classes.push_back(A);
现在,我们来梳理一下流程。classes容器在定义时默认会申请1个元素的内存空间。当第1次执行classes.push_back(A);时,对象A会被拷贝到容器第1个元素的位置:
当第2次执行classes.push_back(A);时,由于classes容器已没有多余的内存空间,因此它需要分配一块新的内存空间。在分配新的内存空间之后,classes容器会做2个操作:将对象A拷贝到容器第2个元素的位置,以及将之前的元素放到新的内存空间中容器第1个元素的位置:
细心的小伙伴一定发现了,如上图所示那般,老的元素是被拷贝到新的内存空间中的。是的,classes容器确实使用的是拷贝构造函数。那么此时我们会想到,既然classes容器已经不需要之前的内存中的数据了,那么将老数据放到新的内存空间中应该使用移动语义,而非拷贝操作。
那么为什么classes容器没有使用移动语义呢?
此时,我们需要提及一个概念,即“强异常保证(strong exception guarantee)”。所谓强异常保证,即当我们调用一个函数时,如果发生了异常,那么应用程序的状态能够回滚到函数调用之前:
那么强异常保证和决定使用移动语义或拷贝操作又有什么关系呢?
这是因为容器的push_back函数是具备强异常保证的,也就是说,当push_back函数在执行操作的过程中(由于内存不足需要申请新的内存、将老的元素放到新内存中等),如果发生了异常(内存空间不足无法申请等),push_back函数需要确保应用程序的状态能够回滚到调用它之前。以上面的例子来说,当第2次执行classes.push_back(A);时,如果发生了异常,应用程序的状态会回滚到第1次执行classes.push_back(A);之后,即classes容器中只有一个元素。
由于我们的移动构造函数没有使用noexcept说明符,也就是我们没有保证移动构造函数不会抛出异常。因此,为了确保强异常保证,就只能使用拷贝构造函数了。那么拷贝构造函数同样没有保证不会抛出异常,为什么就能用呢?这是因为拷贝构造函数执行之后,被拷贝对象的原始数据是不会丢失的。因此,即使发生异常需要回滚,那些已经被拷贝的对象仍然完整且有效。但移动语义就不同了,被移动对象的原始数据是会被清除的,因此如果发生异常,那些已经被移动的对象的数据就没有了,找不回来了,也就无法完成状态回滚了。
为移动语义使用noexcept说明符
在了解了以上的规则后,我们就清楚了,要想使用移动构造函数来将老的元素放到新的内存中,我们就需要告知编译器,我们的移动构造函数不会抛出异常,可以放心使用,这就是通过noexcept说明符完成的。
我们来修改下MyClass类的移动构造函数,为其加上noexcept说明符:
class MyClass
{
public:
MyClass()
{}
MyClass(const MyClass& lValue)
{
std::cout << "拷贝构造函数" << std::endl;
}
MyClass(MyClass&& rValue) noexcept // 注意这里,为移动构造函数使用noexcept
{
std::cout << "移动构造函数" << std::endl;
}
private:
std::string str{ "hello" };
};
现在,我们再次执行上文的例子,会发现使用的是移动构造函数来创建新的内存中的元素了:
关于noexcept说明符,是个庞大的话题,这里我们只是粗略的提及和移动语义有关的部分。值得注意的是,noexcept说明符是我们对于不会抛出异常的保证,如果在执行的过程中有异常被抛出了,应用程序将会直接终止执行。
NRVO
在C++中,存在称为“NRVO(named return value optimization,命名返回值优化)”的技术,即如果函数返回一个临时对象,则该对象会直接给函数调用方使用,而不会再创建一个新对象。听起来有点晦涩,我们来看一个例子:
class MyClass
{};
MyClass GetTemporary()
{
MyClass A{};
return A;
}
MyClass myClass = GetTemporary(); // 注意这里
在上面的例子中,GetTemporary函数会创建一个临时的MyClass对象A,接着在函数结束时返回。在没有NRVO的情况下,当执行语句MyClass myClass=GetTemporary();时,会调用MyClass类的拷贝构造函数,通过对象A来拷贝创建myClass对象。
我们可以发现,在创建完myClass对象之后,对象A就被销毁了,这无疑是一种浪费。因此,编译器会启用NRVO,直接让myClass对象使用对象A。这样一来,在整个过程中,我们只有一次创建对象A时构造函数的调用开销,省去了拷贝构造函数以及析构函数的调用开销。
为NRVO点赞!
此时,可能有细心的小伙伴已经发现了,这种返回临时对象的情况不就是移动语义发挥的场景嘛。没错,机智的你是不是会想到如下的修改:
MyClass GetTemporary()
{
MyClass A{};
return std::move(A); // 使用移动语义
}
这样一来,通过移动语义,即使没有NRVO,也可以避免拷贝操作。乍看上去没啥毛病,但我们忽略了一种情况,那就是返回的对象类型并没有实现移动语义。
让我们来分析一下这种情况,我们改写一下MyClass类:
class MyClass
{
public:
~MyClass() // 注意这里,通过声明析构函数,我们禁止了编译器去实现默认移动构造函数
{}
};
现在,MyClass类型没有实现移动语义,当我们执行语句MyClass myClass=GetTemporary();时,编译器没有办法调用移动构造函数来创建myClass对象。同时,遗憾的是,由于std::move(A)返回的类型是MyClass&&,与函数的返回类型MyClass不一致,因此编译器也不会使用NRVO。最终,编译器只能调用拷贝构造函数来创建myClass对象。
因此,当返回局部对象时,我们不用画蛇添足,直接返回对象即可,编译器会优先使用最佳的NRVO,在没有NRVO的情况下,会尝试执行移动构造函数,最后才是开销最大的拷贝构造函数。