C++ Primer 学习笔记 第十三章 拷贝控制

拷贝构造函数和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么;拷贝赋值运算符和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么;析构函数定义了当此类型对象销毁时做什么。这些操作被称为拷贝控制操作。(以上移动构造函数和移动赋值运算符是C++11新标准引进的)

当一个类没有定义拷贝控制操作时,编译器会自动定义它们,因此,很多类可以忽略不定义这些操作,然而,这些默认的定义可能会造成灾难。

定义C++类时,自定义拷贝控制都是必要的,因为编译器自定义的版本的行为可能并非我们所想。

拷贝构造函数的第一个参数是自身类类型的引用,且任何额外的参数都要有默认值:

class Foo {
public:
    Foo();    //默认构造函数
    Foo(const Foo&);    //拷贝构造函数
};    

拷贝构造函数的参数也可以是非const的,但实际使用时总是使用const的,且它很多时候会被隐式使用,因此它总是非explicit的(如它是explicit的,那么只能通过调用运算符()使用)。

如果我们没有定义拷贝构造函数,编译器会自动定义一个,即使我们定义了其他的构造函数,编译器也会为我们定义一个拷贝构造函数。

对某些类,合成的拷贝构造函数用来阻止我们拷贝该类类型对象。

一般,合成的拷贝构造函数会将其参数对象的成员(非static成员)逐个拷贝到该类类型的对象中。成员类型决定它如何被拷贝,类类型成员会用其拷贝构造函数来拷贝,内置类型成员会直接拷贝,我们不能拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型成员,若数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。

Sales_data的合成的拷贝构造函数等价于:

//与合成的等价
Sales_data::Sales_data(const Sales_data& orig) : bookNo(orig.bookNo), units_sold(orig.units_sold), revenue(orig.revenue) { }

直接初始化和拷贝初始化:

string dots(10, '.');    //直接初始化
string s(dots);    //直接初始化,但调用的是拷贝构造函数
string s2 = dots;    //拷贝初始化,隐式调用拷贝构造函数
string null_book = "9-999-99999-9";    //拷贝初始化
string nines = string(100, '9');    //拷贝初始化

直接初始化时,实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。拷贝初始化,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如需要,还需进行类型转换。

拷贝初始化一般会使用拷贝构造函数来完成,但一个类如果有一个移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数。

拷贝初始化除了发生在用=定义变量时,还发生在:
1.将对象作为实参传递给非引用类型的形参。
2.从一个返回类型为非引用类型的函数返回一个对象。
3.用花括号列表初始化一个数组中的元素或一个聚合类中的成员。

某些类类型还会对它们所分配的对象使用拷贝初始化,如初始化标准库容器、调用其insert或push成员时。与之相对,emplace成员创建元素进行直接初始化。

拷贝构造函数被用来初始化非引用类类型参数,这解释了为什么拷贝构造函数的参数必须是引用类型,如不是引用类型,为了调用拷贝构造函数,必须再调用拷贝构造函数拷贝将实参拷贝到形参,无限循环。

如果我们使用的初始化值要求通过一个explicit的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就重要了:

vector<int> v1(10);    //正确,直接初始化
vector<int> v1 = 10;    //错误,接受大小参数的构造函数是explicit的
void f(vector<int>);    //f()的声明
f(10);    //错误,不能用一个explicit的构造函数拷贝一个实参
f(vector<int>(10));    //正确

拷贝初始化过程中,编译器可以跳过拷贝/移动构造函数,直接创建对象:

string null_book = "9-999-99999-9";    //编译器隐式地将C风格字符串转换成string,再拷贝初始化null_book
string null_book("9-999-99999-9");    //编译器跳过了拷贝构造函数

但即使跳过了拷贝/移动构造函数,但此时,拷贝/移动构造函数必须是存在且可访问的(如,不是private的)。

与类控制其对象如何初始化一样,类也可以控制其对象如何赋值:

Sales_data trans, accum;
trans = accum;    //使用Sales_data的拷贝赋值运算符

重载运算符本质上是函数,其名字是由operator关键字和其后接的表示要定义的运算符的符号组成,因此,赋值运算符就是一个名为operator=的函数,类似于其它函数,运算符函数也有一个返回类型和参数列表。

重载运算符的参数表示运算符的运算对象,某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数上。对于二元运算符,如赋值运算符,其右侧运算对象作为显式参数传递。

拷贝赋值运算符接受一个与其所在类相同类型的参数:

class Foo {
public:
    Foo& operator=(const Foo&);    //赋值运算符
}

为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用。

如一个类未定义拷贝赋值运算符,编译器会为它生成一个合成的拷贝赋值运算符。对某些类,合成的拷贝赋值运算符用来禁止该类型对象的赋值,如拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,如成员是类类型,则使用该成员类型的拷贝赋值运算符来完成,对数组成员,逐个赋值数组元素。合成的拷贝赋值运算符返回一个指向其左侧运算对象的引用。

