【C++】类和对象(中篇)

简介: 【C++】类和对象(中篇)

在往期 类和对象(上篇) 中,我们初步接触了C++中的类和对象,接下来我会和大家分享有关这个知识点更多的内容~

类的六大默认成员函数

如果一个类中什么成员都没有,简称为空类。空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成默认成员函数。

默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数,下面我会带大家介绍类的六大默认成员函数。

一、构造函数

1. 构造函数的概念

我们在类和对象上篇的时候,我们写了一个对日期初始化的函数 Init,但如果每次创建对象时都调用该方法设置信息,未免有点麻烦,那能否在对象创建时,就将信息设置进去呢?

// 日期类
    class Date
    {
    public:
      // 初始化
      void Init(int year, int month, int day)
      {
        _year = year;
        _month = month;
        _day = day;
      }
    private:
      // 声明
      int _year;
      int _month;
      int _day;
    };

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

2. 构造函数的特性

构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。开空间是我们创建对象的时候,栈帧已经帮我们开好空间了。

构造函数有以下的特征:

  1. 函数名与类名相同。
  2. 无返回值。
  3. 对象实例化时编译器自动调用对应的构造函数。
  4. 构造函数可以重载。

例如以下的日期类:

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

根据我们以前学的缺省参数,可以将上面两个构造函数合成一个,例如:

// 全缺省构造函数
    Date(int year = 1, int month = 1, int day = 1)
    {
      _year = year;
      _month = month;
      _day = day;
    }

在这个时候我们可以引入第五点:

  1. 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。

这里所指的默认构造函数是指,我们不传参就可以调用的。如果我们将无参的构造函数和全缺省的构造函数都放在类的内部实现,并实例化一个对象,会发生什么呢?我们来看结果:

在这里出现的主要问题是 d1,实例化时没有参数,属于无参对象,而上面我们有三个构造函数,虽然它们构成函数重载,但是 d1 在调用构造函数时,无参的构造函数全缺省的构造函数存在歧义,编译器不知道该调用哪一个,对于 d1 来说,它可以调用无参的构造函数,也可以调用全缺省的构造函数,所以这很好地说明了,多个构造函数并存会存在调用二义性。

所以上面这段代码应该把无参的构造函数和有参的构造函数去掉,只保留全缺省的构造函数,因为全缺省的构造函数是它们两个的合并,使用于两种情况,如以下代码:

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;
    };
  1. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。

例如下面这个日期类,我们证明一下以上的特性:

class Date
    {
    public:
      // 没有缺省值的构造函数
      Date(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;
    };
    int main()
    {
      // 实例化对象
      Date d1;
      d1.Print();
      return 0;
    }

这里的 d1 是无参的对象,我们写了一个没有缺省值的构造函数,按正常来说编译是不会通过的,因为一旦显式定义任何构造函数,编译器将不再生成,编译报错如下:

这里说明我们一旦显式写了任何一个构造函数,编译器都不会生成默认构造函数。

如果我们将我们自己写的构造函数屏蔽掉,代码可以通过编译,因为编译器生成了一个无参的默认构造函数,但是会有以下的现象:

我们可以看到,编译器默认生成的构造函数并没有对对象进行初始化,这就引出了我们的第七点:

  1. 关于编译器生成的默认成员函数,我们会有疑惑:不实现构造函数的情况下,编译器会生成默认的构造函数。但是看起来默认构造函数又没什么用?d1 对象调用了编译器生成的默认构造函数,但是 d1 对象 _year/_month/_day,依旧是随机值。也就说在这里编译器生成的默认构造函数并没有什么用吗?

答案是并不是的,C++ 把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char…,包括所有的指针都是内置类型;自定义类型就是我们使用 class/struct/union 等自己定义的类型。

我们可以观察下面的程序,我们在日期类的成员变量中,添加了一个时间类的自定义类型 _t ,我们会发现编译器生成默认的构造函数会对自定类型成员 _t 调用了它的默认成员函数。

// 时间类
    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;
    }

我们在时间类的构造函数中打印了它的构造函数的字符串,证明编译器默认生成的构造函数确实调用了 _t 对应类的默认成员函数,执行结果如下:

我们看到它确实打印出来了,说明自定义类型确实调用它对应的类的默认成员函数。

  1. 编译器不会对内置类型的成员进行处理,但是 C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值,即缺省值。

例如以下代码:

