【C++】类的默认成员函数之——拷贝构造函数

举报
YIN_尹 发表于 2023/08/21 18:26:47 2023/08/21
【摘要】 拷贝构造函数4.1 概念我们再来看上面写的这个Date类:class Date{public: //构造函数 Date(int year = 1, int month = 1, int day = 1) { _year = year; _month = month; _day = day; }private: int _...

拷贝构造函数

4.1 概念

我们再来看上面写的这个Date类:

class Date
{
public:
    //构造函数
    Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
private:
    int _year;
    int _month;
    int _day;
};

那现在我们用这个类创建一个对象d1:

int main()
{
    Date d1;
    return 0;
}

然后大家思考一下,如果我们现在想在创建一个对象,让这个对象和d1一样,或者说是d1的一份拷贝,应该怎么搞?

1a2d847d1a824001b90f931a2cbe5c4c.png

🆗,那经过了上面的学习,我们现在创建一个对象一般都直接用构造函数对其进行初始化,想初始化什么值传参就行了。

那现在我们想创建一个和d1一样的新对象,是不是可以用d1去初始化创建出来的新对象啊。

怎么做,是不是把构造函数的参数类型设置成类对象的类型就行了。


那这其实就是我们接下来要学的拷贝(复制)构造函数。


拷贝构造函数:

只有单个形参的构造函数,该形参是对本类 类型对象的引用(一般常用const修饰),在我们用已存在类的类型对象创建新对象(对象的拷贝)时由编译器自动调用。


接下来我们来更加详细的认识一下它:

4.2 特性

拷贝构造函数也是一种特殊的成员函数,其特征如下:

  1. 拷贝构造函数是构造函数的一个重载形式
  2. 因为我们刚才上面说了嘛,它的作用其实也是用来初始化对象的,只不过参数类型指定了是我们当前类的类型嘛。

所以它算是构造函数的一种重载形式。

那我们先自己来尝试实现一下它好吧:

Date(Date d)
{
    _year = d._year;
    _month = d._month;
    _day = d._day;
}

这样是不是就行了啊。

然后要创建一个和d1一样的对象,是不是这样:

int main()
{
    Date d1;
    Date d2(d1);
    return 0;
}

直接把d1作为参数初始化d2,然后我们构造函数的参数类型正好是Date 嘛,可以接收,然后把d1的成员变量一个一个赋给d2不就搞定了嘛。

但是呢,我们发现:c253baf222e3406a82d3176cbbfd3fee.png

这样写编译器直接就报错了,还没运行就报错了。

那相信大家刚才也注意到上面的概念了,在拷贝构造函数的概念中其实就指明了说它的参数类型应该是类对象的引用。

360611973d5a4294823fcaa61f25300d.png


确实,我们这样修改之后就可以了。

那这里为啥非得是引用呢?我们来看拷贝构造函数的第2条特性:

  1. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
  2. 那为啥直接传值就会引发无穷递归呢?
  3. 399ad7d7a4394c32bb3a18dfe7c89aba.png
  4. 结合这张图给大家解释一下:

ps:图中还在形参前加了const,大家可以先不管,后面会解释。

2572ce7914e74a5f9d15e9e8df9073b0.png

大家想一下,首先我们这里是用已有的类对象去创建一个相同的新对象(类对象的拷贝),所以会调用拷贝构造函数,那要调用函数是不是要先传参啊,而传值调用传的是啥(形参是实参的一份临时拷贝),是不是传的实参的拷贝,那要拷贝实参,是不是又是一个类对象的拷贝啊,那既然是类对象的拷贝,就又要调用拷贝构造函数,那就又需要传参,一传参就会再次调用拷贝构造函数,那这样是不是就陷入一个死递归了。



所以这里不能直接传对象,而是要传对象的引用(别名):

我们传对象的引用还需要拷贝实参吗,是不是就不用了,所以也就不会出现上面的问题了。

这时我们再运行程序:

045cfd32bf9c4792a901beb0cfa12d1b.png

不就达到我们想要的效果了吗。

另外呢,对一个对象进行拷贝构造也可以这样写:

0f64e1ec74b14962a1d22bd67bf14c36.png

直接用“=”也可以,这样也是拷贝构造。

除此之外,大家是不是还注意到:

上面一开始拷贝构造函数的概念中说它的形参一般用const修饰:

87971abc9d3e4d319bf2cda9fab5de6c.png

为什么要加个const呢?

其实很容易理解,大家想形参d是用来干嘛的,是用来初始化我们新创建的对象的,那我们肯定不希望形参d被修改,所以加个const修饰

b2b31e0098db47859d54a1488fcb94f1.png

这样我们如果不小心写反了啥的是不是就直接报错了。

所以,正确的实现应该是这样的:

Date(const Date& d)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }

