S13拷贝控制

来源:互联网 发布:网络机房装修 编辑:程序博客网 时间:2024/06/01 13:12

S13拷贝控制


一、拷贝、赋值与销毁

1、拷贝构造函数:构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则为拷贝构造函数,且一般都是一个const的引用,同时往往不是explicit的
(1)合成拷贝构造函数:若类没有自定义拷贝构造函数,则编译器将生成一个合成拷贝构造函数,一般情况下这个合成拷贝构造函数将参数的成员逐个拷贝到正在创建的对象中,编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中

注意:类的每个成员决定如何拷贝,对类类型的成员会使用其拷贝构造函数来拷贝,对内置类型则直接拷贝

(2)拷贝初始化:直接初始化实际上是要求编译器使用普通的函数匹配来选择最佳的构造函数,而拷贝初始化实际上是要求将右侧对象拷贝到正在创建的对象中,必要时进行类型转换,拷贝初始化在以下情况会发生:

  • 拷贝初始化,即在用=定义变量时
  • 将一个对象作为实参传递给一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
  • 某些类类型对它们分配的对象使用拷贝初始化,如标准库容器调用insert/push

(3)参数和返回值:具有非引用类型的参数和返回值会调用拷贝构造函数(因此拷贝构造函数自身需要引用类型,否则会陷入矛盾,另外注意返回优化,即RVO的情况)
(4)编译器可以绕开拷贝/移动构造函数,前提是拷贝/移动构造函数在此处必须存在且可访问,则编译器允许

string null_book = "9-99";//直接改写为string null_book("9-99");

2、拷贝赋值运算符
(1)重载赋值运算符:重载运算符必须定义为成员函数,如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数,对于二元运算符则右侧运算对象作为显式参数传递,并且一般要求赋值运算符返回一个指向其左侧运算对象的引用

注意:标准库通常要求保存在容器中的类型具有赋值运算符,且其返回值是左侧运算对象的引用

(2)合成拷贝赋值运算符:若类没有自定义拷贝赋值运算符,则编译器将生成一个合成拷贝赋值运算符,完成的工作参考合成拷贝构造函数

3、析构函数
析构函数与构造函数执行相反的操作:析构函数释放对象使用的资源并销毁对象的非static数据成员,并且析构函数是类的一个成员函数,名字由波浪号接类型名构成,没有返回值也不接受参数
(1)析构函数完成的工作:析构函数中,首先执行函数体,然后在隐含的析构阶段按照初始化顺序的逆序销毁成员,析构函数体自身并不直接销毁成员

注意:隐式销毁一个内置指针类型的成员不会自动delete它所指的对象,而智能指针是类类型并定义有析构函数,在销毁智能指针时会调用对应的析构函数

(2)析构函数被调用的时机:任何时候一个对象被销毁时,都会调用析构函数

  • 变量离开其作用域时
  • 当一个对象被销毁时其成员也被销毁
  • 容器被销毁时其元素被销毁
  • 对于动态分配的对象,当对指向它的指针使用delete时被销毁
  • 对于临时对象,当创建它的完整表达式结束时被销毁

(3)合成析构函数:若类没有自定义析构函数,则编译器将生成一个合成析构函数

4、三/五法则

  • 三个基本操作:拷贝构造函数、拷贝赋值运算符、析构函数
  • 新标准下额外的两个操作:移动构造函数、移动赋值运算符

这些操作通常应该被看作一个整体,即只需要其中一个操作而不需要其他操作的情况是非常少见的,一般要么都用合成的,要么都进行自定义