// 日期类
    class Date
    {
    public:
      void Print()
      {
        cout << _year << '.' << _month << '.' << _day << endl;
      }
    private:
      // 内置类型
      // 声明给的缺省值
      int _year = 1;
      int _month = 1;
      int _day = 1;
    };
    int main()
    {
      // 实例化对象
      Date d;
      d.Print();
      return 0;
    }

我们没有对它显式地写构造函数,默认生成的构造函数也不会对内置类型进行处理,但是我们在声明的时候给了缺省值,执行结果如下:

最后对构造函数进行总结:一般情况都需要我们自己写构造函数,决定初始化方式,例如成员变量中有指针类型的,需要我们自己决定如何初始化;如果成员变量全是自定义类型的,可以考虑不写构造函数。

二、析构函数

1. 析构函数的概念

通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?

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

2. 析构函数的特性

析构函数是特殊的成员函数,其特征如下:

  1. 析构函数名是在类名前加上字符 ~。
  2. 无参数无返回值类型。
  3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载。
  4. 对象生命周期结束时,C++ 编译系统系统自动调用析构函数。

例如日期类,日期类中的析构函数虽然没有资源需要清理,最后系统直接将其内存回收即可,但是在对象销毁时还是会自动调用;我们在析构函数中打印了它的函数名,证明它有调用析构函数:

// 日期类
    class Date
    {
    public:
      Date(int year = 1, int month = 1, int day = 1)
      {
        _year = year;
        _month = month;
        _day = day;
      }
      // 日期类的析构函数
      ~Date()
      {
        cout << "~Date()" << endl;
        _year = 0;
        _month = 0;
        _day = 0;
      }
      void Print()
      {
        cout << _year << '.' << _month << '.' << _day << endl;
      }
    private:
      // 内置类型
      int _year;
      int _month;
      int _day;
    };

我们实例化一个对象 d,什么都不做,直到函数结束,对象销毁,观察它是否自动调用了析构函数:

很明显,它确实自动调用了析构函数。

那么对于日期类我们没有什么资源需要清理,我们是可以不用显示写的,默认生成的析构函数就够用,默认生成的会做什么,我们后面再看;现在我们再实现一个栈的析构函数;

因为栈我们是用数组实现的,我们先需要向堆申请一块空间,最后对象销毁时,堆上的空间并没有释放,如果没有释放会造成内存泄漏,所以需要我们实现一个析构函数去完成这块的清理工作:

class Stack
    {
    public:
      // 栈的构造函数
      Stack(int capacity = 4)
      {
        cout << "Stack(int capacity = 4)" << endl;
        _a = (int*)malloc(sizeof(int) * capacity);
        assert(_a);
        _top = 0;
        _capacity = capacity;
      }
      // 栈的析构函数
      ~Stack()
      {
        cout << "~Stack()" << endl;
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
      }
    private:
      int* _a;
      int _top;
      int _capacity;
    };

例如上面是我们栈的析构函数,我们再观察一下它的调用情况:

我们可以看到,它确实调用了构造函数和析构函数。

  1. 关于编译器自动生成的析构函数,是否会完成一些事情呢?其实,编译器生成的默认析构函数,对内置类型的成员不做处理,对自定义类型成员调用它的析构函数。

下面的代码我们会看到,编译器生成的默认析构函数,对自定类型成员调用它的析构函数:

// 时间类
      class Time
      {
      public:
        ~Time()
        {
          cout << "~Time()" << endl;
        }
      private:
        int _hour;
        int _minute;
        int _second;
      };
      // 日期类
      class Date
      {
      private:
        // 内置类型
        int _year = 2023;
        int _month = 7;
        int _day = 21;
        // 自定义类型
        Time _t;
      };

我们写了时间类的析构函数,并在里面打印它的函数名字,证明它确实被调用;然后我们在日期类的成员变量中添加自定义类型 _t,我们观察它是否会自动调用 Time 类的析构函数:

我们可以看到,对于自定义类型,编译器确实调用了它的析构函数。

最后对这部分的析构函数进行总结,如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如日期类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。

  1. 类的析构函数调用一般按照构造函数调用的相反顺序进行调用,但是要注意static对象的存在,因为static改变了对象的生存作用域,需要等待程序结束时才会析构释放对象

例如,设已经有A, B, C, D4个类的定义,程序中A, B, C, D构造函数和析构函数调用顺序是什么呢,看以下程序:

C c;
    int main()
    {
      A a;
      B b;
      static D d;
      return 0;
    }

