vector与不抛出异常的移动构造

概念说明

  C++ Primer中有这个一条提示:不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。
  C++11引入了移动语义,其中一个重要的概念是移动构造函数。移动构造函数是一个特殊的构造函数,用于将一个对象的资源所有权从一个对象转移到另一个对象,而不是进行复制。这可以提高性能,特别是对于大型对象或需要频繁传递的对象,因此我们希望在合适的时机尽可能使用移动语义。
  vector作为C++中常用的标准库容器,大多数人都知道vector有扩容机制,即vector在插入元素时,如果vector的内部存储空间不足时,会自动扩容以容纳更多的元素。具体而言,vector会自动分配一个新的内存块,并将原有的元素复制到新的内存块中。通常,我们希望在扩容复制的时候有更好的性能,因此希望vector能够用到移动语义。
  noexcept能够帮助我们进一步理解其中的细节。通常来说,移动操作只是“转移”资源,是不抛出异常的,但抛出异常也是被允许的。另外,标准容器能对异常发生时的自身行为提供保障。比如,vector保证,如果push_back是发生异常,那么vector自身不会发生改变。从vector自身的异常保障机制来看,如果vector在扩容重新分配时使用拷贝构造很容易满足上述需求。当在新的内存块中构造元素时,旧的元素依然存在,如果此时发生异常,vector可以放弃重新分配的内存返回到原始状态,保证了异常发生时不会发生改变。然而,移动构造是一种“窃取”资源的行为,一旦发生异常,旧空间中的元素已经被改变,新空间中的构造尚未完成,难以满足vector自身保持不变的要求。
  vector会尽可能的避免上述问题的发生,除非知道元类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造。因此,如果希望vector扩容时会使用移动构造而不是拷贝,我们就需要显式地告知标准库移动构造函数可以安全使用,即通过noexcept来标记为不抛出异常。

代码验证

  本文通过一段代码示例验证上述机制。

vector的异常保护

  首先,通过一段代码测试如果在扩容期间拷贝构造抛出异常,vector的行为特征。代码如下所示:

#include <iostream>
#include <vector>
using namespace std;

class MyException : public std::exception {
public:
    explicit MyException(const std::string& message) : m_message(message) {}

    const char* what() const noexcept override {
        return m_message.c_str();
    }

private:
    std::string m_message;
};

class A {
public:
    A() { cout << "A constructor" << endl; }
    A(const A& a) {
        count ++; 
        cout << "A copy constructor" << endl; 
        // 当拷贝构造被调用超过1次时抛出异常
        if(count > 1){
           throw MyException("exception from copy constructor"); 
        } 
    }
    A(A&& a) { cout << "A move constructor" << endl; }
    static int count;
};

int A::count = 0;

void printVector(const vector<A>& vA){
    cout << "size: " << vA.size() << endl;
    for(const auto& a : vA){
        cout << &a << " ";
    }
    cout << endl;
}

int main(){
    vector<A> vA;

    vA.emplace_back();
    vA.emplace_back();

    try{
        cout << "before exception:" << endl;
        printVector(vA);
        vA.emplace_back();
    } catch(exception & e){
        cout << "Exception caught:" << endl;
        printVector(vA);
    }

    return 0;
}

  某次的执行结果如下所示:

A constructor
A constructor
A copy constructor
before exception:
size: 2
0x1d620a41a70 0x1d620a41a71
A constructor
A copy constructor
Exception caught:
size: 2
0x1d620a41a70 0x1d620a41a71

  其中,第一次emplate_back()时,调用了一次构造函数;第二次emplace_back()时,先调用了一次构造函数,然后因为需要扩容调用了一次拷贝构造;第三次先输出vector的基本信息(此时有先前加入的两个元素),然后emplace_back(),先调用了一次构造函数,然后因为需要扩容调用拷贝构造(超过一次,抛出异常),然后捕获异常后输出vector的基本信息。此时,可以观察到vector处理异常时能够保证其前后的内容不变。

不抛异常的移动构造

  为了验证扩容时的调用规则,用以下代码进行测试。其中,类A和类B都有拷贝构造和移动构造,不同的是类B的移动构造不能抛出异常。通过向vector中插入元素,观察其输出结果。

#include <iostream>
#include <vector>
using namespace std;
class A {
public:
    A() { cout << "A constructor" << endl; }
    A(const A& a) { cout << "A copy constructor" << endl; }
    A(A&& a) { cout << "A move constructor" << endl; }
};

class B{
public:
    B() { cout << "B constructor" << endl; }
    B(const B& b) { cout << "B copy constructor" << endl; }
    B(B&& b) noexcept { cout << "B move constructor" << endl; }
};

int main(){
    vector<A> vA;
    vector<B> vB;

    vA.emplace_back();
    vA.emplace_back();
    vA.emplace_back();
    cout << "==========================\n";
    vB.emplace_back();
    vB.emplace_back();
    vB.emplace_back();

    return 0;
}

  以上代码的运行结果如下:

A constructor
A constructor
A copy constructor
A constructor
A copy constructor
A copy constructor
==========================
B constructor
B constructor
B move constructor
B constructor
B move constructor
B move constructor

  类A和类B的执行结果顺序都是一致的,区别在于调用的类方法不同。其中,第一次emplate_back()时,调用了一次构造函数;第二次emplace_back()时,先调用了一次构造函数,然后因为扩容对已有的一个元素进行复制/移动;第三次emplace_back()时,先调用了一次构造函数,同样因为扩容对已有的两个元素进行复制/移动。显然,类B的因为移动构造函数不抛出异常,所以vector扩容时调用了移动构造而不是拷贝构造,从而提升了性能。


当珍惜每一片时光~