class HasPtr{public:    HasPtr(const string &s = string()): ps(new string(s)), i(0) { }    ~HasPtr() { delete ps; }    //由于没有定义拷贝构造函数和拷贝赋值运算符,这会带来严重错误    //合成的拷贝构造函数/拷贝赋值运算符会直接将一个对象的ps值初始化新对象的ps    //这会导致两个对象的ps指向同一个string,则在析构时会对同一个string连续delete两次private:    string *ps;    int i;}

(1)法则:需要析构函数的类也需要拷贝和赋值操作
(2)法则:需要拷贝操作的类也需要赋值操作,但不一定需要析构操作

5、使用=default:将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本,在类内使用=default则是内联的,在类外使用=default则是非内联的

注意:只有具有合成版本的成员函数才能使用=default(默认构造函数、拷贝控制成员)

6、阻止拷贝

注意:大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是显式还是隐式,有一些类这些操作没有意义,则应该通过自定义的函数来阻止拷贝

(1)定义删除的函数:通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数=delete来阻止拷贝,删除的函数是被声明的函数但是不能以任何方式被使用

注意:=delete必须出现在函数第一次声明的时候,且对大多数函数可以使用,与=default不同

(2)析构函数不能是删除的成员:理所当然的,对象必须被销毁因此析构函数不应该=delete(但是语法上没有问题)

注意:析构函数=delete的类不能定义该类型的变量或释放指向该类型动态分配对象的指针,但可以动态分配

(3)合成的拷贝控制成员可能是删除的:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的

(4)private拷贝控制:旧版本通过private来实现阻止拷贝,现在推荐使用=delete来实现

二、拷贝控制和资源管理

1、行为像值的类:副本和原对象完全独立互相不影响
(1)类值拷贝复制运算符通常组合了析构函数和构造函数的操作,类似析构函数,赋值操作会销毁左侧运算对象的资源,类似构造函数,复制操作会从右侧运算对象拷贝数据,这些操作以正确的顺序执行,则即使将一个对象赋予它自身也不会出错,如果可能,更要做到异常安全

HasPtr &HasPtr::operator=(const HasPtr &rhs){    auto newp = new string(*rhs.ps);   //拷贝底层的string    delete ps;                         //释放旧内存,即销毁左侧运算对象的资源    ps = newp;                         //从右侧运算对象拷贝数据到左侧对象    i = rhs.i;    return *this;                      //返回左侧对象}

注意:编写赋值运算符时,要注意两点,一是将一个对象赋予自身也能正常工作,二是赋值运算符往往结合了析构和构造的工作,要正确处理执行顺序

2、行为像指针的类:共享状态,副本和原对象使用相同的底层数据,因此需要定义拷贝构造函数和拷贝赋值运算符来拷贝指针成员本身而不是其指向的数据

  • 每个(非拷贝)构造函数要创建一个引用计数
  • 拷贝构造函数不创建计数,而是递增计数器
  • 析构函数递减计数器
  • 拷贝赋值运算符递增右侧对象的计数器,递减左侧对象的计数器,当归零时销毁对象

三、交换操作

1、自定义swap操作:对于一些算法操作,swap显得非常重要,如果一个类自定义了swap则算法将使用类自定义的版本,否则算法将使用标准库定义的swap
2、swap函数应该调用swap而不是std::swap,每个swap调用都应该是未加限定的

void swap(Foo &lhs, Foo &rhs){    using std::swap;    swap(lhs.h, rhs.h);    //当Foo类定义了swap时,此处的swap会优先匹配类自定义的swap    //当没有自定义的swap时会调用std::swap,不可以显式写成std::swap(…)}

3、在赋值运算符中使用swap:定义了swap的类通常用swap来定义它们的赋值运算符,使用了一种名为拷贝并交换的技术

HasPtr &HasPtr::operator=(HasPtr rhs){    swap(*this, rhs);    //注意到rhs不是引用,而是拷贝进来的参数,因此这里发生了左侧对象与rhs副本交换,即拷贝并交换    return *this;   //此时rhs指向原来this指向的内容,并且随着return,旧内容被销毁}

注意:拷贝并交换技术自动就是异常安全的,且能正确处理自赋值,可以参考《Effective C++》item29

四、拷贝控制示例

参考随书的Message.[ch]

五、动态内存管理类

参考随书的StrVec.[ch]

六、对象移动

1、右值引用
(1)对于常规引用(左值引用)而言,右值引用通过&&实现,只能绑定到一个将要销毁的对象,而不能直接绑定到一个左值上,由于右值引用绑定的是即将销毁的对象,即使用右值引用的代码可以自由接管所引用对象的资源

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是i*42结果的右值引用int &&rr3 = rr2;             //错误,rr2是右值引用类型的变量,本身是左值

注意:const T &T &&都能匹配T类型的右值,但右值到const需要一次转换而&&是精确匹配,故此时T &&优先,另一方面不能有TT &&的函数,对于这两个参数传入的合适实参匹配都是精确的,会有二义性错误

(2)定义在utility中的std::move函数可以获得绑定到左值上的右值引用,同时也意味着原来的左值将被视为右值一样将要被销毁(可以销毁也可以赋予新值,但不能使用原值)

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

注意:不要对move使用using,而是使用std::move以避免潜在的冲突

2、移动构造函数和移动赋值运算符
(1)移动构造函数的第一个参数必须是该类类型的一个右值引用,与拷贝构造函数一样除了第一个参数意外额外的参数都必须有默认实参,同时移动构造函数还必须确保移后源对象处于销毁它是无害的这样一种状态,一旦移动完成,源对象必须不再指向被移动的资源,这些资源已属于新创建的对象

StrVec::StrVec(StrVec &&s) noexcept                               //移动操作不抛出任何异常    : elements(s.elements), first_free(s.first_free), cap(s.cap)  //成员初始化器接管源对象中的资源{    s.elements = s.first_free = s.cap = nullptr;                  //将源对象指针置空来确保析构函数销毁}

(2)移动操作、标准库容器和异常
由于移动操作通常只接管而不分配任何资源,因此不抛出任何异常,可以通过在构造函数参数列表后紧跟着noexcept来通知标准库这个函数不抛出异常(抛出异常也是允许的),在定义和声明处都要标记noexcept(类似const

注意:由于为了避免一些潜在的异常错误,不标注noexcept但安全的移动操作也可能被编译器以拷贝操作实现,因此确保安全的情况下需要用noexcept来确保用移动操作

(3)移动赋值运算符
移动赋值运算符完成与析构函数和移动构造函数相同的工作,同样可以标记noexcept,需要注意的是虽然是移动,但还是要检查自赋值的情况,这是因为传入的右值引用有可能是std::move获得的(此时可能与被赋值对象同源),若不检查可能会出错
(4)移后源对象必须可析构
移动操作必须保证移后源对象是处于析构安全的状态,同时也仍然是有效的(有效即可以安全的为其赋予新值或安全的使用而不依赖其当前值),用户不能对移后源对象的值有任何假设
(5)合成的移动操作
与拷贝操作不同,只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会合成移动构造函数或移动赋值运算符

struct X                        //编译器为X合成移动操作{    int i;                      //内置类型,可以移动    string s;                   //string定义了自己的移动操作};             struct hasX{    X mem;                      //X有合成的移动操作};X x, x2 = std::move(x);         //使用合成的移动构造函数hasX hx, hx2 = std::move(hx);   //使用合成的移动构造函数

注意:若定义了移动操作则必须定义拷贝操作,否则拷贝操作会被默认定义为删除的

(6)移动右值,拷贝左值
如果一个类既有移动构造函数又有拷贝构造函数,根据普通函数匹配规则来确定使用哪个构造函数,赋值运算符同理

StrVec v1, v2;v1 = v2;                       //v2是lvalue,拷贝赋值v1 = std::move(v2)             //通过move来实现移动赋值StrVec getVec(istream &);      //返回右值v2 = getVec(cin);              //getVec返回rvalue,移动赋值

(7)移动迭代器
一般来说一个迭代器解引用运算符返回一个指向元素的左值,而移动迭代器解引用返回一个右值引用,通过调用定义在iterator头文件中的标准库函数make_move_iterator将一个普通迭代器转换为一个移动迭代器,函数接受一个迭代器参数,返回一个移动迭代器,原迭代器的所有操作在移动迭代器中都照常,特别的对于uninitialized_copy这种函数,使用移动迭代器就可以对每个元素用移动构造函数来完成

注意:标准库不保证哪些算法适用移动迭代器,由于移动对象可能销毁原对象,因此程序员来把握使用

注意:在移动控制操作这些类实现代码中谨慎使用std::move可以大幅提升性能,而在以外的地方,只有确保安全才可以使用std::move

知乎:如何评价C++11的右值引用?

3、右值引用和成员函数
区分移动和拷贝操作的重载函数通常有两个版本来完成,一个接受T &&,一个接受const T &
(1)右值和左值引用成员函数
通过使用引用限定符来强制左侧运算对象是一个左值,对于&限定的函数,只能由左值来调用,对于&&限定的函数,只能由右值来调用,若同时有const限定符,const在前,如foo somefun() const &;
(2)重载和引用函数

class Foo{public:    Foo sorted() &&;        //正确    Foo sorted() const;     //错误,必须有&或&&    Foo sorted() const &;   //正确...}

注意:在重载函数中,const可有可无,而引用限定符&/&&则要么每个重载函数都没有,要么每个重载函数都标明,本质上都是为了函数匹配正确