我们分别在对应类的构造函数和析构函数中打印对应的构造或者析构函数的信息,以便我们观察谁先调用和析构;我们先观察谁先调用构造函数:

如上图,我们可以观察到,构造函数的调用是按照从全局域先构造,再到局部域顺序进行构造的;

然后我们观察谁先调用析构函数:

我们可以观察到,是局部域先调用析构函数,而且是按照栈的结构顺序,先构造的后析构;当局部域全部析构完,再到静态区的对象析构;最后是全局域的对象析构。

三、拷贝构造函数

1. 拷贝构造函数的概念

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

2. 拷贝构造函数的特征

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

  1. 拷贝构造函数是构造函数的一个重载形式。
  2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
class Date
  {
  public:
    // 构造函数
    Date(int year = 1, int month = 1, int day = 1)
    {
      _year = year;
      _month = month;
      _day = day;
    }
    // 拷贝构造函数
    Date(Date d)
    {
      _year = d._year;
      _month = d._month;
      _day = d._day;
    }
  private:
    int _year;
    int _month;
    int _day;
  };

例如上面代码的拷贝构造函数,我们使用传值的方式,会引发下面的问题:

所以在写拷贝构造函数的时候,我们应该在参数那里加上引用,这样调用拷贝构造就是实参的别名,不会造成无穷递归,应该写成下面的形式:

// 拷贝构造函数
      Date(Date& d)
      {
        _year = d._year;
        _month = d._month;
        _day = d._day;
      }
  1. 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。

注意:在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。

例如下面这段代码,我们实现一个时间类和一个日期类,在日期类的成员变量中加入时间类的对象,即一个自定义对象,观察在拷贝构造的时候是否会调用它的拷贝构造;同时,我们的日期类没有写拷贝构造,我们也观察编译器生成的默认拷贝构造能否对内置类型完成浅拷贝:

// 时间类
    class Time
    {
    public:
      // 时间类的构造函数
      Time()
      {
        _hour = 1;
        _minute = 1;
        _second = 1;
      }
      // 时间类的拷贝构造
      Time(const Time& t)
      {
        cout << "const Time& t" << endl;
        _hour = t._hour;
        _minute = t._minute;
        _second = t._second;
      }
    private:
      int _hour;
      int _minute;
      int _second;
    };

上面是一个时间类,我们对它写了拷贝构造函数,下面在日期类中定义一个时间类的自定义类型,观察它是否调用时间类的拷贝构造函数:

// 日期类
    class Date
    {
    public:
      void Print()
      {
        cout << _year << '.' << _month << '.' << _day << endl;
      }
    private:
      // 内置类型
      int _year = 2023;
      int _month = 7;
      int _day = 21;
      // 自定义类型
      Time _t;
    };

执行的结果如下:

从结果我们可以看出,自定义类型确实调用了它自己的拷贝构造;并且我们没有显式写的拷贝构造,编译器默认生成的拷贝构造会对内置类型完成浅拷贝的工作。

  1. 类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝,会造成内存泄漏。

为什么说涉及到资源申请时一定要写拷贝构造呢?又怎么会发生内存泄漏呢?理由如下:

例如我们有一个栈的类如下:

class Stack
    {
    public:
      Stack(int capacity = 4)
      {
        _a = (int*)malloc(sizeof(int) * capacity);
        assert(_a);
        _top = 0;
        _capacity = capacity;
      }
      ~Stack()
      {
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
      }
    private:
      int* _a;
      int _top;
      int _capacity;
    };

但是我们没有对它写拷贝构造函数,现有以下程序:

void func(Stack s)
    {}
    int main()
    {
      Stack st;
      func(st);
      return 0;
    }

func(st) 在传参的过程中就是对 Stack 类调用拷贝构造的过程,但是我们没有对 Stack 类写拷贝构造,所以编译器默认生成的拷贝构造会完成浅拷贝的过程,具体过程如下图:

例如上图所示,func 中的 _a 和 main 中的 _a 指向了同一个空间,当 fun 函数结束时,编译器会调用析构函数对 func 函数中的临时拷贝对象进行析构,也就是说,_a 指针指向的空间被释放了,而这个空间同时也是 main 函数中对象 st 的成员变量 _a 的,当 main 函数也结束时,会再次调用析构函数对 st 对象进行析构,可是它的成员变量 _a 指向的空间已经被释放了,现在再次释放就是同一个空间释放两次,程序会崩溃。

所以我们应该自己写一个拷贝构造函数,例如:

// 拷贝构造函数
      Stack(const Stack& s)
      {
        // 深拷贝
        _a = (int*)malloc(sizeof(int) * s._capacity);
        assert(_a);
        memcpy(_a, s._a, sizeof(int) * s._top);
        _capacity = s._capacity;
        _top = s._top;
      }

其实我们对 _a 指针的处理就是深拷贝的问题,关于深拷贝问题我们以后会学习。

四、赋值运算符重载

1. 运算符重载

C++ 为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。其作用是使对象之间可以使用某种运算符,这种运算符需要我们重载。

假设我们实现的日期类要进行大小比较,是无法直接使用 >,<,= 进行比较的,因为这个类是我们实现的,编译器无法知道比较的规则,所以需要我们直接写。

函数名字为:关键字 operator 后面接需要重载的运算符符号。返回类型根据需要返回。

假设我们在日期类中重载一个 >== 的运算符:

// >
      bool operator>(const Date& d)
      {
        if (_year < d._year)
        {
          return false;
        }
        else if (_year == d._year && _month < d._month)
        {
          return false;
        }
        else if (_year == d._year && _month == d._month && _day < d._day)
        {
          return false;
        }
        return true;
      }
      // == 
      bool operator==(const Date& d)
      {
        return _year == d._year
          && _month == d._month
          && _day == d._day;
      }

如上就是两个运算符重载的示例,我们需要根据运算符的特性和类的需求实现运算符的重载,有了以上两个运算符重载,我们可以复用以上的代码,实现剩下的比较运算符,例如 <,!=,>=,<= :

// >=
      bool operator>=(const Date& d)
      {
        return ((*this) > d) || ((*this) == d);
      }
      // <
      bool operator<(const Date& d)
      {
        return !(*this >= d);
      }
      // <=
      bool operator<=(const Date& d)
      {
        return (*this < d) || (*this == d);
      }
      // != 
      bool operator!=(const Date& d)
      {
        return !((*this) == d);
      }

我们还可以实现一些对日期类有意义的运算符,例如我需要知道 x 天以后的日期,或者 x 天以前的日期,其实就是对 +=,+,-=,- 运算符的重载;

假设我们从现在的时间开始加 100 天,首先我们得知道这个月一共有几天才可以进行加减,还需要判断闰年非闰年的二月,所以我们需要写一个函数判断每个月的天数,代码如下:

// 获得月份天数
      int GetMonthDay(int year, int month)
      {
        static int Day[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
        if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)))
        {
          return 29;
        }
        return Day[month];
      }

首先我们定义了一个 static 的数组,因为这个函数有可能会被大量调用,每次调用都需要创建一个数组,效率会变低;这个数组就是每个月的天数,其中二月默认是 28 天,我们在下面判断如果是闰年并且是二月,再返回二月的 29 天。

然后我们实现 += 的运算符重载:

// 日期 += 天数
      Date& operator+=(const int day)
      {
        _day += day;
        while (_day > GetMonthDay(_year, _month))
        {
          _day -= GetMonthDay(_year, _month);
          _month++;
          if (_month > 12)
          {
            _year++;
            _month = 1;
          }
        }
        return *this;
      }

我们直接先让日期中的天数直接加上需要加的天数,再判断这个日期的天数是否符合这个月的天数,不符合的话就减去这个月的天数,然后月份+1,+1后还要判断月份是否合理,如果大于十二月,年份就+1,月份置为一月,逻辑就如上;

我们可以根据上面的逻辑再实现一些运算符的重载,例如以下:

// 日期 + 天数
      Date operator+(const int day)
      {
        Date tmp(*this);
        tmp += day;
        return tmp;
      }

注意,日期 + 天数的重载是不改变日期本身的,所以返回的时一个日期 + 天数之后的临时变量,所以这里的返回也不可以用引用返回。

// 日期 -= 天数
      Date& operator-=(const int day)
      {
        _day -= day;
        while (_day <= 0)
        {
          --_month;
          if (_month < 1)
          {
            _month = 12;
            --_year;
          }
          _day += GetMonthDay(_year, _month);
        }
        return *this;
      }
      // 日期 - 天数
      Date operator-(const int day)
      {
        Date tmp(*this);
        tmp -= day;
        return tmp;
      }

注意,C++ 在对前置++和后置++进行运算符重载的时候进行了特殊处理以便进行区分,前置++括号内没有参数类型,而后置++在括号内添加了一个参数类型,如下:

