揭开C++移动与复制的神秘面纱

简介: 本次分享主要围绕C++中的移动与复制问题,结合了六个特殊函数讲解了移动与复制过程中涉及的一系列概念,具体场景中存在的问题以及解决方案。帮助大家深入学习C++中移动与复制,并解决实际问题。
摘要:本次分享主要围绕C++中的移动与复制问题,讲解了移动与复制过程中涉及的一系列概念,具体场景中存在的问题以及解决方案。帮助大家深入学习C++中移动与复制,并解决实际问题。
演讲嘉宾简介:付哲(花名:行简),阿里云高级开发工程师,哈尔滨工业大学微电子学硕士,主攻方向为分布式存储与高性能服务器编程,目前就职于阿里云表格存储团队,负责后端开发。



左值与右值。
左值与右值的概念来自赋值表达式。以x=a+b+c;为例,其中“x”就是左值,而“a+b+c”的结果就是右值。左值就是可以放到等号左边的值,或者称“变量”。右值是只能放在右边,不能放在左边的值。那么,什么值可以放在左边呢?C++标准规定,一个可取地址的变量就可以放在左边。右值是表达式的值,是临时变量,无法对它取地址,因为当表达式计算结束之后,它的地址就析构了。因此,上面的表达式就不能写成a+b+c=x;
右值引用
· C++98只有左值引用,C++11增加了有值引用。
· 非const左值引用只能绑定左值,不能绑定右值。
· const左值引用是既可以绑定左值又可以绑定右值的。
· 右值引用只能绑定右值,不能绑定左值。
· 右值引用允许移动。
在C++98中,虽然编译器本身是有左值和右值的划分的,但它没有将右值本身暴露给用户使用,因此用到的引用都是左值。而C++11中增加了右值引用,如下面这段代码,一个右值引用只能绑定到右值上。如果尝试将其绑定到左值上,那么编译就会报错。注意,右值引用和右值本身也是不一样的,右值本身是没有名字的,也是无法取地址的。而右值引用本身有名字有地址,因此右值引用本身是左值,只不过它绑定到了右值上。
 
int x;
int&& rref = x; // error!
int&& rref = GetTemp(); // ok

右值引用有什么用呢?大家知道右值代表一个临时变量,在C++98中,我们只能对临时变量值进行复制,完成后临时变量会被析构。大家可能会思考一个问题,我们为什么不能在临时变量析构之前把变量的值取出来呢?如何判断一个变量是临时变量,可以把它的值取走,而不是复制呢?那就要用到右值引用。因为用传统的左值引用会将值绑定到非临时变量上,那么就只能对变量进行复制,而右值引用会绑定到一个临时变量上,那么就可以安全地移走它的值。C++11中就将这种操作称为移动。相应的也增加了移动构造函数和移动赋值函数。

特殊函数
现在C++11中大致包含以下这些特殊函数,编译器会帮助我们生成。默认一个类型至少会有这些函数。后面会讲这些函数的特殊之处。
· 默认构造函数
· 析构函数
· 复制构造函数
· 复制赋值函数
· 移动构造函数
· 移动复制函数
 
class Widget {
public:
    Widget(); // 默认构造函数
    ~Widget(); // 析构函数
    Widget(const Widget& rhs); // 复制构造函数
    Widget& operator=(const Widget& rhs); // 复制赋值函数
    Widget(Widget&& rhs); // 移动构造函数
    Widget& operator=(Widget&& rhs); // 移动赋值函数
private:
    std::string mName;
    int32_t mCount;
};

发生构造与赋值的场景
· 发生复制或移动构造的场景:
    ·使用括号或花括号初始化
    ·使用等号初始化
    ·函数的实参到形参
· 发生赋值的场景:
    ·使用等号赋值

// 场景0
Widget w1(w0);
// 场景1
Widget w2 = w0;
// 场景2
void Func(Widget w);
Func(w0);

