C++之类和对象(三)

简介: C++之类和对象

析构函数

基础知识

在C语言中很容易被忘记将开辟的动态内存空间进行释放,但是如果不释放就会造成内存泄漏的问题。而C++引入了析构函数作为默认成员函数,它会在程序结束时由编译器自动调用完成资源的释放(与构造函数并不是开辟空间类似,析构函数并不是销毁对象,销毁对象是由编译器来进行的),与构造函数正好相反。

当变量的生命周期结束时变量就被销毁,所以位于函数中的局部对象在函数调用完成时销毁,位于全局的对象在main函数调用完成时销毁;另外,后定义的对象会被先销毁

析构函数的特性如下:

1.析构函数名是在类名前加上字符 ~ (表示与构造函数功能相反);;

2.无参数无返回值;

3.一个类只能有一个析构函数,若未显式定义,系统会自动生成默认的析构函数;(注意:析构函数不能重载)

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

5.析构函数对内置类型不处理,对自定义类型调用它自身的析构函数

可以看到,虽然我没有调用析构函数,但是编译器在main函数结束时自动调用完成了资源的清理。

选择处理

对于没有资源申请的类可以不用显示定义析构函数,编译器自动生成的析构函数就够用,但是对于有资源申请的类就必须要显示定义析构函数否则会造成资源损失。

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

拷贝构造

基础知识

复制是我们经常使用到的操作,如果想复制一个类的话该怎么办?C++对于这个问题的解决方案是提供了一种叫做拷贝构造的成员函数。

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

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

1.拷贝构造函数是构造函数的一个重载形式,当我们使用拷贝构造实例化对象时,编译器不再调用构造函数;

2.拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用;

3.若未显式定义,编译器会生成默认的拷贝构造函数;

4.默认的拷贝构造函数对内置类型以字节为单位直接进行拷贝 – 浅拷贝,对自定义类型调用其自身的拷贝构造函数;

引用做参数

拷贝构造的第二个特性说拷贝构造函数的参数必须是引用,否则会有无穷递归的现象产生,这是因为传值传参本身就是一次拷贝(传值传参是建立一个临时变量,然后将值拷贝给这个临时变量),而拷贝类就需要调用拷贝构造,也就是说如果传值的话编译器会陷入一个”调用拷贝构造需要先传值,传值需要拷贝,拷贝需要调用拷贝构造 “这样一个死循环。

如果是用引用做参数的话,形参作为实参的别名,可以说是实参的本身也就不需要拷贝实参了,就可以避免无穷递归的发生。此外拷贝构造并不需要改变拷贝方,为了防止下面意外的发生,建议用const修饰参数:

深浅拷贝

默认的拷贝构造函数对内置类型以字节为单位直接进行拷贝 – 浅拷贝,对自定义类型调用其自身的拷贝构造函数。这种浅拷贝对于要动态开辟空间的自定义类型来说并不够用:

可以看到浅拷贝虽然将变量st1拷贝给st2了,但是它们两个指向的是同一块空间,这样在向其中任意一个变量种插入元素时都会影响到另外一个变量,而且还会有一个隐藏问题就是在free释放空间时,同一块空间被释放了两次,程序将执行失败:

正确的拷贝应该是为st2单独开辟一块空间,并将st1中的所有数据拷贝到st2中:

对于没有申请资源举动的类来说,编译器生成的默认拷贝构造函数已经够用,就没有必要再显示定义拷贝构造函数了,比如日期类:

可以看到,我没有写拷贝构造,但是编译器生成的默认拷贝构造将d1变量成功拷贝给了d2。

总结

如果类中没有资源申请,则不需要手动实现拷贝构造,直接使用编译器自动生成的即可;如果类中有资源申请,就需要自己定义拷贝构造函数,否则就可能出现浅拷贝以及同一块空间被析构多次的情况;

其实,拷贝构造和函数析构函数在资源管理方面有很大的相似性,可以理解为需要写析构函数就需要写拷贝构造,不需要写析构函数就不需要写拷贝构造;

拷贝构造的经典使用场景:

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

函数参数类型为类类型对象;

函数返回值类型为类类型对象;

运算符重载

基础知识