// 前置++
      Date& operator++()
      {
        (*this) += 1;
        return *this;
      }
      // 后置++
      Date operator++(int)
      {
        Date tmp(*this);
        ++(*this);
        return tmp;
      }

前置- -和后置 - - 也类似:

// 前置--
      Date& operator--()
      {
        (*this) -= 1;
        return *this;
      }
      // 后置--
      Date operator--(int)
      {
        Date tmp(*this);
        --(*this);
        return tmp;
      }

我们还可以重载一个日期 - 日期的运算符重载,以便知道两个日期相差的天数,代码如下:

// 日期 - 日期
      int operator-(const Date& d)
      {
        Date max = *this;
        Date min = d;
        int n = 0;
        int flag = 1;
        if (*this < d)
        {
          max = d;
          min = *this;
          flag = -1;
        }
        while (min != max)
        {
          ++min;
          ++n;
        }
        return n * flag;
      }

想要求出两个日期相差的天数,我们先假设第一个日期是大的那个,即 *this ,小的是 d;如果假设错了就换过来,并且用一个 flag 变量控制正负;然后使用一个计数变量 n ,统计两个日期的相差天数,即两个日期不相等,就让小的那个 ++ ,然后 n++ 统计。

.*::sizeof?:. 注意以上5个运算符不能重载。

假设我们需要将一个对象赋值给另外一个对象呢,所以这里就引入我们的赋值运算符重载。

2. 赋值运算符重载

(1)赋值运算符重载格式

  • 参数类型:const T&,传递引用可以提高传参效率
  • 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
  • 检测是否自己给自己赋值
  • 返回*this :要复合连续赋值的含义

日期类的赋值运算符重载代码如下:

// 赋值运算符重载
      Date& operator=(const Date& d)
      {
        // 判断是否是给自己赋值
        if (*this != d)
        {
          _year = d._year;
          _month = d._month;
          _day = d._day;
        }
        return *this;
      }

这里返回类型 Date& 是为了支持连续赋值,例如 d1 = d2 = d3;而且出了作用域 *this 还在,所以可以使用引用返回。

(2)赋值运算符重载也是类的默认成员函数,我们不显式写,编译器也会默认生成一个,和拷贝构造一样,编译器默认生成的赋值运算符重载对内置类型进行值拷贝(浅拷贝),对自定义类型调用它自己的赋值运算符重载。

class Time
    {
    public:
      Time()
      {
        _hour = 1;
        _minute = 1;
        _second = 1;
      }
      // 时间类的赋值运算符重载
      Time& operator=(const Time& t)
      {
        cout << "Time& operator=(const Time& t)" << endl;
        if (this != &t)
        {
          _hour = t._hour;
          _minute = t._minute;
          _second = t._second;
        }
        return *this;
      }
    private:
      int _hour;
      int _minute;
      int _second;
    };

例如上面是一个时间类,我们在下面日期类中定义一个时间类的自定义类型,观察它是否调用自己的赋值运算符重载:

class Date
    {
    private:
      // 基本类型(内置类型)
      int _year = 1;
      int _month = 1;
      int _day = 1;
      // 自定义类型
      Time _t;
    };
    int main()
    {
      Date d1;
      Date d2;
      d1 = d2;
      return 0;
    }

结果如下:

所以验证了结论是正确的。

但是既然编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝了,是不是我们只用编译器默认生成的就够了呢?对于日期类确实是的,但是对于 Stack 类,我们还是需要自己显式写的,具体的原因和拷贝构造的一样,参考拷贝构造函数。

(3)拷贝构造赋值运算符重载的区别:拷贝构造是一个已经存在的对象对一个创建的对象进行实例化,例如以下代码:

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

其中,Date d2 = d1; 是 d1 对 d2 进行拷贝构造,所以会调拷贝构造函数;其实它等价于 Date d2(d1);

赋值运算符重载是两个已经存在的对象进行赋值操作,例如以下代码:

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

其中,d1 和 d2 都是已经存在的两个对象,所以 d1 = d2; 会调用赋值运算符函数。

五、取地址及 const 取地址操作符重载

到目前为止我们已经学了类的四个默认成员函数,那么还有两个就是取地址及 const 取地址操作符重载,为什么取地址操作符重载分为 const 和非 const 类型呢?下面带大家了解了解。

1. const 成员

我们将 const 修饰的 “成员函数” 称之为 const 成员函数,const 修饰类成员函数,实际修饰该成员函数隐含的 this 指针指向的对象,表明在该成员函数中不能对类的任何成员进行修改。