在这个例子中,case0,1,2都会调用构造函数。如果构造函数的参数是Widget,且为左值就会调用复制构造函数,如果参数是Widget的右值,就会调用移动构造函数,移动后右值对应的对象就成为空对象,不持有任何资源。
赋值函数的规则也一样,如果参数是Widget左值就调用复制赋值函数,如果参数是Widget右值就调用移动赋值函数。在case3中,在声明函数时,传入的参数w称为形参,而实际调用时传入的w0称为实参。在实参到形参的过程中存在构造行为,同样遵循上述原则。
如果一个类没有移动构造函数和移动赋值函数,并且它在进行构造和赋值时,参数是右值,会发生什么呢。在C++11以前规定,要么编译器为类生成这两个移动函数,要么编译器调用复制构造函数或复制赋值函数,来代替移动。这也是之前提过的,两个赋值函数的参数必须是const引用的原因,只有const引用才能绑定右值,编译器才能通过两个复制函数来代替两个移动函数。

编译器生成特殊函数的规则
刚才介绍的六个特殊函数,编译器会按照某种规则生成这些函数,生成的函数都是public,内联,非虚的。一个例外是如果一个类有虚函数那么编译器生成的析构函数也是虚函数。其中,默认构造函数与析构函数的默认构造规则已经介绍过了。下面介绍生成复制函数和移动函数的生成规则。
· 生成复制函数的规则:没有声明复制函数,且代码中调用了复制函数。
· 生成移动函数的规则:没有声明复制函数,且没有声明析构函数,且没有声明移动函数,且代码中调用了移动函数。
当一个类没有自定义的复制构造函数或复制赋值函数,且没有禁止生成它,且代码中调用了它,注意,一定要是某行代码调用了复制构造函数或复制赋值函数,编译器才会为它生成这两个函数之一。另外,两个函数是独立生成的,互不影响。不会因为自定义了复制构造函数,就不生成对应的复制赋值函数。但这样其实是存在问题的。复制构造函数、复制赋值函数、析构函数三者应该是有关联的,如果定义了其中一个,那么另外两个也应该自定义。因为这三个函数都与资源管理有关,比如自定义了复制构造函数,如下面这段代码,默认的赋值函数会把pb指针赋值过去,即浅复制。但这个例子中,需要进行深复制,因此需要定义一个复制构造函数。但只定义这个复制构造函数还不够,编译器还为它生成了对应的赋值函数和析构函数。而这两个函数的行为显然是错的。因为赋值函数进行了浅复制,析构函数也没有将对应的内存释放。这就是为什么三个函数要么都不定义,要么一起定义。
 
class Widget {
    ...
private:
    Bitmap* pb;
};

Widget::Widget(const Widget& rhs) {
    pb = new Bitmap(*rhs.pb);
}

在C++98中,没有将这三个函数实现关联,大家需要牢记这些规则。而在C++11中明确的提出前面提到的这五个函数都是关联的(复制构造函数、复制赋值函数、移动构造函数、移动赋值函数、析构函数)。只要用户定义了一个,编译器就不会生成其他任意的一个函数。
以Widget类为例,编译器自动生成的构造函数会依次构造每个成员,析构函数会逆向依次析构成员,复制函数就是按顺序依次进行复制,移动函数会依次进行移动。

要求编译器生成特殊函数
当已经声明了某个特殊函数,比如析构函数,这时会导致编译器不在生成两个移动函数,但如果我们还是想要编译器生成它们,在C++11中,可以将特殊函数声明为default,以下面这段代码为例,自定义了构造函数和析构函数。之所以要自定义构造函数是因为,mCount这个变量是int32_t型的,对于这样的类型,默认的构造函数是不会对它进行初始化的,是不符合需求的。但对于其他几个函数而言,只需要定义成default,就可以让编译器自动为我们生成。
 
class Widget {
public:
    Widget(); // 默认构造函数
    ~Widget(); // 析构函数
    Widget(const Widget& rhs) = default;
    Widget& operator=(const Widget& rhs) = default;
    Widget(Widget&& rhs) = default;
    Widget& operator=(Widget&& rhs) = default;
private:
    std::string mName;
    int32_t mCount;
};

