【C++】类和对象(第二篇)(一)

简介: 【C++】类和对象(第二篇)(一)

1. 类的6个默认成员函数

通过上一篇文章的学习,我们知道如果一个类中没有成员变量,也没有成员函数,啥也没有,那我们把它叫做空类。

即如果一个类中什么成员都没有,简称为空类。

比如:

class Date
{
};

那现在问大家一个问题:空类中真的什么都没有吗?

🆗,其实并不是的。

对于任何一个类来说,它们都有6个默认成员函数,即使是空类。

默认成员函数:即用户没有显式实现,编译器自动生成的成员函数称。

那这6个默认成员函数都是什么呢?

66a1cc6c4bb1443cbc135a7db749d863.png

大家先简单了解一下,接下来我们会一一学习。

2. 构造函数

2.1 构造函数的引出

通过上一篇文章的学习,相信大家已经有能力能够写一个简单的类了。

那现在有这样一个类:

class Date
{
public:
  void Init(int year, int month, int day)
  {
    _year = year;
    _month = month;
    _day = day;
  }
  void Print()
  {
    cout << _year << "-" << _month << "-" << _day << endl;
  }
private:
  int _year;
  int _month;
  int _day;
};

那对于一个类来说,我们实例化出来对象之后一般会对其进行一个初始化:

87f61992671e46f7a99fd58e010a2ea3.png

如果有时候不初始化直接用可能就会出现问题,但是有时候我们可能会忘记初始化,直接就对对象进行一些操作了。

7aa655a2445749788ba4b60a70c212f8.png再举个大家可能经历过的例子:

比如我们写了一个栈的类,然后用该类创建一个对象,对象创建好之后我们就迫不及待地往栈里放数据了,上去直接调用压栈的成员函数,哐哐哐数据就搞进去了。

但是一运行发现程序崩溃了,最后吭哧吭哧去调试发现没有对创建出来的栈进行初始化,空间都没开呢,就放数据了。

有可能忘了不说,每次创建一个对象都要初始一次,好像也有点麻烦。

那针对上面提到的这种情况呢,C++呢就提供了一种方法帮助我们解决这个问题:

那就是我们接下来要学的——构造函数。

有了构造函数,我们每创建完一个对象,就不用手动去调用Init函数进行初始化了,因为在创建对象时编译器会自动去调用构造函数对对象进行初始化。

那构造函数到底是个啥呢?

构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。

那接下来我们就来详细地认识一下构造函数。

2.2 构造函数的特性

构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象

其特征如下:

  1. 构造函数的函数名与类名相同

也就是说定义好一个类,它的构造函数的函数名就确定好了,跟当前类的类名是相同的。

  1. 构造函数无返回值

要注意这里说的无返回值不是说返回类型是void,而是根本就不写返回类型

比如上面我们定义的那个Date类,如果要写它的构造函数就应该是这样的:

df8d81ef714f489ab53b944d12afcef7.png

  1. 对象实例化时编译器自动调用对应的构造函数

有了构造函数我们初始化对象就不用再手动初始化了,实例化一个对象时编译器会自动调用其对应的构造函数。

  1. 构造函数可以重载

🆗,构造函数可以重载,那是不是就意味着一个类可以有多个构造函数,那也就是说,该类创建的对象可以有多种初始化方式

那不能光说不练啊,现在已经认识了构造函数了,那我们练习一下呗,就给上面的Date类写一下构造函数:

Date()
{
}

首先看这是不是就是一个构造函数啊,当然是,没有返回值,并且函数名和类名相同嘛。

但是我们说构造函数是用来初始化对象的,那啥也不写是不是没意思啊,写点东西吧:

Date()
  {
    _year = 1;
    _month = 1;
    _day = 1;
  }

这样,我们把年月日都初始化成1

那我们来试一下,刚才不初始化都打印出来随机值了,那现在有构造函数不是说会自动初始化吗,行不行啊,验证一下:

74578d7fa6174fcea287f5fd0fed614c.png

哦豁,可以啊,这次我们并没有调用初始化函数,但是打印出来不是随机值,而是我们在构造函数中给定的初值,说明我们实例化对象的时候确实自动调用构造函数进行初始化了。

那这样的话我们每次创建Date类的对象初值都是1 1 1,如果我们想每次都按照自己的想法给对象进行初始化呢?能做到吗?