下面代码等价于Sales_data合成的拷贝赋值运算符:

Sales_data& Sales_data::operator=(const Sales_data &rhs) {
    bookNo = rhs.bookNo;
    units_sold = rhs.units_sold;
    revenue = rhs.revenue;
    return *this;
}

析构函数执行与构造函数相反操作:构造函数初始化对象的非static数据成员,还可能做其他工作;析构函数释放对象使用的资源,并销毁对象的非static数据成员。

析构函数是类的一个成员函数,名字由波浪号和类名构成,没有返回值,也不接受参数:

class Foo {
public:
    ~Foo();
};

由于析构函数不接受参数,因此它不能被重载,一个类只有唯一一个析构函数。

析构函数首先执行函数体,然后销毁成员,成员按构造函数初始化列表的初始化顺序的逆序销毁。

通常,析构函数体释放对象在生存期分配的所有资源,因为销毁一个内置指针类型的成员不会delete它指向的对象,但智能指针是类类型,销毁时会调用它的析构函数。

析构函数销毁对象是隐式销毁的,不像构造函数有初始化列表,销毁类类型对象调用类类型的析构函数,内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。

无论何时一个对象被销毁,自动调用其析构函数:
1.变量离开其作用域时。
2.一个对象被销毁时,它的成员也被销毁。
3.容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
4.对于动态分配的对象,delete指向它的指针时被销毁。
5.对于临时对象,创建它的完整表达式结束时被销毁。

但当指向一个对象的引用或指针离开作用域时,析构函数不会执行。

当类未定义自己的析构函数时,编译器会自动生成合成的析构函数,对某些类,合成的析构函数被用来阻止该类型对象被销毁,如不是这种情况,合成的析构函数的函数体为空。

下面代码等价于Sales_data的合成析构函数:

class Sales_data {
public:
    ~Sales_data() { }
};

析构函数体不销毁成员,成员是在析构函数体之后的析构阶段中被隐式销毁的。

通常,如果我们的类需要一个析构函数,那么几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。如一个类有个成员是指向动态分配的对象的指针,那么就需要一个析构函数在销毁对象的同时也销毁其动态分配的内存,但如果这个类只有析构函数而没有拷贝构造函数和拷贝赋值运算符时,当这个类进行赋值等操作时,它会将指针成员直接赋值给另一个类,但不会新开辟一块动态内存,结果就是两个类的指针成员都指向一块动态内存,如果其中一个类被销毁,即使用了析构函数,那么另一个类的指针成员就失效了,并且当两个类都被销毁时,这块内存就被delete了两次。并且在调用函数时,形参会用实参初始化,隐式地使用了拷贝构造函数,此时,当函数结束,实参的指向动态内存的指针就失效了。

有的类不需要析构函数,只需要拷贝构造函数和拷贝赋值运算符,如一个类需要给它的每个成员一个独一无二的编号,此时就只需要拷贝构造函数和拷贝赋值运算符了。即,如果一个类需要一个拷贝构造函数,那么几乎可以肯定它也需要一个拷贝赋值运算符,反之亦然,但并不意味着需要析构函数。

C++11中,我们可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本:

class Sales_data {
public:
    Sales_data() = default;    //默认构造函数合成的版本
    Sales_data(const Sales_data&) = default;    //拷贝构造函数合成的版本
    Sales_data& operator=(const Sales_data&);
    ~Sales_data() = default;    //析构函数合成的版本
};
Sales_data& Sales_data::operator=(const Sales_data&) = default;    //拷贝赋值运算符合成的版本,与类中的合成版本不同,此合成版的函数非内联

我们只能对具有合成版本的成员函数使用=default(即默认构造函数和拷贝控制成员)。

大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。

对某些类,应该阻止拷贝或赋值,如iostream类阻止了拷贝,以避免多个对象写入或读入相同的IO缓冲。在C++11新标准下,可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝:

struct NoCopy {
    NoCopy() = default;    //使用合成的默认构造函数
    NoCopy(const NoCopy&) = delete;    //阻止拷贝
    NoCopy& operator=(const NoCopy&) = delete;    //阻止赋值
    ~NoCopy() = default;    //使用合成的析构函数
};

与=default不同,=delete必须出现在第一次声明的时候,并且=delete可以对任何函数指定,可以用在引导函数匹配过程时。对于书上所写的引导函数匹配过程,我认为类似于以下情况:

class A {
public:
    void func(const int& i) {
        cout << "const int &i" << endl;
    }

    void func(int& i) = delete;
};

