C++基础的不能再基础的学习笔记——拷贝控制(一)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/fancynece/article/details/79346314

拷贝控制

当定义一个类时,我们显示地或隐式地指定此类型对象 拷贝、移动、赋值和销毁时做什么。

一个类通过定义五种特殊的成员函数来控制这些操作,称为拷贝控制操作

  • 拷贝构造函数:用同类型对象初始化时的操作
  • 移动构造函数:用同类型对象初始化时的操作
  • 拷贝赋值运算符:用同类型对象赋值时的操作
  • 移动赋值运算符:用同类型对象赋值时的操作
  • 析构函数:此类型对象销毁时的操作

在定义任何C++类时,拷贝控制操作都是必要部分。如果我们不显示定义这些操作,编译器会为我们定义(但常常不是我们想要的)。

一、拷贝 赋值 销毁

1. 拷贝构造函数

如果一个构造函数,第一个参数是自身类型的引用其他参数都有默认值,则这个构造函数是拷贝构造函数。

由于拷贝构造函数用来初始化非引用类型的参数,拷贝构造函数的第一个参数必须为引用类型,否则将进入死循环(调用拷贝构造函数,非引用形参需拷贝),且几乎都是const修饰的。

class Fancy{
    Fancy();
    Fancy(const Fancy & …);
};
合成拷贝构造函数

与合成默认构造函数不同,不管我们有没有定义拷贝构造函数,编译器都会为我们合成一个拷贝构造函数。

对于某些类来说,合成拷贝构造函数用来阻止我们拷贝该类类型的对象。

一般情况下,合成的拷贝构造函数会将给定对象中每个非static成员依次拷贝到正在创建的对象中。对类类型成员,使用拷贝构造函数来拷贝;对内置类型成员则直接拷贝。

拷贝初始化

现在,我们可以完全理解 直接初始化拷贝初始化 之间的差异了。

string dots(10,'.');             //直接
string s(dots);                  //直接
string s2 = dots;                //拷贝
string num_bool = "2-4232-454";  //拷贝
string nai = string(10,'2');     //拷贝

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

拷贝初始化: 要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要还要进行类型转换。

拷贝初始化通常使用拷贝构造函数来完成。但是如果一个类有移动构造函数,也有可能使用移动构造函数来完成。

那么,拷贝初始化会在何时发生呢?

  • 用 = 定义变量时
  • 将一个对象作为实参传给非引用类型的形参
  • 从返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素
  • 某些类类型还会对它们所分配的对象使用拷贝初始化,如初始化标准库容器或调用insert和push时,容器会对其元素进行拷贝初始化。
拷贝初始化的限制

当我们以explicit修饰构造函数时,只能直接初始化,不可拷贝初始化。

vector<int> v1(10);    //正确
vector<int> v2 = 10;   //错误

void func(vector<int>);
func(10);              //错误
func(vector<int>(10)); //正确

vector的接受单一大小参数的构造函数是explicit的。

2. 拷贝赋值运算符

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

Person m,f;
m = f;      //使用拷贝赋值运算符
重载赋值运算符

重载运算符本质上是一个函数,参数为运算对象

有些重载运算符(如赋值运算符),需定义为类的成员函数,其左侧运算对象就绑定到隐式的this上

有些重载运算符(如输入输出运算符),一般定义为类的非成员函数。

class Foo{

public:
    Foo& operator=(const Foo&);

};
合成拷贝赋值运算符

若类未定义自己的拷贝赋值运算符,编译器会为它合成一个合成拷贝赋值运算符。

类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。

如果不是出于禁止的目的,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员。

3. 析构函数

析构函数执行与构造函数相反的工作,构造函数初始化对象的非static数据成员,还可能做一些其他工作。析构函数释放对象使用的资源,并销毁对象的非static数据成员。

class Foo{
public:
    ~Foo();    //析构函数,无参数无返回值
}

由于析构函数不接受参数,因此不能被重载,一个类的析构函数是唯一的

析构函数的工作

构造函数:先对成员初始化,再执行函数体,且按成员在类内定义的先后顺序进行初始化。

析构函数:先执行函数体,再销毁成员,成员按初始化的逆序销毁。

销毁类类型的成员需要执行类本身的析构函数,而内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。

销毁一个内置指针类型的成员不会删除它所指向的对象,但是与普通指针不同,智能指针是类类型,所以具有析构函数,因此智能指针成员在析构阶段会被自动销毁。

什么时候调用析构函数

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

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

当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。

类似拷贝构造函数和拷贝赋值运算符,对于某些类,合成析构函数被用来阻止该类型的对象被销毁。

如果不是阻止,合成析构函数的函数体就为空。在空析构函数体执行完成后,成员会被自动销毁。

由此,我们可以看出,析构函数体自身并不直接销毁成员成员是在析构函数体之外的析构阶段中被销毁的

4. 三/五法则

如前所述,有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符、析构函数。而且在新标准下,一个类还可以定义一个移动构造函数和一个移动赋值运算符。

