文章目录
- 前言
- 拷贝、赋值与销毁
- 拷贝构造函数
- 合成拷贝构造函数
- 拷贝初始化和直接初始化
- 拷贝初始化的发生:
- 参数和返回值
- 拷贝初始化的限制
- 拷贝赋值运算符
- 重载赋值运算符
- 合成拷贝赋值运算符
- 析构函数
- 析构函数完成的工作
- 什么时候会调用析构函数
- 合成析构函数
- 代码片段调用几次析构函数
- 根据代码理解 拷贝构造函数、拷贝赋值运算符以及析构函数何时执行
- 三 / 五法则
- 需要析构函数的类也需要拷贝和赋值操作
- 示例代码
- 需要拷贝操作的类也需要赋值操作,反之亦然
- 示例代码
- 没有拷贝构造的类
- 有拷贝构造的类
- =default
- 阻止拷贝
- 定义删除的函数
- 析构函数不能是删除函数的成员
- 合成的拷贝控制成员可能是删除的
- 拷贝控制和资源管理
- 行为像值的类
- 示例代码
- 类值拷贝赋值运算符
- 定义行为像指针的类
- 示例代码
- 交换操作
- 拷贝控制示例
- 示例代码【两个类相互调用】
- 动态内存管理类
- StrVec类的设计
- reallocate成员函数
- 移动构造函数
- std::move
- StrVec代码
- 对象移动
- 右值引用
- 左值持久,右值短暂
- 变量是左值
- 标准库move函数
- 移动构造函数和移动赋值运算符
- 移动构造函数
- 移动操作、标准库容器和异常
- 移动赋值运算符
- 移后源对象必须可析构
- 合成的移动操作
- 移动操作永远不会隐式定义为删除的函数
- 移动右值,拷贝左值
- 如果没有移动构造函数,右值也被拷贝
- 建议:更新三 / 五法则
- 移动迭代器
- 右值引用和成员函数
- 右值和左值引用成员函数
前言
当定义一个类时,我们显式或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。我们称这些操作为拷贝控制操作。
在定义任何c++类时,拷贝控制操作都是必要部分。必须定义对象拷贝、移动、赋值或销毁时做什么。如果我们不显式定义这些操作,编译器也会为我们定义,但编译器定义的版本的行为可能并非我们所想。
拷贝、赋值与销毁
拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数:
class Foo{Foo();//默认构造函数Foo(const Foo&); //拷贝构造函数
}
虽然我们可以定义一个接受非const引用的拷贝构造函数,但此参数几乎总是一个const的引用。拷贝构造函数在几种情况下都会被隐式地使用。因此拷贝构造函数通常不应该是explicit的。
拷贝构造函数的第一个参数必须是一个引用类型的原因在这里。
合成拷贝构造函数
如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
一般情况下,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。对内置类型的成员,直接进行内存拷贝,对类类型的成员,调用其拷贝构造函数进行拷贝。
拷贝初始化和直接初始化
当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
拷贝初始化的发生:
- 用 = 定义变量时
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
- 初始化标准库容器或调用其insert/push操作时,容器会对其元素进行拷贝初始化
参数和返回值
在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。
拷贝构造函数的第一个参数必须是一个引用类型的原因:
拷贝构造函数被用来初始化非引用类类型参数。如果拷贝构造函数自己的参数不是引用类型,则调用永远也不会成功——为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。
拷贝初始化的限制
如前所述,如果我们使用的初始化值要求通过一个explicit的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了:
拷贝赋值运算符
与类控制其对象如何初始化一样,类也可以控制其对象如何赋值:
Sales_data trans,accum;
trans = accum; //使用Sales_data的拷贝赋值运算符
与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。
重载赋值运算符
重载运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。因此赋值运算符就是一个名为 operator= 的函数,类似于任何其他函数,运算符函数也有一个返回类型和一个参数列表。
重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。
为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。另外,标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用。
赋值运算符通常应该返回一个指向其左侧运算对象的引用。
合成拷贝赋值运算符
与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。
通常情况下,合成的拷贝赋值运算符会将右侧对象的非static成员逐个赋予左侧对象的对应成员,这些赋值操作是由成员类型的拷贝赋值运算符来完成的。
析构函数
析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非static数据成员。
析构函数没有返回值,也不接受参数:
class Foo{
public:~Foo();//析构函数
}
由于析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一一个析构函数。
析构函数完成的工作
在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
隐式销毁一个内置指针类型的成员不会delete它所指向的对象。
与普通指针不同,智能指针是类类型,所以具有析构函数。因此与普通指针不同,智能指针成员在析构阶段会被自动销毁。
什么时候会调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数
- 变量在离开其作用域时被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁
- 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁
- 对于临时对象,当创建它的完整表达式结束时被销毁
由于析构函数自动运行,我们的程序可以按需分配资源,无需担心何时释放这些资源。
当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。
析构函数体自身并不直接销毁成员。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。
代码片段调用几次析构函数
代码一:指针 调用3次
bool fcn(const Sales_data *trans,Sales_data accum){Sales_data item1(*trans),item2(accum);return item1.isbn()!=item2.isbn();
}
测试:
Sales_data s(string("001"),10,5);Sales_data s2(string("001"), 10, 5);Sales_data *s1=&s;fcn(s1,s2);
其中析构函数为:
~Sales_data() { cout << "这是在执行Sales_data的析构函数..." << endl; }
输出结果:
这是在执行Sales_data的析构函数...
这是在执行Sales_data的析构函数...
这是在执行Sales_data的析构函数...
调用三次析构函数:
- 函数结束时,局部变量item1和item2的生命期结束,被销毁,Sales_data的析构函数被调用
- 函数结束时,参数accum的生命期结束,被销毁,Sales_data的析构函数被调用
- 在函数结束时,trans的生命期也结束了,但它是Sales_data的指针,并不是它指向的Sales_data对象的生命期结束(只有delete指针时,指向的动态对象的生命期才结束)
代码二:引用 调用3次
bool fcn(const Sales_data &trans, Sales_data accum) {Sales_data item1(trans), item2(accum);return item1.isbn() != item2.isbn();
}
输出结果:
这是在执行Sales_data的析构函数...
这是在执行Sales_data的析构函数...
这是在执行Sales_data的析构函数...
代码三:调用4次
bool fcn(const Sales_data &trans, Sales_data accum) {Sales_data item1(trans), item2(accum);return item1.isbn() != item2.isbn();
}
输出结果:
这是在执行Sales_data的析构函数...
这是在执行Sales_data的析构函数...
这是在执行Sales_data的析构函数...
这是在执行Sales_data的析构函数...
根据代码理解 拷贝构造函数、拷贝赋值运算符以及析构函数何时执行
Y类
class Y {Y() { cout << "构造函数Y()" << endl; }Y(const Y&) { cout << "拷贝构造函数Y(const Y&)" << endl; }Y& operator=(const Y&rhs) { cout << "拷贝赋值运算符=(const Y&)" << endl; return *this; }~Y() { cout << "析构函数~Y()" << endl; }
};
测试代码:
void f1(Y y){}
void f2(Y& y) {}
void testY() {cout << "局部变量:" << endl;Y y;cout << endl;cout << "非引用参数传递:" << endl;f1(y);cout << endl;cout << "引用参数传递:" << endl;f2(y);cout << endl;cout << "动态分配:" << endl;Y *py = new Y;cout << endl;cout << "添加到容器中:" << endl;vector<Y>vy;vy.push_back(y);cout << endl;cout << "释放动态分配对象:" << endl;delete py;cout << endl;cout << "间接初始化和赋值:" << endl;Y tmp = y;tmp = y;cout << endl;cout << "程序结束;" << endl;
}
输出结果:
局部变量:
构造函数Y()非引用参数传递:
拷贝构造函数Y(const Y&)
析构函数~Y()引用参数传递:动态分配:
构造函数Y()添加到容器中:
拷贝构造函数Y(const Y&)释放动态分配对象:
析构函数~Y()间接初始化和赋值:
拷贝构造函数Y(const Y&)
拷贝赋值运算符=(const Y&)程序结束;
析构函数~Y()
析构函数~Y()
析构函数~Y()
程序结束后的三次Y的析构函数分别是tmp,vector中的元素和y
编译器可以略过对拷贝构造函数的调用。
三 / 五法则
有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。而且,在新标准下,一个类还可以定义一个移动构造函数和一个移动赋值运算符。
C++语言并不要求我们定义所有这些操作,可以只定义其中一个或两个,而不必定义所有。但是,这些操作通常应该被看做一个整体。通常,只需要其中一个操作,而不需要定义所有操作的情况是很少见的。
需要析构函数的类也需要拷贝和赋值操作
当我们决定一个类是否有必要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数。通常,对析构函数的需求要比对拷贝构造函数或赋值运算符的需求更为明显。如果这个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
示例代码
如果我们为HasPtr定义一个析构函数,但使用合成版本的拷贝构造函数和拷贝赋值运算符:
class HasPtr{
public:HasPtr(const string &s=string()):ps(new string(s)),i(0){}~HasPtr(){delete ps;}
private:string *ps;int i;
}HasPtr foo(HasPtr hp){HasPtr ret = hp;return ret;
}
在该示例中,当foo运行完毕后,hp和ret都会被销毁,在两个对象上都会调用HasPtr的析构函数,此析构函数会delete ret和hp中的指针成员,但这两个对象包含相同的指针值(因为合成的拷贝构造函数和拷贝赋值运算符,只是简单的拷贝指针成员,因此ret和hp中的指针成员指向相同的内存),此代码会导致此指针值被delete两次,这显然是一个错误。
因此,如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数。
需要拷贝操作的类也需要赋值操作,反之亦然
需要拷贝操作的类也需要赋值操作,反之亦然。然而无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。
示例代码
没有拷贝构造的类
class Numbered {public:Numbered() { mysn = num++; }int mysn;
private:static int num;
};
int Numbered::num = 0;
无论 f 函数是值传递还是引用传递结果均为0 0 0 0
void f(Numbered &n) {cout << "n:" << n.mysn << endl;
}
void testNumbered() {Numbered n,n2=n;cout << "n:" << n.mysn << endl;cout << "n2:" << n2.mysn << endl;cout << "f(n): " << endl;f(n);cout << "f(n2): " << endl;f(n2);
}
输出结果:
n:0
n2:0
f(n):
n:0
f(n2):
n:0
有拷贝构造的类
class Numbered {public:Numbered() { mysn = num++; }Numbered(Numbered&n) { mysn = num++; }int mysn;
private:static int num;
};
int Numbered::num = 0;
参数是值传递,在传参的过程中,又进行了拷贝构造,故输出结果为0 1 2 3
void f(Numbered n) {cout << "n:" << n.mysn << endl;
}
void testNumbered() {Numbered n,n2=n;cout << "n:" << n.mysn << endl;cout << "n2:" << n2.mysn << endl;cout << "f(n): " << endl;f(n);cout << "f(n2): " << endl;f(n2);
}
输出结果:
n:0
n2:1
f(n):
n:2
f(n2):
n:3
参数是引用传递:
void f(Numbered &n) {cout << "n:" << n.mysn << endl;
}
void testNumbered() {Numbered n,n2=n;cout << "n:" << n.mysn << endl;cout << "n2:" << n2.mysn << endl;cout << "f(n): " << endl;f(n);cout << "f(n2): " << endl;f(n2);
}
输出结果:
n:0
n2:1
f(n):
n:0
f(n2):
n:1
=default
我们可以通过将拷贝控制成员定义为 =default 来显式地要求编译器生成合成的版本。
我们只能对具有合成版本的成员函数使用 =default (即,默认构造函数或拷贝控制成员)
阻止拷贝
大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。但对某些类来说,这些操作没有合理的意义。在此情况下,定义类时必须采用某种机制阻止拷贝或赋值。例如,iostream类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。如果我们不定义拷贝控制成员,编译器依然会为它生成合成的版本,因此这种策略不能避免类的拷贝。
定义删除的函数
我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。 删除的函数是这样一种函数:我们虽然生命了它们,但不能以任何方式使用它们。在函数的参数列表后面加上 =delete 来指出我们希望将它定义为删除的
class Nocopy{Nocopy() = default;//使用合成的默认构造函数Nocopy(const Nocopy&)=delete;//阻止拷贝Nocopy &operator=(const Nocopy&)=delete;//阻止赋值~Nocopy() = default;//使用合成的析构函数
};
与 =default 不同,=delete 必须出现在函数第一次声明的时候。此外,我们可以对任何函数指定 =delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用 =default)。虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。
析构函数不能是删除函数的成员
值得注意的是,我们不能删除析构函数。因为如果析构函数被删除,就无法销毁此类型的对象了。对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时变量。
对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象,但是不能释放指向该类型动态分配对象的指针。
合成的拷贝控制成员可能是删除的
如果一个类未定义拷贝控制成员或构造函数,编译器会定义默认的合成版本。对某些类来说,编译器将这些合成的成员定义为删除的函数:
- 如果类的某个成员的析构函数是删除的或不可访问的,则类的合成析构函数被定义为删除的
- 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。
- 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
- 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个const成员,它没有类内初始化器且其类型未显式定义默认构造函数,则类的默认构造函数被定义为删除的
本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
拷贝控制和资源管理
通常,管理类外资源的类必须定义拷贝控制成员,这种类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数,那么它几乎肯定也需要一个拷贝构造函数和一个拷贝赋值运算符。
为了定义这些成员,我们首先必须确定此类型对象的拷贝语义。一般来说,有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。
行为像指针的类则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。
标准库容器和string类的行为像一个值。shared_ptr类提供类似指针的行为。IO类型和unique_ptr不允许拷贝或赋值,因此它们的行为既不像值也不像指针。
行为像值的类
为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。这意味着对于ps指向的string,每个HasPtr对象都必须有自己的拷贝。为了实现类值行为,HasPtr需要:
- 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
- 定义一个析构函数来释放string
- 定义一个拷贝赋值运算符来释放对象当前的string,并从右侧运算对象拷贝string
示例代码
类值版本的HasPtr如下所示:
class HasPtr{
public:HasPtr(const string &s=string()):ps(new string(s)),i(0){}HasPtr(const HasPtr &p):ps(new string(*p.ps)),i(p.i){}HasPtr& operator=(const HasPtr &);~HasPtr(){delete ps;}
private:string *ps;int i;
}
此处要注意,HasPtr(const HasPtr &p):ps(new string(*p.ps)),i(p.i){}
为什么拷贝构造函数的参数可以直接去访问它自己的私有成员?
对象能否访问到私有成员与其定义的位置有关:在类内定义,可以访问,在类外定义,不能访问。在类的成员函数中可以访问同类型实例对象的私有成员变量。
类值拷贝赋值运算符
赋值运算符通常组合了析构函数和构造函数的操作。类似析构函数, 赋值操作会销毁左侧运算对象的资源。类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。这些操作是以正确的顺序执行的,即使将一个对象赋予它自身,也保证正确。而且,如果可能,我们编写的赋值运算符还应该是异常安全的——当异常发生时能将左侧运算对象置于一个有意义的状态。
HasPtr& HasPtr::operator=(const HasPtr &rhs){auto newp = new string(*rhs.ps);//拷贝底层stringdelete ps;//释放旧内存ps=newp;//从右侧运算对象拷贝数据到本对象i=rhs.i;return *this;//返回本对象
}
当编写赋值运算符时,有两点需要记住:
- 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
- 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。
当编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。
定义行为像指针的类
对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的string。我们的类仍然需要自己的析构函数来释放接受string参数的构造函数分配的内存。只有当最后一个指向string的HasPtr销毁时,它才可以释放string。
令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源。
但是,有时我们希望直接管理资源,在这种情况下我们可以使用引用计数。引用计数的工作方式如下:
- 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
- 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为0,则析构函数释放状态。
- 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。
我们将计数器保存在动态内存中,当创建一个对象时,我们也分配一个新的计数器。当拷贝或赋值对象时,我们拷贝指向计数器的指针。使用这种方法,副本和原对象都会指向相同的计数器。
示例代码
class HasPtr {
public://构造函数分配新的string和新的计数器,将计数器置为1HasPtr(const string &s = string()) :ps(new string(s)), i(0),use(new size_t(1)) {}//拷贝构造函数拷贝所有三个数据成员,并递增计数器HasPtr(const HasPtr &p) :ps(p.ps), i(p.i), use(p.use) { ++*use; }//赋值运算符HasPtr& operator=(const HasPtr &rhs) {++*rhs.use;//递增右侧运算对象的引用计数if (--*use == 0) { //递减本对象的引用计数,delete ps; //如果计数为0,则释放本对象分配的成员delete use;}ps = rhs.ps; //将数据从rhs拷贝到本对象i = rhs.i;use = rhs.use;return *this; //返回本对象}//析构函数~HasPtr() {//如果引用计数变为0,则释放string内存,释放计数器内存if (--*use == 0) {delete ps;delete use;}}//打印usevoid printUse() { cout << "use:" << *use << endl; }
private:string *ps;int i;size_t *use;
};
测试代码:
HasPtr hp1("hello");hp1.printUse(); //1HasPtr hp2(hp1);hp1.printUse(); //2hp2.printUse(); //2HasPtr hp3;HasPtr hp4(hp3);hp3.printUse(); //2hp4.printUse(); //2hp4 = hp1;//增加 hp1 的计数,减少hp4原计数器的计数,赋值操作后,hp4和hp1指向相同的内存,故hp3计数为1,其余计数为3hp1.printUse(); //3hp2.printUse(); //3hp3.printUse(); //1hp4.printUse(); //3
交换操作
除了定义拷贝控制成员,管理资源的类通常还定义一个名为swap的函数。
如果一个类定义了自己的swap,那么算法将使用类自定义版本。否则,算法将使用标准库定义的swap。虽然与往常一样我们不知道swap是如何实现的,但理论上很容易理解,为了交换两个对象我们需要进行一次拷贝和两次赋值。
定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换的技术。这种技术将左侧运算对象与右侧运算对象的一个副本进行交换。(在这个版本的赋值运算符中,参数并不是一个引用,因此rhs是右侧运算对象的一个副本)
//rhs是按值传递的,意味着HasPtr的拷贝构造函数
//将右侧运算对象中的string拷贝到rhs
HasPtr& HasPtr::operator=(HasPtr rhs){//交换左侧运算对象和局部变量rhs的内容swap(*this,rhs); //rhs现在指向本对象曾经使用的内存return *this; //rhs被销毁,从而delete了rhs中的指针
}
拷贝控制示例
虽然通常来说,分配资源的类更需要拷贝控制,但资源管理并不是一个类需要定义自己的拷贝控制成员的唯一原因。一些类也需要拷贝控制成员的帮助来进行簿记工作或其他操作。
示例代码【两个类相互调用】
我们定义Message和Folder类,为了记录Message位于哪些Folder中,每个Message都会保存一个它所在Folder的指针的set集合,同样的,每个Folder都保存一个它包含的Message的指针的set集合。
Message类:
Message.h文件
#ifndef __MESSAGE__
#define __MESSAGE__#include<iostream>
#include<set>
#include<string>
#include"Folder.h"
using namespace std;class Message {friend class Folder;
public:explicit Message(const string &str="") :contents(str){}Message(const Message&);//拷贝构造函数Message& operator=(const Message&);//拷贝赋值运算符~Message();//析构函数//从给定Folder集合中添加/删除本Messagevoid save(Folder&);void remove(Folder&);void addFldr(Folder *f) {folders.insert(f);}private:string contents; //实际消息文本set<Folder*>folders; //包含本Message的Folder//工具函数//将本Message添加到指向参数的Folder中void add_to_Folders(const Message&);//从folders中的每个Folder中删除Messagevoid remove_from_Folders();
};
#endif
Message.cpp文件
#include"Folder.h"
#include"Message.h"void Message::save(Folder &f) {folders.insert(&f);//将给定Folder添加到我们的Folder列表中f.addMsg(this); //将本Message添加到f的Message集合中
}
void Message::remove(Folder &f) {folders.erase(&f);//将给定Folder从我们的Folder列表中移除f.remMsg(this); //将本Message从f的Message集合中移除
}//工具函数
//将本Message添加到指向m的Folder中
void Message::add_to_Folders(const Message&m) {for (auto f : m.folders) {f->addMsg(this);}
}
Message::Message(const Message&m) :contents(m.contents), folders(m.folders) {add_to_Folders(m);
}
//从对应的Folder中删除本Message
void Message::remove_from_Folders() {for (auto f : folders)f->remMsg(this);
}
Message::~Message() {remove_from_Folders();
}
//我们先从左侧运算对象的folders中删除此Message的指针,
//然后再将指针添加到右侧运算对象的folders中,
//从而实现了自赋值的正确处理
Message& Message::operator=(const Message&rhs) {remove_from_Folders();contents = rhs.contents;folders = rhs.folders;add_to_Folders(rhs);return *this;
}
Folder类:
Folder.h文件
#ifndef __FOLDER__
#define __FOLDER__#include<iostream>
#include<set>
#include<string>
using namespace std;class Message;
class Folder {
public:Folder() {}Folder(const Folder &f) :message(f.message) { add_to_message(f); }Folder& operator=(const Folder&f) {remove_from_message();message = f.message;add_to_message(f);return *this;}~Folder() {remove_from_message();}void addMsg(Message *m) {message.insert(m);}void remMsg(Message *m) {message.erase(m);}void printMsg();
private:set<Message*>message;void add_to_message(const Folder &f);void remove_from_message();
};
#endif
Folder.cpp文件
#include"Folder.h"
#include"Message.h"void Folder::add_to_message(const Folder &f) {for (auto msg : f.message) {msg->addFldr(this);}
}void Folder::remove_from_message() {while (!message.empty()) {(*message.begin())->remove(*this);}
}void Folder::printMsg() {for (auto m : message) {cout << "message.contents: " << (*m).contents << endl;}
}
测试函数:
Message m1("hello,m1");Folder f1,f2,f3,f4;m1.save(f1);m1.save(f3);Message m2("hello,m2");m2.save(f1);m2.save(f2);m2.save(f4);Message m3("hello,m3");m3.save(f2);m3.save(f3);m3.save(f4);cout << "f1:" << endl;f1.printMsg();cout << "f2:" << endl;f2.printMsg();cout << "f3:" << endl;f3.printMsg();cout << "f4:" << endl;f4.printMsg();m1.remove(f1);m2.remove(f2);m3.remove(f3);cout << "f1:" << endl;f1.printMsg();cout << "f2:" << endl;f2.printMsg();cout << "f3:" << endl;f3.printMsg();cout << "f4:" << endl;f4.printMsg();
输出结果:
f1:
message.contents: hello,m2
message.contents: hello,m1
f2:
message.contents: hello,m3
message.contents: hello,m2
f3:
message.contents: hello,m3
message.contents: hello,m1
f4:
message.contents: hello,m3
message.contents: hello,m2
f1:
message.contents: hello,m2
f2:
message.contents: hello,m3
f3:
message.contents: hello,m1
f4:
message.contents: hello,m3
message.contents: hello,m2
本测试代码起初有报错,原因是将h文件和cpp文件均写到了h文件中,后来将二者分开(即,成员声明和定义分离即可) 程序即可以运行了。此处可参见某博客链接。
动态内存管理类
某些类需要在运行时分配可变大小的内存空间。这种类通常可以使用标准库容器来存放它们的数据。这种类通常可以(并且如果它们确实可以的话,一般应该)使用标准库容器来保存它们的数据。
如果某些类需要自己进行内存分配,这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存。
StrVec类的设计
我们实现标准库vector类的一个简化版本,即不使用模板,只用于string。
vector类将其元素保存在连续内存中。为了获得可接受的性能,vector预先分配足够的内存来保存可能需要的更多元素。vector的每个添加元素的成员函数会检查是否有空间容纳更多的元素。如果有,成员函数会在下一个可用位置构造一个新对象。如果没有可用空间,vector就会重新分配空间:它获得新的空间,将已有元素移动到新空间中,释放旧空间,并添加新元素。
reallocate成员函数
移动构造函数
通过使用标准库引入的两种机制,我们就可以避免string的拷贝。有一些标准库类,包括string,都定义了所谓的“移动构造函数”,移动构造函数通常是将资源从给定对象“移动”而不是拷贝到正在创建的对象。
std::move
标准库函数move,定义在utility头文件中。关于move,我们需要了解两个关键点,首先,当reallocate在新内存中构造string时,它必须调用move来表示希望使用string的移动构造函数。如果它漏掉了move调用,将会使用string的拷贝构造函数。其次,我们通常不为move提供一个using声明,当我们使用move时,直接调用std::move而不是move
StrVec代码
StrVeC类:
#ifndef STRVEC_H
#define STRVEC_H#include<iostream>
#include<memory>
#include <string>
using namespace std;class StrVec
{
public:StrVec():elements(nullptr),first_free(nullptr),cap(nullptr) {};StrVec(const StrVec&);//拷贝构造函数StrVec &operator=(const StrVec&);//拷贝赋值运算符~StrVec();//析构函数void push_back(const string&);//拷贝元素size_t size()const { return first_free - elements; }size_t capacity()const { return cap - elements; }string * begin()const { return elements; }string * end()const { return first_free; }
private:allocator<string> alloc;//被添加元素的函数使用void chk_n_alloc() {if (size() == capacity())reallocate();}//工具函数,被拷贝构造函数、赋值运算符和析构函数所使用pair<string*, string*> alloc_n_copy(const string*,const string*);void free();//销毁元素并释放内存void reallocate();//获得更多内存并拷贝已有元素string * elements;//指向数组首元素的指针string * first_free;//指向数组第一个空闲元素的指针string * cap;//指向数组尾后位置的指针
};void StrVec::push_back(const string&s) {chk_n_alloc();alloc.construct(first_free++,s);
}//此函数返回一个指针的pair,两个指针分别指向新空间的开始位置和拷贝的尾后的位置pair<string*, string*> StrVec::alloc_n_copy(const string*b, const string*e) {//分配空间保存给定范围中的元素auto data = alloc.allocate(e-b);//初始化并返回一个pair,该pair由data和uninitialized_copy的返回值构成auto end = uninitialized_copy(b, e, data);return{ data, end};
}
void StrVec::free() {if (elements) {for (auto ptr = first_free;ptr != elements;) {alloc.destroy(--ptr);}alloc.deallocate(elements, cap-elements);}
}StrVec::StrVec(const StrVec&s) {pair<string*, string*>p=alloc_n_copy(s.begin(),s.end());elements = p.first;first_free = p.second;cap = p.second;
}
StrVec::~StrVec() {free();
}
StrVec & StrVec::operator=(const StrVec&rhs) {pair<string*, string*>p = alloc_n_copy(rhs.begin(), rhs.end());free();elements = p.first;first_free = p.second;cap = p.second;return *this;
}
//reallocate函数
//为一个新的更大的string数组分配内存
//在内存空间的前一部分构造对象,保存现有元素
//销毁原内存空间中的元素,并释放这块内存
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;
}
#endif
测试代码:
void printSize(StrVec &s) {cout << "size(): " << s.size() << endl;cout << "capacity(): " << s.capacity() << endl;cout << endl;
}
void testStrVec() {StrVec s;printSize(s);for (int i = 0;i <10;i++) {s.push_back(to_string(i));printSize(s);}for (auto beg = s.begin();beg != s.end();beg++) {cout << *beg<<" ";}}int main() {testStrVec(); system("pause");return 0;
}
输出结果:从输出结果可以看出,当进行push_back操作时,若没有空间添加新元素,则将分配当前大小两倍的内存空间
size(): 0
capacity(): 0size(): 1
capacity(): 1size(): 2
capacity(): 2size(): 3
capacity(): 4size(): 4
capacity(): 4size(): 5
capacity(): 8size(): 6
capacity(): 8size(): 7
capacity(): 8size(): 8
capacity(): 8size(): 9
capacity(): 16size(): 10
capacity(): 160 1 2 3 4 5 6 7 8 9
对象移动
新标准的一个最主要的特性是可以移动而非拷贝对象的能力。在某些情况下,对象拷贝后就立即被销毁了,此时移动而非拷贝对象会大幅度提升性能。
标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。
右值引用
为了支持移动操作,新标准引入了一种新的引用类型——右值引用。所谓右值引用就是必须绑定到右值的引用。我们通过&&而不是&来获得右值引用。右值引用有一个重要的性质——只能绑定到一个将要销毁的对象。因此我们可以自由地将一个右值引用的资源“移动”到另一个对象中。
一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。
类似任何引用,一个右值引用也不过是某个对象的另一个名字而已。对于常规引用(为了区别右值引用,我们可称之为左值引用),我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引用有着完全相反的绑定特性:我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上。
int i=42;
int &r=i; //正确:r引用i
int &&rr=i; //错误:不能将一个右值引用绑定到一个左值上
int &r2=i*42;//错误:i*42是一个右值
const int &r3=i*42;//正确:我们可以将一个const的引用绑定到一个右值上
int &&rr2=i*42; //正确:将rr2绑定到乘法结果上
返回左值引用的函数,连同赋值、下标/解引用和前置递增/递减运算符,都是返回左值的表达式。我们可以将一个左值引用绑定到这类表达式的结果上。
返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个const的左值引用或者一个右值引用绑定到这类表达式上。
左值持久,右值短暂
左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象
右值引用只能绑定到临时对象:
- 所引用的对象将要被销毁
- 该对象没有其他用户
这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。
变量是左值
变量表达式都是左值,变量是持久的,直至离开作用域时才被销毁。
因此我们不能将一个右值引用绑定到一个右值引用类型的变量上:
int &&rr1 = 42;//正确:字面常量是右值
int &&rr2 = rr1;//错误:表达式rr1是左值
标准库move函数
虽然我们不能将一个右值引用直接绑定到一个左值上,但是我们可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用,move函数定义在头文件utility中。
int &&rr3 = std::move(rr1);//正确
使用move的代码应该使用std::move而不是move。这样做可以避免潜在的名字冲突。
移动构造函数和移动赋值运算符
类似string类(及其他标准库类),如果我们自己的类也同时支持移动和拷贝,那么也能从中受益。为了让我们自己的类型支持移动操作,需要为其定义移动构造函数和移动赋值运算符。这两个成员类似对应的拷贝操作,但它们从给定对象“窃取”资源而不是拷贝资源。
移动构造函数
类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用,不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。
除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。
StrVec的移动构造函数示例:
StrVec::StrVec(StrVec&&s)noexcept:elements(s.elements),first_free(s.first_free),cap(s.cap)
{s.elements = s.first_free = s.cap=nullptr;
}
与构造函数不同,移动构造函数不分配任何新内存;它接管给定的StrVec中的内存。在接管内存之后,它将给定对象中的指针都置为nullptr。这样就完成了从给定对象的移动操作,此对象将继续存在。最终,移后源对象会被销毁,意味着将在其上运行析构函数。StrVec的析构函数在first_free上调用deallocate。如果我们忘记了改变s.elements ,s.first_free,s.cap,则销毁移后源对象就会释放掉我们刚刚移动的内存。
移动操作、标准库容器和异常
由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。我们将看到,除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为移动我们的类对象时,可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。
我们在一个函数的参数列表后指定noexcept,即为通知标准库函数不抛出异常。
移动赋值运算符
移动赋值运算符执行与析构函数和移动构造函数相同的工作。与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为noexcept。类似拷贝赋值运算符,移动赋值运算符必须正确处理自赋值:
StrVec的移动赋值运算符示例:
StrVec & StrVec::operator=(StrVec&&rhs)noexcept {//直接自检测if (this!=&rhs) {free();//释放已有元素elements = rhs.elements;//从rhs接管资源first_free = rhs.first_free;cap = rhs.cap;//将rhs置于可析构状态rhs.elements = rhs.first_free = rhs.cap = nullptr;}return *this;
}
在此例中,我们直接检查this指针与rhs的地址是否相同。如果相同,右侧和左侧运算对象指向相同的对象,我们不需要做任何事情。否则,我们释放左侧运算对象所使用的内存,并接管给定对象的内存。与移动构造函数一样,我们将rhs中的指针置为nullptr。
移后源对象必须可析构
从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。因此,当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。我们的StrVec的移动操作满足这一要求,这是通过将移后源对象的指针成员置为nullptr来实现的。
在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
合成的移动操作
与处理拷贝构造函数和拷贝赋值运算符一样,编译器也会合成移动构造函数和移动赋值运算符。 但是合成移动操作的条件与合成拷贝操作的条件大不相同。
与拷贝操作不同,编译器根本不会为某些类合成移动操作。特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员:
编译器会为X和hasX合成移动操作:
class X{
private:int i; //内置类型可以移动string s; //string定义了自己的移动操作
}
class hasX{X mem; //X有合成的移动操作
}X x, x2 = std::move(x); //使用合成的移动构造函数
hasX hx, hx2=std::move(hx); //使用合成的移动构造函数
移动操作永远不会隐式定义为删除的函数
如果一个类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。【即,定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则这些成员默认地被定义为删除的】
移动右值,拷贝左值
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。赋值操作的情况类似。
StrVec v1,v2;
v1=v2; //v2是左值,使用拷贝赋值
StrVec getVec(istream &);//getVec返回一个右值
v2=getVec(cin);//getVec(cin)是一个右值,使用移动赋值
如果没有移动构造函数,右值也被拷贝
如果一个类有一个拷贝构造函数但未定义移动构造函数,在此情况下,编译器不会合成移动构造函数,这意味着此类将有拷贝构造函数但不会有移动构造函数。
class Foo{
public:Foo() = default;Foo(const Foo&);//拷贝构造函数...
}Foo x;
Foo y(x);//拷贝构造函数,x是一个左值
Foo z(std::move(x));//拷贝构造函数,因为未定义移动构造函数
//std::move(x)返回一个绑定到x的Foo&&,因为没有移动构造函数, 因此我们可以将一个Foo&&转换为一个const Foo&,因此使用拷贝构造函数
建议:更新三 / 五法则
所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。如前所述,某些类必须定义拷贝构造函数、拷贝赋值运算符和析构函数才能正确工作。这些类通常拥有一个资源,而拷贝成员必须拷贝此资源。一般来说,拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。
移动迭代器
新标准库中定义了一种移动迭代器适配器。一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。
我们通过调用标准库的make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。此函数接受一个迭代器参数,返回一个移动迭代器。
原迭代器的所有其他操作在移动迭代器中都照常工作。由于移动迭代器支持正常的迭代器操作,我们可以将一对移动迭代器传递给算法:
auto first = alloc.allocate(newcapacity);
auto last = uninitialized_copy(make_move_iterator(begin()),make_move_iterator(end()),first)
uninitialized_copy对输入序列中的每个元素调用construct将元素“拷贝”到目的位置。此算法使用迭代器的解引用运算符从输入序列中提取元素。由于我们传递给它的是移动迭代器,因此解引用运算符生成的是一个右值引用,这意味着construct将使用移动构造函数来构造元素。
标准库不保证哪些算法适用移动迭代器,哪些不适用。由于移动一个对象可能销毁原对象,因此只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法。
右值引用和成员函数
拷贝 / 移动构造函数和赋值运算符有相同的参数模式——一个版本接受一个指向const的左值引用,一个版本接受一个指向非const的右值引用。
例如,定义了push_back的标准库容器提供两个版本:
void push_back(const X&) //拷贝:绑定到任意类型的X
void push_back(X&&) //移动:只能绑定到类型X的可修改的右值
我们可以将能转换为类型X的任何对象传递给第一个版本的push_back,此版本从其参数拷贝数据。对于第二个版本,我们只可以传递给它非const的右值,因此当我们传递一个可修改的右值时,编译器会选择运行这个版本,此版本会从其参数移动数据。
string s = "hello";
vec.push_back(s); //调用push_back(const string&)
vec.push_back("done"); //调用push_back(string&&)
这些调用的差别在于实参是一个左值还是一个右值(从“done”创建的临时string),具体调用哪个版本据此来决定。
右值和左值引用成员函数
通常,我们在一个对象上调用成员函数,而不管该对象是一个左值还是一个右值。
我们指出this的左值 / 右值属性的方式与定义const成员函数相同,即,在参数列表后放置一个引用限定符。引用限定符可以是&或&&,分别指出this可以指向一个左值或右值。类似const限定符,引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中。
一个函数可以同时用const和引用限定,在此情况下,引用限定符必须跟随在const限定符之后。
如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。