int main() {
    A a;
    int j = 1;
    a.func(j);    //错误,对于实参j来说,显然被删除的函数版本更合适,但此版本函数被删除了,因此编译失败
}

如上,虽然实参j也能调用未被删除的版本,但我们引导了函数匹配过程,从而导致不允许非const的引用的同类实参使用该函数。

但析构函数不能被删除,对于一个删除了析构函数的类,编译器不允许定义该类型变量和临时对象,并且如果一个类的某个类类型成员的类删除了析构函数,那么这个类也不能定义变量和临时对象。

但对于删除了析构函数的类型,可以动态分配这种类型的对象:

struct NoDtor {
    ~NoDtor() = delete;
};
NoDtor nd;    //错误
NoDtor *p = new NoDtor();    //正确,但不能delete它
delete p;    //错误

编译器将以下合成的成员定义为删除的函数:
1.类的某个成员的析构函数是删除的或不可访问的(如private的),则类的合成析构函数被定义为删除的。
2.类的某个成员的拷贝构造函数或析构函数是删除的或不可访问的,则类合成的拷贝构造函数被定义为删除的。
3.类的某个成员的拷贝赋值运算符是删除的或不可访问的,或类有一个const或引用成员,则类的拷贝赋值运算符被定义为删除的。
4.类的某个成员的析构函数是删除的或不可访问的,或类有一个没有类内初始化的引用成员,或类中有一个没有类内初始化的const成员且该成员类型没有默认构造函数,则该类的默认构造函数被定义为删除的。

本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、赋值或销毁,则对应的成员函数被定义为删除的。

一个成员有删除的或不可访问的析构函数会导致合成的默认构造函数和合成的拷贝构造函数被定义为删除的,因为可能会创建出无法销毁的对象。

对于具有引用成员或无法默认构造的const成员的类,编译器不会合成默认构造函数。一个类有const成员,则它不能使用合成的拷贝赋值运算符,因为此运算符会改变这个const成员的值,这是不合法的。

虽然我们可以将新值赋予一个引用成员,但这样做改变的是被引用变量的值,而不是引用本身,与我们所期望的不同,因此对于有引用成员的类,不能使用拷贝赋值运算符。

在C++11新标准发布前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝,这样用户代码就不能拷贝对象了,但友元和成员函数仍然可以拷贝对象,为阻止友元和成员函数的拷贝,我们将拷贝控制成员声明为private的但并不定义它。试图访问一个未定义的成员会导致一个链接时错误,这样就可以阻止任何拷贝该类对象的企图:试图拷贝对象的用户代码将在编译阶段被标记为错误,而成员函数和友元中的拷贝操作将会导致链接时错误。

定义Employee类,包含雇员姓名和唯一的雇员证号,这个类定义了默认构造函数和一个接受表示雇员姓名的string的构造函数,构造函数应该通过递增一个static数据成员来生成一个唯一的雇员证号:

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

class Employee {
public:
	Employee() : Ename("undefined"), Eno(no++) { }
	Employee(string name) : Ename(name), Eno(no++) { }
	unsigned GetEno() {
		return Eno;
	}
private:
	string Ename;
	unsigned Eno;
	static unsigned no;    //定义为private的static成员也能在类外定义,因为类内的static类型变量不属于类的对象,在类外也可以访问
};

unsigned Employee::no = 0;    //非const或constexpr的整型变量需要定义在外边

int main() {
	Employee e0 = Employee("a");
	Employee e1 = Employee("b");
	Employee e2 = Employee("c");
	cout << e0.GetEno() << endl;
	cout << e1.GetEno() << endl;
	cout << e2.GetEno() << endl;
}

通常,管理类外资源的类必须定义拷贝控制成员,这种类需要通过析构函数来释放对象所分配的资源,为定义这些拷贝控制成员,我们必须确定此类型的拷贝语义,即使类的行为看起来像一个值或一个指针。类的行为像一个值,意味着它也有自己的状态,当我们拷贝一个像值的对象时,副本和原对象是独立的,改变副本不会对原对象有任何影响,反之亦然;行为像指针的类共享状态,当拷贝一个这种类的对象时,副本和原对象使用相同的底层数据,改变副本也会改变原对象,反之亦然。string像值,shared_ptr像指针,而IO类型和unique_ptr不允许拷贝和赋值,它既不像值也不像指针。

为了提供类值的行为,对于每个类管理的资源,每个对象应该都有自己的一份拷贝,类需要:
1.定义一个拷贝构造函数,完成资源的拷贝,而不是只拷贝指针。
2.定义一个析构函数来释放资源。
3.定义一个拷贝赋值运算符来释放当前指针所指资源,同时将该指针指向一份右侧运算对象的资源的拷贝。

