13.拷贝控制

C++

Posted by HustDsy on January 11, 2021

13.拷贝控制

当定义一个类的时候,往往需要显式或者隐式地指定在此类型的对象拷贝,移动,赋值和销毁时需要做什么,类通过五种特殊的成员函数来控制这些操作,称之为拷贝构造函数,拷贝赋值运算符,移动构造函数,移动赋值运算符和析构函数。拷贝构造和移动构造定义了当用同类型的另一个对象初始化本对象需要做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时需要做些什么。这些统称为拷贝控制操作。

13.1 拷贝,赋值与销毁

13.1.1 拷贝构造函数

定义:如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,那么此构造函数是拷贝构造函数。

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

Tips1:拷贝构造函数的第一个参数一定是引用类型,比如现在一个拷贝构造函数为Foo(Foo foo),那么为这个函数传参的时候(比如传f)那么f=foo会不停的调用这个拷贝构造函数,一直循环下去!!!!

合成拷贝构造函数:如果没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其它构造函数,编译器也会为我们合成一个拷贝构造函数。数组不能进行拷贝!!!!int a[10]

直接初始化:要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。

拷贝初始化:在声明一个变量的时候使用拷贝构造函数或者移动构造函数对其进行初始化,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果有需要的话还要进行类型转换。

string dots(10,'.');//直接初始化
string s(sots);//直接初始化
string s2=dots;//拷贝初始化

拷贝初始化不仅在我们用=定义变量时会发生,在下列情况下也会发生:

  • 将一个对象作为实参传递给一个非引用类型的形参

  • 从一个返回类型为非引用类型的函数返回一个对象

  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员

    (vector<int>v={1,2,3})

    某些类类型还会对它们所分配的对象使用拷贝初始化,比如初始化标准库容器是调用insert或push成员时,容器会对其元素进行拷贝初始化,与之相对用empalce成员创建的元素都进行直接初始化,直接初始化效率更高。刷题经常用到!

    /*
    *假定Point是一个类类型,它有一个public的拷贝构造函数,指出下面程序片段中哪些地方使用了拷贝构造函数。
    */
    Point global;
    Point foo_bar(Point arg){//一次拷贝构造
      Point local=arg,*help=new Point(global);//2次拷贝构造
      *heap=local;
      Point pa[4]={local,*head};//2次拷贝构造
      return *heap; //一次拷贝构造
    }
    

参数和返回值:在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的当函数具有非引用的返回类型时,返回值会被用来初始化调用方法的结果。

拷贝初始化的限制:如果我们使用的初始化要求通过一个explicit(不允许隐式类型转换)的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了。

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

编译器可以绕过拷贝构造函数:在拷贝初始化过程中,编译器可以跳过拷贝/移动构造函数,直接创建对象。即,编译器允许下面的代码。

string null_book="99999999";//拷贝初始化
//改写为
string null_book("99999");//编译器略过了拷贝构造函数
13.1.2 拷贝赋值运算符

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

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

与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。

重载赋值运算符&合成拷贝赋值运算符

赋值运算符通常应该返回一个指向其左侧运算对象的应用。

Sales_data& Sales_data::operator=(const Sales_data&rhs){
  bookNo=rhs.bookNo;
  uints_sold=rhs.uints_sold;
  revenue=rhs.revenue;
  return *this; //返回一个此对象的引用
}
13.1.3 析构函数

析构函数释放对象使用的资源,并销毁对象的static数据成员。析构函数是类的一个成员函数,没有返回值也不接受任何参数:

class Foo{
public:
  	~Foo();//析构函数
}

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

析构函数完成什么工作

在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化>,在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁

隐式销毁一个内置指针类型的成员不会delete它所指向的对象

Tips2:上句话的理解大概为指针被销毁了,但是它指向的内存并没有被释放,打个比方,你银行卡丢了,银行卡里的钱总不能归银行了吧,还有一点就是多个指针指向同一片内存,当这个指针被销毁的时候,可能还有其它指针指向这个内存!

什么时候调用析构函数

无论何时一个对象被销毁,就会自动调用其析构函数:

  1. 变量在离开其作用域时被销毁

  2. 当一个对象被销毁时,其成员被销毁

  3. 容器被销毁时,其元素被销毁

  4. 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁

  5. 对于临时对象,当创建它的完整表达式结束时被销毁,但是存在对该临时对象的引用,临时对象的生命周期与引用它的引用相同

    //对于第5点的说明
    double a=3.14;
    int b=a;
    int &c=a;
    //类型发生了转换,产生临时对象,这时候表达式结束后temp被销毁
    int temp=a;
    int b=temp;
    //对于c,temp的声明周期与c一样
    int temp=a;
    int &c=temp;
    

    当指向一个对象的引用或者指针离开作用域时,析构函数不会执行,原因同Tips2

