1

什么是移动构造

在 C++ 11 标准之前(C++ 98/03 标准中),如果想用其它对象初始化一个同类的新对象,只能借助类中的复制(拷贝)构造函数。在C++11中,引入了右值引用,提供了左值转右值的方法,避免了对象潜在的拷贝。而移动构造函数和移动赋值运算符也是通过右值的属性来实现的。直观的来讲,移动构造就是将对象的状态或者所有权从一个对象转移到另一个对象。只是转移,没有内存的搬迁或者内存拷贝所以可以提高利用效率,改善性能。

右值和左值

CPU视角的右值和左值

通过一个最简答的程序来看一下CPU是如何看待左值和右值的。

void push(int && x){
    int y = x;
}

void push(int & x){
    int y = x;
}

上面程序对应的汇编代码为:

push(int&&):
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-24], rdi
        mov     rax, QWORD PTR [rbp-24]
        mov     eax, DWORD PTR [rax]
        mov     DWORD PTR [rbp-4], eax
        nop
        pop     rbp
        ret
push(int&):
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-24], rdi
        mov     rax, QWORD PTR [rbp-24]
        mov     eax, DWORD PTR [rax]
        mov     DWORD PTR [rbp-4], eax
        nop
        pop     rbp
        ret

可以看到汇编指令对于右值和左值的处理是完全相同的,因此无论是左值和右值在CPU看来都是完全相同的。

语言层面的概念

对于底层CPU来说,左值和右值是完全无感的,但是对于C++语言来说左值和右值是非常重要的概念。浅显的来看,对于左值来说,编译器是允许写操作的;然而对于右值来说,只允许读操作。左值是有完整的生命周期的,而右值往往在执行完需要它的代码就直接被销毁了(如x=1;中的1)。

早期的C++左值就是左值,右值就是右值,不可改变。来到了C++11,语言为程序员提供了将左值转换为右值的方法---std::move。

std::move并不能移动任何东西,它唯一的功能是将一个左值强制转化为右值引用,继而可以通过右值引用使用该值,以用于移动语义。从实现上讲,std::move基本等同于一个类型转换。右值在完成期任务的时候会立刻被析构,因此在使用移动语义时需要防止产生空指针的问题。

移动构造函数和移动构造赋值函数

以一个例子来理解移动构造函数和移动构造赋值函数

class Item{
    public:
    int* x;

    Item()=default;
    Item(int val){ x = new int(val);};
    Item(const Item& item){
        x = new int(*item.x);
        printf("copy\n");
    };
    Item(Item&& item){
        x = item.x;
        item.x = NULL;
        printf("move\n");
    };

    Item& operator=(const Item& item){
        if(this != &item){
            this->x = new int(*item.x);
        }
        printf("copy=\n");
        return *this;
    }

    Item& operator=(Item&& item){
        if(this != &item){
            this->x = item.x;
            item.x = NULL;
        }
        printf("move=\n");
        return *this;
    }

    ~Item(){
        delete x;
    };
};

我们首先看一下移动构造函数和普通复制构造函数的区别:

Item(const Item& item){
    x = new int(*item.x);
    printf("copy\n");
};

Item(Item&& item){
    x = item.x;
    item.x = NULL;
    printf("move\n");
};

可以发现有以下几点不同:

  1. 移动构造函数没有新申请成员变量内存,而是直接拿来了输入成员变量指向的内存。
  2. 移动构造函数对输入(右值)的x指针赋值为NULL,因为右值在执行完之后会被析构,x指向的内存会被释放,因此安全的做法是将右值中的x指向NULL(析构时不会产生任何内存释放)。
  3. 移动构造函数输入不能是const变量。因为需要修改成员变量x指向NULL。

接下来观察以下复制赋值运算符和移动赋值运算符的区别:

Item& operator=(const Item& item){
    this->x = new int(*item.x);
    printf("copy=\n");
    return *this;
}

Item& operator=(Item&& item){
    if(this != &item){
        this->x = item.x;
        item.x = NULL;
    }
    printf("move=\n");
    return *this;
}

这两种运算符类比于对应的构造构造函数原理基本相同,需要注意赋值运算符是单目运算符,调用方是等号左侧的对象。因此可以用this指针来对左侧元素进行修改。

需要注意的是:移动赋值运算符多了一步判断:判断当前指针与输入的右值地址是否相同。

这是为了防止把自身作为输入进行移动赋值,这样会导致得到的对象成员x指向NULL。

总结

移动构造提升了赋值和初始化的性能,看似与复制构造只有输入上的不同,但是内部实现充满了种种细节,在编写代码的时候一定要留心,防止在拷贝过程中出现内部指针对象为空的情况。


侯磊
13 声望5 粉丝