另外,加const还有什么好处呢?


大家想,如果我们不加const,但传过来的参数是const修饰的,这样的话是不是根本就接收不了啊,这个问题我们之前也讲了,是不是属于权限放大了,是不行的。

但是如果我们加了const,传过来的不管是否加了const是不是都可以接收啊。


所以呢:


这里一般加上const会比较好。


若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数 拷贝对象 按内存存储字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。

🆗,那我们上面说了拷贝构造函数是构造函数的一种重载形式,那其实就也属于是构造函数了,那构造函数我们不写的话编译器不是会自动生成嘛,那拷贝构造函数是不是也具有这样的特性呢?

是的,对于拷贝构造函数来说,若未显式定义,编译器也会生成默认的拷贝构造函数。


那默认生成的拷贝构造函数是什么样的?我们来研究一下:


我们刚才不是对Date类实现了一个拷贝构造函数嘛,先我们现在把它屏蔽调:

class Date
{
public:
    //构造函数
    Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    //拷贝构造函数
    /*Date(const Date& d)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }*/
    void Print()
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};

那这次我们自己没写拷贝构造,我们看看编译器自动生成的能不能帮助我们完成拷贝构造:

int main()
{
    Date d1;
    d1.Print();
    Date d2(d1);
    d2.Print();
    Date d3 = d1;
    d3.Print();

    return 0;
}

我们运行程序:11e572dbb68346d480cc4975d7e1925d.png

欸,是不是可以啊。

那既然编译器自动生成的拷贝构造函数就可以帮助我们完成类对象的拷贝了,那我们还需要自己写吗?

那为了解决这个问题,我们再来看这样一个类:

typedef int DataType;
class Stack
{
public:
    Stack(int capacity = 4)
    {
        _array = (DataType*)malloc(sizeof(DataType) * capacity);
        if (NULL == _array)
        {
            perror("malloc申请空间失败!!!");
            return;
        }
        _capacity = capacity;
        _size = 0;
    }
    void Push(DataType data)
    {
        // CheckCapacity();
        _array[_size] = data;
        _size++;
    }
    // 其他方法...
    ~Stack()
    {
        cout << "~Stack" << endl;
        free(_array);
        _array = NULL;
        _capacity = 0;
        _size = 0;
        
    }
private:
    DataType* _array;
    int _capacity;
    int _size;
};

还是我们之前用过的这个栈Stack类,大家看它的成员变量是不是也都是内置类型啊,前面提到过指针也属于内置类型嘛。

那对于Stack这个类,我们也是没写拷贝构造函数的,那编译器自动生成的能不能完成下面这样的拷贝呢?

int main()
{
    Stack s1;
    s1.Push(1);
    s1.Push(2);
    s1.Push(3);

    Stack s2(s1);
    return 0;
}

这里是把s1拷贝给s2,我们运行一下:

2c69b395cd9648e7bb66d4caabb6f717.png

但是呢,嗯??? 一运行发现我们的程序挂掉了。


为什么会这样呢,刚才Date类不也都是内置类型,为啥就没事呢?


大家有没有注意到我们上面的特性3,后面的一句话是:

默认的拷贝构造函数 拷贝对象 按内存存储字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。

在这里其实就是对逐个成员变量依次进行拷贝,里面存的是啥就把啥拷过去。


那原因其实就出现在这里,我们来对比一下Date和Stack这两个类进行的拷贝:


首先对于Date类来说,进行这样的浅拷贝有没有问题啊。

7578fa3894be4abd91c5fece990c148a.png

是不是没问题啊,一共12个字节的内容,直接拷贝过去就行了嘛。

但是,对于Stack类来说呢?

我们还是这样进行浅拷贝的话:

503d97a90a4c44b6b3804b721934521d.png

大家看出来有什么问题了吗?


这样拷贝过后两个栈对象是不是指向同一块堆上的空间啊。