移动与复制函数的写法
下面展示了自定义移动与复制函数的例子。
 
Widget::Widget(const Widget& rhs)
    : mName(rhs.mName)
    , mCount(rhs.mCount) {}

Widget& Widget::operator=(const Widget& rhs) {
    mName = rhs.mName;
    mCount = rhs.mCount;
    return *this;
}

Widget::Widget(Widget&& rhs)
    : mName(std::move(rhs.mName))
    , mCount(rhs.mCount) {}

Widget& Widget::operator=(Widget&& rhs) {
    mName = std::move(rhs.mName);
    mCount = rhs.mCount;
    return *this
}

这里需要注意几点。第一点,为什么两个赋值函数都要返回指向自身的引用。这是为了能像内置类型一样做连续赋值,如x=y=z。第二点,两个移动函数中调用了C++11中新增的函数std::move,这个函数的功能是将一个左值转化成右值,这样才能进行移动。其中rhs是一个右值引用,但为什么它的成员是左值?因为它有名字,可以取到地址。前面提到过,右值引用本身是左值,它的成员也是左值。通过std::move就可以将它转化成右值,此时进行移动才是安全的。
下面这个例子类似智能指针。
 
避免无意的移动变复制
· 通常移动要比复制开销更低。
· 如果没有移动函数,会调用复制函数。
· 造成性能损失,且难以发现。
· 建议:将默认生成的特殊函数声明为default。
实际上我们推荐大家对每个需要编译器生成的复制函数和移动函数进行显式定义并声明为default。这样可以避免无意的将移动操作变为复制操作。比如下面展示的代码中,StringTable本身没有声明析构、复制和移动函数,因此编译器为自动为它生成这些函数。
 
class StringTable {
public:
    StringTable() {}
    ...               // 编译器生成析构、复制、移动函数
private:
    std::map<int, std::string> values;
};

一旦用户为它定义了析构函数,希望在析构时写入日志。那么根据规则,编译器就不再会为它生成移动函数了。但在实际用到移动函数的时候编译也不会失败,编译器会去调用相应的复制函数,复制函数是不会受到析构函数的影响的。这时,我们以为发生的是移动,即移动指针,开销很低。但实际上发生了复制,开销变得很高。
 
class StringTable {
public:
    StringTable() {
        makeLogEntry("Creating StringTable object");
    }
    ~StringTable() {
        makeLogEntry("Destroying StringTable object");
    }
private:
    std::map<int, std::string> values;
};

禁止移动或复制
· 有些类型不希望被移动或复制
· C++98中通过只声明private的复制函数来实现。
· C++11中通过声明特殊函数为delete来实现。
但有些类型是不希望被移动或复制的,这时该如何做呢?C++98中,通过下面这段来实现,声明复制函数并标记为private。类外无法调用,类内无法链接。但这种方式比较隐晦,不直接。
 
class Widget {
public:
    ...
private:
    Widget(const Widget&);
    Widget& operator=(const Widget&);
};

C++11中给出了一种更为安全的方法。如下面这段代码所示,将这些函数声明称delete,那么编译器就不会生成这些函数了,并且也无法被自定义,彻底禁用了移动和复制。Delete还有一些其他用途,但在这里就不展开介绍了。
 
class Widget {
public:
    Widget(const Widget&) = delete;
    Widget& operator=(const Widget&) = delete;
    Widget(Widget&&) = delete;
    Widget& operator=(Widget&&) = delete;
};

移动和复制函数可以是虚函数吗?
下面对这些特殊函数进行一些探索,大家知道构造函数不可以是虚函数,而析构函数有时必须为虚函数,那么两个赋值函数可以是虚函数吗?以下面这段代码为例,将Base类中的赋值函数声明为虚的,并在Derived子类中改写它。
 
struct Base {
    virtual ~Base() {}
    virtual Base& operator=(const Base& b);
};

struct Derived: public Base {
    virtual Derived& operator=(const Derived& d);
};

