13拷贝控制
拷贝控制操作包括拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、和析构函数。
一、先介绍自定义拷贝构造函数:
1 1 //拷贝构造函数:如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值 2 2 class Foo{ 3 3 public: 4 4 Foo(); //默认构造函数 5 5 Foo(const Foo&) //拷贝构造函数 6 6}
拷贝构造函数的第一个参数必须是引用类型,且总是一个const的引用。
二、介绍合成的拷贝构造函数:
与默认构造函数不同, 即使我们定义了其他拷贝构造函数,编译器也会为我们合成一个拷贝构造函数。
合成拷贝构造函数分为两种:1.对某些类来说,合成的拷贝构造函数用于阻值我们拷贝该类类型的对象。2.合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中,从给定对象中将每个非static成员拷贝到正在创建的对象中。
对类类型成员使用其拷贝构造函数来拷贝;内置类型则直接进行拷贝。虽然数组不能被拷贝,但是合成拷贝构造函数会逐元素的拷贝一个数组类型的成员。若数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。
1 class Sale_data{ 2 public: 3 //与合成的拷贝构造函数等价的拷贝构造函数的声明,这是自定义的 4 Sales_data(const Sales_data&); 5 private: 6 std::string bookNo; 7 int units_sold = 0; 8 double revenue = 0.0; 9 } 10 //与Sales_data的合成拷贝构造函数等价 11 Sales_data::Sales_data(const Sales_data&orig): 12 bookNo(orig.bookNo), //使用string的拷贝构造函数 13 units_sold(orig.units_sold), //拷贝orig.units_sold 14 revenue(orig.revenue) //拷贝orig.revenue 15 {} //空函数体
三、介绍拷贝初始化:
当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话,还需要进行类型转换。而直接初始化则是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。
拷贝初始化在以下情况会发生:
- 用=定义变量时
- 将一个对象作为实参传递给非引用类型的形参。(非引用才会拷贝)
- 从一个返回类型为非引用类型的函数返回一个对象。
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员。
- 某些类类型还会对它们所分配的对象使用拷贝初始化。
拷贝构造函数为什么必须是引用类型? 因为拷贝构造函数用来初始化非引用类类型参数,如果其参数不是引用类型 ,则调用永远也不会成功,为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝它的实参,我们又需要调用拷贝构造函数,如此无限循环。
13.1 拷贝、赋值与析构
拷贝构造函数
一般创建对象,拷贝或赋值的方式有构造函数,拷贝构造函数,赋值函数这三种方法。下面就详细比较下三者之间的区别以及它们的具体实现
1.构造函数
构造函数是一种特殊的类成员函数,是当创建一个类的对象时,它被调用来对类的数据成员进行初始化和分配内存。(构造函数的命名必须和类名完全相同)
首先说一下一个C++的空类,编译器会加入哪些默认的成员函数
- 默认构造函数和拷贝构造函数
- 析构函数
- 赋值函数(赋值运算符)
- 取值函数
即使程序没定义任何成员,编译器也会插入以上的函数! 注意:构造函数可以被重载,可以多个,可以带参数; 析构函数只有一个,不能被重载,不带参数
而默认构造函数没有参数,它什么也不做。当没有重载无参构造函数时, A a就是通过默认构造函数来创建一个对象
2.拷贝构造函数
拷贝构造函数是C++独有的,它是一种特殊的构造函数,用基于同一类的一个对象构造和初始化另一个对象。当没有重载拷贝构造函数时,通过默认拷贝构造函数来创建一个对象
class 类名{ public: 类名(形参参数) //构造函数的声明/原型 类名(类名& 对象名) //拷贝构造函数的/原型 }; 构造函数的实现: 类名::类名(类名& 对象名) //拷贝构造函数的实现/定义
拷贝构造函数的目的是成员复制,不应修改原对象,所以建议使用const关键字
- 先说下什么时候拷贝构造函数会被调用:在C++中,3种对象需要复制,此时拷贝构造函数会被调用
当对象直接作为参数传给函数时,函数将建立对象的临时拷贝,这个拷贝过程会调用拷贝构造函数,即一个对象以值传递的方式传入函数体
当函数中的局部对象作为返回值被返回给函数调用者时,也会建立此局部对象的一个临时拷贝,即一个对象以值传递的方式从函数返回
定义新对象,并用已有对象初始化新对象时,需要调用拷贝构造函数,即一个对象需要通过另一个对象进行初始化
- 什么时候编译器会生成默认的拷贝构造函数:
如果用户没有自定义拷贝构造函数,并且在代码中使用到了拷贝构造函数,编译器就会生成默认的拷贝构造函数。但如果用户定义了拷贝构造函数,编译器就不在生成。 如果用户定义了一个构造函数,但不是拷贝构造函数,而此时代码中又用到了拷贝构造函数,那编译器也会生成默认的拷贝构造函数。
- 为什么拷贝构造函数必须是引用传递,不能是值传递?
简单的回答是为了防止递归引用。具体一些可以这么讲:当一个对象需要以值方式传递时,编译器会生成代码调用它的拷贝构造函数以生成一个复本。如果类A的拷贝构造函数是以值方式传递一个类A对象作为参数的话,当需要调用类A的拷贝构造函数时,需要以值方式传进一个A的对象作为实参;而以值方式传递需要调用类A的拷贝构造函数;结果就是调用类A的拷贝构造函数导 致又一次调用类A的拷贝构造函数,这就是一个无限递归。
深拷贝和浅拷贝
系统提供的默认拷贝构造函数工作方式是内存拷贝,也就是浅拷贝。如果对象中用到了需要手动释放的对象,则会出现问题,这时就要手动重载拷贝构造函数,实现深拷贝。
浅拷贝:如果复制的对象中引用了一个外部内容(例如分配在堆上的数据),那么在复制这个对象的时候,让新旧两个对象指向同一个外部内容,就是浅拷贝。(指针虽然复制了,但所指向的空间内容并没有复制,而是由两个对象共用,两个对象不独立,删除空间存在) 深拷贝:如果在复制这个对象的时候为新对象制作了外部对象的独立复制,就是深拷贝。
当出现类的等号赋值时,会调用拷贝函数,在未定义显示拷贝构造函数的情况下,系统会调用默认的拷贝函数——即浅拷贝,它能够完成成员的一一复制。当数据成员中没有指针时,浅拷贝是可行的。但当数据成员中有指针时,如果采用简单的浅拷贝,则两类中的两个指针将指向同一个地址,当对象快结束时,会调用两次析构函数,而导致指针悬挂现象。所以,这时,必须采用深拷贝。 深拷贝与浅拷贝的区别就在于深拷贝会在堆内存中另外申请空间来储存数据,从而也就解决了指针悬挂的问题。简而言之,当数据成员中有指针时,必须要用深拷贝。 拷贝构造函数重载声明如下:
A (const A&other)
拷贝初始化和直接初始化
当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择
当使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话,还要进行类型转换。
拷贝初始化时依靠拷贝构造函数或移动构造函数来完成。
- 拷贝初始化在以下情况下发生:
用“=”定义变量时
将一个对象作为实参传递给一个非引用类型的形参
从一个返回类型为非引用类型的函数返回一个对象
用花括号列表初始化一个数组中的元素或一个聚合类中的成员
某些类类型还会对它们所分配的对象使用拷贝初始化。比如:当我们初始化标准库容器或是调用insert或push成员时,容器会对其元素进行拷贝初始化。 与之相对,用emplace成员创建的元素都进行直接初始化。 在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。
3.赋值函数
当一个类的对象向该类的另一个对象赋值时,就会用到该类的赋值函数。当没有重载赋值函数(赋值运算符)时,通过默认赋值函数来进行赋值操作。
A a;
A b;
b=a;
强调:这里a,b对象是已经存在的,是用a 对象来赋值给b的!
赋值运算的重载声明如下:
A& operator = (const A& other)
通常大家会对拷贝构造函数和赋值函数混淆,这儿仔细比较两者的区别:
- 拷贝构造函数是一个对象初始化一块内存区域,这块内存就是新对象的内存区,而赋值函数是对于一个已经被初始化的对象来进行赋值操作。
- 一般来说在数据成员包含指针对象的时候,需要考虑两种不同的处理需求:一种是复制指针对象,另一种是引用指针对象。拷贝构造函数大多数情况下是复制,而赋值函数是引用对象
- 实现不一样。拷贝构造函数首先是一个构造函数,它调用时候是通过参数的对象初始化产生一个对象。赋值函数则是把一个新的对象赋值给一个原有的对象,所以如果原来的对象中有内存分配要先把内存释放掉,而且还要检察一下两个对象是不是同一个对象,如果是,不做任何操作,直接返回。(这些要点会在下面的String实现代码中体现)
如果不想写拷贝构造函数和赋值函数,又不允许别人使用编译器生成的缺省函数,最简单的办法是将拷贝构造函数和赋值函数声明为私有函数,不用编写代码。 所以如果类定义中有指针或引用变量或对象,为了避免潜在错误,最好重载拷贝构造函数和赋值函数。
- 对象不存在,且没用别的对象来初始化,就是调用了构造函数;
- 对象不存在,且用别的对象来初始化,就是拷贝构造函数(上面说了三种用它的情况!)
- 对象存在,用别的对象来给它赋值,就是赋值函数。
四、拷贝构造函数的几个细节
-
拷贝构造函数的作用。
作用就是用来复制对象的,在使用这个对象的实例来初始化这个对象的一个新的实例 3.参数传递过程到底发生了什么? 将地址传递和值传递统一起来,归根结底还是传递的是"值"(地址也是值,只不过通过它可以找到另一个值)! i)值传递: 对于内置数据类型的传递时,直接赋值拷贝给形参(注意形参是函数内局部变量); 对于类类型的传递时,需要首先调用该类的拷贝构造函数来初始化形参(局部对象);如void foo(class_type obj_local){}, 如果调用foo(obj); 首先class_type obj_local(obj) ,这样就定义了局部变量obj_local供函数内部使用 ii)引用传递: 无论对内置类型还是类类型,传递引用或指针最终都是传递的地址值!而地址总是指针类型(属于简单类型), 显然参数传递时,按简单类型的赋值拷贝,而不会有拷贝构造函数的调用(对于类类型). -
在类中有指针数据成员时,拷贝构造函数的使用? 如果不显式声明拷贝构造函数的时候,编译器也会生成一个默认的拷贝构造函数,而且在一般的情况下运行的也很好。但是在遇到类有指针数据成员时就出现问题 了:因为默认的拷贝构造函数是按成员拷贝构造,这导致了两个不同的指针(如ptr1=ptr2)指向了相同的内存。当一个实例销毁时,调用析构函数 free(ptr1)释放了这段内存,那么剩下的一个实例的指针ptr2就无效了,在被销毁的时候free(ptr2)就会出现错误了, 这相当于重复释放一块内存两次。这种情况必须显式声明并实现自己的拷贝构造函数,来为新的实例的指针分配新的内存。
问题1和2回答了为什么拷贝构造函数使用值传递会产生无限递归调用的问题; 问题3回答了回答了在类中有指针数据成员时,拷贝构造函数使用值传递等于白显式定义了拷贝构造函数,因为默认的拷贝构造函数就是这么干的。
- 拷贝构造函数里能调用private成员变量吗? 解答:这个问题是在网上见的,当时一下子有点晕。其时从名子我们就知道拷贝构造函数其时就是一个特殊的构造函数,操作的还是自己类的成员变量,所以不受private的限制
拷贝赋值运算符
1.重载运算符
- 重载运算符本质上是函数,其名字由operator关键字后接要定义的运算符的符号组成。
- 重载运算符的参数表示运算符的运算对象,某些运算符必须定义为成员函数
1.赋值运算符
赋值运算符通常组合了析构函数和构造函数的操作,类似析构函数,赋值操作会销毁左侧运算对象的资源。类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。
class Foo{
public:
Foo & operator = (const Foo&); //赋值运算符
return *this;
}
Foo a;
b = a;//将一个类对象赋值给另一个对象需要调用,拷贝赋值运算符“ = ”;
- 赋值运算符是一个名为operator=的函数,也有一个返回类型和参数列表。
- 赋值运算符通常返回一个指向其左侧运算对象的引用。
- 如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数。
- 标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用。
2.合成拷贝赋值运算符
- 如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝运算符 与处理拷贝构造函数一样,如果一个类没有定义自己了拷贝赋值运算符,则编译器会为它生成一个合成拷贝赋值运算符。 类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用
Sales_data &
Sales_data::operator = (const Sales_data &rhs){
bookNo = rhs.bookNo; //调用string::operator=
units_sold = rhs.units_sold; //使用内置的int赋值
revenue = rhs.revenue; //使用内置的double进行赋值
return *this; //返回一个此对象的引用
}
3.类值拷贝赋值运算符
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); //拷贝底层string
delete ps; // 释放旧内存
ps = newp; // 从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this; // 返回本对象
}
当编写赋值运算符时,有两点需要记住:
- 如果将一个对象赋予它自身,赋值运算符必须能正确工作
- 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。
当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当你拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中去了。
析构函数
- 析构函数释放对象使用的资源,并销毁对象的非static数据成员。
- 析构函数是类的一个成员函数,名字由波浪号接类名构成,它没有返回值,也不接受参数。
- 由于不接受参数,所以它不能被重载,给定一个类,只有唯一一个析构函数。
class Foo{
public:
~Foo(); //析构函数
}
- 在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
- 析构函数的函数体可执行类设计者希望执行的任何收尾工作。
- 通常,析构函数释放对象在生存期分配的所有资源。
- 析构函数是隐式的。
- 成员销毁时发生什么完全依赖于成员的类型。
- 销毁类类型的成员需要执行成员自己的析构函数。
- 内置类型没有析构函数,因此销毁内置类型什么也不需要做。
隐式销毁一个内置指针类型的成员不会delete它所指向的对象。与普通指针不同,智能指针是类类型,所以具有析构函数。所以智能指针成员在析构阶段会被自动销毁。
2.何时调用析构函数
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
- 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。
- 对于临时对象,当创建它的完整表达式结束时被销毁。
{//新作用域
//p和p2指向动态分配的对象。
Sales_data *p = new Sales_data; //p是一个内置指针
auto p2 = make_shared<Sales_data>(); //p2是一个shared_ptr
Sales_data item(*p); //拷贝构造函数将*p拷贝到item中
vector<Sales_data>vec; //局部对象
vec.push_back(*p2); //拷贝p2指向的对象
delete p; //对p指向的对象执行析构函数
} //退出局部作用域;对item、p2和vec调用析构函数; 销毁p2会递减其引用计数;如果引用计数变为0,对象被释放;销毁vec会销毁它的元素。
我们的代码唯一需要直接管理的内存就是我们直接分配的Sales_data对象,我们的代码只需直接释放绑定到p的动态分配对象。 其他Sales_data对象会在离开作用域时被自动销毁。
当一个对象的引用或指针离开作用域时,析构函数不会执行。
2.合成析构函数
- 当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。
- 对于某些类,合成析构函数被用来阻止该类型的对象被销毁。
class Sales_data{
public:
//成员会被自动销毁,除此之外不需要做其他事情
~Sales_data() { }
//其他成员的定义,如前
}
在析构函数体执行完毕后,成员会被自动销毁。特别的,string的析构函数会被调用,它将释放bookNo成员所用的内存。
析构函数体并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的一部分而进行的。
1.三/五法则
- 当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数。
- 如果一个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
- 如果一个类需要拷贝构造函数,那么它也需要拷贝赋值运算符。
- 如果一个类需要拷贝赋值运算符,那么它也需要拷贝构造函数。
例子
void f(numberd s) {cout << s.mysn<< endl;}
numberd a,b=a,c=b;
f(a);f(b);f(c);
若采用合成构造函数,则只能简单复制成员,使得三个对象具有相同的序号。 若采用拷贝构造函数,则定义能生成新的序号的拷贝构造函数。在定义变量a时,默认构造函数起作用,将其序号设为0,当定义b和c时,拷贝构造函数起作用,将它们的序号设置为1和2.由于参数是numberd类型,在每次调用f时,又会触发拷贝构造函数,使得每一次都将形参s的值设为新值,从而导致3次输出的结果为3 4 5 ;
void f(const numberd &s) {cout << s.mysn<< endl;}
numberd a,b=a,c=b;
f(a);f(b);f(c);
由于形参类型变为引用类型,传递的不再是类对象而是类对象的引用,意味着调用f时不再触发拷贝构造函数将实参拷贝给形参,而是传递形参的引用。 因此,对于每次调用,s都是指向实参的引用,序号自然是实参的序号,而不是创建一个新的对象,获得一个新序号。
2.使用=default
- 当我们在类内使用=default修饰成员的声明符时,合成的函数将隐式的声明为内联的。
- 如果我们不希望合成的成员是内联函数,则应该只对成员的类外定义使用=default;
class Sales_data{
public:
//拷贝控制成员,使用=defult
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();
大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式的还是显式的。
3.阻值拷贝
我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻值拷贝。
删除的函数:虽然我们声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的。
struct NoCopy{
NoCopy() = default; //使用合成的默认构造函数
NoCopy(const NoCopy&) = delete; //阻值拷贝
NoCopy &operator = (const NoCopy&) = delete; //阻值赋值
~NoCopy() = default; //使用合成的析构函数
//其它成员
};
- =delete通知编译器,我们不希望定义这些成员
- 与=default不同,=delete必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是吻合的。
- 我们可以对任何函数指定=delete
- 虽然函数函数的用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。
对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针。
4.合成的拷贝控制成员可能是删除的
- 如果某个类的某个成员的析构函数是删除的或不可访问的(例如,是private的),则类的某个合成析构函数被定义为删除的。
- 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。
- 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。
- 如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
- 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
- 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始器,或是类有一个const成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。
如果一个类有数据成员不能默认构造、拷贝、赋值或销毁,则对应的成员函数将被定义为删除的。 一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的。如果没有这条规则,我们可能创建出无法销毁的对象。
5.private拷贝控制
通过将拷贝构造函数声明为private的,我们可以预先阻值拷贝该类型对象的企图:试图拷贝对象的代码将在编译阶段被标记错误,成员函数或友元函数中的拷贝操作将会导致链接时错误。
希望阻止拷贝的类应该使用=delete来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private的。
13.2、拷贝控制与资源管理
管理类外资源的类必须定义拷贝控制成员。为了定义成员,必须确定此类型对象的拷贝语义,一种是像值,一种是像指针
- 类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象时完全独立的。改变副本不会对原对象有任何影响,反之亦然。
- 类的行为像一个指针,则其处于共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。
标准库类中:像值的有string/标准库容器/ 像指针的有shared_ptr。 IO类型和unique_ptr不允许拷贝和赋值,所以其行为既不像指针又不像值。
行为像值的类
行为像值的类,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。
class HasPtr { public: HasPtr(const std::string &s = std::string()): ps(new std::string(s)), i(0) { } //构造函数 HasPtr(const HasPtr &p): ps(new std::string(*p.ps)), i(p.i) { } //定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针。 HasPtr& operator=(const HasPtr &); //定义一个拷贝赋值运算符来释放当前的string,并从右侧运算对象拷贝string ~HasPtr() { delete ps; } //定义一个析构函数来释放string. private: std::string *ps; int i; };
//拷贝赋值运算符、
HasPtr& HasPtr::operator=(const HasPtr &rhs) { auto newp = new string(*rhs.ps); //拷贝底层string delete ps; // 释放旧内存 ps = newp; // 从右侧运算对象拷贝数据到本对象 i = rhs.i; return *this; // 返回本对象 }
当编写赋值运算符时,有两点需要记住:
- 如果将一个对象赋予它自身,赋值运算符必须能正确工作
- 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。
当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当你拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中去了。
行为像指针的类
在C++智能指针share_ptr之所以能够比较智能的进行资源的动态分配和回收,一个非常非常重要的概念就是引入了引用计数。智能指针不仅仅是一个裸指针,而是一个行为像指针的类。引用计数用来记录有多少对象与正在创建的对象共享状态。
所谓的引用计数的工作原理,有以下四点:
- 当我们创建一个对象时,只有一个对象共享状态,所以我们把当前对象的引用计数置为1。
- 拷贝构造函数不分配新的计数器,而是拷贝给定的对象的数据成员,并且包括计数器,两者共享一个计数器,因为给定的对象的共享状态多了一个,所以递增共享计数器。
- 析构函数递减计数器,如果当前的计数器值递减后为零,则析构函数释放改给定对象。
- 拷贝赋值运算符递增右侧对象的计数器,递减左侧对象的计数器,如果左侧的对象的计数器被递减为零,就释放掉该对象。
将计数器保存在动态内存中,当创建一个对象时,我们也分配一个新的计数器。当拷贝或赋值对象时,我们拷贝指向计数器的指针,采用这种方法,副本和原对象都会指向相同的计数器。
class HasPtr{ public : //构造函数分配新的string和新的计数器,将计数器置为1 HasPtr(const std::string &s = std::string()): ps(new std::string(s)),i(0),use(new std::size(1)) {} //添加use数据成员,记录有多少对象共享相同的string. HasPtr(const HasPtr &p): ps(p.ps),i(p.i),use(p.use) {++*use;} HasPtr& operator= (const HasPtr&); ~HasPtr(); private: std::string *ps; int i; std::size_t * use;//用来记录有多少对象共享*ps的成员 };
//析构函数 HasPtr:: ~HasPtr(){ if(--*use == 0) //如果引用计数变为0 delete ps; //释放string 内存 delete use; //释放计数器内存。 }
//拷贝赋值运算符必须递增右侧运算对象的引用计数(拷贝构造函数的工作),并递减左侧运算对象的引用计数,在必要时释放使用的内存(即析构函数的工作)。 赋值运算符必须实现自赋值,通过先递增rhs中的计数然后再递减左侧运算对象中的计数来实现这一点。通过这种方法,当两个对象相同时,在我们检查ps是否应该被释放之前,计数器就已经被递增过了。
HasPtr& HasPtr::operatro = (const HasPtr& rhs){ ++*rhs.use; //递增右侧运算对象的引用计数 if(--*use == 0){ //然后递减本对象的引用计数 delete ps; //如果没有其它用户 delete use; //释放本对象分配的成员 } ps = rhs.ps; //将数据从rhs拷贝到本对象 i = rhs.i; use = rhs.use; return *this; //返回对象本身 }
13.6、动态内存管理类
动态内存管理类
某些需要我们进行内存分配的类,必须定义自己的拷贝控制成员来管理所分配的内存。
我们将实现一个用于string的标准库vector的简化版本,使用一个allocator来获得原始内存,allocator分配的内存是未构造的,我们将在需要添加新元素时用allocator的construct成员在原始内存中创建对象,当我们需要删除一个元素时,将使用destroy成员销毁元素
三个指针
- elements:指向分配的内存中的首元素。
- first_free:指向最后一个实际元素之后的位置。
- cap:指向分配的内存末尾之后的位置。
静态成员
StrVec还有一个名为alloc的静态成员,其类型为allocator<string> alloc成员会分配StrVec使用的内存。
四个工具函数
- alloc_n_copy会分配内存,并拷贝给一个给定范围中的元素。
- free会销毁构造的元素并释放内存。
- chk_n_alloc保证StrVec至少有容纳一个新元素的空间。如果没有空间添加新元素,chk_n_alloc会调用reallocate来分配更多内存。
- reallocate在内存用完时为StrVec分配新内存。
//StrVec类定义 class StrVec{ public: Strvec(); //allocator成员进行默认初始化 elements(nullptr),first_free(nullptr),cap(nullptr) { } 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; } //返回StrVec可以保存的元素的数量。 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*); void free(); //销毁元素并释放内存 void reallocate(); //获得更多内存并拷贝已有元素 std::string *elements; //指向数组首元素的指针 std::string *first_free; //指向数组第一个空闲元素的指针 std::string *cap; //指向数组尾后位置的指针 }; //push_back void StrVec::push_back(const string &s){ chk_n_alloc(); //确保有空间容纳新元素 //在first_free指向的元素中构造s的副本 alloc.construct(first_free++,s); //s被传递给类型为string的构造函数,用来在first_free++指向的内存中构建一个对象。 } //alloc_n_copy pair<string*,string*> StrVec::alloc_n_copy(const string *b,const string *e){ //分配空间保存给定范围的元素 auto data = alloc.allocate(e - b); //初始化并返回一个pair,该pair由data和uninitialized_copy的返回值构成 return {data,uninitialized_copy(b,e,data)}; //b、e之间填充data } //此函数返回一个指针的pair,两个指针分别指向新空间的开始位置和拷贝后的尾后的位置。 //alloc_n_copy用尾后指针减去首元素指针,来计算需要多少空间。 //free free成员有两个责任,首先destroy元素,然后释放StrVec自己分配的内存空间。for循环调用allocator的destroy成员,从构造的尾元素开始,到首元素为止,逆序销毁所有元素: void StrVec::free(){ //不能传递给deallocate一个空指针,如果elements为0,函数什么也不作。 if(elements){ //逆序销毁旧元素 for(auto p = first_free;p != elements; ) alloc.destroy(--p); alloc.deallocate(elements,cap-elements); } } // destroy会运行string的析构函数。string的析构函数会释放strign自己分配的内存空间。 一旦元素被销毁,我们就调用deallocate来释放StrVec对象分配的内存空间。且在调用之前首先检查elements是否为空。
在重新分配内存的的过程中移动而不是拷贝元素
通过新标准库引入的两种机制,我们就可以避免string的拷贝。 当reallocate在新内存中构造string时,它必须调用move来表示希望使用string的移动构造函数,如果漏掉了move调用,将会使用string的拷贝构造函数。
通常不为move提供using声明,直接调用std::move。
13.6、对象移动:
在某些情况下,对象被拷贝以后就立即被销毁了,在这些情况下,移动而非拷贝会大幅度提升性能。另一个原因是对于IO类或unique_ptr类,这些类都包含不能被共享的资源,因此这些类型的对象不能拷贝但是可以移动。
右值引用只能绑定到一个将要销毁的对象,因此我们可以自由的将一个右值引用的资源移动到另一个对象中。变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。
在头文件utility中,有个move函数可以获得绑定到左值上的右值引用。
移动构造函数不分配任何新内存,它接管给定的StrVec中的内存。在接管内存后,它将给定对象中的指针都置为nullptr。这样就完成了从给定对象的移动操作。
移动对象原因:
- 在重新分配内存的过程中,从旧内存将元素拷贝到新内存是不必要的,移动对象会大幅度提升性能。
- 有些类,比如IO类或unique_ptr类,都包含不能被共享的资源(如指针或IO缓冲),因此这些对象都不能被拷贝但可以被移动。
1.右值引用
1.右值引用
右值引用只能绑定到一个即将销毁的对象。一个左值表达式表示的是一个对象的身份,一个右值表达式表示的是对象的值。
对于左值表达式,我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。 但我们可以将一个右值引用绑定到这类表达式上,但不能绑定到左值上。
- 返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值表达式的例子。 ————左值引用绑定
- 返回非引用类型的函数、连同算术、关系、位以及后置递增/递减运算符,都生成右值。————const左值引用或右值引用绑定。
2.左值持久,右值短暂
左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
右值引用只能绑定到临时对象,所以:
- 该引用的对象将要被销毁。
- 该对象没有其它用户。
- 意味着使用右值引用的代码可以自由的接管所引用的的对象的资源。
- 因此我们可以从绑定到右值引用的对象“窃取状态”。
变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量时右值引用类型也不行。
C++ 中根据++ 值的是否有标志和是否可移动++ 特性,可分为左值和右值。C++ 中左值和右值的概念可以说是继承于C语言,在C语言中左值表示可以出现在等号左边也能够出现在等号右边的值,右值则指只能出现在等号右边的值。对于C++中的左值和右值,需要区分基础类型和自定义类型。
- 基础类型的左值和右值的概念基本和C语言相通。
- 对于自定义类型,可以通过非const成员函数更改属性。
- const左值引用会延长右值的生命周期。
-
左值lvalue:可被引用的数据对象,例如,变量、数组元素、结构成员、引用和解除引用的指针都是左值。在C语言中,左值最初指的是出现在赋值语句左边的实体,但这是引入const之前的情况。现在,常规变量和const变量都可视为左值,因为可通过地址访问它们。常规变量属于可修改的左值,const变量属于不可修改的左值。左值基本上和以前的认知没有太大的改变。
-
右值rvalue:字面常量(用括号括起来的字符串除外,因为它们表示地址)、包含多项的表达式以及返回值的函数(条件是该函数返回的不是引用)。
-
区别方法:右值就是一个临时变量(后面将详细的解释),只有临时地址空间,左值有其地址空间,换句话说,使用取地址符&对某个值取地址,如果不能得到地址,则是右值!
-
右值特性:
- 允许调用成员函数。
- 只能被 const reference 指向。
- 右值不能当成左值使用,但左值可以当成右值使用
2.移动构造函数
我们自己的类同时支持移动和拷贝是有好处的;
为了让我们自己的类型支持移动操作,需要为其定义移动构造函数和移动赋值运算符。
她们从给定对象中“窃取资源”而不是拷贝资源。
- 移动构造函数的第一个参数是该类类型的一个引用,不同于构造函数的是,这个引用参数在构造函数中是一个右值引用。
- 任何额外的参数都必须有默认实参。
- 必须保证销毁移后源对象是无害的。
- 一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。
StrVec::StrVec(StrVec &&s) noexpect //移动操作不应抛出任何异常
//成员初始器接管s中的资源
:elements(s.elements),first_free(s.first_free),cap(s.cap){
//令s进入这样的状态——对其运行析构函数是安全的
s.elements = s.first_free = s.cap = nullptr;
}
与拷贝构造函数不同,移动构造函数不分配新的内存,它接管给定的StrVec中的内存。在接管内存之后,它将给定对象中的指针都置为nullptr。这样就完成了从给定对象的移动操作,此对象将继续存在;
最终移后源对象将被销毁,意味着将在其上运行析构函数。如果我们忘记改变s.first_free,则销毁移后源对象就会释放掉我们刚刚移动的内存。
移动操作、标准库容器和异常
因为移动构造函数不会分配任何资源,所以移动操作通常不会抛出任何异常。 所以我们事先要通知标准库,如果我们不事先通知,否则它会认为移动类对象可能会抛出异常。
在函数的参数列表中指定noexcept,出现在参数列表和初始化列表开始的冒号之间,且在类头文件的声明和定义中都指定noexpect;
基于两个事实:
- 移动操作本身不抛出异常,但是抛出异常是允许的;
- 标准库容器能对异常发生时,其自身的行为提供保障。
当vector中新增元素时,vector将元素从旧空间移动到新内存中,如果重新分配过程使用了移动构造函数,且在移动了部分而不是全部后抛出一个异常,此时旧空间的移动源元素已经被改变,但是新空间中未构造的元素可能尚不存在。此情况下,vector将不能满足自身不变的要求。
若vector使用拷贝构造函数,当在新内存中构造元素时,旧元素保持不变,如果此时发生了异常,vector可以释放新分配的内存并返回。vector原有的元素仍然存在。
移动赋值运算符
移动赋值运算符执行与析构函数和移动构造函数相同的工作。 同样,如果其不抛出异常,则将其标记为noexcept。
StrVec &StrVec::operator=(StrVec &&rhs) noexcept{
//直接检测自赋值
if(this != &rhs){
//释放已有元素
free();
//从rhs接管资源
elements = rhs.elements;
first_free = rhs.first_free;
cap = rhs.cap;
//将rhs置于可析构状态
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
// 在本例中,我们检查this指针与rhs的地址是否相同。如果相同,右侧和左侧运算对象指向相同的对象,我们不要做任何事情,否则我们将释放左侧运算对象所使用的内存,并接管给定对象的内存。
最重要的是我们不能在使用右侧运算对象的资源之前就释放左侧运算对象的资源。
移后源对象的状态
当编写一个移动操作后,必须确保移后源对象处于一个可析构的状态,通过将移后源对象的指针成员置为nullptr来实现。
移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
合成的移动操作
只有当一个类没有定义任何版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。
编译器可以移动内置类型的成员; 如果一个类类型,其有自己的移动操作,编译器也能移动该成员。
struct X{
int i ; //内置类型可以移动
std::string s; //string定义了自己的移动操作
};
struct hasX{
X men; //x有合成的移动操作
};
X x,x2 = std::move(x); //使用合成的移动构造函数
hasX hx,hx2 = std::move(hx); //使用合成的移动构造函数
c++11新引入了右值引用和移动语义两个概念。
C++(包括C)中所有的表达式和变量要么是左值,要么是右值。通俗的左值的定义就是非临时对象,可以在多条语句中使用的对象。
右值是指临时的对象,它们只在当前的语句有效。在C++ 11之前,右值是不能被引用的。
int &a =1; //无法从“int”转化为“int&”。 我们最多只能用常量引用来绑定一个右值。因为规定不允许修改右值。
在C++11中,可以引用右值,使用&&实现:
在理解右值引用之前,先理解临时对象(临时变量)的概念。 临时对象是编译器在编译代码过程中为了实现某些代码可能会产生出一些临时对象来满足一些效果。产生临时变量的地方可能有以下几种:
不同类型(对象)变量的转换
函数以pass by value传递参数的时候
表达式求值中
规定不允许修改临时变量,所以在没有引入移动构造函数的时候,拷贝构造的参数为const &类型,以支持通过临时对象来构造对象。临时对象在函数返回后很快就会销毁,为了能物尽其用而达到节省时间(进行对象拷贝)的效果,考虑将临时对象纳为己有,这样能节省内存开辟和繁琐的赋值时间。
如:
MyString(……):_ptr(mystr._ptr)
{
mystr._ptr = NULL;
}
将_ptr指向的真正的字符串的所有权拿过来了。括号里空出来的参数应该是临时对象,在c++ 1.0中,无法判断什么时候是临时对象。为此,c++11引入了一个新的概念——右值引用(&&)。
右值引用专门用于引用右值(临时对象、匿名对象(即没有名字的对象))。 所以上述构造函数可改写为:
MyString(MyString &&mystr):_ptr(mystr._ptr)
{
myptr._ptr = NULL;
}
当传入临时变量(右值)时,编译器会调用MyString(MyString &&mystr)版本。在函数中,临时对象的指针置为NULL,这很重要,防止临时对象析构时候销毁字符串,使之成为悬挂指针。
2.拷贝构造函数与移动构造函数
拷贝构造是开辟一个新空间然后对临时对象(函数参数)进行深度拷贝,返回该新对象。
移动构造不另外开辟新空间,直接偷走临时对象的内存空间,占为己有。
移动构造的优点:节省了开辟内存与赋值的时间。
3.移动构造函数
程序员提供移动构造函数(参数为右值引用的构造函数)使得当临时变量构造新对象时可以提供较好的优化。提供移动构造函数在某些情况下对性能是极大的提供,尤其是对于需要深拷贝的对象来说。当这一类对象配合标准库的容器的时候(vector、deque),如果提供移动构造函数,将会在容器内存重分配时带来极大效率的优化。
4.移动构造函数的调用
用到临时对象(右值)的时候就会执行移动语义。除了编译器创建的临时对象作为右值外,使用std::move也能得到一个左值的右值引用
3.右值引用和成员函数
除了构造函数和赋值运算符以外,如果一个成员函数能够提供拷贝和移动两个版本,它将能从中收益。
一个版本接受一个指向const的左值引用;第二个版本指向一个非const的右值引用。
void push_back(const X&); //拷贝:绑定到任意类型的X
void push_back(X&&); //移动:只能绑定到类型为X的可修改的右值。
- 我们能将转换为类型X的任何对象传递给第一个版本的push_back,此版本从其参数拷贝数据。
- 我们只能传递给第二个版本非const的右值,此版本对于非const的右值是精确匹配,因此当我们传递一个可修改的右值时,编译器会选择此版本,此版本会从其参数中窃取数据。
当我们从实参窃取对象时,通常传递一个右值引用,则实参不能是const的;类似当从一个实参拷贝对象时,不应该改变该对象的值。