C++并不要求我们定义所有这些操作,但是这些操作通常看做一个整体,只需要其中一个而不需要定义所有操作的情况是很少见的。

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

当我们决定一个类是否要定义自己版本的拷贝控制成员时,一个基本原则是确定这个类是否需要一个析构函数。

通常,对析构函数的需求比对拷贝控制成员的需求成为明显。并且,如果一个类需要析构函数,那么也一定需要拷贝控制成员。

当我们需要自己管理内存时,需要析构函数。例如:类的数据成员中有一个指针,我们释放对象时希望将指针所指对象释放,而合成析构函数不会做到这一点,因此我们需要自定义析构函数。

而对于有了析构函数的类而言,必须要有自定义的拷贝构造函数、拷贝赋值运算符。因为,合成拷贝操作只会进行简单的数据拷贝,在拷贝指针时,会使多个对象指向同一内存,只要进行对象拷贝就极容易出错。

HasPtr func(HasPtr hp){

    HasPtr ret = hp;
    return ret;

}

在上述代码中,当函数调用结束,会调用hp和ret的析构函数,而析构函数会删除指针所指向的内存,即一块内存要被删除两次,是非常错误的。

合成拷贝构造函数、合成拷贝赋值运算符进行的拷贝是浅拷贝,即只拷贝一级元素的内容,不拷贝子元素的内容。于指针而言,就是只拷贝指针,不拷贝指针所指向的内存。

而我们需要自定义的拷贝构造函数、拷贝赋值运算符需要进行的拷贝是深拷贝,即拷贝所有元素的内容。与指针而言,就是既拷贝指针,又拷贝指针所指向的内存。

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

当我们需要自定义拷贝构造函数的时候,也需要自定义拷贝赋值运算符,反之亦然。但是两种情况下,都不一定需要自定义析构函数。

例如:我们需要定义一个类,为类里的每个对象分配一个唯一标识。那么在进行拷贝初始化时,就不可以简单的拷贝对象的唯一标识;同时进行拷贝赋值时,也不可将对象的标识赋值到另一对象。

5. 阻止拷贝

虽然大多数类应该定义拷贝构造函数和拷贝赋值运算符,但对于某些类而言,这些操作没有合理的意义。因此必须组织拷贝或赋值。如:iostream类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。

我们自然而然会想到,不定义拷贝操作来阻止,但是此时编译器会自动生成合成的,因此这是无效的。

定义删除的函数

在新标准下,我们可以通过将拷贝构造函数、拷贝赋值运算符定义为删除的函数来阻止拷贝。

什么是删除的函数呢?我们虽然声明了它们,但不能以任何方式使用它们。

class Person{
    Person() = default;   //默认构造函数
    Person(const Person& ) = delete;  //阻止拷贝
    Person& operate=(const Person& ) = delete;   //阻止赋值
    ~Person() = default;  //默认析构函数
};

=default与=delete有两个不同之处,“=delete”必须出现在函数第一次声明的时候,并且“=delete”可以用于任何函数。

析构函数不能是删除的成员

我们不能删除析构函数,如果析构函数被删除,就无法销毁此类型的对象。对于一个删除了析构函数的类型,编译器不允许定义该类型变量或创建该类的临时对象。

对于删除了析构函数的类型,我们不能定义这种类型的变量,但是可以动态分配这种类型的对象,但是没有办法释放对象。

class Person{
public:
    Person() = default;
    ~Person() = delete;
};


Person p1;      //错误,析构函数删除
Person *p2 = new Person();  //正确
delete p2;     //错误,析构函数删除
合成的拷贝控制成员可能是删除的
  • 如果类的 某个成员的析构函数 是删除的或不可访问的(如private),则类的合成析构函数、合成拷贝构造函数是删除的。
  • 如果类的 某个成员的拷贝构造函数 是删除的或不可访问的,则类的合成拷贝构造函数是删除的。
  • 如果类的 某个成员的拷贝赋值运算符 是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符是删除的。
  • 如果类的 某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,没有类内初始化器,或是类有一个const成员,它没有类内初始化器且其类型未显示定义默认构造函数,则该类的默认构造函数被定义为删除的。

本质上,这些规则含义是:如果一个类有数据成员不能默认构造、拷贝、赋值或销毁,则对应的成员函数将被定义为删除的。

二、拷贝控制和资源管理

对于拷贝构造函数和拷贝赋值运算符,一般来说,可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针

  • 类的行为像一个值:意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的,改变原对象不会改变副本,反之亦然。
  • 类的行为像一个指针:意味着共享状态。当我们拷贝一个像指针的对象时,副本和原对象使用相同的底层数据,改变副本也会改变原对象,反之亦然。

为了说明这两种方式,我们会为HashPtr类定义这两种不同行为的拷贝控制成员。
HashPtr类有两个成员,一个int和一个string指针。

1. 行为像值的类

为了提供类值的行为,HashPtr需要

  • 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
  • 定义一个拷贝赋值运算符,释放对象当前的string,并拷贝新的string
  • 定义一个析构函数,释放string
