13.1拷贝、赋值和销毁

拷贝、赋值和销毁

当定义一个类时,我们显式或隐式地指定再次类型地对象拷贝、移动、赋值和销毁时做什么,这些操作统称为拷贝控制操作,主要包括:

  • 拷贝构造函数
  • 拷贝赋值运算符
  • 移动构造函数
  • 移动赋值运算符
  • 析构函数

如果我们没有显式定义这些操作,编译器会自动默认定义这些操作。

1.拷贝构造函数

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

class Foo {
public:
  Foo(); //默认构造函数
  Foo(const Foo&); //拷贝构造函数
  // ...其他类成员
};

①合成拷贝构造函数

如果我们没有给类类型自定义一个拷贝构造函数,编译器会给我们定义一个。与默认构造函数不同(定义了其他构造函数系统便不会给我们定义默认构造函数),即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数

一般情况下,合成的拷贝构造函数会将参数要拷贝对象的成员逐个拷贝到正在创建的对象中。每个成员的类型决定了它如何拷贝:

  • 类类型成员,会使用其拷贝构造函数来拷贝
  • 内置类型成员直接拷贝
  • 虽然不能直接拷贝数组,但默认拷贝构造函数会逐个数组元素拷贝数组。

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

class Sales_data {
public:
    Sales_data() = default; //合成的默认构造函数
private:
    string _bookNo;
    int _sold = 0;
    double _revenue = 0.0;
}
//其没有定义拷贝构造函数,编译器合成的拷贝构造函数如下:
Sales_data(const Sales_data& rhs):
	_bookNo(rhs._bookNo), 
	_sold(rhs._sold), 
	_revenue(rhs._revenue){}

②拷贝初始化

直接初始化调用与实参匹配的有参构造函数,而拷贝初始化时,要求编译器将右侧运算对象拷贝正在创建的对象中,如果需要的话要进行类型转换。

拷贝初始化在以下情况发生:

  • 使用=定义变量时
  • 将一个对象作为实参传递到一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素或者聚合类中的成员。

如:

string dots(10, '.'); //直接初始化
string s(dots); //直接初始化
string s2 = dots; //拷贝初始化
string s3 = "999-9999"; //类型转换的拷贝初始化
string s4 = string(100, '9'); //拷贝初始化

③为什么拷贝构造函数的参数必须是引用类型

我们知道,进行值传递时,会将实参拷贝到形参会调用拷贝构造函数,在返回非引用类型的返回值时,会在调用点调用拷贝构造函数创建一个临时量

从上述特点来看,可以知道为什么拷贝构造函数的参数必须是引用类型:如果不是引用类型,在进行参数值传递的时候又要调用拷贝构造函数自身,这样就会陷入无限死循环。

2.拷贝赋值运算符

①重载赋值运算符

重载赋值运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=的函数

赋值运算符必须定义成成员函数。返回类型为指向其左侧运算对象的引用。

②合成拷贝赋值运算符

与处理拷贝构造函数一样,如果一个类未定义拷贝赋值运算符,编译器会为它生成一个**合成拷贝赋值运算符。**类似拷贝构造函数,合成拷贝赋值运算符将右侧对象的每个非static成员赋值给左侧对象。

如:Sales_data的合成拷贝赋值运算符等价于:

class Sales_data {
public:
    Sales_data() = default; 
private:
    string _bookNo;
    int _sold = 0;
    double _revenue = 0.0;
}
//其没有定义拷贝赋值运算符,编译器合成的拷贝构造函数如下:
Sales_data &operator= (const Sales_data &rhs) {
    _bookNo = rhs._bookNo;
    _sold = rhs._sold;
    _revenue = rhs._revenue;
    return *this;
}

3.析构函数

析构函数释放对象使用的资源,并销毁对象的非static数据成员。

①析构函数完成什么工作

在一个析构函数中,首先执行函数体,然后销毁成员。成员函数按初始化顺序的逆序销毁。对象在最后一次使用之后,析构函数会释放对象生存期分配的所有资源。

在一个析构函数中,与构造函数的函数体初始化列表来初始化不同,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型:

  • 销毁类成员需要执行成员自己的析构函数
  • 内置类型没有析构函数,销毁内置类型成员什么也不需要做。