对于C/C++编译器来说,它知道内置类型的运算规则,比如整形+整形、指针+整形、浮点型+整形;但是它不知道自定义类型的运算规则,比如日期+天数 、日期直接比较大小、日期-日期;我们要进行这些操作就只能去定义对于的函数,比如AddDay、SubDay;但是这些函数的可读性始终是没有 + - > < 这些符号的可读性高的,而且不同程序员给定的函数名称也不一样相同;所以为了提高代码的可读性,C++引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。运算符重载的函数名为关键字operator后面接需要重载的运算符符号 ,函数原型为:返回值类型 operator操作符(参数列表)

有关运算符重载有以下几点是需要我们注意的:

1.不能通过连接其他符号来创建新的操作符:比如operator@

2.重载操作符必须有一个类类型参数

3.用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义

4.作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this

5." . * " " :: " " sizeof " " ? " " : " . 注意以上5个运算符不能重载。这个经常在笔试选择题中出现

日期类运算符重载的实现

再日常生活中我们经常会对日期进行计算,所以后面我将用日期类来进行讲解,首先这里放上正确的+=重载形式:

class Date  //全是内置类型,只要写构造函数
{
public:
  Date(int year = 2022, int month = 2, int day = 17)
  {
    _year = year;
    _month = month;
    _day = day;
  }
  int GetMonthDay(int year,int month)
  {
    //要获得每个月的天数,使用数组是最方便的办法
    static int day[13] = { 0,31,28,30,30,31,30,31,31,30,31,30,31 };
    //对于二月这个特殊的月份而言,如果是闰年则是29天
    if (month == 2 && (year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)))
    {
      return 29;
    }
    return day[month];
  }
  Date& operator+=(int day)//最好用引用返回
  {
    //如果当前天数加上day超过本月该有的天数,则月份加一
    _day += day;
    while (_day > GetMonthDay(_year, _month))
    {
      _day -= GetMonthDay(_year, _month);
      _month++;
      if (_month == 13)
      {
        _year++;
        _month = 1;
      }
    }
        return *this;
  }
  void Print()
  {
    cout << _year << " " << _month << " " << _day << endl;
  }
private:
  int _year;
  int _month;
  int _day;
};
int main()
{
  Date d1;
  d1 += 30;
  d1.Print();
  return 0;
}

2022年不是闰年,所以2月17日的三十天后是3月19日。在数组前面加了static将数组变成了静态的是为了提高效率,数组作为函数内的变量在函数栈帧销毁以后就会被销毁,在函数调用时随着函数栈帧的建立才会建立,也就是说每次函数调用的时候都要重新建立数组。但是如果加上一个statci关键字将数组从栈区改到静态区,就只会初始化一次,只有第一次函数调用的时候需要建立数组,此后每次函数调用都不用重新建立数组。 此外还可以发现两件事,首先我把这个运算符重载写在类中而不是类外,其次我只传了一个参数。接下来解答这两个问题:


前面讲访问限定符的时候就有提到过,C++并不希望能在类外访问到类的成员数据,所以类的成员变量一般都是私有的,在类外是无法访问的,总不能因为需要写运算符重载就将成员变量的权限改成公有吧,那就因小失大了,所以最好的办法就是讲运算符重载写在类里面,否则是无法访问到成员变量的:


那么为什么我在写运算符重载的时候只传了一个参数,而且是需要加的天数而不对象呢?因为这个“+=”本来也就是只要两个参数。在前面有说,C++给每个非静态的函数加了一个隐藏的this指针,这个指针就代表了对象本身,所以说只需要传一个参数就够了。如果你非要传两个参数,那也可以无非就是程序错误而已嘛:

前后置++重载

这里有必要提一下++运算符的实现,++运算符是一个单操作运算符,也就是说只需要一个参数,也就是说只用隐含的this指针就可以了,但是这样的话有一个问题就产生了,那就是怎么区分前后置++呢?C++为了解决这个问题,有一个约定**后置++多一个整形参数,这个参数没有实际意义,只是用于区分。**下面实现一下前后置++的重载:

//前置++不用写参数,返回++后的值
  Date& operator++()
  {
    *this += 1;
    return *this;
  }
  //后置++需要多写一个整形类型的参数作为区分
  Date operator++(int i)
  {
    //返回的是++前的值,所以需要使用到拷贝构造
    Date ret(*this);
    *this += 1;
    return ret;//这个ret是一个临时变量,出了函数以后就不存在了,所以只能传值返回
  }

常见的运算符重载