是不是可以啊。

上面提到的构造函数的第4条特性是啥?

是不是构造函数可以重载啊,那我们重载一下给参数不就行了。

这样的话我们不知道初始化给什么初值的时候就可以调用无参的构造函数,自己想指定初值的话调用有参数的传参不就行了

Date(int year, int month, int day)
  {
    _year = year;
    _month = month;
    _day = day;
  }

这样是不是就搞定了。

575463cad3d943688c6bc3806cf91911.png

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

ea3f752c02124856ba7d9b07b73792bb.png

但是要注意,调用无参构造函数的时候我们不要写成这样Date d1();即后面不要加括号。

f027d0f4130e49d18892717eca3553e3.png

这样的话编译器会报一个警告,大家看这样写的话是不是可能会被认为是一个函数声明啊,是吧。

一个返回类型为Date,函数名为d1,无参的函数声明是不是也长这样啊。

那大家再来思考一下:70ca1d3c543d43b8be45f00557aee957.png

这两个构造函数有没有必要分开写,或者说,能不能一个函数就搞定了。

🆗,当然是可以的,怎么做呢?

上一篇文章刚学的——缺省参数

是不是可以这样写:

Date(int year = 1, int month = 1, int day = 1)
  {
    _year = year;
    _month = month;
    _day = day;
  }

用这一个是不是就行了啊:

fcead770430c42f9869876a842d656f2.png我们不传参,就用默认的,传了,就用我们传的。

另外,还有一个需要注意的点:

我们现在呢,实现了一个带缺省值的构造函数,那大家思考一下,这两个构造函数可以同时存在吗?

91e54ed7b7ec43bb8bff8f313cf41fd7.png

那要告诉大家的是,首先在语法上,它们两个是可以存在的,因为它们构成重载嘛,但是,我们现在再去运行程序:

451883cfc177479ca31dc476268689c4.png

报错了,为什么?

原因在于,我们这里是不是调用了无参的构造函数啊,d1我们创建时没传参嘛,但是上面这两个构造函数是不是都适用于无参的情况啊,所以编译器就不知道该调那个了,就报错了。

那我们把d1的创建注释掉呢?

cc06661685244d3f90c804cd8314c5fc.png

就不报错了,好吧,这是需要大家注意的一个地方。


那除了上面这些,其实构造函数还有一些其它的特性:


如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。

哦豁,那也就是说,构造函数不一定非要我自己写,如果我们自己没有定义构造函数,编译器会自动生成一个。只不过是无参的嘛。

那现在把我们自己定义的构造函数全部注释掉:

b3430763a47a4c58aa66fffb4ddc4024.png

fd70a03f0d3f4f489914aa422a873c6d.png

我们发现确实没问题,编译通过了。

将Date类中构造函数屏蔽后,代码可以通过编译,因为编译器生成了一个无参的默认构造函数


那特性还说了,如果我们自己定义的有,编译器就不再生成了:


这个大家好理解,我们上面自己写的无参构造函数把_year、 _month、 _day全部初始化为1,打印出来确实是全1。


那编译器会自动生成的话,我们以后是不是就不用自己写构造函数了?


我们把自己写的构造函数屏蔽掉,然后直接运行:

d0c3b3174b6f42e6b1f23afd4466c61e.png

欸!这~怎么回事嘛?

不是说有自动生成的构造函数嘛,怎么还是随机值啊。

这编译器自动生成的默认构造函数怎么没用啊?

什么原因呢?

🆗,这个地方呢,大家可以认为是我们的祖师爷设计的不好的一个地方,或者说是一个失误。

具体是这样的:

C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char...(包括各种指针类型),自定义类型就是我们使用class/struct/union等自己定义的类型

而编译器自动生成的构造函数不会对内置类型进行处理,对于自定义类型会处理,怎么处理?会去调用该自定义类型对应的默认构造函数


所以,刚才为什么打印出来是随机值?

因为我们Date类中的成员变量都是int,是内置类型,但是编译器自动生成的构造函数不会处理内置类型,所以还是随机值。

那我们来看这样的场景:

