C++初阶 —— 类和对象
类和对象
- 1. 前言
- 2. 类的基础知识
- 3. this指针
- 4. 类的默认函数
- 4.1 构造函数
- 4.2 析构函数
- 4.3 拷贝构造函数
- 4.4 赋值重载函数
- 4.4.1 运算符重载
- 4.4.1.1 日期 += 天数 与 日期 + 天数
- 4.4.1.2 日期 -= 天数 与 日期 - 天数
- 4.4.1.3 改进前面实现的代码
- 4.4.1.4 重载前置与后置运算符
- 4.4.1.5 重载比较运算符
- 4.4.1.6 日期 - 日期
- 4.4.1.7 重载流插入与流提取函数
- 4.4.2 赋值运算符重载
- 4.5 取地址运算符重载
- 4.5.1 const 成员函数
- 4.5.2 取地址运算符重载
- 5. 类的其它知识
- 5.1 类型转换
- 5.2 static 成员
- 5.3 友元函数
- 5.4 内部类
- 5.5 匿名对象
- 6. 结言
1. 前言
类和对象是在学习C++过程中的一个重要部分,也是比较难以理解的部分,下面就来详细的讲解类和对象的重点知识。
2. 类的基础知识
类和C语言中的结构体有着相似之处,只是C++ 中的类里面可以声明函数。接下来就来介绍类的定义格式,下面先写出一个简单的日期类:
class Date
{//成员函数void DatePrint(){cout << year << "年" << month << "月" << day << "日" << endl;}//成员变量int year;int month;int day;
};
这就是一个简单的类,其中class为定义类的关键字,Date为类的名字,{ } 中的为类的主体,与结构体一样,在类定义结束时,后面的分号不能省略。类体中的内容成为类的成员:类中的变量称为类的属性或成员变量,如:year,month,day;类中的函数称为类的方法或成员函数,如 DatePrint。
类中还使用了访问限定符对类中的对象进行限定,访问限定符有:public,private,protected,这些访问限定符分别代表的意思为 公有,私有,保护。用访问限定符来限定上面所写的日期类:
class Date
{
public:void DatePrint(){cout << year << "年" << month << "月" << day << "日" << endl;}private:int year;int month;int day;
};
C++用类将对象的属性和方法结合到一块,让对象更加的完整,通过访问权限选择性的将其接口提供给外部的用户使用。
public 修饰的成员在类外时可以直接被访问的;protected和private修饰的成员在类外不能直接被访问。
一般情况下,类中的成员变量都定义成私有的,而想让某些对象在类外也能被访问,就可以用public来修饰。
访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现为止;如果没后面没有其它的访问限定符,那么作用域就直到 },即类结束。
class定义的成员若没有被访问限定符修饰时,默认为private
要想访问类中的对象,需要根据这个类实例化出对象,在C++中用类类型在物理内存中创建对象的过程,称为类实例化出对象。为什么要实例化出对象呢?类是对象进形⼀种抽象描述,是⼀个模型⼀样的东西,限定了类有哪些成员变量,这些成员变量只是声明,没有分配空间,用类实例化出对象时,才会分配空间。⼀个类可以实例化出多个对象,实例化出的对象会占用实际的物理空间,存储类成员变量。如下所示:
int main()
{//实例化出对象Date date1;Date date2;return 0;
}
实例化出对象后就可以通过 . 操作符 访问类中的成员,在没有任何的前提下,只能访问类中的公有的成员,若访问了类中的私有成员,编译器会报错。下面再为 Date 类增加几个成员:
void DateInit(int year = 1, int month = 1, int day = 1)
{year = year;month = month;day = day;
}
这个成员函数的作用是给成员变量 year,month,day 初始化,但是这样写很容易分不清,哪边是成员变量,哪边是函数形参。为了区分开,可以改变函数参数,也可以改变成员变量,在成员变量前或后加上 _ 或者 m开头,当然C++中并没有规定必须要在成员变量的前或后面加上什么。之后在写成员变量时,我会在成员变量前加上 m_ 。如下所示:
class Date
{
public:void DateInit(int year = 1, int month = 1, int day = 1){m_year = year;m_month = month;m_day = day;}void DatePrint(){cout << m_year << "年" << m_month << "月" << m_day << "日" << endl;}private:int m_year;int m_month;int m_day;
};
这样一看就能知道哪个是成员变量,哪个是函数参数。
在开头时,经常提到C语言中的结构体,那么C++中还可以使用结构体吗?当然可以,C++兼容C中struct的用法,同时将 struct 升级为类,也就是说,在 C++ 中,struct 与 class 一样都是类定义关键字,明显发生变化的是,struct 中可以定义函数。如下所示:
//C语言中的struct的用法
typedef struct SListNode
{int data;struct SListNode* next;
};//C++中的struct的用法
struct SListNode
{
public:void SLNodeInit(){next = nullptr;}private:int data;SListNode* next;
};
在C++使用方法中,SListNode 是类名。与 class 关键字定义结构体不同的是:class 定义的成员若没有被访问限定符修饰时,默认为 private ,而 struct 默认为 public。下面来感受一下它俩的区别:
但是一般情况下推荐使用 class 来定义类.
此外还需要注意的是,定义在类里面的成员函数默认为 inline ,但是内联函数在汇编的时候是否会展开,取决于编译器。下面就从汇编窗口,看这个成员函数是否展开:
int main()
{Date date1;date1.DateInit();date1.DatePrint();return 0;
}
汇编窗口得到的结果:
从汇编窗口可知, DateIint 函数展开了,而 DatePrint 函数未展开,只是 call DataPrint 函数地址(Date::DatePrint 后面括号中的就是函数的地址,函数的地址是函数的首行代码的地址)。倘若您用的vs编译器并没有观察到上述的内联展开结果,可以采用以下的方法:
第一步:
第二步:
第三步:
完成这几个步骤,就可以观察到和我一样的结果。
所以如果想让一个函数成为内联函数,可以将该函数直接定义在类当中,成为类的成员函数。
学过C++的知道,C++中域有四种,分别是:局部域,全局域,类域,命名空间域。类是一个域,它可以解决类和类之间的命名冲突问题,不同类中的函数名可能是一样的,倘若没有类域这个概念,这样不同类之间的函数就会产生命名冲突问题,而类域恰好解决了这个问题;而命名空间域解决的是全局的函数,变量或类类型之间的的命名冲突问题。
了解了类的一些基本知识之后,下面来思考怎么计算类实例化出的对象的大小呢?它与C语言一致,要考虑内存对齐问题。下面来求 Date 类的大小:
class Date
{
public:void DateInit(int year = 1, int month = 1, int day = 1){m_year = year;m_month = month;m_day = day;}private:int m_year;int m_month;int m_day;
};
分析一下,根据内存对齐规则,可知成员变量的总内存大小为12个字节。但是还有一个成员函数呀?我们知道类中的成员变量是存储对象的里面,那么类的成员函数在对象的里面吗?毕竟对象都可以访问到。
若成员函数是存在对象中,那么存储的就是这个指向函数的指针(也就是函数的地址),默认当前环境为x86环境,那么指针的大小为4个字节,再根据内存对齐的规则,对象的大小为16个字节。下面我们使用sizeof来求取对象的大小:
可以观察到,计算对象的大小时并没有把成员函数的指针大小计算在内,而只是计算了成员变量的大小,这是为什么呢?根据下面的代码,回答以下的问题:
int main()
{Date date1;Date date2;date1.m_day++;date2.m_day++;date1.DateInit(2025, 5, 4);date2.DateInit(2027, 6, 10);return 0;
}
date1 和 date2 访问的 m_day 是不是一样的,是不是存储在同一块内存空间?可能不一样,date1 和 date2 中的 m_year,m_month,m_day 有各自的值,都需要独立的内存空间来存储;那么data1和data2所调用的函数的指针是不是一样的?是一样的,因为它们调用的都是同一个函数。
回答了这两个问题后我们就可以知道为什么类的实例化对象中没有存储成员函数了:因为类可以实例化多个对象,对象想要使用该成员函数时,直接调用该函数即可,并不需要在对象中存储该函数的指针;倘若对象中存储了成员函数的地址/指针,若实例化了100个对象,那么这100个对象都存储了同一个成员函数的指针,这是不是太过浪费了,根本就没有必要。所以对象/类中不会存储成员函数的指针,而成员函数会存储在一个公共区域 —— 代码段(常量区)。
总的来说,对象只存储类的成员变量,不会存储类的成员函数。计算类的大小时,只需要计算它的成员变量的大小,不必计算成员函数的大小。
既然这样,下面来计算这个类的内存大小:
class Test
{void TestInit(){}
};
这个类中只有成员函数,没有成员变量,那么 Test 类的大小是不是 0 呢?用sizeof来计算这个类的大小,看看是否是0个字节:
咦,不是说类中不存储成员函数的指针吗?这里类的大小不应该是0吗?为什么是1呢?因为如果一个字节都不给,怎么表示对象 tst 存在过呢?所以这里给1个字节只是为了占位标识对象的存在,这一个字节大小的空间不存储有效值。总的来说,对于没有成员变量的类的对象,只分配1个字节,且纯粹为了占位,来表示对象是存在的。
3. this指针
观察以下的代码:
int main()
{Date date1;Date date2;date1.DateInit(2025, 5, 4);date2.DateInit(2027, 6, 10);date1.DatePrint();date2.DatePrint();return 0;
}
根据前面的分析对象 date1 与 date2 调用的是同一个 DateInit 函数与同一个 DatePrint函数,那么调用DatePrint函数后打印出来的结果是不是一样的呢?打印结果如下所示:
根据打印的结果可知,结果并不一样。既然data1和data2调用的都是同一个函数,且参数又是一样的,为什么会打印出的值却不一样呢?函数调用的结果不同是因为参数不一样,但是这里并没有写明有不一样的参数呀?到底是什么一回事呢?这里就是由于 this 指针的存在,C++提供了一个隐含的 this 指针来解决这里的问题。 编译器编译后,类的成员函数默认都会在形参第⼀个位置,增加⼀个当前类类型的指针,叫做this指针。所以在编译器编译时,代码是这样的:
这个过程编译器帮助我们把这件事给做了。那么我们可不可以在这里显示传地址给形参this指针了呢?不可以,C++规定不能在实参和形参的位置显示的写 this 指针(编译时编译器会处理),但是可以在函数体内显示使用 this 指针。如下所示:
void DatePrint()
{cout << this << endl;cout << m_year << "年" << m_month<< "月" << m_day << "日" << endl;
}
编译器编译后,类的成员函数默认都会在形参第⼀个位置,增加⼀个当前类类型的指针,叫做 this 指针。比如 Date 类的 DateInit 的真实原型为,void DateInit(Date* const this, int year, int month, int day) 这里 this 指针被 const 修饰,const 在 * 号的右边,表示 this 指针本身不能改变。那么 this 指针的内存是存在哪个区域的?存在栈或寄存器中。
了解了 this 指针后,来回答以下的问题:
问题一:
问题二:
题目三:
4. 类的默认函数
什么是默认函数?默认成员函数就是用户没有显式实现,编译器会自动生成的成员函数。⼀个类,在用户不写的情况下编译器会默认生成以下6个默认成员函数,在C++11中新增了两个默认成员函数:移动构造函数和移动赋值函数。本篇博客主要将的是前6种默认成员函数,这6个默认函数的名称与作用是:
既然当用户没有显示实现这些成员函数时,编译器自己会自己自动生成,那还要我们来实现这些函数干什么?
这里的自动生成是半自动化生成,有些情况下编译器默认生成的函数符合我们的要求,有些情况下编译器默认生成的函数不符合我们的要求,这个时候就需要自己来实现这些函数了。
4.1 构造函数
构造函数类似于数据结构中写的 Init 函数。构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要作用并不是开辟空间创建对象(我们常使用的局部对象是栈帧创建时,空间就开好了),而是对象实例化时初始化对象。构造函数的本质是要替代以前 Date 类中写的 Init 函数的功能,构造函数自动调用的特点就完美的替代的了 Init 。接下来来介绍构造函数的特点:
1. 函数名与类名相同
2. 无返回值 (返回值什么都不需要写,也不需要写void,这是C++语法规定的)
3. 构造函数可以重载
根据以上的特点来写 Date 类的构造函数:
class Date
{
public://以下两个函数均为构造函数,这两个构造函数构成了函数重载//传参的构造函数Date(int year = 1, int month = 1, int day = 1){m_year = year;m_month = month;m_day = day;}//不传参的构造函数Date(){m_year = 1;m_month = 1;m_day = 1;}void DatePrint(){cout << m_year << "年" << m_month<< "月" << m_day << "日" << endl;}private:int m_year;int m_month;int m_day;
};
4. 对象实例化时系统会自动调用对应的构造函数
对象实例化时系统会自动调用对应的构造函数,这该怎么理解呢?以具体的代码来理解:
class Date
{
public://以下两个函数均为构造函数,这两个构造函数构成了函数重载//传参的构造函数Date(int year = 1, int month = 1, int day = 1){m_year = year;m_month = month;m_day = day;}void DatePrint(){cout << m_year << "年" << m_month<< "月" << m_day << "日" << endl;}private:int m_year;int m_month;int m_day;
};int main()
{Date date1;Date date2(2025, 5, 4);date1.DatePrint();date2.DatePrint();return 0;
}
运行结果:
对象实例化时一定会调用对应的构造函数,这样保证了对象实例化后一定被初始化了,当然这是在编写了正确的构造函数的前提下。这里的 date1 和 date2 调用相对应的构造函数时,实际上也将自身的地址传给函数的参数 this 指针了。
有人可能会有问题,既然实例化 date2 的时候后面加上了括号,那么实例化 date1 的时候可不可以后面也加上括号呢?答案是不可以,为什么呢?这里可能会存在一些问题:假设实例化date1的时候,后面可以加上括号,那么下面的代码又该如何解释呢?
Date date1();
Date Func();
Func是实例化的对象,还是无参的函数的声明呢?所以倘若date1的后面可以加括号,那么函数的声明与对象的实例化就分不清了。为了区分,如果对象没有参数就不要在后面加上括号。
C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的原生数据类型,如:int/char/double/指针等,自定义类型就是我们使用 class/struct 等关键字自己定义的类型。
5. 如果类中没有显式定义构造函数,则C++编译器会自动生成⼀个无参的默认构造函数,⼀旦用户显式定义构造函数(无论是否正确,如果显式定义的构造函数有错误,程序会直接编译失败),那么编译器将不再生成
6. 如果用户不写构造函数,编译器默认生成的构造,对内置类型成员变量的初始化没有要求,也就是说是是否初始化是不确定的,看编译器。对于自定义类型成员变量,要求调用这个成员变量的默认构造函数初始化
7. 无参构造函数、全缺省构造函数、我们不写构造时编译器默认生成的构造函数,都叫做默认构造函数。但是这三个函数有且只有⼀个存在,不能同时存在(默认构造函数可以理解为不传实参即可调用的函数就是默认构造函数)。无参构造函数和全缺省构造函数虽然构成函数重载,但是调用时会存在歧义。大多数情况下构造函数都要自己来实现,只有少数情况下,如MyQueue函数(用两个栈实现队列)这样的,就不需要自己来实现构造函数。一般我们编写的构造函数写成全缺省的形式。
无参构造函数和全缺省构造函数虽然构成函数重载,但是调用时会存在歧义。这是为什么呢?全缺省构造函数与无参构造函数的功能重叠了,调用哪一个函数都可以,那么到底调用哪一个构造函数呢?这是分不清的,调用两个都可以,为了避免这种歧义的存在,这两个函数有且只有⼀个存在,不能同时存在。
来看下面的代码:
class Date
{
public:void DatePrint(){cout << m_year << "年" << m_month<< "月" << m_day << "日" << endl;}private:int m_year;int m_month;int m_day;
};int main()
{Date date1;date1.DatePrint();return 0;
}
运行结果:
明明没有写构造函数,当时 date1 对象却被初始化了,这就是编译器自动生成的默认构造函数发挥了作用。也从侧面反应出了,若用户不写构造函数,编译器会自动生成默认构造函数。
再来看以下的代码:
class Date
{
public:void DatePrint(){cout << m_year << "年" << m_month<< "月" << m_day << "日" << endl;}Date(int year = 1, int month = 1, int day = 1){m_year = year;m_month = month;m_day = day;}private:int m_year;int m_month;int m_day;
};int main()
{Date date1();date1.DatePrint();return 0;
}
编译器会报错,表示没有对应的构造函数。这是因为用户写的构造函数 date1 对象并不能调用,而用户写了构造函数后,编译器就不会再自动生成默认构造函数了,date1对象没有对应的构造函数可以调用,所以编译器会报错。
前面实现构造函数时,初始化成员变量主要的使用函数体内赋值,构造函数初始化还有⼀种方式,就是初始化列表,初始化列表的使用方式是以⼀个冒号开始,接着是⼀个以逗号分隔的数据成员列表,每个"成员变量"后面跟着⼀个放在括号中的初始值或表达式,具体的初始化列表的实现如下代码所示:
//初始化列表初始化成员变量
Date(int year = 1, int month = 1, int day = 1): m_year(year), m_month(month), m_day(day)
{}
需要注意的是每个成员变量在初始化列表中只能出现⼀次,语法理解上初始化列表可以认为是每个成员变量定义初始化的地方。
当然一些成员变量写在初始化列中,一些变量写在函数体内,这样也可以:
Date(int year = 1, int month = 1, int day = 1):m_month(month), m_day(day)
{m_year = year;
}
但是有几个特殊的变量必须要在初始化列表初始化:引用类型成员变量, const 成员变量,没有默认构造的类类型变量。这三个变量有一个共同的特点:都需要在定义的时候初始化。如下所示:
class Test
{
public://构造函数Test(char ch): digital(ch){cout << " " << endl;}private:char digital;
};class Date
{
public://初始化列表初始化成员变量Date(int& rdate, int year = 1, int month = 1, int day = 1): m_ret(rdate) //最好引用出作用域后不会销毁的变量, m_buf(6), m_character('G'){m_month = month;m_day = day;m_year = year;}private:int m_year;int m_month;int m_day;int& m_ret; // 引用变量const int m_buf; // const成员变量Test m_character; // 没有默认构造的成员变量
};
C++11⽀持在成员变量声明的位置给缺省值(缺省值可以是常量值,也可以是表达式),这个缺省值主要是给没有显示在初始化列表初始化的成员使用的,如下代码所示:
private:// 主要这里不是初始化,而是给成员变量缺省值int m_year = 2025;int m_month = 5;int m_day = 6;int& m_ret; // 引用类型的变量不能在声明的时候给缺省值const int m_buf = 20; Test m_character = 'Q';
对于初始化列表,如果用户写了初始化列表,那么编译器就使用用户写的初始化列表;若用户未写初始化列表,编译器仍然会通过初始化列表读对成员变量初始化。现在问题来了,既然可以在变量声明的时候给予初始值,那么成员初始化的值是怎么确定的呢?这需要分成不同的情况来看,如下图:
无论是否显示写初始化列表,每个构造函数都有初始化列表;无论是否在初始化列表显示初始化成员变量,每个成员变量都要走初始化列表初始化。上图中的逻辑都是从上到下依次判断的,上一个逻辑不满足,就执行下一个逻辑。初始化列表中按照成员变量在类中声明顺序进行初始化,跟成员在初始化列表出现的的先后顺序无关。建议声明顺序和初始化列表顺序保持⼀致。
为了更好的理解上述的逻辑,下面来通过几个例子来说明:
样例1:
样例2:
样例3:
样例4:
之后初始化成员变量时,都尽可能的用初始化列表初始化,但并不意味着不使用函数体来初始化,二者要搭配使用,一些特殊的情况用初始化列表来初始化是不能达到目的的,如:对数组的初始化,更复杂的逻辑。
4.2 析构函数
析构函数类似于数据结构中写的 Destory 函数。析构函数与构造函数功能相反,析构函数不是完成对对象本身的销毁,比如局部对象是存在栈帧的,函数结束栈帧销毁,它就释放了,不需要我们管,C++规定对象在销毁时会自动调用析构函数,完成对象中资源(指对象在其生命周期内申请的外部资源)的清理释放工作。而像 Date 类没有 Destroy,其实就是没有资源需要释放,所以严格说 Date 类是不需要析构函数的。那么析构函数的特点有哪些呢?
1. 析构函数名是在类名前加上字符~ (按位取反符)
2. 无参数无返回值(这里与构造类似,也不需要加void)
3. ⼀个类只能有⼀个析构函数。若未显式定义,系统会自动生成默认的析构函数
4. 对象生命周期结束时,系统会自动调用析构函数
前面有说过 Date 是不需要析构函数的,但是 Stack 类是需要析构函数的,下面来写一个 Stack 类和一个 MyQueue 类:
typedef int STDataType;
class Stack
{
public:Stack(int n = 4){m_arr = (STDataType*)malloc(sizeof(STDataType) * n);if (nullptr == m_arr){perror("malloc申请空间失败");return;}m_capacity = n;m_top = 0;}~Stack(){//若真的调用了析构函数,就打印"调用了析构函数"cout << "调用了析构函数" << endl;if (m_arr != nullptr){free(m_arr);m_arr = nullptr;}m_capacity = m_top = 0;}private:STDataType* m_arr;size_t m_capacity;size_t m_top;
};// 两个Stack实现队列
class MyQueue
{
public:// 编译器默认⽣成MyQueue的构造函数调⽤了Stack的构造// 完成了两个成员的初始化
private:Stack pushst;Stack popst;
};
实例化一个 Stack 对象,观察对象是否调用了析构函数:
从结果来看 Stack 类确实会调用析构函数。
5. 跟构造函数类似,用户不写析构函数,编译器自动生成的析构函数对内置类型成员不做处理,自定义类型成员会调用它的析构函数。还需要注意的是我们显示写析构函数,对于自定义定义类型成员也会调用它的析构函数,也就是说自定义类型成员无论什么情况都会自动调用析构函数
6. 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,如 Date ;如果默认生成的析构就可以用,也就不需要显示写析构,如 MyQueue ;但是有资源申请时,⼀定要自己写析构,否则会造成资源泄漏,如 Stack
根据以上所述,下面来验证以下,对于 MyQueue 类来说,不需要显示写析构:
typedef int STDataType;
class Stack
{
public:Stack(int n = 4){m_arr = (STDataType*)malloc(sizeof(STDataType) * n);if (nullptr == m_arr){perror("malloc申请空间失败");return;}m_capacity = n;m_top = 0;}~Stack(){//若真的调用了析构函数,就打印"调用了析构函数"cout << "调用了析构函数" << endl;if (m_arr != nullptr){free(m_arr);m_arr = nullptr;}m_capacity = m_top = 0;}private:STDataType* m_arr;size_t m_capacity;size_t m_top;
};// 两个Stack实现队列
class MyQueue
{
public:// 编译器默认⽣成MyQueue的构造函数调⽤了Stack的构造// 完成了两个成员的初始化
private:Stack pushst;Stack popst;
};int main()
{MyQueue mq;return 0;
}
上面的代码,并没有写 MyQueue 的析构函数,但是写了 Stack 类的析构函数,MyQueue 类调用析构函数时,会调用 Stack 类的析构函数,因为 MyQueue 就是通过两个栈来实现的,下面来看运行结果:
7. 若⼀个局部域有多个对象,C++规定后定义的对象先调用析构函数进行析构(后进先出)
4.3 拷贝构造函数
如果⼀个构造函数的第⼀个参数是自身类类型的引用,且任何额外的参数都有默认值,则此构造函数也叫做拷贝构造函数,也就是说拷贝构造是⼀个特殊的构造函数。具体如下所示:
Date date1(2025, 5, 4); // 构造函数
Date date2(date1); // 拷贝构造函数
Date date3 = date1; // 拷贝构造函数
接下来来介绍拷贝构造的特点:
1. 拷贝构造函数是构造函数的⼀个重载
2. 拷贝构造函数的第⼀个参数必须是类类型对象的引用,若使用传值方式进行传参,那么编译器会直接报错,因为语法逻辑上会引发无穷递归调用
下面就来写出 Date 类的拷贝构造函数:
//拷贝构造函数
// date2(date1) --- rdate是date1的别名
Date(Date& rdate)
{m_year = rdate.m_year;m_month = rdate.m_month;m_day = rdate.m_day;
}
为什么使用传值传参的方式进行传参就会发生无穷递归调用?这是因为C++规定了自定义类型对象在直接拷贝或传值传参拷贝中都需要调用拷贝构造函数,在使用传值方式传参时,每次在调用拷贝构造函数之前都得先传值传参,传值传参是一种拷贝,所以又会形成一个新的拷贝构造,以此往复就形成了无穷递归;若是引用传参,就不会发生这样的问题,因为这样在调用拷贝构造函数之前都得先传参,而这里是传引用传参,传引用传参不会拷贝,自然也就不会形成新的拷贝构造函数,也就不会发生无穷递归。
3. C++规定自定义类型对象在进行拷贝形为时必须调用拷贝构造函数,所以这里自定义类型传值传参和传值返回都会调用拷贝构造函数来完成
4. 若未显式定义拷贝构造,编译器会自动生成拷贝构造函数。自动生成的拷贝构造对内置类型成员变量会完成值拷贝/浅拷贝(⼀个字节⼀个字节的拷贝),对自定义类型成员变量会调用它的拷贝构造
来看以下的代码:
class Date
{
public://构造函数Date(int year = 1, int month = 1, int day = 1){m_year = year;m_month = month;m_day = day;}void DatePrint(){cout << m_year << "/" << m_month << "/" << m_day << endl;}private:int m_year;int m_month;int m_day;
};int main()
{Date date1(2025, 5, 5); // 调用构造函数Date date2(date1); // 调用拷贝构造函数Date date3 = date1;date2.DatePrint();date3.DatePrint();return 0;
}
由上代码可知,并没有显示写拷贝构造函数,那么 date2 与 date3 调用 DatePrint 函数的结果是什么呢?我们来运行一下:
date2 与 date3 竟然初始化了,这是因为这里自定义类型成员变量会调用了它的拷贝构造函数,而这个拷贝构造函数是编译器自己生成的。
既然编译器能自动生成拷贝构造函数,并且也能达到用户想要的效果,那么为什么还要用户自己去实现拷贝构造函数呢?
因为对于 Data 类实行一个字节一个字节的拷贝方法没有什么问题,但是对于其它的类,如 Stack 类,就会有问题了,由于默认生成的拷贝构造函数是浅拷贝/值拷贝,一个对象的修改会影响另一个对象的内容;并且在程序运行结束时,会调用析构函数两次,也就是说对同一个资源释放两次,这么做程序会崩溃。
像 Date 类这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的拷贝构造就可以完成需要的拷贝,所以不需要我们显⽰实现拷贝构造。像 Stack 这样的类,虽然也都是内置类型,但是当中的成员变量 m_arr 指向了资源,编译器自动生成的拷贝构造完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。 像 MyQueue 这样的类型内部主要是自定义类型 Stack 成员,编译器自动生成的拷贝构造函数会调用 Stack 的拷贝构造函数,也不需要我们显示实现 MyQueue 的拷贝构造函数。
这里还有⼀个小技巧,如果⼀个类显示实现了析构函数并释放资源,那么它就需要显示写拷贝构造函数,否则就不需要显示写拷贝构造函数。
前面多次提到了浅拷贝/值拷贝 和 深拷贝,那么这些名词的意思是什么呢?
• 浅拷贝:仅复制指针(地址),多个对象共享同一块内存(危险,可能导致崩溃)
• 深拷贝:复制指针(地址) + 数据,每个对象独立管理内存(安全,但稍慢)
• 默认拷贝是浅拷贝
5. 传值返回会产生⼀个临时对象调用拷贝构造函数,传值引引用返回,返回的是返回对象的别名(引用),没有产生拷贝。但是如果返回对象是⼀个当前函数局部域的局部对象,函数结束就销毁了,那么使用引用返回是有问题的,这时的引用相当于⼀个野引用,类似⼀个野指针⼀样。传引用返回可以减少拷贝,但是⼀定要确保返回对象在当前函数结束后还在,这样才能用传引用返回
4.4 赋值重载函数
日期计算器网址链接: link
4.4.1 运算符重载
若我们想要比较两个日期的大小,可以使用函数来实现,那么我们可不可以直接用算术运算符号来实现呢?答案是不可以,因为日期是自定义类型。为什么自定义类型不能直接用符号去比较两个日期的大小,而内置类型就可以用符号来比较大小呢?因为内置类型的比较都是较为简单的,并且库中针对这些内置类型可以转化成相应的指令;而自定义类型的比较方式很复杂,自定义的类型又多样,因此自定义类型的比较方式应该由使用它们的用户自己来实现的。因此C++为用户提供了运算符重载 —— 当运算符被用于类类型的对象时,C++语言允许我们通过运算符重载的形式指定新 的含义。C++规定类类型对象使用运算符时,必须转换成调用对应运算符重载,若没有对应的运算符重载,则会编译报错
运算重载的语法规则:
1. 运算符重载是具有特殊名字的函数,它的名字是由 operator 和后⾯要定义的运算符共同构成。和其他函数⼀样,它也具有其返回类型(看运算后的结果的类型是什么)和参数列表以及函数体
2. 重载运算符函数的参数个数和该运算符作⽤的运算对象数量⼀样多。⼀元运算符有⼀个参数,⼆元运算符有两个参数,⼆元运算符的左侧运算对象传给第⼀个参数,右侧运算对象传给第⼆个参数
接下来我们就运用运算符重载来比较两个日期是否相等:
class Date
{
public://构造函数Date(int year = 1, int month = 1, int day = 1){m_year = year;m_month = month;m_day = day;}//拷贝构造函数// date2(date1) --- rdate是date1的别名Date(Date& rdate){m_year = rdate.m_year;m_month = rdate.m_month;m_day = rdate.m_day;}void DatePrint(){cout << m_year << "/" << m_month << "/" << m_day << endl;}private:int m_year;int m_month;int m_day;
};//全局函数
//重载 == 符号 —— 用引用来作为函数的参数可以减少拷贝次数,提高效率
bool operator==(const Date& rdatef, const Date& rdateb)
{return rdatef.m_year == rdateb.m_year&& rdatef.m_month == rdateb.m_month&& rdatef.m_day == rdateb.m_day;
}
date1 == date2,rdatef是date1的别名,rdateb是date2的别名。此外在写 == 运算符时,很容易写成 = 运算符,写成这样之后编译可能不会报错,为此这里可以使用 const 引用,使用const 引用的好处:
- 既可以接收被const修饰的实参,也可以接收未被const修饰的实参
- 避免了函数的参数被改变(前提是这些参数本应该不被改变)
事实上,上述的代码在编译器中运行时会报错,这是因为类的成员变量是私有的,外部的对象不能访问这些私有的成员变量,那么有什么办法可以访问到这些成员变量呢?下面来介绍三个方法:
1. 提供公有的成员函数 Get### ,来访问类的成员变量,由于它是公有的成员函数,所以外部的对象也可以访问这个 Get### 函数,这样就间接访问到了成员变量
2. 直接将运算符重载函数放在类里面。这样不就可以直接访问到类中的成员变量了吗
3. 友元函数
下面使用方法2来编写判断两个日期类对象的大小是否相等:
class Date
{
public://构造函数Date(int year = 1, int month = 1, int day = 1){m_year = year;m_month = month;m_day = day;}//拷贝构造函数// date2(date1) --- rdate是date1的别名Date(const Date& rdate){m_year = rdate.m_year;m_month = rdate.m_month;m_day = rdate.m_day;}void DatePrint(){cout << m_year << "/" << m_month << "/" << m_day << endl;}//重载 == 符号bool operator==(const Date& rdatef, const Date& rdateb){return rdatef.m_year == rdateb.m_year&& rdatef.m_month == rdateb.m_month&& rdatef.m_day == rdateb.m_day;}private:int m_year;int m_month;int m_day;
};
但是当我们将原来位于全局域的函数复制到类域里面后,运行该程序,编译器会报错:
咦,== 运算符不就是二元运算符吗?那么不就是两个参数吗?因为这里的运算符重载函数是成员函数,成员函数的参数有一个隐含的 this 指针,且在成员函数中默认第一个参数为this指针,所以这里表面上运算符重载函数只有两个参数,但是实际上有三个参数。所以直接显示写一个参数即可,如下代码所示:
// 重载 == 符号
// date1 == date2
// this指针接收的是date1的地址 rdate是date2的别名
bool operator==(const Date& rdate)
{return m_year == rdate.m_year&& m_month == rdate.m_month&& m_day == rdate.m_day;
}
接下来调用该函数:
int main()
{Date date1(2025, 5, 4);Date date2(2025, 5, 5);Date date3(2025, 5, 5);//调用方式有两种cout << (date1 == date2) << endl;cout << (date2.operator==(date3)) << endl; //这里的最外的括号可以去掉return 0;
}
调用结果:
3. C++中提到如果⼀个重载运算符函数是成员函数,则它的第⼀个运算对象默认传给隐式的this指针,因此运算符重载作为成员函数时,参数比运算对象少⼀个。因此尤其要注意重载二元操作符时形参的顺序不能轻易交换
4. 不能通过连接语法中没有的符号来创建新的操作符:比如operator¥,operator@;⼀个类需要重载哪些运算符,是看哪些运算符重载后有意义,如 Date 类中,日期 - 日期 就有意义,日期 + 日期 就没有意义。
重载操作符至少有⼀个类类型参数,不能通过运算符重载改变内置类型对象的含义,以上5个运算符不能重载: .* (点星操作符) ::(域作用限定符) sizeof ?:(三目操作符) .(点操作符)
了解运算符重载函数的语法规则之后,接下来就以日期类 Date 为基础,实现相关运算符的重载:
4.4.1.1 日期 += 天数 与 日期 + 天数
实现日期 += 天数,这也就是需要重载 += 运算符:
日期 += 天数的结果对象仍然是日期类类型,也就是 Date ,所以函数的返回值类型是Date 类型。那么怎么实现日期 += 天数呢?天数满了月就进1,月数满了,年就进1即可;最复杂的就是月的天数是不一样的,因此需要定义一个数组,将每个月有多少天存储起来,二月的天数先为28天,若为闰年,那么二月就为29天。先让调用该函数的对象的m_day成员变量加上天数,然后不断的减当前月份的天数,直到剩下的天数小于当前月份的天数,若月份加到13时,年就可以进1,并且月份变为1月,为此需要创建一个函数用来获取当前月份的天数。分析完毕后,下面开始编写代码:
获取当前月份的天数的函数:
int GetMonthDay(int year, int month)
{assert(month > 0 && month < 13);int arr[13] = { -1, 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;}else{return arr[month];}
}
日期 += 天数:
// 日期 += 天数 —— date + day(100)
// +=天数之后,原对象会改变
//出作用域后,原对象还存在,所以可以用传引用返回
Date& operator+=(int day)
{m_day += day;while (m_day > GetMonthDay(m_year, m_month)){m_day -= GetMonthDay(m_year, m_month);++m_month;if (m_month == 13){m_month = 1;++m_year;}}//this指针指向的就是调用该函数的对象return *this;
}
调用该函数,并观察其运行结果:
可以看到调用该函数的对象被改变了。
接着用日期计算器来验证代码运算的结果:
根据日期 += 天数,来实现日期 + 天数:
//日期 + 天数 —— date + day(100)
// + 天数之后,原对象不会改变,因此要事先将原对象拷贝一份
//出作用域后,对象被析构了,所以不能用传引用返回,只能用传值返回
Date operator+(int day)
{//this指针指向的就是调用该函数的对象Date tmp(*this); // 调用了拷贝构造函数tmp.m_day += day;while (tmp.m_day > GetMonthDay(tmp.m_year, tmp.m_month)){tmp.m_day -= GetMonthDay(tmp.m_year, tmp.m_month);++tmp.m_month;if (tmp.m_month == 13){tmp.m_month = 1;++tmp.m_year;}}return tmp;
}
调用该函数,并观察其运行结果:
可以看到调用该函数的对象未被改变。
4.4.1.2 日期 -= 天数 与 日期 - 天数
实现了日期 += 天数,接下来来实现 日期 -= 天数:
思路与日期 += 天数的是一样的,日期 -= 天数的结果对象仍然是日期类类型,也就是 Date ,所以函数的返回值类型是Date 类型。那么怎么实现日期 -= 天数呢?先让调用该函数的对象的m_day成员变量减去天数,然后不断的加前一个月份的天数,直到剩下的天数大于0。分析完毕,下面开始编写代码:
//日期 -= 天数
// -= 天数之后,调用该函数的对象会改变
Date& operator-=(int day)
{m_day -= day;while (m_day <= 0){--m_month;if (m_month == 0){m_month = 12;--m_year;}//取上一个月份的天数m_day += GetMonthDay(m_year, m_month);}return *this;
}
调用该函数,并观察其运行结果:
可以看到调用该函数的对象被改变了。
接着用日期计算器来验证代码运算的结果:
根据日期 -= 天数,来实现日期 - 天数:
//日期 - 天数
// - 天数之后,调用该函数的对象不会改变
Date operator-(int day)
{Date tmp(*this);tmp.m_day -= day;while (tmp.m_day <= 0){--tmp.m_month;if (tmp.m_month == 0){tmp.m_month = 12;--tmp.m_year;}//取上一个月份的天数tmp.m_day += GetMonthDay(tmp.m_year, tmp.m_month);}return tmp;
}
调用该函数,并观察其运行结果:
但是日期 -= 天数与日期 - 天数的代码实现有些冗余,可不可以只实现其中一个,另一个调用已实现的代码呢?当然可以。下面就先实现日期 -= 天数,然后实现日期 - 天数时,就调用日期 -= 天数的函数:
//日期 -= 天数
// -= 天数之后,调用该函数的对象会改变
Date& operator-=(int day)
{m_day -= day;while (m_day <= 0){--m_month;if (m_month == 0){m_month = 12;--m_year;}//取上一个月份的天数m_day += GetMonthDay(m_year, m_month);}return *this;
}//调用日期 -= 天数的函数来实现日期 - 天数
Date operator-(int day)
{Date tmp(*this);tmp -= day; //调用了日期 -= 天数的函数return tmp;
}
调用该函数,并观察其运行结果:
接下来先实现日期 - 天数,然后实现日期 -= 天数时,就调用日期 - 天数的函数:
//日期 - 天数
// - 天数之后,调用该函数的对象不会改变
Date operator-(int day)
{Date tmp(*this);tmp.m_day -= day;while (tmp.m_day <= 0){--tmp.m_month;if (tmp.m_month == 0){tmp.m_month = 12;--tmp.m_year;}//取上一个月份的天数tmp.m_day += GetMonthDay(tmp.m_year, tmp.m_month);}return tmp;
}//调用日期 - 天数的函数来实现日期 -= 天数
Date& operator-=(int day)
{*this = *this - day; //赋值拷贝return *this;
}
也许会有人认为可以这样写:
//调用日期 - 天数的函数来实现日期 -= 天数
Date& operator-=(int day)
{return *this - day;
}
直接返回 *this - day ,这样写不对,因为日期 -= 天数之后,调用该函数的对象会被改变,如果日期 -= 天数的代码写成上面的那样,那么调用该函数的对象并未发生改变,只是返回了 -= 之后的结果。
调用该函数,并观察其运行结果:
既然两种方法都可以达到目标结果,那么哪种方法更好?先实现 -= 重载函数,再实现 – 重载函数时调用 -= 重载函数更好一些;下面来一一分析:因为先实现 - 重载函数,再实现 –= 重载函数时调用 - 重载函数的拷贝会多一些。
假设这两个版本实现的函数各调用一次,也就是 data1 – 100,data2 -= 100
第一种方法:-=重载函数中没有拷贝的地方,在 – 重载函数中Data tmp(*this)这里拷贝了一次,return tmp这里拷贝了一次,所以一共拷贝了两次;
第二种方法:- 重载函数中 Data tmp(*this) 这里拷贝了一次,return tmp 这里拷贝了一次,在 -= 重载函数中,由于 -= 重载函数调用了 – 重载函数,所以也有两次,再加上最后的 *this = *this - day 这里的赋值,也拷贝了一次,所以一共拷贝了五次。
由此可见第一种方法好,即先实现 -= 重载函数,再实现 – 重载函数时调用 -= 重载函数更好一些
4.4.1.3 改进前面实现的代码
需要注意的是,有可能用户会实现以下的逻辑:
为什么 += 后的天数是负的?出现这个问题的原因是 += 这里的参数没有处理好,既然+=重载函数中的参数day可以传正值,那么也可传负值,负值是我们没有考虑到的。改进后的 += 函数代码如下:
// 日期 += 天数 —— date + day(100)
// +=天数之后,原对象会改变
Date& operator+=(int day)
{//处理参数day为负值的情况if (day < 0){//调用 -= 重载函数return *this -= -day;}m_day += day;while (m_day > GetMonthDay(m_year, m_month)){m_day -= GetMonthDay(m_year, m_month);++m_month;if (m_month == 13){m_month = 1;++m_year;}}//this指针指向的就是调用该函数的对象return *this;
}
调用该函数,观察其运行结果:
-= 重载函数的代码也可以稍作改进:
//日期 -= 天数
// -= 天数之后,调用该函数的对象会改变
Date& operator-=(int day)
{//处理参数day为负值的情况if (day < 0){return *this += -day;}m_day -= day;while (m_day < 0){--m_month;if (m_month == 0){m_month = 12;--m_year;}//取上一个月份的天数m_day += GetMonthDay(m_year, m_month);}return *this;
}
4.4.1.4 重载前置与后置运算符
若想要重载 前置++ 与 后置++ 这两个操作符,那么该怎么区分哪个是前置++,哪个是后置++呢?重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,无法很好的区分。 C++规定,后置++重载时,增加⼀个int形参,跟前置++构成函数重载,方便区分,前置 – 与 后置 – 也是如此。如下所示:
//前置++
Date operator++() // ++date
{}//后置++
Date operator++(int) // date++
{}//前置--
Date operator--() // --date
{}//后置--
Date operator--(int) // date--
{}
后置 ++ 与 后置 – 的重载函数的括号里面可以任意 int 类型的变量,也可以不写;而传参时,可以传任意 int 类型的值,如下所示:
Date date1(2025, 5, 5);
Date date2 = date1++(12); //后置++
Date date3 = date1--(12); //后置--
下面来具体实现重载前置与后置操作符:
前置操作符,++ 或 – 之后,调用该函数的对象会改变:
//前置++
Date& operator++()
{*this += 1;return *this;
}//前置--
Date& operator--()
{*this -= 1;return *this;
}
后置操作符,++ 或 – 之后,调用该函数的对象不会改变:
//后置++
Date operator++(int)
{Date tmp(*this);tmp += 1;return tmp;
}//后置--
Date operator--(int)
{Date tmp(*this);tmp -= 1;return tmp;
}
调用前置 ++ 与前置 – 运算符重载函数:
调用后置 ++ 与后置 – 运算符重载函数:
4.4.1.5 重载比较运算符
重载 == ,!=,<,<=,>,>= 这六个运算符,可以先实现 == 与 < 或 > 重载函数,之后的重载函数调用这两个函数即可:
重载 == 运算符:
// 重载 == 运算符
bool operator==(const Date& rdate)
{return m_year == rdate.m_year&& m_month == rdate.m_month&& m_day == rdate.m_day;
}
重载 < 运算符:
//重载 < 运算符
bool operator<(const Date& rdate)
{// 先把小的情况都找出来,返回true// 那么剩下的就是大的情况,直接返回false// 年小则小if (m_year < rdate.m_year){return true;}//年相等,月小则小else if (m_year == rdate.m_year && m_month < rdate.m_month){return true;}//年相等,月相等,天小则小else if (m_year == rdate.m_year && m_month == rdate.m_month && m_day < rdate.m_day){return true;}return false;
}
实现了 == 与 < 的重载函数后,实现剩下的运算符重载函数就简单多了。
重载 != 运算符:
//重载 != 运算符
bool operator!=(const Date& rdate)
{//对等于取反,就是不等于return !(*this == rdate);
}
重载 <= 运算符:
//重载 <= 运算符
bool operator<=(const Date & rdate)
{//<= 就是 < 和 == 并起来return (*this < rdate) || (*this == rdate);
}
重载 > 运算符:
//重载 > 运算符
bool operator>(const Date& rdate)
{//对 <= 取反,就是大于return !(*this <= rdate);
}
重载 >= 运算符:
//重载 >= 运算符
bool operator>=(const Date& rdate)
{//对 < 取反,就是大于等于return !(*this < rdate);
}
若有什么地方需要修改的,直接修改源头函数即可。这种实现类的函数的方法适用于任何类,在实现某些逻辑相关的函数时,可以使用这种实现方法。
下面调用这些比较运算符的重载函数:
4.4.1.6 日期 - 日期
若想要计算两个日期之间相差的天数,可以编写一个函数来实现。那么怎么设计这个函数呢?怎么计算两个日期之间相差的天数呢?首先要找到较大的日期与较小的日期,这个在数据结构的堆中遇到过很多次。先假设一个日期为大日期,再假设另一个日期为假日期,若假设不正确,之后再调整。再定义一个计数器变量,用来计算两个日期之间相差的天数,让小的日期不断的 ++ ,直到等于大的日期,此时 count 的值就是这两个日期之间相差的天数。分析完毕,下面开始编写代码:
//日期 - 日期 —— datefront - datebefore
int operator-(const Date& rdate)
{//先假设 this 指针指向的日期对象为大的日期//rdate日期对象为小的日期Date max = *this;Date min = rdate;//若假设不正确,进行调整if (max < min) // 调用了 < 重载函数{max = rdate;min = *this;}//定义一个计算器变量int count = 0;while (min != max){count++;++min;}return count;
}
调用该函数,观察其运行结果:
接着用日期计算器来验证代码运算的结果:
4.4.1.7 重载流插入与流提取函数
在我们使用 << 打印数据,>> 输入数据时,可以打印或者输入任意内置类型的数据,这是因为 std 库中,重载了 << 流插入运算符 与 >> 流提取运算符。cout 被包含在 ostream 对象中,cin 被包含在 istream 对象中。接下来我们重载这两个操作符,以便自定义类型也能使用这两个操作符:与之前写的重载函数一样,写在类中,作为成员函数:
ostream& operator<<(ostream& rout)
{rout << m_year << "年" << m_month << "月" << m_day << "日";return rout;
}
调用该函数:
cout << date1;
这是不是有些不对劲?流插入运算符重载函数是成员函数,那么它的默认第一个参数是 this 指针,而这个this指针指向的对象是 cout ,那么参数 rout 就是 date1 的别名,那么调用该重载函数时,需要写成这样 —— date1 << cout,这样不符合使用的习惯。为了避免 this 指针占用第一个参数,需要将该函数重载成全局函数。
重载 << 和 >> 时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第⼀个形参位置,第⼀个形参位置是左侧运算对象,调用时就变成了对象 <<cout ,不符合使用习惯和可读性。重载为全局函数把ostream/istream放到第⼀个形参位置就可以了,第二个形参位置当类类型对象
但是问题又来了作为全局函数是不能访问到类的私有对象的,既然如此该怎么办呢?前面有提到过三种方法,现在使用第三种方法 —— 友元函数。友元函数的关键字为friend ,运用关键字 friend 将该函数设置为友元函数,友元函数的声明是写在类里的,声明之后就可以使用调用类中的成员变量了,友元函数的具体相关特点请看下文。下面就来正确编写流插入运算符的重载函数:
class Date
{
public://友元函数的声明friend ostream& operator<<(ostream& rout, Date& rdate);private:int m_year;int m_month;int m_day;
};//全局函数 —— 流插入运算符重载函数
ostream& operator<<(ostream& rout, Date& rdate)
{rout << rdate.m_year << "年" << rdate.m_month << "月" << rdate.m_day << "日";return rout;
}
调用该函数,观察其运行结果:
由于返回值是 ostream 类型。所以支持连续流插入,也就是连续打印。
实现了流插入运算符的重载函数之后,接下来来实现流提取运算符的重载函数:
class Date
{
public://友元函数的声明friend istream& operator>>(istream& rin, Date& rdate);private:int m_year;int m_month;int m_day;
};//全局函数 —— 流提取运算符重载函数
istream& operator>>(istream& rin, Date& rdate)
{cout << "请输入年月日:";rin >> rdate.m_year >> rdate.m_month >> rdate.m_day;return rin;
}
调用该函数,观察其运行结果:
但是重载的流提取函数存在一些问题:当输入的日期是非法值时,它仍会打印,如下所示:
所以对于非法日期需要进行处理,改进后的代码如下:
class Date
{
public:int GetMonthDay(int year, int month){assert(month > 0 && month < 13);int arr[13] = { -1, 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;}else{return arr[month];}}bool CheckDate(int m_year, int m_month, int m_day){if (m_year < 0 || m_month < 1 || m_month > 12 ||m_day > GetMonthDay(m_year, m_month) || m_day < 1){return false;}return true;}//友元函数的声明friend istream& operator>>(istream& rin, Date& rdate);private:int m_year;int m_month;int m_day;
};istream& operator>>(istream& rin, Date& rdate)
{while (1){cout << "请输入年月日:";rin >> rdate.m_year >> rdate.m_month >> rdate.m_day;if (rdate.CheckDate(rdate.m_year, rdate.m_month, rdate.m_day) == true){break;}else{cout << "非法日期,请重新输入" << endl;}}return rin;
}
由于返回值是 istream 类型。所以支持连续流提取,也就是连续输入。
调用该函数,观察其运行结果:
4.4.2 赋值运算符重载
赋值运算符重载是⼀个默认成员函数,用于完成两个已经存在的对象直接的拷贝赋值,这里要注意跟拷贝构造区分,拷贝构造用于⼀个对象拷贝初始化给另⼀个要创建的对象。如下所示:
int main()
{Date date1(2025, 5, 5);Date date2(date1); // 调用拷贝构造函数Date date4 = date1; // 调用拷贝构造函数Date date3(2027, 6, 10);date1 = date3; //调用赋值运算符重载函数return 0;
}
下面就来介绍赋值运算重载符的特点:
1. 赋值运算符重载是⼀个运算符重载,规定必须重载为成员函数。赋值运算重载的参数建议写成 const 当前类类型引用,否则会进行传值传参,且会有拷贝
接下来自己来实现运算符重载:
//赋值运算符重载
void operator=(const Date& rdate)
{m_year = rdate.m_year;m_month = rdate.m_month;m_day = rdate.m_day;
}
调用该函数,观察其运行结果:
由结果可知, date2 的值赋值给 date1 了。
2. 赋值运算符重载函数有返回值,且建议写成当前类类型引用,引用返回可以提⾼效率,有返回值目的是为了支持连续赋值场景
代码如下所示:
Date& operator=(const Date& rdate)
{m_year = rdate.m_year;m_month = rdate.m_month;m_day = rdate.m_day;return *this;
}
调用该函数,观察其运行结果:
连续赋值是从右往左依次赋值。赋值重载运算符函数也支持对象自己给自己赋值,当要考虑对象是否自己给自己赋值时,代码可以这样写:
Date& operator=(const Date& rdate)
{if (this != &rdate){m_year = rdate.m_year;m_month = rdate.m_month;m_day = rdate.m_day;}return *this;
}
3. 没有显式实现时,编译器会自动生成⼀个默认赋值运算符重载,默认赋值运算符重载行为跟默认拷贝构造函数类似,对内置类型成员变量会完成值拷贝/浅拷贝(⼀个字节⼀个字节的拷贝),对自定义类型成员变量会调用它的赋值重载函数
4. 像 Date 这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的赋值运算符重载就可以完成需要的拷贝,所以不需要我们显示实现赋值运算符重载。
像 Stack 这样的类,虽然也都是内置类型,但是 m_arr 指向了资源,编译器自动生成的赋值运算符重载完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。
像 MyQueue 这样的类型内部主要是自定义类型Stack成员,编译器自动生成的赋值运算符重载会调用 Stack 的赋值运算符重载, 也不需要我们显示实现 MyQueue 的赋值运算符重载。
这里还有⼀个小技巧,如果⼀个类显示实现了析构并释放资源,那么它就需要显示写赋值运算符重载,否则就不需要。
4.5 取地址运算符重载
4.5.1 const 成员函数
观察如下代码:
class Date
{
public://构造函数Date(int year = 1, int month = 1, int day = 1){m_year = year;m_month = month;m_day = day;}void DatePrint(){cout << m_year << "/" << m_month << "/" << m_day << endl;}private:int m_year;int m_month;int m_day;
};int main()
{Date date1(2025, 5, 5);const Date date2(2027, 6, 10);date1.DatePrint();date2.DatePrint();return 0;
}
运行该代码会发现程序会报错,因为 date2 被 const 修饰了,不能调用 DatePrint 函数,这是为什么呢?因为 date1 的地址的类型为 Date*,date2的地址的类型为 const Date* ,而 DatePrint 函数的参数 this 指针为 Date* ,这里涉及到了权限的放大。
date2 将地址传给 DatePrint 函数的参数 this 指针时,涉及到了权限的放大,权限只能缩小,不能放大。要想让该程序不报错,只能修改 DatePrint 函数的参数 this 指针或者将 DatePrint 变成 const 类型的函数,但是前面有提到过形参 this 指针不能显示写,所以只剩下一个办法,修改函数。
C++规定在函数的后面加上 const 后,该函数就成为了 const 函数,若原来的函数是成员函数,被 const 修饰后也称为 const 成员函数。放在函数后面的 const 修饰的是成员函数隐含的 this 指针,表明在该成员函数中不能对类的任何成员进行修改。修改后的代码如下所示:
class Date
{
public://构造函数Date(int year = 1, int month = 1, int day = 1){m_year = year;m_month = month;m_day = day;}void DatePrint() const{cout << m_year << "/" << m_month << "/" << m_day << endl;}private:int m_year;int m_month;int m_day;
};int main()
{Date date1(2025, 5, 5);const Date date2(2027, 6, 10);date1.DatePrint();date2.DatePrint();return 0;
}
了解了 const 成员函数之后,我们可以对之前实现的函数进行优化,只要是不改变调用函数对象的函数,其后都可以加上 const,如之前写的 日期 ± 天数,前置运算符重载函数,所有的比较运算符重载函数等等。
4.5.2 取地址运算符重载
取地址运算符重载分为普通取地址运算符重载和 const 取地址运算符重载。当普通取地址运算符重载和const取地址运算符重载同时存在的时候,对象调用函数时会调用最匹配的重载函数,对于普通对象调用取地址运算符函数,若只有两个函数中的其中一个,就调用该函数;对于被 const 修饰的对象调用取地址运算符函数,只能调用 const 取地址运算符重载函数
这两个重载函数如下所示:
//取地址运算符重载//普通取地址运算符重载函数
Date* operator&()
{//return nullptr;return this;
}//被const修饰的取地址运算符重载函数
const Date* operator&() const
{//return nullptr;return this;
}
不同于之前的四个重载函数,由于这两个取地址重载函数都是默认重载函数,用户不写编译器会自动帮助生成,所以这个不需要用户去自己实现,了解即可。当你不期望取得对象的地址时,可以自己实现取地址运算符重载,甚至自己实现可以返回假地址,但是正常编写代码的情况下是不可能这样写的。
5. 类的其它知识
5.1 类型转换
C语言的类型转换有:整型之间的转换,整型与浮点型之间的转换,整型与指针之间的转换,指针与指针之间的转换。
C++支持内置类型隐式类型转换为类类型对象,需要有相关内置类型为参数的构造函数 (注意:没有关联的两个类型之间是不能相互类型转换的,那怎么有关联呢?需要有相关内置类型为参数的构造函数)。转化方式如下所示:
class A
{
public:A(int txt): m_txt1(txt){}void APrint(){cout << m_txt1 << endl;}private:int m_txt1;int m_txt2;
};int main()
{A a = 5;a.APrint();return 0;
}
打印的结果为 5 。为什么 a 可以直接 =5 呢?这是因为它们之间发生了类型转换。5 构造了一个 A 的临时对象,再用的这个临时对象拷贝构造 a,编译器遇到连续构造和拷贝构造时,会直接优化为直接构造。不仅可以单参数类型转换,也可以多参数类型转换,如下代码所示:
class A
{
public://多参数A(int txt1, int txt2):m_txt1(txt1), m_txt2(txt2){}void APrint(){cout << m_txt1 << " " << m_txt2 << endl;}private:int m_txt1;int m_txt2;
};int main()
{A a = { 5, 6 };a.APrint();return 0;
}
内置类型可以转换为类类型,类类型的对象之间也可以隐式转换,同样需要相应的构造函数支持。
class A
{
public://单参数A(int txt): m_txt1(txt){}//多参数A(int txt1, int txt2):m_txt1(txt1), m_txt2(txt2){}int GetTxt1() const{return m_txt1;}void APrint(){cout << m_txt1 << " " << m_txt2 << endl;}private:int m_txt1;int m_txt2;
};class B
{
public://相关构造函数B(const A& txt): m_work1(txt.GetTxt1()){}private:int m_work1;int m_work2;
};int main()
{A txt1 = 5;A txt2 = { 5, 6 };//类类型对象之间的转换B work1 = txt1;const B work2 = txt2;return 0;
}
构造函数前面加explicit就不再⽀持隐式类型转换。
5.2 static 成员
用 static 修饰的成员变量,称之为静态成员变量,静态成员变量⼀定要在类外进行初始化。static 成员一般在类中声明,在类外定义,定义的时候要指定类域。如下所示:
class C
{
public:void CPrint(){cout << s_c << endl;}private://声明static int s_c;
};//定义 —— 指定类域
C::int s_c = 0;
静态成员变量为所有类对象所共享,不属于某个具体的对象,属于整个类,不存在对象中,存放在静态区。
静态成员变量不能在声明位置给缺省值初始化,因为缺省值是个构造函数初始化列表的,静态成员变量不属于某个对象,不走构造函数初始化列表。
静态成员也是类的成员,受public、protected、private访问限定符的限制。
若外部的成员想要访问到 static 成员变量,与访问普通的成员类似,只是这里要编写一个 static 成员函数 Get### 来获取想要访问的成员变量:
static int GetC()
{return s_c;
}
用 static 修饰的成员函数,称之为静态成员函数,静态成员函数没有 this 指针。静态成员函数中可以访问其他的静态成员,但是不能访问非静态的,因为没有this指针。非静态的成员函数,可以访问任意的静态成员变量和静态成员函数。
若 static 成员变量和 static 成员函数是公有的,可以通过 类名::静态成员 或者 对象.静态成员 来访问静态成员变量和静态成员函数。
5.3 友元函数
前面提到过,在类的外面是不能访问类的私有或保护成员的,若想要访问,有三种办法,而第三种办法就是友元函数,下面就来详细的介绍友元函数。
友元提供了⼀种突破类访问限定符封装的方式,友元分为:友元函数和友元类,在函数声明或者类声明的前面加 friend,并且把友元声明放到⼀个类的里面。外部友元函数可访问类的私有和保护成员,友元函数仅仅是⼀种声明,它不是类的成员函数。
友元函数可以在类定义的任何地方声明,不受类访问限定符限制;⼀个函数可以是多个类的友元函数
一个函数可以是多个类的友元函数:
//前置声明,否则Work的友元函数声明都不认识Test
class Test;class Work
{
public:// 友元函数的声明friend void TotalPrint(const Work& work, const Test& test);private:int m_work1;int m_work2;
};class Test
{
public:// 友元函数的声明friend void TotalPrint(const Work& work, const Test& test);private:int m_test1;int m_test2;
};void TotalPrint(const Work& work, const Test& test)
{cout << work.m_work1 << work.m_work2 << endl;cout << test.m_test1 << test.m_test2 << endl;
}int main()
{Work work;Test test;TotalPrint(work, test);return 0;
}
友元类中的成员函数都可以是另⼀个类的友元函数,都可以访问另⼀个类中的私有和保护成员。
友元类的关系是单向的,不具有交换性,比如A类是B类的友元,但是B类不是A类的友元;友元类关系不能传递,如果A是B的友元,B是C的友元,但是A不是C的友元。
友元类的使用如下:
class Test
{
public://友元类的声明friend class Work;private:int m_test1;int m_test2;
};class Work
{
public:void TotalPrint(const Test& test){cout << test.m_test1 << " " << test.m_test2 << endl;cout << m_work1 << " " << m_work2 << endl;}private:int m_work1;int m_work2;
};
尽管友元函数为我们提供了便利,但是友元会增加耦合度,本来C++设计这些关键字是为了避免外部随意访问,现在一个友元函数声明,就可以随意访问了,破坏了类的封装性,所以友元不宜多用。
5.4 内部类
如果⼀个类定义在另⼀个类的内部,那么这个定义在类的内部的就叫做内部类。下面就来看一串代码:
class Test
{
public:class Work{public:void TotalPrint(const Test& test){cout << m_work1 << " " << m_work2 << endl;}private:int m_work1;int m_work2;};private:int m_test1;static int s_test2;
};
在上面的代码中,Work 就是 Test 类的内部类,那么下面来回答一个问题,Test 类的大小是多少字节?猜想: Test 类中不计算成员函数的大小,static 成员的大小(静态成员变量存储在静态区,没有存储在对象上),那么 Test 类中就还有3个成员对象,一个是 Test 类中的 m_test1 ,另外两个是 Work 类的m_work1,m_work2,且都是int类型,所以 Test 类的大小为12个字节。接下来就来验证猜想是否正确,用 sizeof 来计算:
怎么是 4 个字节呢?其实 Test 类中只有一个成员对象—— m_test1。Work 类的成员变量不属于 Test 类,即便 Work 是 Test 的内部类。内部类是一个独立的类,跟定义在全局相比,它只是受外部类类域限制和访问限定符限制,所以外部类定义的对象中不包含内部类,内部类不是外部类的成员。上面的代码将内部类设置成为公有的,那么在类的外部就可以实例化 Work 类的对象,但是若将内部类设置成私有的,那么类的外部就不能直接访问 Work 类中的对象。还有一点是内部类默认是外部类的友元类,即 Work 类默认是 Test 类的友元类。什么时候使用内部类呢?当一个类是另一个类专属使用的类的时候,这时就可以使用内部类。但是C++中很少使用内部类。
5.5 匿名对象
什么是匿名对象呢?在此之前,我们定义的对象都是有名对象,有名对象与匿名对象的定义区别如下:
//有名对象
Test test1;
Test test2(5);//匿名对象
Work();
Work(5, 7);
当要调用无参的默认构造函数的时,定义匿名对象时需要在最后面加上括号,而有名对象的最后不能加括号。匿名对象与临时对象有些许相似,只是匿名对象是用户自己主动创建的,而临时对象是涉及类型转换,传返回值等场景下,编译器自己创建的。尤其要注意的时,匿名对象的生命周期是在它当前所在的行,用下面的代码来进一步的说明 —— 匿名对象的生命周期是在它当前所在的行:
若果真匿名对象的生命周期在它当前所在行,那么程序执行完改行后,它就会调用析构函数,那么调试控制台窗口就会打印 " 调用了析构函数 "。
class Test
{
public:Test(int test):m_test1(test){}Test(): m_test1(7){}~Test(){cout << "调用了~Test析构函数" << endl;}private:int m_test1;static int s_test2;
};static int s_test2 = 2;class Work
{
public:Work(int work1, int work2): m_work1(work1), m_work2(work2){}Work(): m_work1(6), m_work2(10){}~Work(){cout << "调用了~Work析构函数" << endl;}void TotalPrint(const Test& test){cout << m_work1 << " " << m_work2 << endl;}private:int m_work1;int m_work2;
};int main()
{ //有名对象Test test1;Test test2(5);//匿名对象Work();Work(5, 7);return 0;
}
调试结果如下:
显而易见,在执行完匿名对象所在的行代码后,就调用了析构函数,而有名对象却没有调用析构函数,这就是二者的区别。
什么时候使用匿名对象呢?一般临时定义⼀个对象当前用⼀下,就可以定义匿名对象。
6. 结言
类和对象是早期学习C++过程中,遇到的较为困难的一个知识点,它为后续的C++学习奠定了基石。