常见的运算符重载有:operator+ (+)、operator- (-)、operator* (*)、operator/(/)、operator+= (+=)、operator-= (-=)、operator== (==)、operator= (=)、operator> (>)、operator< (<)、operator>= (>=)、operator<= (<=)、operator!= (!=)、operator++ (++)、operator-- (–)等;

其中,对于 operator++ 和 operator-- 来说有一些不一样的地方 –因为 ++ 和 – -分为前置和后置,二者虽然都能让变量自增1,但是它们的返回值不同;但是由于 ++ 和 – -只有一个操作数,且这个操作数还会由编译器自动传递;所以正常的 operator++ 和 operator-- 并不能对二者进行区分;最终,C++规定:后置++/–重载时多增加一个int类型的参数,此参数在调用函数时不传递,由编译器自动传递;

其次,重载函数中的 operator= 就是默认成员函数之一 – 赋值重载函数;

注:由于运算符重载函数很多,情况也比较复杂,所以我们将运算符重载的详细细节 (比如引用做返回值、引用做参数、函数的复用、对特殊情况的处理等知识) 放在 Date 类的实现中去介绍;

赋值重载

基础知识

**赋值重载函数也是C++默认的六个成员函数中的一个,是运算符重载的一种形式,它的作用是在两个已经存在的函数间赋值。**有以下特性:

  1. 赋值重载的格式规范;
  2. 赋值运算符只能重载成类的成员函数不能重载成全局函数;
  3. 若未显式定义,编译器会生成默认的赋值重载函数;
  4. 默认的赋值重载函数对内置类型以字节为单位直接进行拷贝 – 浅拷贝,对自定义类型调用其自身的赋值重载函数;

日期类赋值重载的实现

赋值重载一般使用引用做参数(这里其实可以使用传值传参,但是传值传参要拷贝临时变量,所以为了提高效率还是使用引用做参数),并用const修饰(是为了防止写错顺序将数据篡改)。在返回值方面也是使用传引用返回,这样也是为了提高效率(毕竟传值返回的话也是需要一次临时变量的拷贝,虽然VS对此做了优化,在传值返回时如果变量较小就使用寄存器返回,但是标准中是有一次临时变量的拷贝)。

此外在赋值重载的时候,有时候会出现给自己赋值的情况,要检查防止这种情况的出现。用户在调用成员函数时有可能发生下面这种情况:Date d1; Date& d2 = d1; d1 = d2; 这种情况对于只需要浅拷贝的对象来说并没有什么大碍,但对于有资源申请,需要进行深拷贝的对象来说就会发生不可控的事情。在《Effective C++》一书中对赋值重载的自我赋值是这样说的:

下面实现Date的赋值重载函数:

class Date
{
public:
  Date(int year = 2023, int month = 1, int day = 17)
  {
    如果只是这样写的话,就算是非法日期也会输出,建议这里还要检查以下日期的合法性
    //_year = year;
    //_month = month;
    //_day = day;
    if ((_year >= 1) && (_month >= 1 && _month <= 12) && (_day >= 1 && _day <= GetMonthDay(_year, _month)))
    {
      _year = year;
      _month = month;
      _day = day;
    }
    else
    {
      cout << "日期非法" << endl;
    }
  }
  bool operator==(const Date& d)
      {
        return _year == d._year 
          && _month == d._month 
          && _day == d._day;
      }
  int GetMonthDay(int year,int month)
  {
    static int arrDay[13] = { 0,31,28,30,30,31,30,31,31,30,31,30,31 };
    if ((month==2)&&(year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)))
    {
      return 29;
    }
    return arrDay[month];
  }
  Date& operator=(const Date& d)
  {
    //要先判断一下是否是自我赋值
    if (*this == d)//要使用“==”首先要运算符重载
    {
      return *this;
    }
    //不是自我赋值再进行下一步
    _year = d._year;
    _month = d._month;
    _day = d._day;
    return *this;
  }
private:
  int _year;
  int _month;
  int _day;
};

这里我还想解释一下,为什么要有返回值。因为在进行赋值操作的时候我们经常会连续赋值如(a=b=c)这样的操作,其实这个连续赋值是从右往左进行的,也就是说其实是先将c赋值给b,在将b=c这个赋值表达式的返回值赋值给a,所以还是需要返回值。

重载限制

**赋值运算符只能重载成类的成员函数而不能重载成全局函数。**因为赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。这一特性在《C++ primer》中也有解释:

深浅拷贝

用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。逐字节拷贝,这和拷贝构造一样因此也存在和拷贝构造一样的问题就是对于有资源申请的类来说,编译器默认生成的赋值重载是不够用的。这里以栈举例:

可以看到这里没写赋值重载函数直接报错了,所以其实只要写了析构函数的就要写拷贝构造也就是要写赋值重载。否则只是浅拷贝,在程序结束时同一块空间会被析构两次,和拷贝构造那里是同一个问题,此外还会有内存泄漏的问题存在,因为st1拷贝st2以后,st1原来的那块空间就没人能找到了:

Stack的赋值重载:

Stack& operator=(const Stack& st)
  {
    free(_a);
    _a = (int*)malloc(sizeof(int) * st._capacity);
    if (_a == NULL)
    {
      perror("malloc fail\n");
      exit(-1);
    }
    //拷贝
    memcpy(_a, st._a, sizeof(int) * st._capacity);
    _top = st._top;
    _capacity = st._capacity;
  }

深拷贝栈st1需要有一块和st2相同大小的空间,或许有人会疑惑为什么一定要先释放然后重新开辟,而不能使用realloc来改变大小。其实是可以使用realloc的,只是并不建议这样做,因为你不知道到底是st1空间大还是st2空间大,如果是缩容,那么就需要重新找一块空间来给st1使用,那么这样的代价是比较大的。如果不缩容,那又可能造成空间的浪费。所以还不如直接重新建立一块新的空间来使用。

前面在实现日期类函数重载的时候有考虑到一个自我赋值的问题,那么栈是否也需要考虑这个问题呢?下面来看一个示例:

这是为什么?其实并不难理解,因为在赋值重载时为了防止内存泄漏的问题会首先将原空间释放再申请新的空间,此时新的空间未被初始化,里面全是随机值。

所以在赋值重载的时候一定要检查是否是自我赋值,正确的Stack赋值重载函数如下:

class Stack
{
public:
  Stack(int capacity=4)
  {
    _a = (int*)malloc(sizeof(int) * capacity);
    if (_a == NULL)
    {
      perror("malloc fail\n");
      exit(-1);
    }
    _top = 0;
    _capacity = capacity;
  }
  ~Stack()
  {
    free(_a);
    _a = NULL;
    _top = _capacity = 0;
  }
  Stack(Stack&st)
  {
    _a = (int*)malloc(sizeof(int) * 4);
    _top = st._top;
    _capacity = st._capacity;
  }
  void Push(int x)
  {
    _a[_top++] = x;
  }
  bool operator==(const Stack& st)
  {
    return _a == st._a && _top == st._top && _capacity == st._capacity;
  }
  Stack& operator=(const Stack& st)
  {
    if (*this == st)
    {
      return *this;
    }
    free(_a);
    _a = (int*)malloc(sizeof(int) * st._capacity);
    if (_a == NULL)
    {
      perror("malloc fail\n");
      exit(-1);
    }
    //拷贝
    memcpy(_a, st._a, sizeof(int) * st._capacity);
    _top = st._top;
    _capacity = st._capacity;
  }
private:
  int* _a;
  int _top;
  int _capacity;
};

相关文章
|
1月前
|
编译器 C++
C++之类与对象(完结撒花篇)(上)
C++之类与对象(完结撒花篇)(上)
35 0
|
9天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
36 4
|
10天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
33 4
|
1月前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
27 4
|
1月前
|
编译器 C语言 C++
【C++打怪之路Lv4】-- 类和对象(中)
【C++打怪之路Lv4】-- 类和对象(中)
23 4
|
1月前
|
存储 编译器 C++
【C++类和对象(下)】——我与C++的不解之缘(五)
【C++类和对象(下)】——我与C++的不解之缘(五)
|
1月前
|
编译器 C++
【C++类和对象(中)】—— 我与C++的不解之缘(四)
【C++类和对象(中)】—— 我与C++的不解之缘(四)
|
1月前
|
C++
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
53 1
|
1月前
|
编译器 C语言 C++
C++入门4——类与对象3-1(构造函数的类型转换和友元详解)
C++入门4——类与对象3-1(构造函数的类型转换和友元详解)
19 1
|
1月前
|
存储 编译器 C语言
【C++打怪之路Lv3】-- 类和对象(上)
【C++打怪之路Lv3】-- 类和对象(上)
16 0