class Time
{
public:
  Time()
  {
    cout << "Time()" << endl;
    _hour = 0;
    _minute = 0;
    _second = 0;
  }
private:
  int _hour;
  int _minute;
  int _second;
};
class Date
{
private:
  // 基本类型(内置类型)
  int _year;
  int _month;
  int _day;
  // 自定义类型
  Time _t;
};
int main()
{
  Date d;
  return 0;
}

大家看这里的Date类与上面那个有什么区别,是不是它的成员变量里既有内置类型又有自定义类型啊。

但是我们现在并没有给Date类写构造函数,那我们在main函数里直接拿Date去创建一个对象,它自然就会去调用编译器自动生成的构造函数,那内置类型不做处理,我们不是还有一个自定义类型Time _t;呢,我们说对于自定义类型,编译器会自动去调用它对应的默认构造函数。

那我们在Time 类的默认构造函数里面故意加了一个打印:

2c4013fd8a95472b821e6ab9bde7c859.png

如果运行会打印,就说名编译器自动调用了:

b48c62c0e39c40c29281e5188b90ebed.png

🆗,是不是调了啊。


那说到底内置类型呢?这样的话内置类型不写构造函数就没法初始化了吗?


🆗,我们的祖师爷呢在后来也发现了这个问题,并在C++11中针对内置类型不初始化的缺陷打了一个补丁。

即非静态成员变量在类中声明的时候可以给缺省值。

这样如果我们不写构造函数,内置类型的初始化就会按给定的缺省值进行初始化

c3f55575794845afb4ba09564608a049.png

6c5836d764294305af6313cff6b3c0da.png

无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数,默认构造函数只能有一个

所以这里想告诉大家的是,不要认为默认构造函数就是我们不写编译器自动生成的那个,除了这个之外,我们自己定义的无参的构造函数,或者全缺省的构造函数,都可以认为是默认构造函数。

为什么说只能有一个呢,因为我们调用这些构造函数是不是都不用传参啊,那这样如果同时存在多个的话,编译器就不知道到底该调哪个了。

这个问题我们上面也有提到过的。

3. 析构函数

3.1 析构函数的引出

首先我们来回顾一个问题:


我们在之前数据结构的学习中,在学到栈的时候,有一个与栈相关的非常经典的题目——括号匹配问题。

链接: link

不知道大家做过这个题没有,只不过当时我们用的栈是用C语言写的,那现在我们也可以用C++的类实现了。

但是这道题里有一个比较恶心的点,是什么呢?


f1ba74f8606148cdb74729b24a68bd3a.png

来看一下我们C语言写出来的代码,我们进行判断之后,需要return的地方可能有好几处,但是呢,每次return之前,其实最好都要去调用一下StackDestroy把我们动态开辟的空间给销毁一下,但是我们可能很容易会忘掉导致内存泄漏。


那现在我们学了C++,有没有什么好的办法可以帮助我们解决这个问题呢?


可不可以像上面的构造函数自动初始化一样自动对对象中的资源进行清理呢?

那当然是有的,就是我们接下来要学习的析构函数

析构函数:

其与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作

解释一下,我们用这样一个类来举例:

typedef int DataType;
class Stack
{
public:
  //构造函数
  Stack(size_t 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++;
  }
  // 其他方法...
private:
  DataType* _array;
  int _capacity;
  int _size;
};

是一个栈类,并且我们已经写好了构造函数。

那我们上面说 析构函数不是完成对对象本身的销毁,而是完成对象中资源的清理工作 是什么意思呢?

🆗,我们那这个类去实例化栈对象:

int main()
{
  Stack s;
  s.Push(1);
  s.Push(2);
  return 0;
}

大家思考一下,这里的对象s需要我们自己去销毁吗?

是不是不需要啊,因为s是定义在栈区上的局部变量,程序结束,它是不是就随着main函数的栈帧自动销毁了啊。

那析构函数的作用是啥呢?

完成对象中资源的清理工作,什么意思?

🆗,像栈这样的对象,它里面是不是有在堆上动态开辟的空间啊,那经过C语言的学习我们都知道,这些空间是不是需要我们手动去释放的啊,否则可能会导致内存泄漏。

所以说,析构函数就是来帮我们干这件事情的。


那析构函数到底是个啥,又怎么用呢?


3.2 析构函数的特性

和构造函数一样,析构函数也是一个特殊的成员函数,其特征如下:


析构函数名是在类名前加上字符 ~

也就是说,一个类定义好之后,它的析构函数的函数名也是确定的,即在类名前面加上“~”。

