C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第1张图片

文章约六万余字,篇幅较长,建议电脑端访问

在这里插入图片描述

文章目录

  • 一、前言
  • 二、构造函数
    • 1、概念
    • 2、特性
  • 三、析构函数
    • 1、概念
    • 2、特性
      • C与C++OJ题对比【手动挡与自动挡】
  • 四、拷贝构造函数【⭐】
    • 1、概念解析
    • 2、内置类型与自定义类型【调试观察】
    • 3、深入探究拷贝构造
    • 4、【浅拷贝】与【深拷贝】
    • 5、产生拷贝构造的三种形式
    • 6、人生赢家类 —— MyQueue
    • 7、实战演练 —— 日期计算器
      • ① 思路分析
      • ② 代码详解
      • ③ 运行测试 + 优化
      • ④ 整体代码展示
  • 五、赋值运算符重载
    • 1、运算符重载
      • ① 概念引入
      • ② 语法明细
      • ③ 练习巩固
      • ④ 代码展示
    • 2、赋值运算符重载
      • ① 语法说明及注意事项
      • ② 默认的赋值运算符重载
      • 注意:赋值运算符不能重载成全局函数!
  • 六、const成员函数
  • 七、取地址及const取地址操作符重载
  • 八、综合案例实战 —— Date日期类
    • 1、需求分析鸟瞰
    • 2、【第一模块】:默认成员函数实现
    • 3、【第二模块】:关系运算符重载
    • 4、【第三模块】:日期计算相关运算符重载
    • 5、【第四模块】:流插入和流提取的实现
      • ① 流插入<<
      • ② 流提取>>
      • ③ 内联函数与成员函数的优化
    • 6、整体代码展示
  • 九、总结与提炼

一、前言

学习了类和对象的封装后,了解了一个类的基本组成以及如何去实例化一个类,今天就让我们进到类的内部,来探一探类里面都有哪些东西

  • 首先看到下面这段代码,定义出一个栈后准备入栈三个元素
Stack st;

st.Push(1);
st.Push(2);
st.Push(3);
  • 但是一运行起来可以发现,程序却奔溃了,这是为什么呢?

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第2张图片

  • 仔细一想就发现好像是忘记Init()初始化了,加上之后就没有问题了

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第3张图片

  • 但是去调试一下又发现了问题,这栈都入完了,好像忘了Destroy(),时间久了便会造成【内存泄漏】

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第4张图片

  • 最后,加上Destroy()之后才是一个完整的从定义一个栈、到使用、销毁一个栈全过程

在这里插入图片描述


  • 看到这个栈,不由地想起我们之前做过的一道题力扣20 - 有效的括号。可以看到,在考虑一些特殊情况的时候判断括号不匹配就需要return false,但此时呢在return之前又要把定义出来的栈给销毁了才行,在力扣上是没关系,也不会给你保存内存泄漏的警告,不过为了【严谨性】,便需要考虑到这一点
bool isValid(char * s){
    ST st;
    InitStack(&st);
    while(*s)
    {
        //1.若为左括号,则入栈
        if((*s == '(')
        || (*s == '{') 
        || (*s == '['))
        {
            Push(&st,*s);
            ++s;
        }
        else    //2.若为有括号,则进行判断匹配
        {
            //若匹配到右括号后无左括号与之匹配,返回false
            if(StackEmpty(&st))
            {
            	DestoryStack(&st);
            	return false;
            }
                
            STDateType top = Top(&st);
            Pop(&st);
            if(top == '(' && *s != ')'
            || top == '{' && *s != '}'
            || top == '[' && *s != ']')
            {
                DestoryStack(&st);
                return false;
            }
            else
                ++s;
            
        }
    }

    //若在匹配完成后栈不为空,则表示还要括号没有完成匹配
    if(!StackEmpty(&st))
        return false;
    DestoryStack(&st);
    return true;
}

你是否发现若是我们要去使用一个栈的话不会忘了去往里面入数据或者是出数据,但是却时常会忘了【初始化】和【销毁】。这要如何是好呢

  • 在上一文的学习中,我们学习到了一个类中的一个东西叫做this指针,只要是在成员函数内部都可以进行调用。而且还知晓了C++中原来是使用this指针接受调用对象地址的机制来减少对象地址的传入,减轻了调用者的工作。这也是C++区别于C很大的一点
  • 那C++中是否还有东西能够替代【初始化】和【销毁】这两个工作呢?答案是有的,就是我们接下来要学习的【构造函数】和【析构函数

首先我们来整体地介绍一下这六个成员函数

【空类的概念】:如果一个类中什么成员都没有,简称为空类。

  • 对于一个空类来说,里面没有任何成员变量和成员函数,那它里面就真的是空的吗
class Date{}
  • 并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。

【默认成员函数概念】:用户没有显式实现,编译器会生成的成员函数称为默认成员函数

二、构造函数

1、概念

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

  • 例如下面这个Date类,有一个Init()函数可以用来初始化年月日,而下面的Date()就是一个构造函数,若是没有调用Init()函数进行初始化的话,这个构造函数就会被自动调用
class Date
{
public:
	void Init(int y, int m, int d)
	{
		_year = y;
		_month = m;
		_day = d;
	}

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