对于上述的第3点,需要特殊处理自赋值情况,即,将一个值赋给它自己,这就需要先拷贝一份右侧运算对象的资源,之后才能释放自己的资源,如果先释放自己的资源,再获取右侧运算对象的资源,在自赋值时就会出现先删除自己的资源,再拷贝已经删除的资源的情况,即访问一个已经被delete过的指针,这种行为是未定义的。

当我们定义行为像指针的类时,由于析构函数要释放资源且不能单方面地释放资源,因此需要shared_ptr管理内存资源,但有时我们想直接管理内存资源,这就需要引用计数,其工作方式如下:
1.除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态,当我们创建对象时,只有一个对象共享状态,因此将计数器初始化为1。
2.拷贝构造函数不分配新的引用计数,而是拷贝对象的数据成员,包括计数器,拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新的对象所共享。
3.析构函数递减计数器,如减为0,则释放状态。
4.拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器,如左侧运算对象计数器变为0,则释放状态。

上述的第4点如往常一样,需要处理自赋值情况,我们可以先递增右侧运算对象的引用计数再递减左侧运算对象的引用计数,这样就不会释放其状态。

我们可以把计数器保存在动态内存中。

管理资源的类通常还定义一个名为swap的函数,对于重排元素顺序的算法,如果一个类定义了自己的swap函数,那么算法会使用类自定义的版本。交换两个对象我们需要一次拷贝和两次赋值,如:

//HasPtr是像值一样使用的类
HasPtr temp = v1;    //此时拷贝构造函数拷贝了一次v1的资源
v1 = v2;     //此时拷贝赋值运算符拷贝了一次v2的资源
v2 = temp;    //此时拷贝赋值运算符拷贝了一次temp的资源

如上,如果使用默认的swap,会分配很多次内存,但这些内存分配不是必要的,我们更希望swap函数交换指针:

string* temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;

完整重载swap过程:

class HasPtr {
    friend void swap(HasPtr&, HasPtr&);    //声明为友元使其可以访问到HasPtr的私有指针成员
    //.....
};

inline void swap(HasPtr &lhs, HasPtr &rhs) {    //显式inline,swap函数不是类的成员,它只是类的友元
    using std::swap;
    swap(lhs.ps, rhs.ps);    //调用的是标准库版本的swap
    swap(lhs.i,rhs.i);    //调用的是标准库版本的swap
}

假如我们有一个Foo类,里边有一个名为h的HasPtr成员,如果我们未定义Foo版本的swap,使用的将会是标准库的swap,因此我们必须使用自定义的swap:

void swap(Foo& lhs, Foo& rhs) {
    using std::swap;
    swap(lhs.h, rhs.h);    //虽然有using声明,但使用的还是HasPtr版本的swap,即优先使用特定类型自定义的swap函数
}

定义swap的类常用swap来定义它们的赋值运算符,这些运算符使用了拷贝并交换的技术:

HasPtr& HasPtr::operator=(HasPtr rhs) {    //非引用的参数,会调用拷贝构造函数重新分配新的资源,rhs是右侧运算对象的副本
    swap(*this, rhs);    //使用类自定义的swap,交换的是两个对象中的指针而非自己管理资源的重新拷贝
    return *this;    //左侧运算对象中的指针已经指向rhs中的资源,rhs中的指针指向左侧运算对象中的资源
}    //结束时rhs被销毁,即左侧运算对象及其资源被释放

上例是自赋值安全的,因为它在传参阶段就已经拷贝了右侧运算对象的资源。

资源管理并不是唯一一个需要定义自己拷贝控制成员的唯一原因,有时也需要拷贝控制成员进行簿记工作。如两个可能用于邮件处理的应用中的类Message和Folder,分别表示电子邮件(或其他类型)消息和消息目录。以下为这两个类的设计思路展示:

每个Message对象可能出现在多个Folder中,但所有相同的Message对象只有一个副本,这样,如果一个Message被改变,其他Folder中浏览Message也会看到改变后的内容。

为了记录Message位于哪些Folder中,每个Message都会保存一个它所在Folder的指针的set,同样,每个Folder都保存一个它包含的Message的指针的set。

Message类提供save和remove操作,来向给定的Folder中增删Message。生成Message时并不需要指定Folder,生成后再save。

拷贝Message成员时,原对象和副本是两个不同的Message对象,但两个Message出现在同一个Folder中,因此,拷贝Message操作包括消息内容和Folder指针的set的拷贝,并且,在每个包含原Message的Folder中都添加一个指向新创建的Message的指针。

销毁一个Message时,我们要从所有Folder中删除指向此Message的指针。

将一个Message对象赋予另一个Message对象时,左侧Message的内容会被右侧Message的内容所取代,同时更新Folder的set,从原来包含左侧Message的Folder中将左侧Message运算对象删除,并将左侧对象添加到右侧Message所在的所有Folder中。