~是啥,在C语言中是不是按位取反啊,表示它的功能和构造函数是相反的。


无返回值且无参数

和构造函数一样,析构函数也是没有返回值的,并且析构函数还没有参数。


对象生命周期结束时,C++编译系统系统自动调用析构函数

析构函数起作用的关键就在这里,对象声明周期结束时编译器会自动调用析构函数对对象的资源进行清理。


析构函数不能重载

注意析构函数不能重载,因为它连参数都没有,何谈重载。


那了解到这里,我们就可以尝试写一个析构函数来练练手了:


就给我们刚才那个栈类写一个析构函数吧。

~Stack()
  {
    free(_array);
    _array = NULL;
    _capacity = 0;
    _size = 0;
  }

那是不是很简单啊,就是释放我们在堆上开辟的空间嘛。

然后呢,它没有返回值,没有参数

那就写好了,那测试一下呗:

为了方便看出来是否自动调用了析构函数,我们可以在加一个打印:

631fff8ca323440790b950fb8e0701b3.png

此时我们的main函数里并没有显式的调用~Stack函数:

e1127edae39b4aa4bb49524f15603ea9.png

然后我们运行:4ee1d65a533c453a94411b6b30699c47.png

是不是自动调用了啊。

一个类只能有一个析构函数。若未显式定义,编译器会自动生成默认的析构函数

这一点呢和构造函数一样,如果我们自己不写析构函数,则编译器会自动生成默认的析构函数。

然后说一个类只能有一个析构函数,我们上面说了析构函数不能重载,所以肯定只能有一个了。


那编译器默认生成的析构函数有什么特点呢?


和编译器默认生成的构造函数一样,内置类型成员不处理,当然如果全是内置类型的成员变量也不需要处理,比如上面写的Date类。

那同样,对于自定义类型,会自动调用其对应的析构函数。


举个栗子:

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;
};
class Date
{
private:
  // 基本类型(内置类型)
  int _year;
  int _month;
  int _day;
  // 自定义类型
  Stack _s;
};
int main()
{
  Date d;
  return 0;
}

这里我们没有给Date显式定义析构函数,那d声明周期结束时,就会调用编译器自己生成的默认析构函数,那里面的内置类型不做处理,当然也不用处理,关键在于自定义类型Stack _s;申请的资源需要清理,那我们看编译器自己生成的默认析构函数会不会调用Stack 类的析构函数:

aa95c41e10234a179ee3659a0f2000b9.png

🆗,是不是调了啊。

  1. 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类

4. 拷贝构造函数

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 对应的拷贝构造。


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


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


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

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

函数参数类型为类对象

函数返回值类型为类对象


当然:


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


目录
相关文章
|
4天前
|
存储 编译器 C语言
【c++丨STL】string类的使用
本文介绍了C++中`string`类的基本概念及其主要接口。`string`类在C++标准库中扮演着重要角色,它提供了比C语言中字符串处理函数更丰富、安全和便捷的功能。文章详细讲解了`string`类的构造函数、赋值运算符、容量管理接口、元素访问及遍历方法、字符串修改操作、字符串运算接口、常量成员和非成员函数等内容。通过实例演示了如何使用这些接口进行字符串的创建、修改、查找和比较等操作,帮助读者更好地理解和掌握`string`类的应用。
18 2
|
10天前
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
36 5
|
16天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
47 4
|
17天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
44 4
|
2月前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
28 4
|
2月前
|
编译器 C语言 C++
【C++打怪之路Lv4】-- 类和对象(中)
【C++打怪之路Lv4】-- 类和对象(中)
25 4
|
2月前
|
存储 安全 C++
【C++打怪之路Lv8】-- string类
【C++打怪之路Lv8】-- string类
22 1
|
2月前
|
存储 编译器 C语言
【C++打怪之路Lv3】-- 类和对象(上)
【C++打怪之路Lv3】-- 类和对象(上)
17 0
|
2月前
|
存储 编译器 C++
【C++类和对象(下)】——我与C++的不解之缘(五)
【C++类和对象(下)】——我与C++的不解之缘(五)
|
2月前
|
编译器 C++
【C++类和对象(中)】—— 我与C++的不解之缘(四)
【C++类和对象(中)】—— 我与C++的不解之缘(四)