我们通常使用 const 修饰成员函数是用以下方式:

void Date::Print() const
    {
      cout << _year << "年" << _month << "月" << _day << "日" << endl;
    }

实际上,这种写法相当于下图:

因为函数名后面的 const 就是修饰 this 指针的。

那么为什么要有这种 const 修饰的成员函数呢?我们来看以下的代码:

class Date
    {
    public:
      Date(int year = 1, int month = 1, int day = 1)
      {
        _year = year;
        _month = month;
        _day = day;
      }
      void Print()
      {
        cout << _year << "年" << _month << "月" << _day << "日" << endl;
      }
    private:
      int _year; // 年
      int _month; // 月
      int _day; // 日
    };
    int main()
    {
      Date d1(2023, 7, 28);
      d1.Print();
      const Date d2(2023, 6, 28);
      d2.Print();
      return 0;
    }

上面的代码是一个日期类,我们只写了一个普通的 Print() 函数,让对象可以调用其打印数据,但是我们在主函数中创建了一个 const 修饰 d2 对象,我们观察是否可以调用 Print() 函数:

很明显不能调用,编译器报错了,因为对 d2 取地址后是一个 const Date* 类型,到 Print() 函数中的 this 指针却是 Date* 类型,很明显这是权限的放大的问题。

所以针对这个问题,我们引入了 const 修饰成员函数的问题,正确的代码应该是以下代码:

class Date
    {
    public:
      Date(int year = 1, int month = 1, int day = 1)
      {
        _year = year;
        _month = month;
        _day = day;
      }
      // 给非 const 对象调用
      void Print()
      {
        cout << _year << "年" << _month << "月" << _day << "日" << endl;
      }
      // 给 const 对象调用
      void Print() const
      {
        cout << _year << "年" << _month << "月" << _day << "日" << endl;
      }
    private:
      int _year; // 年
      int _month; // 月
      int _day; // 日
    };
    int main()
    {
      Date d1(2023, 7, 28);
      d1.Print();
      const Date d2(2023, 6, 28);
      d2.Print();
      return 0;
    }

我们观察是否能正常运行:

从上图可以看出,是可以正常运行的,说明 const 对象是调用了 const 修饰的成员函数。

那么是不是所有的成员函数都可以使用 const 修饰呢?并不是的,一般来说,只读函数可以加 const,只读函数就是内部不涉及修改成员的函数。 例如我们实现的日期类中,以下成员函数都可以使用 const 修饰:

//运算符重载
      bool operator==(const Date& d) const;
      bool operator!=(const Date& d) const;
      bool operator>(const Date& d) const;
      bool operator>=(const Date& d) const;
      bool operator<(const Date& d) const;
      bool operator<=(const Date& d) const;
      Date operator+(int day) const;
      Date operator-(int day) const;

注意,这里是函数的声明,如果成员函数要想被 const 修饰,我们不仅要在声明给 const ,函数的定义处也要给 const 修饰。

而以下几个都涉及内部修改成员的函数,就不适合用 const 修饰:

Date& operator+=(int day);
      Date& operator-=(int day);
      Date& operator++(); //前置
      Date operator++(int); //后置
      Date& operator--(); //前置
      Date operator--(int); //后置

2. 取地址及 const 取地址操作符重载

这两个默认成员函数相对于上面我们所学的其他四个,是非常简单的;这两个默认成员函数一般不用重新定义 ,编译器默认会生成。

编译器默认生成的一般就是以下的形式:

class Date
    {
    public:
      // 非 const 对象取地址运算符重载
      Date* operator&()
      {
        return this;
      }
      // const 对象取地址运算符重载
      const Date* operator&()const
      {
        return this;
      }
    private:
      int _year; // 年
      int _month; // 月
      int _day; // 日
    };

这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如不想让别人取到对象的地址,可以重载成以下形式:

// 非 const 对象取地址运算符重载
      Date* operator&()
      {
        return nullptr;
      }
      // const 对象取地址运算符重载
      const Date* operator&()const
      {
        return nullptr;
      }

如果不想让别人取到对象的地址,返回空指针即可。

最后,我们类的六大默认成员函数就学完啦!感觉有用的小伙伴帮忙点个赞吧~

预告:类和对象(下篇)将会是类和对象的最后一篇文章,我会和大家补充一下类和对象有关的知识,并会完善我们的日期类噢!~

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