从编译器的角度看,赋值函数也是一种普通函数,当然可以是虚的。但从使用者的角度,虚函数用在多态场景下,也就是用基类的指针或引用,调用赋值操作,实际调用的却是派生类的赋值函数。那么可能是这个场景:

int main() {
    Base* p0 = new Derived();
    Base* p1 = new Derived();
    *p0 = *p1;
}


在这一例子中通过基类指针完成派生类的赋值。但这里派生类根本没有改写基类的虚函数,因为虚函数的改写规则是,函数名、参数等要与基类完全相同。因此,编译器不会认为它们是改写的关系,而会认为Derived又声明了一个自己的虚函数。

struct Derived: public Base {
    virtual Derived& operator=(const Base&);
    virtual Derived& operator=(const Derived&);
};


这才是编译器看到的Derived类。那么应该如何实现呢?可以在Base基类中实现Clone接口,来实现多态复制。

struct Base {
    ...
    Base* Clone() const = 0;
};


小结:
· 虚函数要求参数类型完全相同。
· 无法正确改写基类的虚的移动和复制函数。
· 通过虚的Clone函数来实现多态复制。
· 移动函数不适合多态行为。

正确的复制与移动基类
在实现派生类的复制和移动时,通常会比较关注,是否有成员忘记处理。除了派生类本身的成员对象外,还需要处理基类的对象。以下面这段代码为例,基类Base中有成员x,派生类Derived类中有成员y。在复制的时候只复制了y,因此x的值并没有发生改变。这就是由于在复制时,没有复制基类的对象,导致基类的对象被默认构造了,丢失了原对象的x。这种错误与之前介绍过的移动与复制问题一样,很难被发现。
 
struct Base {
    Base(): x(0) {}
    Base(const Base& b): x(b.x) {}
    Base& operator=(const Base& b) {
        x = b.x;
    }
    int x;
};

struct Derived: public Base {
    Derived(): y(1) {}
    Derived(const Derived& d): y(d.y) {}
    Derived& operator=(const Derived& d) {
        y = d.y;
    }
    int y;
};

int main() {
    Derived d0;
    d0.x = 2;
    d0.y = 2;
    Derived d1 = d0;
    printf("%d %d\n", d1.x, d1.y); //0 2
    d1.x = 3;
    d1 = d0;
    printf("%d %d\n", d1.x, d1.y); //3 2
}

为了避免这样的错误,鼓励大家做到以下两点:
· 移动/复制构造函数初始化列表首先处理基类。
· 移动/复制赋值函数首先调用基类赋值函数。
正确的写法如下。
 
Derived::Derived(const Derived& d): Base(d), y(d.y) {}

Derived& Derived::operator=(const Derived& d) {
    Base::operator=(d);
    y = d.y;
}

移动与复制前先判断是否为自身
在前面介绍的例子中,在移动与复制前都先判断了参数和this是否指向了同一个对象。因为,如果不判断是否为自身,可能会导致资源泄露、进程崩溃。
以下面这段代码为例。在做复制时先将自身的pb删除,然后根据目标pb进行深复制,再返回自身引用。在这种情况下,如果自身的pb和目标pb指向同一个值,那么在删除自身的同时,目标pb也被删除了。在移动时同理。
 
class Widget {
    ...
private:
    Bitmap* pb;
};

Widget& Widget::operator=(const Widget& rhs) {
    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
}

正确的做法如下面这段代码所示。先判断再进行移动或复制。

Widget& Widget::operator=(const Widget& rhs) {
    if (this != &rhs) {
        delete pb;
        pb = new Bitmap(*rhs.pb);
    }
    return *this;
}


但这样做还是存在问题。假设在进行Bitmap的深复制的时候抛出了异常。被复制的pb对象已经被删除了,那么说明这两个函数不是异常安全的。这种情况该如何处理呢?
 
实现异常安全的复制赋值函数。
异常安全是指当异常抛出后:
· 不泄露资源
· 不破坏已有数据