注意:析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象的销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。隐式销毁一个内置指针类型的成员不会delete它所指向的对象,因此,析构函数函数体中的部分主要是用来delete掉对象使用时在堆区开辟的动态空间。

②什么时候会调用析构函数

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

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

如:

{ //新作用域
    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);     //拷贝指向的对象执行析构函数
    delete p;               //对p指向的对象执行析构函数
}
//退出局部作用域;对item、p2和vec调用析构函数
//销毁p2会递减其引用计数;如果引用计数变为0,对象被释放
//销毁vec会销毁它的元素

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

4.三/五法则

①需要析构函数的类也需要拷贝和赋值操作

当我们决定一个类是否要定义自己版本的拷贝控制操作的时,一个基本原则是确定一个类是否需要一个析构函数。如果一个类需要一个析构函数,我们几乎可以肯定它需要一个拷贝构造函数和拷贝赋值运算符。

如:

class HasPtr {
public:
	HasPtr() : _ps(new string), _i(0){}
	HasPtr(int val) : _ps(new string), _i(val) {}
	~HasPtr() { delete _ps; }
private:
	string* _ps;
	int _i;
};
int main() {
    HasPtr a;
	HasPtr b = a;
}
//错误:造成了浅拷贝

很显然,这个类在构造函数中动态分配了内存合成析构函数不会delete一个指针数据成员,因此,此类需要定义一个析构函数来释放构造函数分配的内存

但是,由于我们引入了合成的拷贝构造函数和拷贝控制运算符。这些函数简单的拷贝指针成员,因此对象a和对象b中的_ps成员指向了相同的动态内存。这两个对象被销毁时,会delete两次指向动态内存的空间,这是未定义的操作,将会产生错误。造成了深拷贝和浅拷贝的问题。

可以得出结论:需要析构函数的类也需要拷贝和赋值操作。

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

有些类所要完成的工作,只需要拷贝或赋值操作,不需要析构函数。

例如:我们要生产同一批苹果手机,我们要求除了序列号不同,其他所有配件都一模一样。这个类需要一个拷贝构造函数为每个新创建的对象生成一个新的独一无二的序号。除此之外,这个拷贝构造函数从给定对象拷贝所有其他数据成员。这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象。但是这个类不需要自定义析构函数。

这个例子引出第二个结论:如果一个类需要一个拷贝构造函数,那么肯定它也需要一个拷贝赋值运算符,反之亦然。

5.使用=default

类似合成的默认构造函数,拷贝控制操作也可以定义为**=default来显式的要求编译器生成合成版本**。

如:

class Sales_data {
public:
    Sales_data() = default;
    Sales_data(const Sales_data &) = default;
    ~Sales_data() = default;
    Sales_data &operator=(const Sales_data &) =default;
private:
    string _bookNo;
    _sold = 0;
    _revenue = 0.0;
}

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

6.阻止拷贝

虽然大多数类应该定义拷贝构造函数和拷贝赋值运算符,但对某些类来说,这些操作没有合理的意义。在此情况下,定义类时可以用 = delete来阻止拷贝或者赋值

例如:iostream类阻止了拷贝,为了避免多个对象写入或读取相同的IO缓冲

①定义删除的函数

我们可以将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。删除函数虽然我们声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的。

如:

struct NoCopy {
	NoCopy() = default;
    NoCopy(const NoCopy &) = delete; //阻止拷贝
    Nocopy &operator=(const NoCopy &) = delete; //阻止赋值
    //其他成员
};

②析构函数不能时删除的成员

我们不能删除析构函数。如果析构函数被删除,就无法删除此类型的对象以及成员。

③合成的拷贝控制成员可能是删除的

在以下情况下,编译器将合成的拷贝控制操作定义为删除的函数:

  • 如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
  • 如果一个类有const成员,则它不能使用合成的拷贝赋值运算符。

④希望阻止拷贝的类应该使用=delete来定义他们自己的拷贝构造函数和拷贝赋值运算符,而不是将其声明private,因为友元函数仍然可以访问到。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值