这样会有什么问题呢?

我们在st1中入栈几个数据,st2里面是不是就也有数据了(因为它俩用的是同一块空间),然后如果我们再用st2去入栈数据,此时st1的_size前面是不是已经++过了,但是st1的_size前面是不是还是0,这样st2入的数据是不是就把之前st1入的数据给覆盖掉了。


除此之外,还会有什么问题。


st1生命周期结束析构一次,st2生命周期结束析构一次,是不是会对一块空间析构两次啊。

那大家先思考一下,这里st1和st2谁先进行析构啊?

简单解释一下:

这里是st2先析构,我们知道st1和st2都是在栈上的(栈区)

,那栈区之所以叫栈区也是有些讲究的,它在这个地方也是遵循先进后出的这个顺序的,即后定义的会先进行析构。

所以这里会有什么问题呢?

🆗,st2先析构,那堆上的这块空间就被释放了,但是接下来st1也会进行它的析构,而此时虽然st1还保留了这块空间的地址,但是这块空间已经被释放,所以st1就是个野指针了。

所以为什么程序崩溃了,就是我们这里对野指针进行free了。

b1bc9fd12d8b47be85516e94e9071059.png

所以:


在编译器生成的默认拷贝构造函数中,内置类型是按照浅拷贝进行拷贝的,浅拷贝在某些场景下是适用的(比如上面的Date类),但是在有些场景下是会出问题的(比如这里的Stack类)。


那总结一下就是:


类中如果没有涉及资源申请时,拷贝构造函数我们自己写不写都可以(因为默认生成的就可以搞定);一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝,就会出现问题。


所以说:


对于Stack这样存在资源申请的类,我们是需要自己去写拷贝构造函数的,那浅拷贝不行,这里我们应该怎么实现呢?

🆗,那要完成这种类的拷贝就需要我们实现一个深拷贝。


那深拷贝呢我们后面会专门去讲,这里我们先来简单的试一下:


那刚才Stack进行浅拷贝为什么不行,是不是导致两个栈对象指向了同一块空间了。

所以我们深拷贝要做的就是让这两个对象各自拥有自己独立的空间就行了。

3a7f1410535d4719856057214395d0b9.png

这样对两个对象进行操作就不会互相影响了。

那我们来实现一下代码吧:

Stack(const Stack& st)
    {
        _array = (DataType*)malloc(sizeof(DataType) * st._capacity);
        if (NULL == _array)
        {
            perror("malloc申请空间失败!!!");
            exit(-1);
        }
        memcpy(_array, st._array, sizeof(DataType) * st._size);
        _capacity = st._capacity;
        _size = st._size;
    }

🆗,我们来运行一下:17e6d5fb63964e159190dc337933374a.png

这次就正常运行了。

再来调试观察一下:

f7d8320768704b64bf413378cfe72426.png是不是没问题啊。

当然:

如果类的成员变量有自定义类型,默认生成的拷贝构造还是会去调用该类对应的拷贝构造。

我们再来看这个类:

class MyQueue
{
public:
    // 默认生成构造
    // 默认生成析构
    // 默认生成拷贝构造

private:
    Stack _pushST;
    Stack _popST;
    int _size = 0;
};

大家看,对于这个类来说,我们还需要自己写构造函数、析构函数包括拷贝构造函数嘛!

是不是不需要啊,默认的是不是都能搞定啊。

对于构造函数来说,内置类型虽然不做处理,但是我们给了缺省值,对于自定义类型,默认生成的会自动调用它对应的构造函数啊,而Stack 的构造函数我们也实现的有了;

对于析构函数,内置类型不用处理,自定义类型这里也会自动调用Stack 对应的析构;

那如果用到拷贝构造的话,这里的_size 直接默认的浅拷贝就能搞定,自定义类型还是会自动调Stack 对应的拷贝构造。


那总结一下这一部分,就是:


在编译器生成的默认拷贝构造函数中,内置类型是按照浅拷贝(值拷贝)进行拷贝的,而自定义类型是调用其对应的拷贝构造函数完成拷贝的。


拷贝构造函数典型调用场景:

使用已存在对象创建新对象

函数参数类型为类对象

函数返回值类型为类对象


当然:


为了提高程序效率,一般对象传参时,尽量使用引用类型(减少拷贝),返回时根据实际场景,能用引用尽量使用引用。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。