拷贝赋值运算符通常执行拷贝构造函数和析构函数的工作,公共的操作应该放在private的工具函数中完成。

Message类:

class Message{
    friend class Folder;
public:
    explicit Message(const std::string& str = "") : contents(str) { }
    Message(const Message&);
    Message& operator=(const Message&);
    ~Message();
    void save(Folder&);
    void remove(Folder&);
private:
    std::string contents;    //保存消息文本
    std::set<Folder*> folders;    //保存指向此Message所在的Folder的指针,隐式初始化为空集
    void add_to_Folders(const Message);    //将Message添加到Folder中
    void remove_from_Folders();    //从folders中的每个Folder中删除本Message
};

标准库中定义了string和set的swap版本,Message可以通过定义自己的swap版本来减少不必要的拷贝。在Message的swap函数中,我们必须也改变Folder中的内容,而不能只改变Message所在Folder,我们可以通过扫描两次Message的folders成员来完成,第一次扫描将两个Message分别从它们所在的Folder中删除,第二次在swap完后,再将两个Message添加到对应的Folder中。

我们不能使用拷贝并交换技术编写Message的赋值运算符,因为拷贝并交换技术会复制一个全新的Message,之后的操作如将该Message移出它所在的Folder事实上是在将右侧运算对象的副本移出Folder,完成后虽然左侧运算对象被放到了正确的Folder们中,但右侧运算对象还存在于它原来的Folder们中。

有些类需要运行时分配可变大小的内存空间,这种类一般应该使用标准库容器保存其数据以达到目的,但某些类不适用于标准库容器,我们就需要拷贝控制成员来管理分配的内存。

实现vector类的简化版本,只适用于string,命名为StrVec。它使用vector的类似策略,先分配比需要的内存大的空间以容纳更多元素,如没有空间容纳更多元素,将已有元素移动到新空间。我们将使用allocator获得原始内存,它分配的内存是未构造的,需要添加元素时使用allocator的construct函数在原始内存中创建对象,删除元素时使用它的destroy函数。

StrVec有三个指针成员访问元素内存:
1.elements,指向分配内存的首元素。
2.first_free,指向最后一个实际元素之后的位置。
3.cap,指向分配的内存末尾之后的位置。

StrVec还有一个静态成员,名为alloc,类型为allocator<string>,用来给StrVec分配使用的内存,此外,类还有4个工具函数:
1.alloc_n_copy:分配内存,并拷贝一个范围中的元素。
2.free:销毁构造的元素并释放内存。
3.chk_n_alloc:保证StrVec至少还能容纳一个新元素,如没有,调用reallocate分配更多内存。
4.reallocate:在内存用尽时给StrVec分配新内存。

class StrVec {
public:
    StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr) { }    //默认构造函数
    StrVec(const initializer_list<string>& s);
    StrVec(const StrVec&);    //拷贝构造函数
    StrVec& operator=(const StrVec&);    //拷贝赋值运算符
    ~StrVec();    //析构函数
    void push_back(const std::string&);    //拷贝元素
    size_t size() const {    //实际元素个数
        return first_free - elements;
    }
    size_t capacity() const {
        return cap - elements;
    }
    std::string* begin() const {
        return elements;
    }
    std::string* end() const {
        return first_free;
    }
private:
    static std::allocator<std::string> alloc;    //分配元素
    void chk_n_alloc() {    //检查是否还有空间
        if (size() == capacity()) {
            reallocate();
        }
    }
    std::pair<std::string*, std::string*> alloc_n_copy(const std::string*, const std::string*);    //将参数中两个指针当做迭代器范围,分配内存并将该范围内内容拷贝到新分配的内存内,再将新分配内存的首元素和尾后元素指针当做一个pair返回
    void free();    //销毁元素、释放内存
    void reallocate();    //获得更大内存并将当前元素放入更大内存内
    std::string* elements;    //指向第一个数组元素指针
    std::string* first_free;    //指向数组第一个空闲元素指针 
    std::string* cap;    //指向数组分配的内存的末尾之后的位置
};

void StrVec::push_back(const string& s) {
    chk_n_alloc();    //确保空间足够
    alloc.construct(first_free++, s);    //第一个参数必须指向未构造的内存,剩余的参数用来决定使用哪个构造函数来构造string,本次使用的是string的拷贝构造函数
}

pair<string*, string*> StrVec::alloc_n_copy(const string* b, const string* e) {
    string* data = alloc.allocate(e - b);
    return { data, uninitialized_copy(b, e, data) };    //unintialized_copy返回指向最后一个构造的元素之后的指针
}