合成析构函数

相当于默认的析构函数,析构函数体自身并不直接销毁成员,成员是在析构函数体字后隐含的析构阶段被销毁的,在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。

class X {
public:
    int c;
    X(){
        c=1;
        std::cout<<"X()"<<std::endl;
    }
    X(int a):c(a){
        std::cout<<"X(a)"<<std::endl;

    }
    X(const X&x):c(x.c){std::cout<<"X(const X&)"<<std::endl;}
    X&operator=(const X&){
        std::cout<<"aaa"<<std::endl;
        return*this;
    }
    ~X(){std::cout<<"~X()"<<std::endl;}
};

void f(const X &rx, X x)
{
    std::vector<X> vec;
    vec.reserve(4);
    vec.push_back(rx);
    vec.push_back(x);
    /*
     调用一次构造函数,一次拷贝构造函数,一次析构函数 X temp(1); X vec_back=temp,temp 再进行析构
     X(a)
     X(const X&)
     ~X()
     */
    vec.push_back(1);
    vec.emplace_back(2); //调用一次构造函数
}

int main()
{
    X*px=new X;
    X p=*px; //调用拷贝构造函数
    p=*px;//拷贝赋值函数
    f(*px, *px);
    delete px;
    return 0;
}
//print
X() 
X(const X&)
aaa      
X(const X&)//X x,const X&rx不需要拷贝构造,因为传入的是
X(const X&)//rx
X(const X&)//x
X(a) //push_back
X(const X&)//push_back
~X() //push_back
X(a) //emplace
~X() //容器
~X() //容器
~X() //容器
~X() //容器
~X() //传入的X x
~X() //删除px
~X() //删除p
13.1.4 三/五法则

1.需要析构函数的类也需要拷贝和赋值操作,假设没有自己写的拷贝构造函数,那么f函数执行之后ret和hp以及h中的ps都指向同一片内存,那么delete ps就会报删除错误的提示。

class HasPtr{
public:
    //构造函数
    HasPtr(const std::string &s=std::string()):ps(new std::string(s)),i(0){}
    //拷贝构造函数
    HasPtr(const HasPtr&hp){
        std::cout<<"111"<<std::endl;
        std::string a=*hp.ps;
        //delete ps;
        ps=new std::string(a);
        i=hp.i;
    }
    //赋值运算符
    ~HasPtr(){
        delete ps;
    }
    std::string*ps;
    int i;
};
HasPtr f(HasPtr hp){
    HasPtr ret=hp;
    return ret;
}
int main()
{
    HasPtr h("aaaaaa");
    f(h);
}

如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数

2.需要拷贝操作的类也需要赋值操作,反之亦然

考虑一个例子,考虑一个类为每个对象分配一个独有的,唯一的序号。这个类需要一个拷贝构造函数为每个新创建的对象生成一个新的,独一无二的序号,除此之外,这个拷贝构造函数从给定的对象拷贝所有其他的数据成员。这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象,但是这个类不需要自定义析构函数。

//假设number是一个类,它有一个默认构造函数,能为每个对象生成一个唯一的序号,保存在名为mysn的数据成员中。假定number使用合成的拷贝控制成员,并给定如下函数void f(numbered s){cout<<s.mysn<<endl;}则下面代码输出什么内容(main 函数)?
#include <iostream>
#include<vector>
#include<string>

class number{
public:
    number(){
        mysn=unique;
        unique++;
    }
    //添加了拷贝构造函数
    number(const number&){
        mysn=unique;
        unique++;
    }
    int mysn;
    static int unique;
   
};
int number::unique=10;

void f(number s)
{
    std::cout << s.mysn << std::endl;
}

int main()
{
    number a, b = a, c = b;
    f(a);
    f(b);
    f(c);
    return 0;
}
//不添加拷贝函数
输出三个10
//添加拷贝构造函数
输出131415
//f函数为引用传递,并且添加了拷贝构造函数
输出101112
13.1.5 使用=default

使用=default来显示地要求编译器生成合成的版本,当在类内用=defalut修饰成员的声明时,合成的函数将隐式地声明为内联的(就像任何其他类内声明的成员函数一样)。如果不希望合成的成员时内联函数,应该只对成员的类外定义使用=default,就像拷贝赋值运算符所做的那样。

class S{
public:
  S()=default;
  S(const S&)=default;
  S&operator=(const S&);
	~()=default;
};
S&S::operator=(const S&)=default;
13.1.6 阻止拷贝

(旧标准下通过private来阻止拷贝)在新标准下,我们可以将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。=delete。析构函数不能是删除的成员。

13.2 拷贝控制和资源管理

管理类外资源的可以定义行为像值或者行为像指针的类,其中行为像指针的类可以通过引用计数来实现。

13.3交换操作