对于前面介绍的场景中,如果调用复制的过程中抛了异常,对构造函数来说,需要把已经构造的成员析构掉。对于赋值函数来说,由于抛异常是在复制操作未完成的时候出现的,要使得已经被赋值的对象不能被修改。如何实现呢?有一种做法是结合移动函数和复制构造函数。对移动函数来说,没有产生新资源,一般不会出现异常。而前面介绍过,复制构造函数比较容易实现异常安全。具体做法如下面这段代码所示。
 
class Widget {
    ...
private:
    Bitmap* pb;
};

Widget::Widget(Widget&& rhs) {
    pb = rhs.pb;
    rhs.pb = nullptr;
}

Widget& Widget::operator=(Widget&& rhs) {
    pb = rhs.pb;
    rhs.pb = nullptr;
}

Widget::Widget(const Widget& rhs) {
    pb = new Bitmap(*rhs.pb);
}

Widget& Widget::operator=(const Widget& rhs) {
    Widget tmp(rhs);
    *this = std::move(tmp);
    return *this;
}

先通过复制构造函数构造tmp,然后将tmp移动赋值给this。假如在调用复制构造时抛出异常,由于还未调用赋值,对象就不会被修改,而在移动过程中也不会出现异常。那么这个复制赋值函数就是异常安全的。而在C++98中,没有move。但可以借助swap函数实现。

总结,实现异常安全的复制赋值函数的方法:
· C++98中使用复制构造+swap
· C++11中使用复制构造+移动赋值函数
移动函数不能抛异常
需要强调的是,在刚才的例子中,对移动函数存在如下假设:
· 廉价
· 不分配资源
· 不抛异常
在具体实现中,我们应尽量保证这种假设,但如果出现例外情况,调用了可能出现异常的函数。建议不要进行处理,而是将移动函数声明为noexcept,让程序奔溃,如下面这段代码。

Widget::Widget(Widget&& rhs) noexcept {
    pb = rhs.pb;
    rhs.pb = nullptr;
}

Widget& Widget::operator=(Widget&& rhs) noexcept {
    pb = rhs.pb;
    rhs.pb = nullptr;
}


理由是绝大多数场景,移动函数抛异常都是遇到很严重的问题了,此时再让程序继续跑下去也没什么意义了,不如早点crash,还能早点恢复。这也是分布式服务的一个理念,任其崩溃。

本文由云栖志愿小组马JY整理,编辑百见
相关文章
|
1月前
|
算法 程序员 编译器
C ++匿名函数:揭开C++ Lambda表达式的神秘面纱
C ++匿名函数:揭开C++ Lambda表达式的神秘面纱
59 0
|
1月前
|
监控 Linux 测试技术
【 C/C++ 性能分析工具 CPU 采样分析器 perf 】掀开Linux perf性能分析的神秘面纱
【 C/C++ 性能分析工具 CPU 采样分析器 perf 】掀开Linux perf性能分析的神秘面纱
63 0
|
C语言 C++ 容器
<C++>详解string容器,揭开string容器的神秘面纱
<C++>详解string容器,揭开string容器的神秘面纱
124 0
|
22天前
|
存储 C++ 容器
C++入门指南:string类文档详细解析(非常经典,建议收藏)
C++入门指南:string类文档详细解析(非常经典,建议收藏)
31 0
|
22天前
|
存储 编译器 C语言
C++入门: 类和对象笔记总结(上)
C++入门: 类和对象笔记总结(上)
31 0
存储 编译器 Linux
18 0
|
2天前
|
编译器 C++
标准库中的string类(上)——“C++”
标准库中的string类(上)——“C++”
|
2天前
|
编译器 C++
自从学了C++之后,小雅兰就有对象了!!!(类与对象)(中)——“C++”
自从学了C++之后,小雅兰就有对象了!!!(类与对象)(中)——“C++”
|
2天前
|
存储 编译器 C++
自从学了C++之后,小雅兰就有对象了!!!(类与对象)(上)——“C++”
自从学了C++之后,小雅兰就有对象了!!!(类与对象)(上)——“C++”
|
3天前
|
C++
【C++成长记】C++入门 | 类和对象(下) |Static成员、 友元
【C++成长记】C++入门 | 类和对象(下) |Static成员、 友元