	void Print()
	{
		cout << "year:" << _year << endl;
		cout << "month:" << _month << endl;
		cout << "day:" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第5张图片

  • 通过调试便可以观察到,对于定义出来的两个对象d1d2,都会去自动调用构造函数,进行一个初始化操作,但是d2又去调用了Init()函数,便对年月日再度进行了一次初始化。打印结果之后可以发现二者的成员变量不同

2、特性

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

  • 从上面的演示可以看出,构造函数并不是用来开空间创建对象的,而是用来代替Init()函数。接下去我们来聊聊它的各种特性,深入化对其进行一个了解
  1. 函数名与类名相同
    • 这点是规定好的,所有构造函数的函数名均与类名相同
  2. 无返回值
    • 可以看出,对于构造函数而言是不存在返回值,和我们日常写的函数不同。不过要注意的一点是:没有返回值不是写void,而是什么都不用写
  3. 对象实例化时编译器自动调用对应的构造函数
    • 通过上面的调试相信你也可以看出,对于构造函数而言,只要你去定义了这个类的对象,那么就会自动调用它所对应的构造函数
  4. 构造函数可以重载【⭐】
  • 这一点我们来细讲一下,在C++函数重载一文讲到了一个函数要重载需要具备哪些条件。那对于构造函数来说,其实也是可以重载的,不仅仅限于上面的Date(),这只是最普通的一种无参默认构造
  • 那既然一个类可以有多个构造函数,那就是有多个初始化的方式,我们立马来试试看
Date()
{
	_year = 1;
	_month = 1;
	_day = 1;
}

Date(int y, int m, int d)
{
	_year = y;
	_month = m;
	_day = d;
}

Date d1;
Date d2(2023, 3, 22);
  • 通过调试观察就可以发现,不同的初始化方式会调用各自的构造函数,就和函数重载后的函数名修饰一样,编译器去进行了一个自动的匹配。此时我们就不再需要这个Init()函数了

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第6张图片

  • 但是呢,你不可以像下面这样去定义对象,同学看构造函数很像函数,所以也用调用函数的形式去调用构造函数。不过上面说到过了,对于构造函数而言是会自动调用的,不需要我们手动去调用,只需要考虑重载的形式传入不同的参数即可
Date d1;
Date d2(2023, 3, 22);
Date d3();
  • 可以看到,像d3()这样去写就会产生歧义,编译器会认为这是一个函数的声明,Date会被它当做是一一个返回值来看待,()会被它当做是一个函数调用符。所以这点是要注意的

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第7张图片

谈到产生歧义这一块,再来给读者扩展一个知识点,既然讲到了【函数重载】,就来顺便说说【缺省参数】吧

  • 仔细观察着两个构造函数,是否可以将它们做一个整合呢,就用到我们前面所学的缺省参数
//Date()
//{
//	_year = 1;
//	_month = 1;
//	_day = 1;
//}

//Date(int y, int m, int d)
//{
//	_year = y;
//	_month = m;
//	_day = d;
//}
Date(int y = 1, int m = 1, int d = 1)
{
	_year = y;
	_month = m;
	_day = d;
}
  • 那这样我们传参的时候就很灵活了,若是不传对应的参数就按照缺省值来进行初始化

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第8张图片

  • 但是不可以像下面这样,【默认无参构造】和【全缺省有参构造】是不可以同时存在的,会产生歧义
  • 因为在没有给到初始值的时候编译器不知道使用哪个作为默认的初始值,所以【存在多个默认构造函数】

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第9张图片

  • 所以为了不产生这样的歧义,一般这两个构造函数不会同时存在,我们会选择下面这种全缺省的构造函数,可以代替无参的构造
  1. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成
  • 通过如下图就可以看出,若是我将自己写的构造函数全部够注释掉之后,那【显式定义】的构造函数就不存在了,此时我们编译一下可以发现没有报错,因为在这里编译器自动去调用了类内默认生成的构造函数

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第10张图片

  • 这里通过调试观察不出是否有调用【默认生成的构造函数】,但是我们可以去打印一下看看这三个成员函数是否又被初始化

在这里插入图片描述

  • 但是通过观察可以发现,怎么都是一些随机值,真的有初始化了吗?这就要涉及到构造函数的第六个特性了
  1. 对于类中的内置类型成员(int、char、double)来说,不会做初始化处理;对自定义类型成员(class、struct、union),对调用它的默认构造函数【⭐】
    • 上面通过打印可以发现,年、月、日似乎并没有初始化成功,而是三个随机值,那根据我们的经验来看,若是一个变量是随机值的话,那一定就是刚刚定义出来,还没有初始化的样子。但上面不是讲到若是自己不写的话会自动调用编译器生成的默认构造函数吗?这又怎么解释呢?
    • 其实这是C++的一个缺陷,不知道是什么原因,可能是祖师爷本贾尼当时当时在设计语法特性的时候忽略了这一点,所以在我们后辈进行学习的时候就造成了很多的困惑

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第11张图片

  • 在C++中呢,那数据类型分为了两种,一种是【内置类型】,另一种则是【自定义类型】。对于内置类型来说就是语言原生就支持的int、char、double..甚至是指针;自定义类型就是class、struct、union..对于默认生成的构造函数可能是没有设计好,对【自定义类型】会做初始化处理,但是对于【内置类型】不会做初始化处理

在这里插入图片描述

  • 所以对于上面的这三个成员函数,都是属于【内置类型】的,均不会做处理。本来按照我们的正常思维来说,因为不写默认构造会去调用编译器自动生成的那个,把内置类型的成员初始化为0,把自定义类型的用构造函数进行初始化,这样就好理解很多,但是呢因为语法设计这一块的问题,就导致了问题的出现

所以为什么说C++那么难学,就是因为C++的语法错综复杂,很多初学者在看到这一幕之后就觉得很奇怪,去网上找问老师又没人给他说得通,于是就带着这个疑惑学下去了,后面也遇到类似的错误,还是这样模棱两可的,便从入门到放弃╮(╯▽╰)╭

  • 所以对于像日期类这样的,拥有内置类型的成员,我们不要用编译器自动生成的,就会导致随机值的现象。难道这个默认生成的构造函数就一点用都没有了吗?还记得之前做过的用栈实现队列吗?
  • 来看看下面的这两个CreateFree函数,可以看到分别调用了我们使用C语言实现的InitStack()DestroyStack()来初始化和销毁【stIn】和【stOut】。如果你使用C语言做了这道题的话一定会感觉这很麻烦现在我们使用C++的类来试试
MyQueue* myQueueCreate() {
    MyQueue* qu = (MyQueue *)malloc(sizeof(MyQueue));
    InitStack(&qu->stIn);
    InitStack(&qu->stOut);

    return qu;
}
void myQueueFree(MyQueue* obj) {
    DestroyStack(&obj->stIn);
    DestroyStack(&obj->stOut);

    free(obj);
}
  • 下面是用C++进行实现,把MyQueue封装成为一个类,【入栈】和【出栈】分别作为成员函数。对于这个Stack而言是我用C++实现的一个栈,它算做是一个内置类型,所以会使用默认的构造函数进行初始化【STL中可以直接用stack】
class MyQueue {
public:
	void push(int x) {
		//..
	}
	//...

	Stack _pushST;
	Stack _popST;
};
  • 我们通过调试来看看编译器是否有进行一个初始化

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第12张图片

  • 可以看出此时我在MyQueue中没有写任何的构造函数,但是这个两个自定义类型依旧调用了Stack的构造函数进行了初始化。从中可以看出这个编译器自动生成的默认构造函数还是有点用的
  • 不仅如此,对于自定义类型的数据,还会去自动调用编译器默认生成的析构函数,这个我们放到下一模块再细讲

不过你一定会想,内置类型不做初始化这一漏洞难道就这么让它放着吗?当然不会,在C++11中,对其进行了整改

  • C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值
  • 此时可以看到,当我们将成员变量设置了默认值之后,再去创建对象的时候然后打印其年月日就不会发生随机值的现象

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第13张图片

  • 再看到下面,若是显式去初始化了月和日,但是【年】不初始化的话也会按照给定的缺省值来

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第14张图片


那有同学问:若是成员变量是内置类型和自定义类型混搭呢?会发生什么?

class Time
{
public:
	Time()
	{
		_hour = 1;
		_minute = 1;
		_second = 1;
	}
	Time(const Time& t)
	{
		_hour = t._hour;
		_minute = t._minute;
		_second = t._second;
		cout << "Time::Time(const Time&)" << endl;
	}
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类中写任何构造函数,那么在实例化对象d的时候,就会去调用编译器默认自动生成的构造函数进行初始化
  • 通过调试可以看出对于自定义类型_t去调用了它的构造函数初始化了【year】、【month】、【day】 ,但是对于内置类型的成员却还是随机值,表明他们没有被初始化

在这里插入图片描述

  • 那此时我们去给一个默认值就可以了

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第15张图片

  1. 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个
    • 【编译器默认自动生成的构造函数】、【无参构造函数】、【全缺省构造函数】三者均为默认构造函数
    • 对于这三个而言只能存在一个,相信在看了上面的内容之后你也一定有所理解:若是自己写了构造函数,那么编译器自动生成的就没有了。而且无参构造和全缺省构造只能存在一个,这也是上面补充过的一点

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第16张图片


【总结一下】:

  • 构造函数是类中默认就带有的,不过日常自己在写一个类的时候尽量不要用默认生成的,最好是自己写一个,无参或者是缺省的都可以,但是不可以无参和全缺省共存,会引发歧义。
  • 若是使用默认生成的构造函数,会引发一些语言本身就带有的缺陷,【内置类型】的数据不会被初始化,还会是一个随机值;【自定义类型】的数据会调用默认构造函数(默认生成、无参、全缺省),若是不想看到随机值的话,可以参照C++11中的特性,在内置类型声明的时候就为其设置一个初始化值,便不会造成随机值的问题

对于构造函数这一块其实还有些内容,像初始化列表、explicit关键字,文章内容过长,后续放链接

三、析构函数

好,接下去我们就来讲讲类的第二大默认成员函数 —— 【析构函数】

1、概念

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

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

  • 这里有很重要的一点:对于析构函数来说是用来释放资源的,即当前对象内部的【成员变量】和【成员函数】这些。但是对象本身的销毁和析构函数没关系,例如下面的这个d这个局部对象,它是如何销毁的呢?学习函数栈帧一文之后就可以知道对于局部变量而言都是存放在它所在的函数栈帧中的,会随着栈帧的销毁而销毁
int main()
{
	Date d;
	return 0;
}
  • 下面就是Date类的析构函数,和构造函数很像,只需要在前面加上一个~即可
~Date()
{
	cout << "Date析构函数的调用" << endl;
	_year = 0;
	_month = 0;
	_day = 0;
}
  • 可以通过调试来看看析构函数是如何调用的【当调试指针走到return的时候按下F11】

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第17张图片

2、特性

认识了什么是析构函数,接下去我们来看看有关析构函数的特性

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

C与C++OJ题对比【手动挡与自动挡】

  • 对于析构来说,多数的特性和构造函数类似,这里便不再赘述,这里本小节主要通过上面学习的【构造函数】以及这里析构函数去实现这道OJ题目。也就是我们本文一开始提到的那题,现在学习C++中的构造和析构后是否可以做一个简化呢

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第18张图片

  • 当然我们不考虑STL中的stack,用自己实现的
bool isValid(char* s) {
	Stack st;
	while (*s)
	{
		//1.若为左括号,则入栈
		if ((*s == '(')
			|| (*s == '{')
			|| (*s == '['))
		{
			st.Push(*s);
			++s;
		}
		else    //2.若为右括号,则进行判断匹配
		{
			//若匹配到右括号后无左括号与之匹配,返回false
			if (st.Empty())
				return false;
			int top = st.Top();
			st.Pop();
			if (top == '(' && *s != ')'
				|| top == '{' && *s != '}'
				|| top == '[' && *s != ']')
			{
				return false;
			}
			else
				++s;
		}
	}

	//若在匹配完成后栈不为空,则表示还要括号没有完成匹配
	return st.Empty();
}
  • 通过对比可以发现,若是用C++去实现的话,因为有默认构造函数的存在,所以我们不需要去调用Init()函数进行手动初始化,接着很不同的一点就是这个DestroyStack(),因为有默认析构函数的存在,所以不需要去显式地写一个析构函数,就方便了许多

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第19张图片

  • 举个形象一点的生活小案例,如果有学过车的老铁应该都知道什么是【手动挡】和【自动挡】,对于手动挡来说需要踩离合换挡位,很多事情都要自己来完成,有过亲身经历的读者脑海中一定浮现了那个画面但是对于自动挡来说就不一样了,油门一给就可以走了,不需要考虑什么换挡的问题
  • 那你就可以把C语言当做是手动挡,像初始化、销毁这些事情都要自己来做;把C++当成是自动挡,很多事情编译器自动就给你弄好了,完全不需要担心

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第20张图片

  1. 关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器生成的默认析构函数,对自定类型成员调用它的析构函数
  • 对于这一点我们在上面讲到构造函数的时候细讲过,对于析构函数来说依旧适用,【内置类型】的成员,不会去进行析构,【自定义类型】的成员会去调用析构函数
  • 讲解构造函数时用到的Time和Date类,Time所定义的对象作为Date类的自定义成员变量,你认为此时会如何去销毁每个成员变量呢?
class Time
{
public:
	~Time()
	{
		cout << "~Time()" << endl;
	}
private:
	int _hour;
	int _minute;
	int _second;
};

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

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

调试结果如下:

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第21张图片

接下去我就来详细解释一下这个析构的流程~

  • 在main方法中我创建了一个Date的对象d,这个对象内部有4个成员变量,三个内置类型的变量_year、_month、_day以及一个自定义类型变量_t。对于内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可
  • 但是对于_t来说,属于Time的类所定义出来的对象,就要调用Time类的析构函数,但是在main函数中销毁Date类对象d的时候,是不可以直接调用Time类析构函数的,所以呢编译器会先去调用Date类的析构函数,不过Date类并没有显示地定义出一个析构函数,所以编译器会去调用【默认的析构函数】,目的是在其内部再去调用Time类的析构函数即当Date对象销毁的时候,编译器要保证其内部的自定义对象可以正确销毁

【总结一下】:

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

四、拷贝构造函数【⭐】

1、概念解析

接下去我们来谈谈类中的第三个天选之子 —— 【拷贝构造函数】,提前预警⚠,本模块在理解上会比较困难,建议多看几遍

在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第22张图片
那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?

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

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

  1. 拷贝构造函数是构造函数的一个重载形式
  2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
  3. 在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的

2、内置类型与自定义类型【调试观察】

  • 接下去我就依照上面这三点,先来见见拷贝构造函数
//全缺省构造函数
Date(int y = 2000, int m = 1, int d = 1)
{
	_year = y;
	_month = m;
	_day = d;
}
//拷贝构造函数
Date(Date d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}
int main(void)
{
	Date d1;
	Date d2(d1);	//调用形式
	return 0;
}
  • 上面这个Date(Date d)指的就是拷贝构造函数,Date d2(d1);便是它的调用形式,用已经定义出来的对象d1来初始化d2
  • 但是我们编译一下却看到报出了错误,说【Date类的复制构造函数不能带有Date类】,这是为什么呢?

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第23张图片

  • 但此时若是我将形参的部分加上一个引用&就可以编过了,这是为什么呢?

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第24张图片

可能上面的这种形式过于复杂了,我先用下面这两个函数调用的形式来进行讲解

  • 如果你看过我的C++引用详解这篇文章的话就可以知道对于Func1(d)来说叫做【传值调用】,对于Func2(d)来说叫做【传引用调用】
void Func1(Date d)
{
	cout << "Func1函数的调用" << endl;
}

void Func2(Date& d2)
{
	cout << "Func2函数的调用" << endl;
}
int main(void)
{
	Date d;

	Func1(d);
	Func2(d);
	return 0;
}
  • 通过下面的调试观察可以发现,Func1传值调用,会去调用Date类的拷贝构造函数,然后再调用本函数;但是Func2传引用调用,却直接调用了本函数
  • 这就源于我们之前讲过的,对于【传值调用】会产生一个临时拷贝,所以此时dd1的拷贝;对于【传引用调用】不会产生拷贝,此时dd1的别名;

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第25张图片

有同学说:这和我们之前学的不一样呀,之前都是像intchar这样的类型,难道规则也同样适用吗?

  • 这里就又要说到【内置类型】和【自定义类型】的区别了,这个我们在上面讲构造和析构的时候也有提到过。对于内置类型的数据我们知道只有4/8个字节,这个其实编译器直接去做一个拷贝就可以了;但是呢对于自定义类型的数据,编译器可没有那么大本事,需要我们自己去写一个拷贝构造函数,然后编译器会来调用

  • 其实你也可以写成像下面这种形式,是一个传址形式进行调用,对于Date*是一个对象指针,所以我们将其看做是内置类型

void Func3(Date* d)
{
	cout << "Func2函数的调用" << endl;
}
Func3(&d1);
  • 通过调试观察便可以发现对于这种调用形式也不会去调用Date类的拷贝构造函数

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第26张图片

  • 通过上面的观察我们来总结一下:对于内置类型(包括指针)/ 引用传值均是按照字节方式直接拷贝(值拷贝);对于自定义类型,需要调用调用其拷贝构造函数完成拷贝

但是在我这么对比分析之后,有位同学提出了下面的问题,这里来解答一下

为什么会存在拷贝构造?C语言中传递结构体也不需要拷贝呀?

  • 同学,要知道你现在学习的是C++,而不是C语言。对于C语言里面那种直接传参,不调用拷贝构造就去进行拷贝的形式是错误的,C++就是为了弥补这种错误才创造出了【拷贝构造函数】
  • 对于内置类型来说只有4/8个字节,直接进行值拷贝,是不会出现问题的

内置类型会直接值拷贝,值拷贝是啥?

  • 同学,你等会放学留一下
  • 对于值拷贝来说就是一个字节一个字节进行拷贝,你可以看成是我们在C语言中学习过的memcpy()。不管你是int、double还是结构体,我都是一个个字节给你拷过去的

3、深入探究拷贝构造

所以,通过上面的一系列观察和总结,基本带读者了解了什么是拷贝构造、如何去调用拷贝构造,接下去我们来深入地研究一下拷贝构造

  • 这个时候我们再来分析一下第二点特性,为什么说使用传值方式编译器直接报错,因为会引发无穷递归调用?
  • 刚才讲到过,对于自定义类型来说都会去调用拷贝构造,那此时我们转换回Date类的拷贝构造函数这里。通过下面的这张图其实你可以看出自定义类型的传值调用引发的递归问题是多么严重!

在这里插入图片描述

  • 通过Date d2(d1)需要实例化对象d2,所以要调用对应的构造函数,也就是拷贝构造函数,但是在调用拷贝构造函数之前要先传参,那刚才说了【自定义类型传参调用】就会引发拷贝构造,那调用拷贝构造就又需要传参数进来,传参数又会引发拷贝构造。。。于是就引发了这么一个无限递归的问题
  • 所以编译器就规定了对于拷贝构造这一块的参数不可以是【传值传参】,而要写成下面这种【传引用传参】的形式。此时d就是d1的别名,那因为是d2去调用的拷贝构造,此时this指针所接收的便是d2的地址,初始化的即为d2的成员变量
Date(Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

Date d2(d1);

不过对于上面这种拷贝构造的形式并不是很规范,一般的拷贝构造函数都写成下面这种形式

Date(const Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}
  • 那此时就会同学很疑惑,为什么要在前面加上一个const呢?这一点其实我们在模拟实现strcpy中其实也有说到过,有的时候你可能会不小心把代码写成下面这样
Date(Date& d)
{
	d._year = _year;
	d._month = _month;
	d._day = _day;
}
  • 通过调试观察可以发现。原本我们想通过对象d1来初始化对象d2,但此时在默认构造函数初始化完d1后,在调用拷贝构造初始化d2时,却将原本初始化好的d1也变成了和d2一般,可见发生了一个颠倒的问题

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第27张图片

  • 但若是将const加上后,编译器便报出了错误❌

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第28张图片

  • 因此可以看到,加上这个const之后,程序的安全性就得到了提升,这就是它的第一个作用①

它还有第二点作用,我们再来看看

  • 我在实例化这个d1对象的时候在前面加上了一个const,此时这个对象就具有常属性,不可以被修改,然后此时再去使用d1对象初始化d2对象会发生什么呢?
int main(void)
{
	const Date d1;
	Date d2(d1);

	return 0;
}
  • 可以看到,编译器报出了错误,说【没有匹配的构造函数】,其实这里真正的问题还是在于权限放大,这点我在引用一文中也重点讲解过,如果不懂的同学去看一看。
  • 本来这个d1对象被const所修饰具有常性,但是呢在将其当做参数传入给一个不具有常性的对象接收时,那么在拷贝构造函数内部便可以去修改这个对象的内容,也就造成了问题。不要以为这种问题不会发生,我们在写程序的时候一定要严谨,尽可能地考虑到多种情况

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第29张图片

  • 但是给形参加上const做修饰之后,便可以做到【权限保持】,此时程序的安全性又增加了↑

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第30张图片

小结一下,对于const Date& d这种不是做输出型参数,加上前面的const的好处在于

① 防止误操作将原对象内容修改
② 防止传入const对象造成【权限放大】

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

对于构造、析构来说我们在上面讲到了若是自己不写的话都会去自动调用编译器默认生成的,那对于拷贝构造也同样适用吗?

  • 此时我将上面所写的拷贝构造去除之后,再去进行一个拷贝的操作,通过下面的运行结果可以看出,d1和d2均完成了初始化操作,而且和构造函数不一样,对于内置类型也会去进行处理。其实在这里就是调用了编译器自动为我们生成的拷贝构造
//以下为有参构造
Date(int year = 2000, int month = 1, int day = 1)
{
	_year = year;
	_month = month;
	_day = day;
}

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第31张图片

内置类型会处理,那自定义类型呢?也会处理吗?

  • 此时我在Date类中声明了一个Time类的对象作为成员函数,并且去除了Date类中上面所写的【拷贝构造函数】,然后再用d1去初始化d2,你认为此刻会发生什么呢?
class Time
{
public:
	Time()
	{
		_hour = 1;
		_minute = 1;
		_second = 1;
	}
	Time(const Time& t)
	{
		_hour = t._hour;
		_minute = t._minute;
		_second = t._second;
		cout << "Time::Time(const Time&)" << endl;
	}
private:
	int _hour;
	int _minute;
	int _second;
};
class Date
{
public:
	//构造..
	//析构
private:
	int _year;
	int _month;
	int _day;
	
	Time _t;	//内置自定义类型的成员
};
  • 通过调试观察可以发现,即使是Date类中没有写拷贝构造函数,d2依旧是完成了初始化工作.这个Time类我们在说析构函数的时候有讲到过,那此时要去析构Date类中的自定义类型成员_t便要调用Time类的析构函数,但是要先调用编译器为Date类自动生成的析构函数,然后再去调用Time类的析构函数,此时自动生成的析构函数就派上了用场【忘记了再翻上去看看】
  • 既然构造、析构都可以自动生成,那么拷贝构造作为类的默认成员函数编译器也是会自动为我们生成。那么此时就会调用默认生成的拷贝构造去拷贝其内部自定义类型_t的时候就会去调用Time类的显式拷贝构造完成初始化工作

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第32张图片

因此对于像Date这种日期类来说,我们可以不用去自己去实现拷贝构造,编译器自动生成的就够用了,那其他类呢,像Stack这样的,我们继续来看看

4、【浅拷贝】与【深拷贝】

  • 继续延用我们上面所讲到过的Stack,而且没有写上拷贝构造函数,首先实例化出对象st1,往里面入栈了3个数据,接下去便通过st1去初始化st2,通过上面的学习可以知道会去调用编译器自动生成的【拷贝构造】来完成,不过真的可以完成吗?我们来运行一下试试
Stack st1;
st1.Push(1);
st1.Push(2);
st1.Push(3);

Stack st2(st1);
  • 不过运行一下就可以发现直接报出了异常,这是为什么呢?

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第33张图片

  • 其实,根本的原因就是在于我们要使用到数组栈,便要去内存中开辟一块空间,那么s1开辟了一块空间后_array就指向堆中的这块内存地址,接着s2去拷贝了s1,里面的数据是都拷贝过来了,但是s2的_array也指向了堆中的这块空间

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第34张图片

  • 那此时我去往s1里面push数据的之后,s2再去push,就会造成【数据覆盖的情况】。假设现在s1push了【1】、【2】、【3】,那么它的size就是3,但是s1与s2二者的size是独立的,不会影响,所以此时s2的size还是0,再去push【4】、【5】、【6】的话还是会从0的位置开始插入,也这就造成了覆盖的情况
    C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第35张图片

不仅如此,二者指向同一块数据空间还会造成其他的问题

  • 现在定义出来两个Stack对象,那此时我想问谁会先去进行析构呢?

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第36张图片

  • 揭晓一下,s2会先去析构,在进程空间章节我们有讲到过【栈】是进程地址空间里面的一块内容,原理都清楚是先进后出的,所以后实例化出的对象s2会先去进行一个析构的操作,接着再去析构对象s1。不过呢通过调试可以观察到s1和s2的_array都指向堆中的同一块空间,因此当s2去调用析构函数释放了这块空间后,那么s1对象的_array就已经是一个野指针了,指向了堆中的一块随机地址,那再去对这块空间进行析构的话就会出现问题⚠

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第37张图片
所以来总结一下指向同一块空间的问题

  1. 插入删除数据会互相影响
  2. 析构两次会造成程序的奔溃

那要如何去解决这个问题呢?此时就要涉及到【深拷贝】了

调用编译器自动为我们生成的拷贝构造函数去进行拷贝的时候会造成【浅拷贝】的问题,那什么又叫做深拷贝呢?

  • 因为浅拷贝是原封不动地拷贝,会使得两个指针指向同一块空间,那若是我们再去自己申请一块空间来使用,让两个对象具有不同的空间,此时便不会造成上面的问题了

接下去我就来实现一下如何去进行【深拷贝】

Stack(const Stack& st)
{
	//根据st的容量大小在堆区开辟出一块相同大小的空间
	_array = (DataType *)malloc(sizeof(DataType) * st._capacity);
	if (nullptr == _array)
	{
		perror("fail malloc");
		exit(-1);
	}

	memcpy(_array, st._array, sizeof(DataType) * st._size);		//将栈中的内容按字节一一拷贝过去
	_size = st._size;
	_capacity = st._capacity;
}
  • 可以看到,对于深拷贝而言,就要去自己再去手动申请一块空间,然后将原先栈中的内容使用memcpy()一一拷贝过来,此时两个对象中的_array就指向了堆中两块不同空间,那么各自去进行入栈出栈的话就不会造成上述的问题了

在这里插入图片描述

  • 而且两块空间是独立的,所以在对象进行析构的时候也不会造成二次析构的问题
  • 通过调试便可以观察到,在我们手动进行深拷贝之后,就不会造成上述的种种问题了

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第38张图片
但是这样自己去写拷贝构造感觉很麻烦诶,哪些类需要这样去深拷贝呢?

  • 你可以观察在当前这这个类中是否存在显式的析构函数,若是存在的话,表示当前这个类涉及资源管理了【资源管理指得就是去堆中申请空间了】,此时你一定要自己去是实现拷贝构造以达到一个深拷贝;若是不涉及资源管理的话,直接使用编译器自动生成的进行浅拷贝就可以了
  • 像Date日期类这种只存在【年】、【月】、【日】这种内置类型的浅拷贝就可以了;像是复杂一些的,例如:链表、二叉树、哈希表这些都会涉及资源的管理,就要考虑到深拷贝

5、产生拷贝构造的三种形式

深刻理解了拷贝构造之后,我们再来看看产生拷贝构造的三种形式

  1. 当用类的对象去初始化同类的另一个对象时
Date d1;
Date d2(d1);
Date d3 = d2;	//也会调用拷贝构造
  • 可以看到在实例化对象d2和d3的时候都去调用了拷贝构造,最后它们初始化后的结果都是一样的

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第39张图片


  1. 当函数的形参是类的对象,调用函数进行形参和实参结合时
void func(Date d)	//形参是类的对象
{
	d.Print();
}

int main(void)
{
	Date d1;
	func(d1);	//传参引发拷贝构造
	
	return 0;
}
  • 函数func()的形参是类的对象,此时在外界调用这个函数并传入对应的参数时,就会引发拷贝构造,通过调试观察一清二楚。而且当func函数执行结束时,内部的形参对象d就会随着当前栈帧的销毁而去调用【析构函数】。到那时在外界为何又去调了一次析构呢?外界也就是main函数栈帧中的对象d1,是作为实参进行传递的,出了main函数的栈帧当前也需要调用【析构函数】进行销毁

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第40张图片


  1. 当函数的返回值是对象,函数执行完成返回调用者时
Date func2()
{
	Date d(2023, 3, 24);
	return d;
}

int main(void)
{
	Date d1 = func2();
	d1.Print();

	return 0;
}
  • 可以看到,这一种方式也会引发拷贝构造,当函数内部返回一个Date类的对象时,此时外界再使用Date类型的对象去接收时,就会引发拷贝构造。通过调试可以观察到外界的对象d1确实是以函数内部通过有参构造初始化完后的对象进行拷贝的

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第41张图片

6、人生赢家类 —— MyQueue

再来看一个很经典的类MyQueue,我在前面说构造函数中自定义类型的时候也有讲到过

typedef int DataType;
class Stack
{
public:
	Stack(size_t capacity = 10)
	{
		cout << "Stack()构造函数调用" << endl;
		_array = (DataType*)malloc(capacity * sizeof(DataType));
		if (nullptr == _array)
		{
			perror("malloc申请空间失败");
			return;
		}
		_size = 0;
		_capacity = capacity;
	}

	void CheckCapacity()
	{
		if (_size == _capacity)
		{
			DataType* tmp = (DataType*)realloc(_array, sizeof(DataType) * _capacity * 2);
			if (nullptr == tmp)
			{
				perror("fail realloc");
				exit(-1);
			}
			_array = tmp;
			_capacity = _capacity * 2;
		}
	}

	void Push(const DataType& data)
	{
		CheckCapacity();
		_array[_size] = data;
		_size++;
	}

	Stack(const Stack& st)
	{
		cout << "Stack()拷贝构造函数调用" << endl;
		//根据st的容量大小在堆区开辟出一块相同大小的空间
		_array = (DataType*)malloc(sizeof(DataType) * st._capacity);
		if (nullptr == _array)
		{
			perror("fail malloc");
			exit(-1);
		}

		memcpy(_array, st._array, sizeof(DataType) * st._size);		//将栈中的内容按字节一一拷贝过去
		_size = st._size;
		_capacity = st._capacity;
	}

	~Stack()
	{
		cout << "~Stack()析构函数调用" << endl;
		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}
private:
	DataType* _array;
	size_t _size;
	size_t _capacity;
};
class MyQueue{
public:
	//默认生成构造函数
	//默认生成析构函数
	//默认生成拷贝构造函数

private:
	size_t _t = 1;
	Stack _pushST;
	Stack _popST;
};
  • 通过下面的调试观察可以发现,这个MyQueue类简直就是捡漏王啊,仅仅是使用编译器默认生成的【构造】、【析构】、【拷贝构造】就可以就可以完成所有的工作

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第42张图片
C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第43张图片
C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第44张图片

7、实战演练 —— 日期计算器

讲了这么多有关拷贝构造的内容后,那它在现实的场景中有什么用呢?接下去我就带你来做一个【日期计算器】

  • 我们平常在面临一些重大的事情时都去翻看日历计算距离现今还剩多少天,但其实有这么一个东西却可以起到非常便捷的效果,那就是【日期计算器】,使用它可以立即计算出多少天后的日期、两个日期之间差多少天
  • 本模块我们就通过拷贝构造函数来简单地实现一下第一个功能 —— 计算几天后的日期
    C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第45张图片
    C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第46张图片

① 思路分析

首先来分析一下思路要如何去进行实现

  • 我们要去计算一下从今天开始计算往后100天是几月几日,其实就是做加法运算,加法是满10进位,但对于日期来说不一样,满30天或者是31天就会发生进位。不仅如此还要平/闰年的二月份是28/29天这种情况
  • 所以先考虑地简单一些,算一算50天后是几月几日,首先用当日的天数加上50,我们就可以得到一个数,此时若 > 当前月份的总天数,就要产生进位,对于月份来说可以单独作为一块逻辑去进行进行实现。首先将加好后的天数减去当前月的天数,然后进位到下一个月,若是这个天数还大于当前月的天数,那又要发生进位,所以这段路逻辑可以放到一个循环中去实现,直到天数小于当前所在月的天数时,就停止进位。【一些细节部分的说明见代码】

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第47张图片

② 代码详解

  • 首先在类中写这样一个成员函数,叫做GetAfterXDay,确定要传入的参数为多少天后的天数 ,返回值类型是一个Date类。然后在外界实例化出一个Date类的对象,调用有参构造进行初始化,通过这个对象去调用类中的日期计算函数,然后将其返回值给到一个日期类的对象做接收
Date GetAfterXDay(int x)
Date d(2023, 3, 25);
Date d1 = d.GetAfterXDay(150);
  • 那一进入函数就先把天数做一个累加_day指得就是当前调用这个函数的日期实例类对象
_day += x;
  • 接下去就要判断加上这个天数后的_day是否超出了当前月的天数,但当前月的天数我们要先去计算出来,这里我又单独封装了一个函数
  • 在求解当前月的天数时,需要传入年份和当前月份,对于一年当中的每个月份天数,我使用一个数组进行保存,这里的数组大小定义为13,把第0个月空出来,后面正好就是1 ~ 12月份,然后通过当前传入的月份去定位即可,但是要考虑到2月比较特殊,有平年和闰年之分,这里单独做一下判断即可
int GetMonthDay(int year, int month)
{
	assert(month > 0 && month < 13);
	int monthArray[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;		//2月闰年29日天
	}
	else
	{
		return monthArray[month];
	}
}
  • 下面是月份进位的逻辑,首先是要减去计算出来的当前月天数,然后把_month++,若是月份加上去之后超过12月了,此时年份就要产生进位,然后月份重新置为1
_day -= GetMonthDay(_year, _month);		//先把这个月的天数减去
_month++;				//月份进位
if (_month == 13)		//若是月份加到13月了
{
	_year++;			//年产生进位
	_month = 1;			//月份置为1
}
  • 将上面这段逻辑套在下面这个循环中,直到_day的天数小于当前月的天数为止停止进位【this代表当前对象,可加可不加】
while (_day > GetMonthDay(this->_year, this->_month))
  • 最后,将累加完后的结果return即可,那return什么呢?
  • 你以为我上面的this->是白写的吗,就是为了告诉你修改的都是当前调用对象的成员变量,那么this指向这个对象,*this指的就是这个对象本身,return它就可以了
return *this;		//this指向这个对象,*this就是这个对象

③ 运行测试 + 优化

接下去我们就用上面写的这段逻辑去试试是否可以运行

  • 可以看到,确实可以将计算出100天后的日期,与在线日期计算器的结果是一样的

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第48张图片

  • 那此时若我想去计算一个200天后几月几号呢,由运行结果和在线日期计算器进行对比可以发现似乎计算得不太准确,这200天后怎么都跑到2024年去了

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第49张图片

  • 此时我通过去修改了一下开始时间,改为一开始计算出来的100天后的日子就发现200天后就是2024年1月19日

在这里插入图片描述

  • 然后我又通过调试的手段去观察就发现原来d在调用GetAfterXDay计算天数后进行返回自身发生了修改,所以就导致在第二次计算的时候初始时间的不对

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第50张图片

  • 也可以通过成员函数中的this指针所接受的当前对象进行判别

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第51张图片
那此时就有同学问:这该如何是好呀!d自己都被修改了

  • 其实学得扎实的同学已经看出来了,当前对象发生了修改,那我们不要让他发生修改不就好了吗?其实在分步讲解代码的时候已经有提到了,函数中修改的是当前调用的对象即d,那么此时便可以使用到本模块所学的【拷贝构造函数】了
  • 其实上面的一切都是在为这个做铺垫,Date tmp(*this)得到了一份当前对象的临时拷贝,那么对于下面的所有操作,我们都修改【tmp】即可,最后也是将tmp进行返回,便可以获取到增加后的日期
Date GetAfterXDay(int x)
{
	Date tmp(*this);	//做一份当前对象的临时拷贝

	tmp._day += x;
	while (tmp._day > GetMonthDay(tmp._year, tmp._month))		//若是加完后的天数 > 这个月的天数
	{
		tmp._day -= GetMonthDay(tmp._year, tmp._month);		//先把这个月的天数减去
		tmp._month++;				//月份进位
		if (tmp._month == 13)		//若是月份加到13月了
		{
			tmp._year++;			//年产生进位
			tmp._month = 1;			//月份置为1
		}
	}
	return tmp;		//返回临时修改的对象
}
  • 通过上面的学习,可以分析出这里会产生两次【拷贝构造】,一次是在临时对象tmp被拷贝出来的时候,一次便是return tmp的时候。我们通过调试来进行观察

你以为这样就完了吗,接下去我要拓展一些下个模块的知识 ——【赋值运算符重载】 ,不过只是一些雏形而已

  • 在C语言操作符章节,我们有讲到过++=这两个操作符,前者在运算之后不会改变,后者在运算之后会发生改变,也就是在自身做一个加法运算

看了上面的这段话,再结合这个日期计算,你是否能想到我要讲什么呢?

  • 没错,对于一开始直接对当前对象进行的操作就是+=,调用对象自身会受到影响,但是后面;后面我们所做的优化修改就是+,调用对象自身不会受到影响
  • 因为还没有见到赋值运算符重载,所以我会使用【Add】和【AddEqual】这两个函数名进行修饰,其本质和++=是一个道理
//+ —— 自身不会改变
Date Add(int x){}

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第52张图片

//+= —— 改变自身
Date AddEqual(int x){}

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第53张图片

其实对于上面的【AddEqual】还可以去进行一个优化

  • 继续调试观察可以发现,无论是Add还是AddEqual在返回的时候都引发拷贝构造,那在学习了【引用返回】的知识后,你是否可以再去取进行一个优化呢

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第54张图片
C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第55张图片

  • 对于AddEqual,我做了如下改变,将Date改成了Date&,相信学得扎实的同学一定知道我为何去这样做,因为AddEqual返回的是*this,也就是当前对象,那对于当前对象来说出了作用域是不会销毁的,那我们便可以使用引用返回去减少一次拷贝;但是呢对于Add来说返回的是tmp,也就是我们通过拷贝得到的一个临时对象,出了作用域会销毁,此时不可以使用引用返回,否则很可能在外界接收的就是一个随机值
Date& AddEqual(int x)
Date Add(int x)
  • 通过观察便可以看到,到我在return的时候按下F11时便不会进入到当前类的拷贝构造中,那也就表明减少了一次拷贝的过程

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第56张图片

更多的内容在【综合案例 —— Date日期类】的实现中还会细讲,继续看下去吧

④ 整体代码展示

Date类

class Date
{
public:
	//3.全缺省构造函数
	Date(int y = 2000, int m = 1, int d = 1)
	{
		_year = y;
		_month = m;
		_day = d;
	}

	Date(const Date& d)		//权限保持
	{
		//cout << "Date拷贝构造的调用" << endl;
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
	
	//获取当前年这一月的天数
	int GetMonthDay(int year, int month)
	{
		assert(month > 0 && month < 13);
		int monthArray[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;		//2月闰年29日天
		}
		else
		{
			return monthArray[month];
		}
	}

	//+ —— 自身不会改变
	Date Add(int x)
	{
		Date tmp(*this);	//做一份当前对象的临时拷贝

		tmp._day += x;
		while (tmp._day > GetMonthDay(tmp._year, tmp._month))		//若是加完后的天数 > 这个月的天数
		{
			tmp._day -= GetMonthDay(tmp._year, tmp._month);		//先把这个月的天数减去
			tmp._month++;				//月份进位
			if (tmp._month == 13)		//若是月份加到13月了
			{
				tmp._year++;			//年产生进位
				tmp._month = 1;			//月份置为1
			}
		}
		return tmp;		//返回临时修改的对象
	}

	//+= —— 改变自身
	Date& AddEqual(int x)
	{
		_day += x;
		while (_day > GetMonthDay(_year, _month))		//若是加完后的天数 > 这个月的天数
		{
			_day -= GetMonthDay(_year, _month);		//先把这个月的天数减去
			_month++;				//月份进位
			if (_month == 13)		//若是月份加到13月了
			{
				_year++;			//年产生进位
				_month = 1;			//月份置为1
			}
		}
		return *this;		//返回临时修改的对象
	}

	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

	~Date()
	{
		//cout << "Date析构函数的调用" << endl;
		_year = 0;
		_month = 0;
		_day = 0;
	}
private:
	int _year;
	int _month;
	int _day;
};

测试

void AddTest()
{
	Date d(2023, 3, 25);
	Date d2 = d.Add(100);

	d.Print();
	d2.Print();
}

void AddEqualTest()
{
	Date d(2023, 3, 25);
	Date d2 = d.AddEqual(100);

	d.Print();
	d2.Print();
}

int main(void)
{
	//AddTest();			//25 + 100 = 125
	AddEqualTest();		//25 += 100
	return 0;
}

【总结一下】:

  • 拷贝构造算是六大默认成员函数中较难理解的了,用了近万字来详解。主要就是要理清【内置类型】和【自定义类型】是否会调用拷贝构造的机制。还有在实现这个拷贝构造时要主要的两点:一个就是在形参部分要进行引用接收,否则会造成无穷递归的现象;还有一点就是在前面加上const进行修饰,可以防止误操作和权限放大的问题
  • 【浅拷贝】和【深拷贝】是拷贝构造里最经典也是最难懂的一块,若是像Date日期类这样的用编译器默认生成的进行浅拷贝(值拷贝)就可以了;但若是涉及到资源管理例如像Stack这样的记住一定要自己去是实现拷贝构造防止浅拷贝的问题
  • 最后又讲到了拷贝构造发生的三种形式,每个案例都做了调试分析,读者也可自己去试着调试看看,观察什么时候调用【构造函数】、【拷贝构造函数】和【析构函数】

拷贝构造这一块还存在编译器的优化,后续也以链接的形式附上

五、赋值运算符重载

1、运算符重载

① 概念引入

  • 上面呢我们都是对一个日期进行初始化、销毁、加加减减等操作,现在若是我要去比较一下两个日期,该怎么实现呢?
Date d1(2023, 3, 25);
Date d2(2024, 3, 25);
  • 可能你会想到直接这么去写,但是编译器允许吗?很明显它完全实现不了这样的比较

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第57张图片

  • 于是就想到了把他们封装成为一个函数来进行实现
//等于==
bool Equal(const Date& d1, const Date& d2)
{
	//...
}

//小于<
bool Less(const Date& d1, const Date& d2)
{
	//...
}

//大于>
bool Greater(const Date& d1, const Date& d2)
{
	//...
}
Equal(d1, d2);
Less(d1, d2);
Greater(d1, d2);
  • 但是,你认为所有人都会像这样去仔细对函数进行命名吗,尤其是打一些算法竞赛的。它们可能就会把函数命名成下面这样

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第58张图片

  • 甚至是下面这样

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第59张图片

若是每个函数都是上面这样的命名风格,那么调用的人该多心烦呀╮(╯▽╰)╭

  • 如果我们不用考虑函数名,可以直接用最直观的形式也就是一开始讲的那个样子去进行调用的话该多好呀

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第60张图片

  • 但是呢编译器不认识我们上面所写的这种形式,之前我们去比较两个数的大小或者相等都是int、char、double这些【内置类型】的数据,对于这些类型是语法定义的,语言本身就已经存在了的,都将它们写进指令里了
  • 不过对于【自定义类型】而言,是我们自己定义的类型,编译器无法去进行识别,也无法去比较像两个日期这样的大小,所以对于自定义类型而言,在于以下两点
  1. 类型是你定义的,怎么比较,怎么用运算符应该由你来确定
  2. 自定义类型不一定可以加、减、乘、除,像两个日期相加是毫无意义的,相减的话还能算出他们之间所差天数【日期类中会实现日期加一个天数】

② 语法明细

基于上述的种种问题,C++为了增强代码的可读性引入了运算符重载

概念】:运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似

函数名字】:关键字operator后面接需要重载的运算符符号

函数原型】:返回值类型 operator操作符(参数列表)

  • 根据上面的语法概念,就可以写出==的运算符重载函数
bool operator==(const Date& d1, const Date& d2)

注意事项:

接下去我便通过几点注意实现来带你进一步了解运算符重载

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

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第61张图片

  1. 重载操作符必须有一个类类型(自定义类型)参数

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第62张图片

  1. 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义
  • 可以看到,我重载了运算符+,内部实现了一个乘法运算,然后用当前对象的月数 * 10,最终改变了+运算符的含义,这种语法虽然是可以编译过的,但是写出来毫无意义,读者可以了解一下

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第63张图片

  1. 运算符重载可以放在全局,但是不能访问当前类的私有成员变量

    • 可以看到,虽然运算符重载我们写出来了,但是在内部调用当前对象的成员 时却报出了错误,说无法访问private的成员,那此时该怎么办呢?

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第64张图片
解决办法1:去掉[private],把成员函数全部设置为公有[public]

解决办法2:提供共有函数getYear()getMonth()getDay()

解决办法3:设置友元【不好,会破坏类的完整性】

解决办法4:直接把运算符重载放到类内

  • 对于二、三两种解决方案暂时先不考虑,最直观的就是第一种方式,我们可以先来试试运行一下

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第65张图片

  • 但是这样不好看运行的结果,函数的返回值是一个bool类型,将其输出一下看看
  • 但是可以看到<<这个操作符似乎出现了什么问题,这就是因为<<的优先级比==来得高,所以会优先执行cout << d1,那么中间的==就不知道和谁结合了,因此出了问题。所以在运算符重载之后我们还要考虑操作符的优先级问题

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第66张图片

  • 此时在d1 == d2的外面加上()即可,让他们俩先进行运算

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第67张图片


  • 此时我们再来试试第四种解决方案,将这个函数放到类内。但是一编译却报出了下面这样的错误,这是为什么呢?【看看下一个点就知道了】

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第68张图片

  1. 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
  • 还记得我们在类和对象的封装思想中学习过的类成员函数中都存在一个隐藏形参this,用于接收调用对象所传入的地址,上面我们在写【日期计算器】的是有也有在类内使用过这个this指针
  • 不过对于==来说是个【双目操作符】,其运算符只能有两个,那此时再加上隐藏形参this的话就会出现问题
bool operator==(Date* this, const Date& d1, const Date& d2)
  • 所以当运算符重载函数放到类内时,就要改变其形参个数,否则就会造成参数过多的现象,在形参部分给一个参数即可,比较的对象就是当前调用这个函数的对象即this指针所指对象与形参中传入的对象
bool operator==(const Date& d)
{
	return _year == d._year
		&& _month == d._month
		&& _day == d._day;
}
  • 那既然这是一个类内的函数,就可以使用对象.的形式去调用,运行结果如下

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第69张图片

  • 其实在编译器看来,它们都是一样的,d1 == d2因为有了运算符重载,所以当编译器执行到这个语句的时候,就会通过call这个重载函数的地址,然后类内的成员函数执行。我们可以通过【反汇编】来进行查看

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第70张图片

  • 此时就可以看到在底层的汇编指令它们其实都是一样的
  1. .*::sizeof ?:. 注意以上5个运算符不能重载。这个经常在笔试选择题中出现。
  2. 运算符重载和函数重载不要混淆了

【运算符重载】:自定义类型对象可以使用运算符
【函数重载】:支持函数名相同,参数不同的函数,同时可以用

③ 练习巩固

上面教了要如何去写==的运算符重载,接下去我们就来对其他运算符写一个重载

  • 首先就是小于<,读者可以试着自己在编译器中写写看
bool operator<(const Date& d) 

接下去展示一下三位同学的代码

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

首先来看一下第一位同学的运行结果

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第71张图片

  • 可以看到第一次运行确实是没问题,但是我修改了一下两个日期的年份,他的代码逻辑就出现错误了

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第72张图片

  • 难道2025年比2024年来得小吗,日期的计算是越是离我们近的日期越大,越离我们远,已经过去的日期来得小,所以2025.2.35 > 2024.3.25,但是这位同学的代码逻辑却出现了问题,我们对照代码来分析一下
  • 首先比较两个年份,2025确实比2024来得大,所以第一个年份不成立,还会去比较第二个,但是呢2月份是比3月份来得小的,所以整个表达式即为真,因为对于逻辑或||来说,有1个为真即为真,全假才为假,所以最后的结果才返回了true
  • 在计算机中我们一般用非0值代表true(1),用0代表false,所以最后的结果才是1,如果上面的这些都不是清楚的话就再去看看C语言操作符章节
//Student1
bool operator<(const Date& d)
{
	return _year < d._year
		|| _month < d._month
		|| _day < d._day;
}

接下去再来看看第二位同学的运行结果

  • 可以看到,第二位同学的代码逻辑似乎是正确的,面对我给出的两个测试用例都跑过了

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第73张图片
C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第74张图片

  • 查看他的代码可以发现,他在每个年月日之间又多加了一些判断,这个逻辑才是正确的,设想两个年份比较大小那谁小一下就比出来了,但是月份的话难道也是谁小就可以确定吗?这是要建立在两个年份相等的情况下才可以确定的,所以我们看到这位同学用了一个逻辑与&&的符号,表示二者都满足才可以;那对于天来说也是一样的,要满足年和月相等的条件下才可以比较大小
//Student2
bool operator<(const Date& d)
{
	return _year < d._year
		|| (_year == d._year && _month < d._month)
		|| (_year == d._year &&  _month == d._month && _day < d._day);
}

但是我却采用了第三位同学的代码来作为标准答案

  • 可以观察到 ,他对于我的测试用例也是可以跑过的,一起再来看看它的代码

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第75张图片
C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第76张图片

  • 为什么要用他的代码呢?就是因为我觉得这个同学的逻辑思维很代码风格都很不错,他的逻辑其实和第二位同学是一致的,只是将第二位同学的逻辑或||改成了if…else条件判断罢了,原理都是一样的。
  • 设想如何你是一位初学者,你愿意看哪种代码呢?那一定是这位同学,但若是你追求高质量、优质简练的风格,那我建议你可以选择第二位同学,如果你在力扣上刷过题目就可以知道对于第二位同学的代码风格是非常常见的,追求的就是一个快!
//Student3
bool operator<(const Date& d)
{
	if (_year < d._year) {
		return true;
	}
	else if (_year == d._year && _month < d._month) {
		return true;
	}
	else if (_year == d._year && _month == d._month && _day < d._day) {
		return true;
	}
	else {
		return false;
	}
}

说点题外话

  • 为什么就上面这一段很简单的逻辑,让同学们去写会有五花八门的样子呢?其实就是因为每个人的思维方式不同而导致的,其实咱们这个行业写代码写的是逻辑,写得代码对不对取决于你想得对不对。如果想法、思路都错了,那一定不可能写对‍
  • 一个人的代码能力其实可以分为下面这四种
    • 想得不对,这是最差的 ——> 要锻炼逻辑思维,尝试考虑地更全面、更细节
    • 想对了,写错了,表明代码能力不行 ——> 多写代码
    • 想得是对的,写的也是对的,但写法很普通 ——> 需要拔高
    • 不仅要想对、写对,而且要以一种比较优的形式去实现【这才是最终目标】

你觉得自己正处于那种状态呢?


好,我们回归正题,继续来讲运算符重载

  • 知道了==<如何去进行重载,那小于等于呢?该如何去实现?
bool operator<=(const Date& d)

有同学说:这简单,把<都改成<=不就好了

bool operator<(const Date& d)
{
	if (_year <= d._year) {
		return true;
	}
	else if (_year == d._year && _month <= d._month) {
		return true;
	}
	else if (_year == d._year && _month == d._month && _day <= d._day) {
		return true;
	}
	else {
		return false;
	}
}
  • 确实上面这样是最直观的形式,但是刚才说了,我们在已经能够写对的情况下要去追求更优的情况。我采取的是下面这种写法,你能很快反应过来吗?
return (*this < d) || (*this == d);
  • 其实很简单,我就是做了一个【复用】,使用到了上面重载后的运算符<==this指向当前对象,那么*this指的就是当前对象,这样来看的话其实就一目了然了

小于、小于等于都会了,那大于>和大于等于>=呢?不等于!=呢?

bool operator>(const Date& d)  
bool operator>=(const Date& d)  
bool operator!=(const Date& d)
  • 其实上面的这两个都可以用【复用】的思想去进行实现,相信此刻不用我说你应该都知道该如何去实现了把
//大于>
bool operator>(const Date& d)
{
	return !(*this <= d);
}

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

//不等于!=
bool operator!=(const Date& d)
{
	return !(*this == d);
}

这里就不给出测试结果了,读者可自己修改日期去查看一下

④ 代码展示

class Date
{
public:
	//3.全缺省构造函数
	Date(int y = 2000, int m = 1, int d = 1)
	{
		_year = y;
		_month = m;
		_day = d;
	}

	Date(const Date& d)		//权限保持
	{
		//cout << "Date拷贝构造的调用" << endl;
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

	//等于==
	bool operator==(const Date& d)
	{
		return _year == d._year
			&& _month == d._month
			&& _day == d._day;
	}

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

	//小于等于<=
	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);
	}

	//不等于!=
	bool operator!=(const Date& d)
	{
		return !(*this == d);
	}

	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

	~Date()
	{
		//cout << "Date析构函数的调用" << endl;
		_year = 0;
		_month = 0;
		_day = 0;
	}
private:
	int _year;
	int _month;
	int _day;
};

2、赋值运算符重载

有了运算符重载的概念后,我们就来讲讲什么是赋值运算符重载

① 语法说明及注意事项

首先给出代码,然后我再一一分解叙述

Date& operator=(const Date& d)
{
	if (this != &d)		//判断一下是否有给自己赋值
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
	return *this;
}

【参数类型】:const T&,传递引用可以提高传参效率

  • 这一点我在上面讲解拷贝构造的时候已经有重点提到过,加&是为了减少传值调用而引发的拷贝构造,加const则是为了防止当前对象被修改和权限访问的问题,如果忘记了可以再翻上去看看

【返回*this】 :要复合连续赋值的含义

  • 这块重点讲一下,本来对于赋值运算符来说是不需要有返回值的,设想我们平常在定义一个变量的时候为其进行初始化使用的时候赋值运算,也不会去考虑到什么返回值,但是对于自定义类型来说,我们要去考虑这个返回值
Date d1(2023, 3, 27);
Date d2;
Date d3;

d3 = d2 = d1;
  • 可以看到,就是上面这种情况,当d1为d2进行初始化后,还要为d3去进行初始化,那此时就要使用到d2,所以我们在写赋值重载的时候要考虑到返回值的问题,那返回什么呢?
  • 因为是为当前对象做赋值,所以应该返回当前对象也就是*this

【返回值类型】:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值

  • 返回值这一块我在引用一文也有相信介绍过,若是返回一个出了当前作用域不会销毁的变量,就可以使用引用返回来减少拷贝构造

【多方位考虑】:检测是否自己给自己赋值

  • 在外界调用这个赋值重载的时候,不免会有人写成下面这种形式
d1 = d1;
  • 那自己给自己赋值其实并没有什么意义,所以我们在内部应该去做一个判断,若是当前this指针所指向的地址和传入的地址一致的话,就不用做任何事,直接返回当前对象即可。若是不同的话才去执行一个赋值的逻辑
if (this != &d)

知晓了基本写法和注意事项后,我们就来测试运行一下看看是否真的可以完成自定义类型的赋值

  • 可以看到 ,确实可以使用=去进行日期之间的赋值

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第77张图片

  • 不仅如此,也可以完成这种【链式】的连续赋值

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第78张图片

  • 那现在我想问,下面的这两种都属于【赋值重载】吗?
d2 = d1;		//赋值重载

Date d3 = d2;	//赋值重载?
  • 通过调试可以观察到d2 = d1就是我们刚才说的赋值重载,去类中调用了对应的函数;但是对于Date d3 = d2来说,却没有去调用赋值重载,而是去调用了【拷贝构造】,此时就会有同学很疑惑?

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第79张图片
这里一定要区分的一点是,赋值重载是两个已经初始化的对象才可以去做的工作;对于拷贝构造来说是拿一个已经实例化的对象去初始化另一个对象

  • 所以对于上面的d1d2是两个已经初始化完的对象,但是d3还未初始化,还记得我在将拷贝构造的时候说过的这一种形式吗,如果忘了就再翻上去看看吧

② 默认的赋值运算符重载

重点地再来谈谈默认的赋值运算符重载,相信在看了构造、析构、拷贝构造后,本小节对你来说不是什么难事

  • 那还是咱熟悉的老朋友Time类和Date类,在Time类中我写了一个赋值重载
class Time
{
public:
	Time()
	{
		_hour = 1;
		_minute = 1;
		_second = 1;
	}
	Time& operator=(const Time& t)
	{
		if (this != &t)
		{
			_hour = t._hour;
			_minute = t._minute;
			_second = t._second;
		}
		return *this;
	}
private:
	int _hour;
	int _minute;
	int _second;
};

class Date
{
public:
	Date(int y = 2000, int m = 1, int d = 1)
	{
		_year = y;
		_month = m;
		_day = d;
	}
private:
	// 基本类型(内置类型)
	int _year;
	int _month;
	int _day;
	// 自定义类型
	Time _t;
};
  • 我们首先通过调试来进行观察

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第80张图片

  • 可以发现,当使用d1初始化d2的时候,去调用了Time类的拷贝构造,这是为什么呢?我们其实可以先来看看Date类中的成员变量有:_year、_month、_day以及一个Time类的对象_t,通过上面的学习我们可以知道对于前三者来说都叫做【内置类型】,对于后者来说都叫做【自定义类型】
  • 那在构造、析构中我们有说到过对于【内置类型】编译器不会做处理;对于【自定义类型】会去调用默认的构造和析构。在拷贝构造中我们有说到过【内置类型】会按照值拷贝一个字节一个字节;对于【自定义类型】来说会去调用这个成员的拷贝构造
  • 那通过上面的调试可以看出赋值重载似乎和拷贝构造是差不多,对于内置类型进行值拷贝,对于自定义类型Time去调用了其赋值重载函数

那我还是一样会提出疑问,既然编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝了,还需要自己实现吗?当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?

没错,也是我们的老朋友Stack类。我想你可能已经猜到了结果

typedef int DataType;
class Stack
{
public:
	Stack(size_t capacity = 10)
	{
		_array = (DataType*)malloc(capacity * sizeof(DataType));
		if (nullptr == _array)
		{
			perror("malloc申请空间失败");
			return;
		}
		_size = 0;
		_capacity = capacity;
	}
	void Push(const DataType& data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}
	~Stack()
	{
		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}
private:
	DataType* _array;
	size_t _size;
	size_t _capacity;
};
int main()
{
	Stack s1;
	s1.Push(1);
	s1.Push(2);
	s1.Push(3);
	s1.Push(4);
	Stack s2;
	s2 = s1;
	return 0;
}
  • 不出所料,程序出现了奔溃,如果你认真看了上面内容的话,就能回忆起上面Stack在进行浅拷贝时出现的问题,和这里的报错是一模一样的

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第81张图片

  • 我们知道,对于浅拷贝来说是就是一个字节一个字节直接拷贝,和拷贝构造不同的是,两个对象是已经实例化出来了的,_array都指向了一块独立的空间,但是在赋值之后,s1和s2的_array还是指向了同一块空间。此时便会造成两个问题
    • 因为它们是同一指向,所以在析构的时候就会造成二次析构
    • 原本s2中的_array所申请出来的空间没有释放会导致内存泄漏
  • 还是一个画个图来分析一下
    C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第82张图片
    所以还是一样,当果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现

注意:赋值运算符不能重载成全局函数!

中间穿插一点内容,就是有关这个赋值运算符所定义的位置

  • 如下,可以看到我将赋值重载运算符放到了类外来定义,编译一下发现报出了错误,这是为什么呢?其实编译器已经给我们写得很明确了,对于operator=也就是赋值重载只能写在类内,不可以写在类外,但一定有同学还是会疑惑为什么要这样规定,且听我娓娓道来~

在这里插入图片描述

  • 上面我们讲到了,若是我们自己不去显式实现赋值运算符重载的话,当出现两个自定义类型进行赋值运算时就会默认调用编译器为当前类自动生成的默认赋值运算符重载进行赋值
  • 但若是我们自己在类外又定义了一个的话,就会发生和类内默认生成冲突的情况
Date& operator=(Date& left, const Date& right)
{
	if (&left != &right)
	{
		left._year = right._year;
		left._month = right._month;
		left._day = right._day;
	}
	return left;
}

拿一些权威性的东西来看看,以下是《C++ primer》中的原话

在这里插入图片描述
【总结一下】:

  • 本模块我们介绍了两样东西,一个是运算符重载,一个是赋值运算符重载
  • 对于运算符重载来说,我们认识到了一个新的关键字operator,使用这个关键字再配合一些运算符封装成为一个函数,便可以实现对自定义类型的种种运算,也加深巩固了我们对前面所学知识的掌握
  • 对于赋值运算符重载而言,就是对赋值运算符=进行重载,分析了一些它的有关语法使用特性以及注意事项,也通过调试观察到了它原来和默认拷贝构造的调用机制是一样的,毕竟大家同为天选之子。但是也要区分二者的使用场景,不要混淆了

六、const成员函数

接下去中间穿插讲一个const成员函数,这个实际的开发中可能会用到,也为下面的const取地址操作符重载做铺垫

【概念】:将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针表明在该成员函数中不能对类的任何成员进行修改

  • 在下面我定义了一个普通的类A,然后在里面写了一个成员函数来输出成员变量a的值
class A {
public:
	void Print()
	{
		cout << _a << endl;
	}
private:
	int _a = 10;
};

int main(void)
{
	A a;
	a.Print();
	return 0;
}

在这里插入图片描述

  • 接着也是正常地输出了结果,但此时我在实例化这个对象aa时在类的前面加上一个const做修饰,编译却报出了错误说了一些有关this指针的内容,那有些同学就看不太懂,这是为啥呀???

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第83张图片

  • 既然和this指针有关的报错,那我们是否可以从这个点入手去分析一下呢?要知道一个对象去调用当前类中的成员函数时会传递它的地址给到成员函数中的隐藏形参this指针,然后在内部this指针就可以通过不同的对象地址去访问不同地址空间中对应的成员变量
void Print(A* this)
  • 那此时你就应该想到,把一个具有常性的const对象地址传递给不具有常性的this指针时,是否会造成一个权限放大呢,本来我外部这个aa是不可以修改其成员变量的,但是因为调了这个Print()函数后却又可以了,那妥妥的是一个权限放大呀!

那有同学问:这该怎么办呀,那可以不要让它放大吗?给this指针也加个const,这样就可以权限保持了吧

  • 上面说到过,this指针是一个隐藏形参,我们是看不到它的,使用this指针接收调用对象的地址是由编译器来完成的,我们不能贸然去做,所以更改this指针的权限这种行为就更不用说了
  • 其实对于this指针来说是一个指针常量,何谓指针常量?也就是指针本身就是一个常量,是不可以修改器指向的,但是指针所指向的内容是可以修改的【联想this指针已经接收到了当前对象的地址,难道在调用的时候还能改吗?但是在访问成员变量时却是可以对其进行修改的】 —— 不懂看看这个吧 链接

小王:那这不是没办法了吗,老师?出现Bug了

  • 诶,不要急嘛,这不是还有一个天选之子没用呢,可以在这个Print()函数的右边空一个空格,然后写一个const,那这个成员函数就是一个const成员函数了
void Print() const
{
	cout << _a << endl;
}
  • 来看一下运行结果就可以发现没有报错了,这才是你说的【权限保持】哦

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第84张图片
小东:但是为什么加这么一个const就可以做到为this指针做修饰呢?编译器到底是怎么识别的?

  • 首先我们来运行一下,看看编译器是怎么说的

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第85张图片

  • 那其实很明确了,当加上这个const做修饰之后,this指针就变成了一个常量指针,对于常量指针而言是不可以去修改它所指向内容的,那_a += 1这个操作就是违法的;而且this指针本身就是一个指针常量,那此时它完全就被锁住了,就像下面这样
void Print(const A* const this)

当我放出这样的东西后,又迎来了一堆的问题。。。。

小明:那限制成这样了外面要传个普通的不具有常性的成员是不是都不可以了?

  • 这个其实你去运行一下就知道了,外界是可以允许你修改了,但是到了内部我不去修改了,那是不是一个权限缩小,这是可以的

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第86张图片
小李:const只能用在成员函数上吗?我去修饰普通函数可不可以呢?

  • 这当然是不可以的啦,同学!一开始我就说了这个const是用来修饰this指针的,那谁有this指针呢?是不是成员变量才有这个隐藏的参数呀,是吧。类外的普通函数是没有this指针滴

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第87张图片
小叶:const修饰成员函数只能放在右边吗?能不能放在左边?

  • 不可以哦, 这是语法规定的。就像我国规定了一辆车的驾驶室只能在左边,不过国外是右边。那你如果强行把驾驶室改到右边,然后放到国内来卖,你看有没有人会来买

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第88张图片

当同学们提问完之后,就轮到我问了,我便提出了一下四个问题,读者在学习完后可以试着解答一下

问题:

  1. const对象可以调用非const成员函数吗?

  2. 非const对象可以调用const成员函数吗?

  3. const成员函数内可以调用其它的非const成员函数吗?

  4. 非const成员函数内可以调用其它的const成员函数吗?


答案:

  1. 这个当然不可以。我们前面已经说过了,若 const对象去调用非const成员函数,会造成【权限放大】的现象,原本在类外const对象的内容是不可以修改的,但是到了函数内部却有可以修改了,这是不被允许的
  2. 这个当然是可以的。非const对象本身就是可读可写的,那在函数内部你要去修改或者不修改都不会有影响
  3. 不可以,const成员函数内部只能调用const成员函数。因为const成员函数内部的this指针已经具有常属性的,万一这个非const成员函数去修改了成员变量的内容就会出问题了

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第89张图片

  1. 这个可以,非const成员函数中this的类型:A* const ,即当前对象可修改也可不修改

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第90张图片

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

这两个天选之子呢类中也是会默认提供的,但是呢我们用得却不多

class Date
{
public:
	Date* operator&()
	{
		return this;
	}
	const Date* operator&() const
	{
		return this;
	}
private:
	int _year; // 年
	int _month; // 月
	int _day; // 日
};
  • 可以看到,就是去打印一下这个对象的地址,通过函数实现可以看到return this,那返回的也就是当前对象的地址

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第91张图片
说实话,这个在平常的开发中真用的不多,甚至用不到,这里提一下读者了解一下就可以了。你要真的想去玩一玩这个东西的话倒也可以

  • 若是外界想要访问这个这个类实例化出对象地址的话,返回一个随机的地址给他就行,那若是他去解引用一个程序就会奔溃,相当于是访问野指针了

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第92张图片

八、综合案例实战 —— Date日期类

学习完了类的六大天选之子,接下去我们来练习一道综合案例 —— Date日期类

1、需求分析鸟瞰

这个案例我打算通过多文件的形式去进行编写,分为:Date.hDate.cpptest.cpp

  • 然后来说说这个Date中我们要去实现哪些功能。以下是[Date.h]中的内容
class Date
{
	friend istream& operator>>(istream& in, Date& d);
	friend ostream& operator<<(ostream& out, const Date& d);
public:
	//获取当月的天数
	int GetMonthDay(int year, int month);

	//构造函数
	Date(int y = 2000, int m = 1, int d = 1);

	//拷贝构造函数
	Date(const Date& d);

	//赋值重载
	Date& operator=(const Date& d);

	//等于==
	bool operator==(const Date& d);

	//不等于!=
	bool operator!=(const Date& d);

	//小于<
	bool operator<(const Date& d);

	//小于等于==
	bool operator<=(const Date& d);

	//大于>
	bool operator>(const Date& d);

	//大于等于>=
	bool operator>=(const Date& d);

	//前置++
	Date& operator++();

	//后置++
	Date operator++(int);

	//前置--
	Date& operator--();

	//后置--
	Date operator--(int);

	//日期 += 天数
	Date& operator+=(int days);

	//日期 + 天数
	Date operator+(int days);

	//日期 -= 天数
	Date& operator-=(int days);

	//日期 - 天数
	Date operator-(int days);

	//日期 - 日期
	int operator-(const Date& d);		//构成重载

	//打印
	void Print();

	//析构函数
	~Date();
private:
	int _year;
	int _month;
	int _day;
};
  • 可以看到,要是实现的功能有很多,无论你上面是否有听懂,我会将其分为四个模块来进行讲解
    • 【第一模块】:写出Date日期类中需要自己实现的默认成员函数
    • 【第二模块】:写出Date日期类中一些关系运算符重载==!=>>=<<=
    • 【第三模块】:写出Date日期类中涉及日期计算的相关运算符重载
    • 【第四模块】:实现流插入<<和流提取>>对日期进行输入输出

注意:下面四个模块的编写都是放在Date.cpp中,因此成员函数前后都要加上域作用限定符::

2、【第一模块】:默认成员函数实现

① 全缺省构造函数

  • 那对于一个类来说,构造函数是最起码的,除了类本身就带有的默认构造函数外,我们还要去自己实现一个全缺省构造函数
  • 在C++缺省参数一文中我有说到过若是一个函数进行了分文件编写的话,对于形参部分的初始值需要再.h的头文件中给出,而不可以写在.cpp中,因为主文件要包含的头文件是Date.h
Date(int y = 2000, int m = 1, int d = 1);
Date::Date(int year, int month, int day)
{
	_year = year;
	_month = month;
	_day = day;
}

但是这样就好了吗?我们首先来main函数中测试一下

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第93张图片

  • 可以看到,上面这个对象被成功地实例化出来了,但是对象d2你觉得这是合法的吗?

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第94张图片

  • 身处2023年的读者一定知晓今年并不是闰年,所以2月份只有28天,那这个29天就是不合法的

因此当外界在实例化对象调用构造函数的时候,我们应该要去进行一个日期是否合法的判断

  • 首先在Date类的全缺省构造函数的内部对传入进来的月份做一个判断,因为月份之后1 ~ 12月,其他都是不合法的,我这里直接使用assert()
assert(month > 0 && month < 13);
  • 那月份合法之后,就要去看看所传入的这个月的天数是否合法了,那我们先要知道这一年的这一个月有多少天,那么便需要使用到我在上面将【日期计算器】时所用到的GetMonthDay,具体实现细节这里便不再多说
int Date::GetMonthDay(int year, int month)
{
	int monthArray[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
	int days = monthArray[month];

	if (month == 2 && (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) {
		return days + 1;
	}
	else {
		return days;
	}
}
  • 当我们获取到这个月真正有多少天时,和传入进来的day去进行一个比较,若是合法的话就对当前对象的年、月、日去做一个初始化的工作,若是不合法的话就输出一句话进行提醒
//判断一下对象实例化的年月日是否合法
int days = GetMonthDay(year, month);
if (day > 0 && day <= days) {
	_year = year;
	_month = month;
	_day = day;
}
else {
	cout << "所传入日期不合法" << endl;
}

在这里插入图片描述


② 拷贝构造函数

  • 拷贝构造函数没有什么需要变化的地方,注意使用引用传参&减少拷贝、提高效率,以及const常修饰对象防止误操作和权限方法即可
Date::Date(const Date& d)		//权限保持
{
	cout << "Date拷贝构造的调用" << endl;
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

③ 赋值运算符重载

  • 这个天选之子类中默认也会生成,而且对于内置类型的year、month、day会进行值拷贝去赋值,不会出现问题,不过这里我也去实现一下,达到联系巩固的目的
  • 需关注的点有两个,一个是在一开始进来的时候判断一下是否给自己赋值,最后在赋值完成后还要返回自身的结果,但是对于*this来说出了作用域不会销毁,因此我们可以使用引用返回Date&来减少临时拷贝的过程
Date Date::operator=(const Date& d)
{
	if (this != &d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
	return *this;
}

④ 析构函数

  • 析构函数很简单,将三个成员变量置0即可
Date::~Date()
{
	cout << "Date析构函数的调用" << endl;
	_year = 0;
	_month = 0;
	_day = 0;
}

这里要说明一点,怕有的同学混淆,对于日期类来说器成员变量都是【内置类型】的,不会出现深拷贝之类的问题,所以构造、拷贝构造、赋值重载、析构都是可以不用去实现的,我这里实现只是为了起到知识点巩固的效果

3、【第二模块】:关系运算符重载

接下去我们进入第二模块,来实现一下关系运算符的重载

① 等于 ==

bool Date::operator==(const Date& d)
{
	return _year == d._year
		&& _month == d._month
		&& _day == d._day;
}

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第95张图片


② 不等于 !=

  • 这里复用一下==即可
bool Date::operator!=(const Date& d)
{
	return !(*this == d);
}

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第96张图片


③ 小于 <

  • 注意判断两个日期的大小不是月份小就是小、天小就是小,而是要在年份相同的情况下才能去进行比较
bool Date::operator<(const Date& d)
{
	if (_year < d._year) {
		return true;
	}
	else if (_year == d._year && _month < d._month) {
		return true;
	}
	else if (_year == d._year && _month == d._month && _day < d._day) {
		return true;
	}
	else {
		return false;
	}
}

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第97张图片


④ 小于等于 <=

  • 也是一样复用一下<==即可
bool Date::operator<=(const Date& d)
{
	return (*this < d) || (*this == d);
}

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第98张图片


⑤ 大于 >

  • 大于就是<=的对立面
bool Date::operator>(const Date& d)
{
	return !(*this <= d);
}

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第99张图片


⑥ 大于 >=

  • 大于等于就是<的对立面
bool Date::operator>(const Date& d)
{
	return !(*this < d);
}

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第100张图片

4、【第三模块】:日期计算相关运算符重载

好,接下去我们来进行第三模块函数的编写,这一块可能会有点难,做好准备,上车了

① 前置++

  • 在C语言中我们有学习过自定义类型的前置++,若是用一个范沪指去接收的话,那返回值得到的就是++之后的结果,然后数值自身也会递增。那我们就可以像下面这样来实现
  • 因为是自己会实现递增,所以使用*this即可,最后要给到++之后的返回值,那便返回*this即可,那返回一个出了作用域不会销毁的成员,我们可以使用引用返回减少临时拷贝
Date& Date::operator++()
{
	*this += 1;
	return *this;	//返回++之后的值
}

② 后置++

  • 对于后置++来说,外界会接收到++之前的数值,但是数值本身自己也要递增,所以我们可以做一份*this的临时拷贝,当自身的值递增之后,返回之前临时拷贝的tmp即可不过要注意的是,tmp出了作用域不会销毁,因此不可以使用【引用返回】
Date Date::operator++(int)
{
	Date tmp(*this);

	*this += 1;

	return tmp;		//返回++之前的值
}

来测试一下

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第101张图片

这里我来解释一下后置++函数内的int是什么意思

  • 这是C++语法自身所规定的,int仅仅是为了占位,跟前置重载产生一个区分,对于编译器来说,当它遇到后置++的时候,会自动将表达式转换为d1.operator++(0),里面填充一个int类型的数据,无论是0或者1 都可以
  • 通过调试来观察一下

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第102张图片

  • 再通过汇编来观察一下底层的转换原理,可以看到,前置++和函数默认不传值的结果是一样的,但是传入了0或1确实和后置++一样的。这其实就一清二楚了

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第103张图片


看完了前置++和后置++,那么前置–和后置–也不会有什么问题

③ 前置- -

Date& Date::operator--()
{
	*this -= 1;
	return *this;	//返回--之后的值
}

④ 后置- -

Date Date::operator--(int)
{
	Date tmp(*this);

	*this -= 1;

	return tmp;		//返回--之前的值
}
  • 一样通过调试来看看

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第104张图片

  • 通过观察可以发现,后置++或者是--都需要现有一份临时拷贝,但是前置++或者是--。这就是为什么我们C语言在写循环条件的递增时要用++i而不是i++的原因了

上面的++--都是一个单位的变化,那是否可以多变化一些呢,例如自己传入想要增加或减少的天数

⑤ 日期 += 天数

  • 首先来看看函数声明,形参部分就是需要传入改变的天数
Date& Date::operator+=(int days)
  • 这一块其实我在前面【日期计算器】的部分也重点说过了,如果忘记的读者可以再翻上去看看
  • 思路再顺理一下:+=是对当前调用对象自身的变化,所以直接在_day在进行操作即可,当加上days后去判断一下是否大于当前月的天数【判断的逻辑上面讲过了,GetMonthDay可以复用】,如果是的话就要先减去这个月的天数,然后将月份进行递增。但月份在递增的过程中也会超出一个临界值,若是_month > 12那我们就要月份改为来年的1月,然后将年份进行递增。一直循环往复前面的操作,知道_day比当前月的天数来的小或者相等为止,因为是对调用对象自身进行操作,所以return *this即可,出了当前作用域不会销毁,那再考虑到使用【引用返回】
Date& Date::operator+=(int days)
{
	_day += days;
	while (_day > GetMonthDay(_year, _month)) {
		//把当月的天数先减掉
		_day -= GetMonthDay(_year, _month);
		++_month;
		if (_month > 12) {
			//将月份置为来年的1月
			_month = 1;
			++_year;
		}
	}
	return *this;
}

⑥ 日期 + 天数

  • +=实现了之后,+就可以去进行一个复用了,不需要再将上面的这块逻辑再写一遍。我们都知道+之后不会改变自身的大小,所以不能对当前对象本身去进行一个操作,而是要做一份临时拷贝去加这个天数。又因为是临时拷贝,所以不可以使用【引用返回】
Date Date::operator+(int days)
{
	Date tmp(*this);	//做一份当前对象的临时拷贝

	tmp._day += days;

	return tmp;		//+不会改变自身,返回临时对象【出了作用域销毁,不能引用返回】
}

来测试一下

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第105张图片

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第106张图片


+=+会写了之后,我们来看看一个日期要怎么减去对应的天数

⑦ 日期 -= 天数

  • 首先来分析一下,若是减去一个天数的话我们还可以像加上一个天数这样使用月进制的方法吗?答案是:可以的
  • 首先很明确,先把对象自身的_day减去传入的days。那若这个天数还是正的话就不会有问题,但减完之后这个天数为负了,就要去考虑月份的进制了
_day -= days;
  • 当这个_day <= 0时我们进入这段逻辑,因为一个日期是没有0日的。那要怎么去处理这个天数呢,就要去上个月借,若是上个月不够了再向它的上个月借。那要这样一直借的话我们就先要去把月处理干净了才可以。但是月份在递减的过程中可能会减到0月,此时就不对了,这和+=到达13月是一个道理,首先将年份减1,然后将月份改为上一年的12月即可
  • 把月份处理干净后,就可以去处理天数,因为_day是一个负数,怎么让它变成一个正数呢,就是加上这个月的天数即可。一直这么循环往复,直到_day > 0为止
while (_day <= 0)
{
	//先把月处理干净
	_month--;
	if (_month == 0) {		//如果月减到0,说明出问题了,轮回到上一年
		_year--;		
		_month = 12;
	}
	_day += GetMonthDay(_year, _month);
}
  • 因为是-=,改变的是当前对象自身,所以需要return *this,还是一样,返回一个出了作用域不会销毁的对象,可以使用【引用返回】
return *this;

⑧ 日期 - 天数

  • 那对于-也是一样,复用一下-=即可
Date Date::operator-(int days)
{
	Date tmp(*this);

	tmp._day -= days;

	return tmp;
}

来测试一下

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第107张图片

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第108张图片


不过对于上面的+=+-=-完全没有任何纰漏了吗?如果我去加上一个负数呢?

Date d1(2023, 3, 1);

Date d2 = (d1 += -20);
  • 可以观察到,日期在加上一个负数后出现了问题

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第109张图片

  • 通过调试观察可以发现,传入operator+=的days是一个负数,那么当前对象的_day在累加后还是一个负数,此时根本都不会进入到下面的while循环中,因此出现了问题

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第110张图片

那该如何去解决呢?

  • 只需要在刚进入函数的时候判断一下即可,若是传入的days < 0的话,此时去复用一下-=即可,那么逻辑就转变为【加上一个负数等于减去一个正数】,所以要在days前加上一个负号就可以起到负负得正的效果
if (days < 0)
{
	*this -= -days;
	return *this;
}

通过调试来看看Bug修正后的结果

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第111张图片

  • +=存在这样的问题,其实-=也是一样的

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第112张图片

  • 那也是一样,在函数传入的时候去进行一个判断即可,若为负数的话,此时的逻辑就变成【减去一个负数等于加上一个正数】,同样用到负负得正的思想
if (days < 0)
{
	*this += -days;
	return *this;
}

这里就不做调试观察分析了,展示一下运行结果

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第113张图片


日期除了可以去加减固定的天数之外,两个日期之间还可以去进行运算,这个之前。有提到过,对于日期 + 日期来说是没有意义,但是日期 - 日期还是存在一定意义的

⑨ 日期 - 日期

对于两个日期的差有很多不同的计算方法,这里我介绍一种比较优的思路

  1. 既然是计算两个日期之间差了多少天,那么一定会存在哪个日期大,哪个日期小。因为调用成员函数的对象会自动被当前this指针所指向,所以我假设当前的*this是为较大的日期,而形参中的d是小的那个日期。然后定义一个flag做标记值
  2. 接下去就可以复用我们上面所重载的小于<运算符了,再去比较一下两个对象谁大谁小,然后做一个更新,若是当前对象来的小的话,flag就要置为-1,因为二者相减的话会变成一个负数,此时就可以用到这个标记值了
  3. 比较出了谁大谁小后,就可以去统计两个日期之间差了几天了,这里我们又可以复用前面的!=运算符,让小的日期不断++,直到和大的日期相同为止
  4. 最后return flag * n即可,二者之间相差的是负数天还是正数天一目了然

下面是代码

int Date::operator-(const Date& d)		//构成重载
{
	Date max = *this;
	Date min = d;
	int flag = 1;

	//再进行比较算出二者中较大的那一个
	if (*this < d)
	{
		max = d;
		min = *this;
		flag = -1;
	}

	//计算二者之间相差几天
	int n = 0;
	while (min != max)
	{
		min++;
		n++;
	}
	
	return flag * n;
}

重载好后对两个日期去进行相减就发现确实是正确的【离下一个清明节放假还剩7天】

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第114张图片

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第115张图片

5、【第四模块】:流插入和流提取的实现

好,最后一个模块,我们来讲讲流插入和流提取

① 流插入<<

  • 可以看到,在上面对日期进行各种操作后,我都调用了Print()函数打印观察日期的变化情况,虽然调用一下也不用耗费多少时间,但总觉得还是有些麻烦了,如果可以像正常变量一样直接用cout << a输出该多好
  • 但是这个去输出一下就可以发现编译器报出了没有与这些操作数匹配的"<<"运算符

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第116张图片

  • 我们可以先到cplusplus中来了解一下cout究竟是个什么东西。从中其实看到了一个很熟悉的东西叫做,这是我们日常在写代码的时候都会包的头文件,而从中又包含了,对于【cin】来说它是属于输入流的,对于【cout】来说是属于输出流的一种

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第117张图片

  • 在namespace命名空间一文中我有细致地讲解了C++中的内容都是封装在std的命名空间,所以我们要真正使用【cin】和【cout】还要去展开一下命名空间using namespace std,我们可以进到这个命名空间中看看

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第118张图片
小王:但为什么像我们平常写的cout << acout << b不会有问题呢?

int a = 10;
int b = 20;

cout << a;
cout << b;
  • 这点我前文已经提到过很多次了,对于像int chardouble来说都内置类型,我们前面重载的这些运算符就是为了去和自定义类型结合,基本每个运算符如果要去使用的话都要进行重载,只有像默认的【赋值重载】和【取地址重载】不需要之外
  • 而内置类型都可以直接和操作符匹配使用是因为语法原生支持,在库里已经帮我们将这些运算符和数据类型重载好了,所以可以直接使用。具体地可以到库里来看看

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第119张图片

那其实这就很清楚了,在官方的库中,已经为我们重载好了相应的运算符,所以内置类型的数据才可以直接去进行输出;而它们为什么可以同时存在呢?原因就在于这些函数又发生了一个重载,如果不清楚规则的话看看C++运算符重载规则

  • 真正的原因就在于:库中进行了运算符重载 + 函数重载,我们才得以直接使用

小李:那我们若是要去输出自定义类型的数据时,是不是也可以使用运算符重载呢?这个也可以重载吗?

  • 这个当然可以重载了,<<在C语言中我们有学习过是【按位左移】的意思,只是在C++中它被赋予了新的含义而已,那此时我们就可以在类中重载一下这个运算符了,然后输出对应的日期
void Date::operator<<(ostream& out)
{
	out << _year << "/" << _month << "/" << _day << endl;
}

但编译器还是报出了一些错误,那有的同学就感到很疑惑了??

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第120张图片

  • 但是当我把输出的表达式写成下面这样时,却出现了神奇的现象,居然不报错了!!

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第121张图片

  • 对于上面讲到过的内置类型重载,其实可以写成下面这样,以一种函数调用的方式

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第122张图片

  • 那对于自定义的日期类型来说,其实应该写成下面这样子

在这里插入图片描述

  • 在讲解this指针的时候我有谈到过对象调用类中的成员函数时会将其对象的地址传递给隐藏的形参this指针,那么无论如何调用的一方对象都会成为第一个参数,而形参列表中的就会成为第二个参数

小叶:哦,那这样的话cout就是第二个参数了,那我用cout.operator<<(d1)去调用不就好了吗?

  • 可以是可以,那这样的话你的这个运算符重载就要写到ostream这个类里面去,和内置类型的重载放在一起,但是你可以去修改官方的库吗?很明显不能╮(╯▽╰)╭
  • 那此时的话你只能去手动修饰这个ostream&参数了,不要当前所调用对象成为第一个参数那只有一个办法,那就是不要把这个函数写在写在类里面了!!!

小叶:嗯,也是。那把它写到类外来吧,作为全局的一个函数

  • 但是现在又出现问题了哦,拿到类外来就访问不到类内的私有成员变量了

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第123张图片

  • 其实我们将运算符重载写成成员函数的原因就是在于这个,想要访问类内的私有成员变量。但是现在放到类外来了确实访问不到了。那此时你就有三个办法
    • 将成员变量全部改为public共有【不推荐】
    • 使用GetYear()GetMonth()GetDay()【Java中常见】
    • 将次函数声明为类的友元函数【C++中常用】
  • 可以看到,只需在最前面加上一个friend做为修饰,就可在类内声明一下这个函数为当前类的友元函数
friend void operator<<(ostream& out, const Date& d); 

在这里插入图片描述

  • 此时再去尝试cout << d1就可以发现可以正常运行了

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第124张图片

  • 但是呢我们一般在使用【流插入】的时候不只是输出一个东西,可能会有多样东西需要输出
cout << d1 << d2;
  • 这一点我上面在讲【赋值运算符重载】的时候也有提到过,如果要实现连续赋值的话就要做一个当前对象的返回值,那这里我们若是要继续实现流插入的话就要返回一个ostream的对象才可以,那也就是把这个【out】返回即可,便可以实现多次流插入了
ostream& operator<<(ostream& out, const Date& d)
{
	out << d._year << "/" << d._month << "/" << d._day << endl;
	return out;
}

在这里插入图片描述

② 流提取>>

既然流插入可以实现了,那还有一个双胞胎兄弟叫做【流提取】,也就是我们常用的输入>>

  • 也是和流插入一样的两块地方,在类内首先声明一下这个函数是当前Date类的友元函数,然后将ostream转换为istream输入流即可
friend istream& operator>>(istream& in, Date& d);
  • 这里要注意的一点是形参的对象不可以用const来进行又是,不然它就具有常属性,我们无法往里面去写入东西
istream& operator>>(istream& in, Date& d)
{
	in >> d._year >> d._month >> d._day;
	return in;
}

来测试一下

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第125张图片

③ 内联函数与成员函数的优化

其实对于上面的【流提取】和【流插入】还可以再去进行一个优化,那就是将其定义为【内联函数

  • 首先若是像我现在这种写法就是声明和定义分离,所以编译完之后还要去进行一个链接,在符号表里面找这个函数的定义,这也是需要花费一定时间,虽然对编译器来说不算什么
  • 但是呢对于内联函数来说就不需要这样的步骤,因为在对比内联函数和宏的优缺点中我们谈起过对于【内联函数】而言是不要声明和定义分离的,因为内联函数是不进符号表的,不像普通函数一样编译过后找不到定义就可以去符号表里面找
  • 内联函数将声明和定义都放在.h的头文件中,因为只有声明没有链接的时候才要去找,直接就有定义不用链接,在编译阶段就能拿到地址,然后call它的地址就可以进到那个函数里了

所以对于内联函数来说不可以写成下面这种形式【只在.h声明】,这样它就找不到函数的定义了

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第126张图片

正确的应该像下面这样,将函数的定义与声明写在一起

C++ | 深度探索类的六大天选之子【附综合实战,建议收藏】_第127张图片

这里再提一个小知识点,其实我在类和对象的封装思想中有讲到了,就是对于类中直接定义的成员函数会被直接当成是【内联函数】看待,这里就不能演示了,因为我们就是从类中将这个两个函数抽离出来的,为了可以cin >> d1 >> d2cout << d1 << d2


  • 除了内联函数,其实还有一块也可以进行一个优化,还记得我上面讲到的const成员函数吗,用于修饰当前的this指针,将其变为一个常量指针,继而无法再函数内部改变其成员变量
  • 所以在这里,我们还可以将一些内部不修改成员变量的成员函数再提升成为【常成员函数】,也可以防止误操作的发生

例如像下面的这些,都是可以声明为const成员函数的【注意在定义部分也要加上】

//获取当月的天数
int GetMonthDay(int year, int month); 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 days) const;

//日期 - 天数
Date operator-(int days) const;

//日期 - 日期
int operator-(const Date& d) const;		//构成重载

6、整体代码展示

Date.h

#pragma once

#include 
#include 
using namespace std;

class Date
{
	friend ostream& operator<<(ostream& out, const Date& d);
	friend istream& operator>>(istream& in, Date& d);
public:
	//获取当月的天数
	int GetMonthDay(int year, int month) const;

	//构造函数
	Date(int y = 2000, int m = 1, int d = 1);

	//拷贝构造函数
	Date(const Date& d);

	//赋值重载
	Date& operator=(const Date& d);

	//等于==
	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++();

	//后置++
	Date operator++(int);

	//前置--
	Date& operator--();

	//后置--
	Date operator--(int);

	//日期 += 天数
	Date& operator+=(int days);

	//日期 + 天数
	Date operator+(int days) const;

	//日期 -= 天数
	Date& operator-=(int days);

	//日期 - 天数
	Date operator-(int days) const;

	//日期 - 日期
	int operator-(const Date& d) const;		//构成重载

	//打印
	void Print() const;

	//析构函数
	~Date();
private:
	int _year;
	int _month;
	int _day;
};

inline ostream& operator<<(ostream& out, const Date& d)
{
	out << d._year << "/" << d._month << "/" << d._day << endl;
	return out;
}

inline istream& operator>>(istream& in, Date& d)
{
	in >> d._year >> d._month >> d._day;
	return in;
}

Date.cpp

#define _CRT_SECURE_NO_WARNINGS 1

#include "Date.h"

//获取当月的天数
int Date::GetMonthDay(int year, int month) const
{
	int monthArray[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
	int days = monthArray[month];

	if (month == 2 && (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) {
		return days + 1;
	}
	else {
		return days;
	}
}

//3.全缺省构造函数
Date::Date(int year, int month, int day)
{
	assert(month > 0 && month < 13);
	//cout << "Date构造的调用" << endl;

	//判断一下对象实例化的年月日是否合法
	int days = GetMonthDay(year, month);
	if (day > 0 && day <= days) {
		_year = year;
		_month = month;
		_day = day;
	}
	else {
		cout << "所传入日期不合法" << endl;
	}
}

//拷贝构造
Date::Date(const Date& d)		//权限保持
{
	//cout << "Date拷贝构造的调用" << endl;
	_year = d._year;
	_month = d._month;
	_day = d._day;
}


//赋值重载
Date& Date::operator=(const Date& d)
{
	if (this != &d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
	return *this;
}

//等于==
bool Date::operator==(const Date& d) const
{
	return _year == d._year
		&& _month == d._month
		&& _day == d._day;
}

//不等于!=
bool Date::operator!=(const Date& d) const
{
	return !(*this == d);
}

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


//小于等于==
bool Date::operator<=(const Date& d) const
{
	return (*this < d) || (*this == d);
}

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

//大于等于>=
bool Date::operator>=(const Date& d) const
{
	return !(*this < d);
}

//前置++
Date& Date::operator++()
{
	*this += 1;
	return *this;	//返回++之后的值
}

//后置++
Date Date::operator++(int)
{
	Date tmp(*this);

	*this += 1;

	return tmp;		//返回++之前的值
}

//前置--
Date& Date::operator--()
{
	*this -= 1;
	return *this;	//返回--之后的值
}

//后置--
Date Date::operator--(int)
{
	Date tmp(*this);

	*this -= 1;

	return tmp;		//返回--之前的值
}

//日期 += 天数
Date& Date::operator+=(int days)
{
	if (days < 0)
	{
		*this -= -days;
		return *this;
	}

	_day += days;
	while (_day > GetMonthDay(_year, _month)) {
		//把当月的天数先减掉
		_day -= GetMonthDay(_year, _month);
		++_month;
		if (_month > 12) {
			//将月份置为来年的1月
			_month = 1;
			++_year;
		}
	}
	return *this;
}


//日期 + 天数
Date Date::operator+(int days) const
{
	Date tmp(*this);	//做一份当前对象的临时拷贝

	tmp._day += days;

	return tmp;		//+不会改变自身,返回临时对象【出了作用域销毁,不能引用返回】
}

//日期 -= 天数
Date& Date::operator-=(int days)
{
	if (days < 0)
	{
		*this += -days;
		return *this;
	}
	_day -= days;
	while (_day <= 0)
	{
		//先把月处理干净
		_month--;
		if (_month == 0) {		//如果月减到0,说明出问题了,轮回到上一年
			_year--;		
			_month = 12;
		}
		_day += GetMonthDay(_year, _month);
	}
	return *this;
}

//日期 - 天数
Date Date::operator-(int days) const
{
	Date tmp(*this);

	tmp._day -= days;

	return tmp;
}

//日期 - 日期
int Date::operator-(const Date& d) const		//构成重载
{
	Date max = *this;
	Date min = d;
	int flag = 1;

	//再进行比较算出二者中较大的那一个
	if (*this < d)
	{
		max = d;
		min = *this;
		flag = -1;
	}

	//计算二者之间相差几天
	int n = 0;
	while (min != max)
	{
		min++;
		n++;
	}
	
	return flag * n;
}

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

Date::~Date()
{
	//cout << "Date析构函数的调用" << endl;
	_year = 0;
	_month = 0;
	_day = 0;
}

test.cpp

int main(void)
{
	//Date d1(2023, 3, 29);
	//Date d2(2023, 4, 5);
	
	Date d1;
	Date d2;

	cin >> d1 >> d2;
	
	cout << endl;

	cout << d1 << d2;

	return 0;
}

九、总结与提炼

好,最后来总结一下本文所学习的内容

  • 首先对于【构造函数】和【析构函数】而言,如果我们不写,编译器就会自动生成;如果我们写了,编译器就不会自动声明。编译器自动生成的构造和析构
    • 对于内置类型不做处理
    • 对于自定义类型会去调用它对应的构造/析构
  • 然后是【拷贝构造函数】和【赋值运算符重载】,他们也是默认成员函数,若是我们自己不写的话编译器也会自动生成。同样分为两种类型去对待:
    • 对于内置类型会完成浅拷贝/值拷贝,按照byte一个字节一个字节来拷
    • 对于自定义类型,回去调用这个成员的拷贝构造/赋值重载
    • 若是类内涉及资源管理,切记要自己去是实现这两个成员函数,完成深拷贝,防止浅拷贝造成的危害
  • 【取地址】和【const取地址重载】一般用的不多,但也属于类的默认成员函数,若是我们不写的话编译器会自动生成
  • 【const成员函数】中使用const去修饰当前对象的this指针,使其变为常量指针,使得在该成员函数中不能对类的任何成员进行修改
  • 最后的Date日期类综合实战涵盖了前面所学的很多知识,包括:命名空间、缺省参数、函数重载、内联函数、引用、运算符重载、类的默认成员函数等等,可以强化对类和对象的编程思想,是重中之重

以上就是本文要介绍的所有内容,感谢您的阅读,如果觉得还可以就三连支持一下吧,完结撒花✿✿ヽ(°▽°)ノ✿


在这里插入图片描述

你可能感兴趣的:(C++,C++,构造函数,拷贝构造,运算符重载,const成员)