void StrVec::free() {
    if (elements) {
        for (string* p = first_free; p != elements; ) {
            alloc.destroy(--p);
        }
        alloc.deallocate(elements, cap - elements);
    }
}

StrVec::StrVec(const StrVec& s) {
    auto newdata = alloc_n_copy(s.begin(), s.end());
    elements = newdata.first;
    first_free = cap = newdata.second;
}

StrVec::StrVec(const initializer_list<string>& s) {
    auto range = alloc_n_copy(s.begin(), s.end());
    elements = range.first;
    first_free = cap = range.second;
}

StrVec::~StrVec() {
    free();
}

StrVec& StrVec::operator=(const StrVec& rhs) {
    auto data = alloc_n_copy(rhs.begin(), rhs.end());    //先将右侧运算对象的内容复制一份,这样自赋值就安全了
    free();
    elements = data.first;
    first_free = cap = data.second;
    return *this;
}

reallocate函数应该:
1.为一个新的、更大的string数组分配内存。
2.在新内存的前一部分空间构造对象,保存现有元素。
3.销毁原内存空间中的元素,并释放内存。

对于string来说,它具有类值行为,即拷贝string时会再生成一块内存存放副本的内容,对于StrVec,重新分配空间时还会销毁原内存的内容,拷贝这些string中的数据是多余的。通过C++11新标准库引入的机制就可以避免string的拷贝,一些标准库类,如string,都定义了移动构造函数,它是将资源从给定对象移动到而不是拷贝到正在创建的对象。还有一个机制是名为move的标准库函数,它定义在utility头文件中,调用move来表示希望使用对象(如上例中string)的移动构造函数,通常,我们不为move使用using声明,而是直接使用std::move,这样可以避免潜在的名字冲突。

reallocate:

void StrVec::reallocate() {
    auto newCapacity = size() ? 2 * size() : 1;
    auto newData = alloc.allocate(newCapacity);
    auto dest = newData;    //指向新数组中的空闲位置
    auto elem = elements;    //指向旧数组中的第一个元素位置
    for (size_t i = 0; i < size(); ++i) {
        alloc.construct(dest++, std::move(*elem++));
    }
    free();
    elements = newData;
    first_free = dest;
    cap = elements + newCapacity;
}

C++11之前的标准中,没有移动构造函数。旧版本的标准库中,容器中所保存的类必须是可以拷贝的,新标准中,可以保存不可拷贝类型,只要它能被移动。

标准库容器、string、shared_ptr既能拷贝,也能移动,IO类、unique_ptr可以移动但不能拷贝。

为支持移动操作,C++11引入了右值引用,即为必须绑定到右值的引用。通过&&来获得对象的右值引用。右值引用只能绑定在一个将要销毁的对象上,因此,我们可以自由地将一个右值引用的资源移动到另一个对象中。

普通引用可被称为左值引用,左值引用不能绑定到字面常量、返回右值的表达式、要求转换的表达式(如int绑定到long)上,但右值引用可以绑定到以上表达式上,但右值引用不能绑定到左值上:

int i = 42;
int &r = i;    //正确
int &&rr = i;    //错误,不能把右值引用绑定到左值上
int &r2 = i * 42;    //错误,左值引用不能绑定到右值上
const int &r3 = i * 42;    //正确
int &&rr2 = i * 42;    //正确

左值持久,右值短暂:左值有持久的状态,而右值要么是字面常量,要么是表达式求值过程中创建的临时变量。

右值引用只能绑定到临时对象,因此:
1.该引用的对象将要销毁。
2.该对象没有其他使用者。
这意味着使用右值引用的代码可以自由地接管所引用的对象的资源。

我们不能将一个右值引用绑定到右值引用类型的变量上:

int &&rr1 = 42;
int &&rr2 = rr1;    //错误,rr1是左值

C++11中我们可以使用move标准库函数获得绑定在左值上的右值引用:

int &&rr3 = std::move(rr1);    //正确

move告诉编译器,我们希望像右值一样处理左值,这意味着除了对rr1赋值或销毁它外,我们不再使用它。

移动构造函数:移动对象而不是拷贝对象:

StrVec::StrVec(StrVec &&s) noexcept : elements(s.elements), first_free(s.first_free), cap(s.cap) {    //第一个参数之外的参数必须有默认实参,s不能为const,因为还要修改s以使其不会再影响到另一个对象
    s.elements = s.first_free = s.cap = nullptr;    //对s析构时不应删除资源,因为资源已经移动给了另一个对象
}

C++11中,我们引入了noexcept,它告知编译器该函数不会产生任何异常,避免了编译器为处理异常所做的一系列工作,它应写在参数列表之后,如果是类成员函数,那么在函数的声明和定义时(如果两者分开写)都要指定noexcept。