class HasPtr{
    int i;
    string *ps;
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 &p);
    ~HasPtr(){ delete p; }
};
类值拷贝赋值运算符

赋值运算符通常组合了析构函数、构造函数的操作。类似析构函数,赋值操作会销毁左侧运算对象的资源;类似构造函数,赋值操作会从右侧运算对象拷贝数据。

HasPtr & HasPtr::operator=(const HasPtr &rhs)
{
    auto newp = new string(*rhs.ps);    //拷贝底层string
    delete ps;                          //释放旧内存
    ps = newp;
    i = rhs.i;
    return *this;
}

编写赋值运算符时,有两点需要记住:

  • 当一个对象赋予它本身时,是否可以正常工作
  • 大多数赋值运算符组合了析构函数、拷贝构造函数的工作

    一个好的模式是,先 将运算符右侧的对象拷贝到临时变量,再 释放左侧对象的现有成员,然后 将临时变量拷贝到左侧对象中。

HasPtr& HasPtr::operator=(const HasPtr& rhs)
{
    delete ps;     
    ps = new string(*rhs.ps);
    i = rhs.i;
    return this;
    //这个函数在拷贝自身时无法正确使用,因为ps指向的内存已被释放
}
2. 定义行为像指针的类

行为像指针的类,在拷贝指针数据成员时,仅拷贝指针而不拷贝所指向的对象。 因此会有多个指针指向同一内存,我们只有在最后一个指向string的HasPtr销毁后,才可以释放string。

实现它的最好方法是利用智能指针,但是当我们希望直接管理资源时,使用引用计数就很有用了。

引用计数

引用计数的工作方式如下:

  • 除了初始化对象外,每个构造函数(除拷贝构造函数)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此计数器初始化为1。
  • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员(包括计数器)。拷贝构造函数递增共享的计数器。
  • 析构函数递减计数器,若计数器为0,则释放。
  • 拷贝赋值运算符,递增右侧运算对象的计数器,递减左侧运算对象的计数器,若计数器为0,则释放。

唯一的难题就是确定 在哪里存放引用计数

HasPtr p1("Fancy");
HasPtr p2(p1);
HasPtr p3(p2);

如果我们将引用计数作为类的数据成员,那么每个计数器都是独立的。当我们构造p3时,无法修改p1的计数器。

因此,我们可以将计数器保存在动态内存中。当创建一个对象时,我们分配一个计数器。当拷贝或赋值对象时,我们拷贝指向计数器的指针。这样副本和原对象都会指向相同的计数器。

class HasPtr{
    string *ps;
    int i;
    size_t *share;
public:
    HasPtr(const string &s = new string()):
            ps(new string(s)),i(0),share(new size_t(1)){ }
    HasPtr(const HasPtr &p):
            ps(p.ps),i(p.i),share(p.share){ ++*share; }
    HasPtr& operator=(const HasPtr &rhs);
    ~HasPtr();
};

HasPtr::~HasPtr(){
    if(--*share == 0){
        delete ps;
        delete share;
    }
}

HasPtr& HasPtr::operator=(const HasPtr& rhs){

    ++*rhs.share;
    if(--*share == 0){
        delete ps;
        delete share;
    }
    ps = rhs.ps;
    i = rhs.i;
    share = rhs.share;

    return *this;
}   

三、交换操作

除了定义拷贝控制成员,管理资源的类通常还定义swap函数。因为对于标准库中的swap函数,需要进行一次拷贝和两次赋值。

HasPtr temp = v1;
v1 = v2;
v2 = temp;

在这个过程中我们会产生内存的消耗,理论上这些消耗是不必要的。我们更希望swap交换指针。

string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;

因此我们为HasPtr类定义更优化的swap函数。

class HasPtr{
    friend void swap(HasPtr&,HasPtr&);     //友元函数,可访问private
    ……
};

inline void swap(HasPtr &lhs, HasPtr&rhs) {

    //swap的存在就是为了优化代码,因此声明为内联函数

    using std::swap;
    swap(lhs.ps, rhs.ps);
    swap(lhs.i, rhs.i);
}

从上述代码中我们可以看出,我们调用的是swap,而不是std::swap。当我们定义了一个类的swap函数时,其匹配程度会优于std中定义的版本。若没有定义才会使用std的版本。

class Foo{
    HasPtr h;
    ……
};

void swap(Foo &lhs,Foo &rhs){
    std::swap(lhs.h,rhs.h);          //调用标准库swap
}

void swap(Foo &lhs,Foo &rhs){
    using std::swap;                //声明一下我在std空间定义了swap函数
    swap(lhs.h,rhs,h);              //调用HasPtr的swap
}
在赋值运算符中使用swap

定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换的技术。这种技术将左侧运算对象与右侧运算对象的一个副本进行交换。

HasPtr& HasPtr::operator=(HasPtr rhs){
    //rhs是右侧运算对象的一个副本
    swap(*this,rhs);
    return *this;
}

猜你喜欢

转载自blog.csdn.net/fancynece/article/details/79346314
今日推荐