不抛出异常的移动构造函数和移动赋值运算符都必须标记为noexcept。因为如果我们将自定义的类型放到标准库容器如vector中,如果push_back时空间不足,则会重新申请一块新的更大的内存,然后使用移动构造函数或拷贝构造函数将元素移动到新内存中,但移动构造函数可能移动到一半出现异常,造成源对象已被移动(改变)从而出现源内存被改变的从而无法恢复的情况,因此,如果我们的自定义类类型不使用noexcept,vector会认为可能不安全从而使用拷贝构造函数。

移动赋值运算符:

StrVec& StrVec::operator=(StrVec&& rhs) noexcept {
    if (this != &rhs) {    //直接检测是否自赋值
        free();    //释放自己的资源
        elements = rhs.elements;    //直接接手右侧运算对象的资源
        first_free = rhs.first_free;
        cap = rhs.cap;
        rhs.elements = rhs.first_free = rhs.cap = nullptr;
    }
    return *this;
}

保证将移后源对象设置为析构安全状态。

只有当一个类没有自定义任何拷贝控制成员且类的每个非static数据成员都可以移动时,编译器会为它合成移动构造函数和移动赋值运算符。内置类型成员可移动。

如果我们显式地要求编译器生成=default的移动操作,且编译器不能移动所有的成员,则移动操作会定义为删除的函数。什么时候将合成的移动操作定义为被删除的函数:
1.有类成员定义了自己的拷贝构造函数而未定义移动构造函数。或有类成员未定义自己的拷贝构造函数而不能生成合成的移动构造函数。则类的移动构造函数被定义为删除的。
2.有类成员的移动构造函数或移动赋值运算符被定义为删除的或不可访问的,则类的移动构造函数或移动赋值运算符被定义为不可访问的。
3.类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。
4.若类有const成员或引用成员,则类的移动赋值运算符被定义为删除的。

如一个类Foo的某个成员的移动构造函数是删除的:

Foo f1, f2 = std::move(f1);    //错误,Foo的某个成员无法移动

如一个类定义了移动构造函数或移动赋值运算符,则类的合成的拷贝构造函数和拷贝赋值运算符被定义为删除的。

StrVec v1, v2;
v1 = v2;    //使用拷贝赋值运算符
StrVec getVec(istream&);
v2 = getVec(cin);    //使用移动赋值运算符

如上。但如果一个类只有拷贝赋值运算符而没有移动赋值运算符,则移动赋值运算符不会被合成,那么此时右值也会被拷贝。

我们的HasPtr函数定义了拷贝并交换的赋值运算符,它通过swap函数实现了对象的指针成员管理资源的交换而非拷贝,若此时该类还有一个移动构造函数,那么这个使用了拷贝并交换技术的赋值运算符可以同时当做拷贝赋值运算符和移动赋值运算符使用,当=的右侧运算对象是一个右值(std::move()的返回值或其他右值)时,传给此赋值运算符的参数时使用的就是移动构造函数而非拷贝构造函数,这样就相当于直接把右侧运算对象的资源直接给了赋值运算符函数的形参,然后形参的资源又和左侧运算对象的资源交换,这样就实现了将右侧运算对象的资源移动到左侧运算对象中。

五个拷贝控制成员应该看成一个整体,如一个类定义了一个拷贝控制成员,那么就应该定义全部。因为定义拷贝构造函数和拷贝赋值运算符和析构函数一般是因为这个类管理堆内存的资源,并且具有类值行为,那么使用这样一个对象的右值赋值或初始化另一个对象时就会重复拷贝销毁(被赋值对象需要重新申请堆空间拷贝右值的资源,而右值的资源需要被销毁)资源,因此这五个拷贝控制成员应该看作一个整体。

以上定义的Message类也应该需要移动构造函数和移动赋值运算符以避免拷贝string(contents)和set(folders)的开销,除了移动folders成员,还需删除每个Folder中指向旧Message的指针以及将新Message加入到Folder的指针中,由于两个移动控制成员都需要以上工作,我们将以上工作写成工具函数:

void Message::move_Folders(Message* m) {    //将m的folders移动给左侧运算对象,之后将右侧对象所在的Folder中将其删除,再将左侧运算对象加入
    folders = std::move(m->folders);    //将新的Message的folders成员用set的移动赋值运算符直接将m的资源移动过去
    for (auto f : folders) {
        f->remMsg(m);    //将Folder f中的指向旧Message的指针m删除
        f->addMsg(this);    //将新对象的指针this添加到Folder中
    }
    m->folders.clear();    //将m中记录其所在Folder的记录删除,确保析构安全
}

以上函数中,向set插入元素(Folder的addMsg操作)可能会抛出bad_alloc异常,因此不能将该函数标为noexcept。

Message移动控制函数:

Message::Message(Message&& m) : contents(std::move(m.contents)) {    //初始化列表中使用string的移动构造函数
    move_Folders(&m);
}

Message& Message::operator=(Message&& rhs) {
    if (this != &rhs) {    //直接检查自赋值
        remove_from_Folders();    //将左侧运算对象从其所在的Folder中删除
        contents = std::move(rhs.contents);
        move_Folders(&rhs);    //将右侧运算对象的folders移动给左侧运算对象,之后将右侧对象所在的Folder中将其删除,再将左侧运算对象加入
    }
    return *this;
}

我们定义的StrVec类的reallocate成员通过一个一个将元素从旧内存用allocator的construct函数拷贝到新内存,此处的拷贝含义为construct函数的表示要构造的位置之外的额外参数通过使用string的右值来调用string的移动拷贝函数来“拷贝”每个string。但如果我们能使用uninitialized_copy来构造新分配的内存会简单很多,但此函数是用来拷贝而非移动元素的,此函数的原理为为每个元素调用construct将元素“拷贝”到目标位置。但C++11定义了一种移动迭代器适配器,它通过改变给定迭代器的解引用运算符行为来适配此迭代器,移动迭代器解引用得到对象的一个右值引用,我们可以通过make_move_iterator将一个普通迭代器变为移动迭代器,移动迭代器可以使用原迭代器的一切操作:

//改进前:
for (size_t i = 0; i < size(); ++i) {
    alloc.construct(dest++, std::move(*elem++));
}
//改进的reallocate,将循环construct右值改为:
auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);

由于移动一个对象会破坏原对象,因此需要保证原对象不再使用。

一个unique_ptr是不能拷贝的,但它可以作为函数的返回值被返回,这是因为函数返回值是一个临时对象,接收返回值时用的是移动构造函数或移动赋值运算符来移动对象而非拷贝它。

如果既有拷贝并交换的赋值运算符版本(接受类类型参数)也有移动赋值运算符版本(接受类类型的右值引用参数),那么对于一个左值,会使用拷贝并交换版本的赋值运算符,对于一个右值会产生二义性错误:

void func(int) {
    cout << "int &" << endl;
}

void func(int&&) {
    cout << "int &&" << endl;
}

int main() {
    int i = 42;
    func(42);    //二义性错误,将右值int传给int &&和int是同样好的
    func(i);    //正确,int不能绑定在int &&,调用第一个
}

如果一个函数也提供拷贝和移动版本,那么也将受益,如,定义了push_back的标准库容器提供两个版本:

void push_back(const T&);    //拷贝
void push_back(T&&);    //移动,接收非const的右值,此版本是类型T非const右值的精确匹配

给StrVec定义接受右值的push_back:

void StrVec::push_back(string&& s) {
    chk_n_alloc();    //检查内存是否足够存s
    alloc.construct(first_free++, std::move(s));
}

StrVec vec;
vec.push_back("dobe");    //使用push_back(string&&)
string s = "a";
vec.push_back(s);    //使用push_back(const string&)

以下是合法的:

string s1 = "s1", s2 = "s2";
auto n = (s1 + s2).find('1');    //正确,调用右值的成员函数
s1 + s2 = "wow";    //合法,给一个右值赋值

为阻止给右值赋值,我们可以强制希望左侧运算对象是一个左值:

class Foo {
public:
    Foo& operator=(const Foo&) &;    //只能向Foo的可修改左值赋值
};

以上我们通过在赋值运算符的参数列表后放置一个引用限定符&来指出this指向一个左值,放置一个&&来指出this指向一个右值。类似const限定符,引用限定符只能用于非static成员函数且必须同时出现在函数的声明和定义中。

对于&限定的函数,我们只能将它用于左值。

Foo& retFoo();    //返回一个引用(左值)
Foo retVal();    //返回一个值(右值)
Foo i, j;    //j和i是左值
i = j;    //正确,i是左值
retFoo() = j;    //正确,retFoo返回一个左值
retVal() = j;    //错误,retVal返回一个右值
i = retVal();    //正确,i是左值

一个函数可以同时被const和引用限定:

class Foo {
public:
    Foo someMem() & const;    //错误
    Foo anotherMen() const &;    //正确,const要在&前面
};

const和&限定符都可以作为函数重载的区分。如StrVec给元素排序的函数,该函数返回一个排序后的副本,我们可以在右值上原址排序,而在左值上必须先拷贝之后再排序。但是如果是const的函数的重载,可以只在需要的函数上加const限定,但如果在一组重载函数中,有一个函数有&限定符或&&限定符,则这组重载函数全部都要加上&或&&限定符。

发布了221 篇原创文章 · 获赞 11 · 访问量 8万+

猜你喜欢

转载自blog.csdn.net/tus00000/article/details/105056423