🚀 作者简介:一名在后端领域学习,并渴望能够学有所成的追梦人。
🚁 个人主页:不 良
🔥 系列专栏:🛸C++ 🛹Linux
📕 学习格言:博观而约取,厚积而薄发
🌹 欢迎进来的小伙伴,如果小伙伴们在学习的过程中,发现有需要纠正的地方,烦请指正,希望能够与诸君一同成长! 🌹
文章目录
- 面向过程和面向对象初步认识
- 类的引入
- 类的定义
- 类的访问限定符及封装
- 访问限定符
- 封装
- 类的作用域
- 类的实例化
- 类对象模型
- 计算类对象的大小
- 类对象存储方式的猜测
- 结构体内存对齐规则
- this指针
- this指针的引出
- this指针的特性
- 类的6个默认成员函数
- 构造函数
- 构造函数概念
- 构造函数特性
- 析构函数
- 析构函数概念
- 析构函数特性
- 拷贝构造函数
- 拷贝构造函数概念
- 拷贝构造函数特性
- 赋值运算符重载
- 运算符重载
- 赋值运算符重载
- const成员
- 取地址及const取地址操作符重载
- 日期类的实现
- 构造函数
- 日期 += 天数
- 日期 + 天数
- 日期 -= 天数
- 日期 - 天数
- 打印日期函数
- 前置++
- 后置++
- 前置--
- 后置--
- 日期类的大小关系比较
- >运算符重载
- ==运算符重载
- >=运算符重载
- <运算符重载
- <=运算符重载
- !=运算符重载
- 日期 - 日期
- 流插入(<<)运算符重载
- 流提取(>>)运算符重载
- 日期类的具体代码
- Date.h头文件(成员函数声明)
- Date.cpp文件(成员函数功能实现)
- test.cpp文件(功能测试)
- 再谈构造函数
- 构造函数体赋值
- 初始化列表
- explicit关键字
- static成员
- 概念
- 特性
- 友元
- 友元函数
- 友元类
- 内部类
- 概念
- 特性
- 编译器优化
- 再次理解类和对象
- 封装
- 面向对象
面向过程和面向对象初步认识
C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
如我们平时洗衣服,按照面向过程的步骤即是:拿盆子 —>放水 —>放衣服 —>放洗衣粉 —>手搓 —>洗净拧干 —>晾衣服。
C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。
而洗衣服如果是使用面向对象的方法:
- 总共有四个对象:人、衣服、洗衣粉、洗衣机
- 整个洗衣服的过程:人将衣服放进洗衣机、倒入洗衣粉、启动洗衣机,洗衣机就会完成洗衣过程并且甩干
- 整个过程主要是:人、衣服、洗衣粉、洗衣机四个对象之间交互完成的,人不需要关心洗衣机具体是如何洗衣服的,是如何甩干的。
类的引入
C++兼容C结构体的用法
struct ListNode {
int val;
struct ListNode* next;
//c语言不支持在结构体里定义函数
int func(int x, int y) //error
{
return x + y;
}
};
int main()
{
struct ListNode L1;//使用struct ListNode定义,因为C语言中struct ListNode要在一起才是类型名
return 0;
}
在C语言中struct ListNode
是类型,C语言中不支持在结构体里面定义函数;在C++中把结构体升级成了类,struct
是声明类,在结构体里面不需要加struct
,ListNode
本身就是类型,同时在C++中还支持定义函数。
上面的代码我们在C++中可以这样写:
struct ListNode {
int val;
ListNode* next;
//在C++中还支持定义函数
int func(int x, int y)
{
return x + y;
}
};
int main()
{
ListNode L1;//在C++中ListNode就是类型
return 0;
}
但是在C++中结构体的定义更喜欢用class
来代替struct
。
类的定义
class classname {
//类体:由成员函数和成员变量组成
};//注意这里的分号
//命名空间这里没有分号,类这里有分号
class
为定义类的关键字,classname
为类的名字,{}
中为类的主体,注意类定义结束时后面分号不能省略。
类体中内容称为类的成员:类中的变量称为类的属性或成员变量;类中的函数称为类的方法或者成员函数。
类的两种定义方式:
- 声明和定义全部放在类体中。需注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理
class Date {
public:
//声明和定义全部放在类里面
void Print()
{
cout << _year << _month << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
- 声明放在头文件(.h)中,定义放在源文件(.cpp)中,需要注意:声明和定义分离,定义时成员函数名前需要加上类名和作用域限定符
::
,即加上classname ::
。
//Date.h头文件
class Date {
public:
//声明和定义分离
void Print();
private:
int _year;
int _month;
int _day;
};
//Date.cpp源文件
#include "Date.h"
void Date::Print()
{
cout << _year << _month << _day << endl;
}
注意:当声明和定义分离时,不能同时出现缺省参数;只能在声明时使用缺省参数,如果同时使用缺省参数会报错。没有声明单独定义时也可以使用缺省参数
class Date {
public:
//声明和定义分离
void Print(int year = 2,int month = 3,int day = 4); //声明时使用缺省参数
private:
int _year;
int _month;
int _day;
};
//Date.cpp源文件
void Date::Print(int year, int month, int day)
{
cout << year << month << day << endl;
}
一般情况下,更期望采用声明和定义分离的方式。
成员变量命名规则的建议:在定义成员变量时,一般都是加个前缀或者后缀标识区分。
class Date{
int _year;
int _month;
int _day;
};
类的访问限定符及封装
访问限定符
C++实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用。
访问限定符:
-
public(公有)
-
protected(保护)
-
private(私有)
访问限定符说明:
public
修饰的成员在类外可以直接被访问;
protected
和private
修饰的成员在类外不能直接被访问;访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
如果后面没有访问限定符,作用域就到
}
即类结束
class
的默认访问权限为private
,struct
为public
(因为struct
要兼容C)
注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别。
面试题:C++中struct
和class
的区别是什么?
C++需要兼容C语言,所以C++中struct
可以当成结构体使用。另外C++中struct
还可以用来定义类。和class定义类是一样的,区别是struct
定义的类默认访问权限是public
,class
定义的类默认访问权限是private
。
封装
面向对象的三大特性:封装、继承和多态。
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
封装本质上是一种管理,让用户更方便使用类。比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。
对于计算机使用者而言,不用关心内部核心部件,比如主板上线路是如何布局的,CPU内部是如何设计的等,用户只需要知道,怎么开机、怎么通过键盘和鼠标与计算机进行交互即可。因此计算机厂商在出厂时,在外部套上壳子,将内部实现细节隐藏起来,仅仅对外提供开关机、鼠标以及键盘插孔等,让用户可以与计算机进行交互即可。
在C++中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。
类的作用域
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用::
作用域操作符指明成员属于哪个类域。
class Date {
public:
void Print();
private:
int _year;
int _month;
int _day;
};
//这里需要指明Print属于Date这个类域
void Date::Print()
{
cout << _year << _month << _day << endl;
}
类的实例化
用类类型创建对象的过程,称为类的实例化。
- 类是对对象进行描述的,只是一个模型一样的东西,限定了类中有哪些成员,定义出一个类并没有分配实际的内存空间来存储它。
就像C语言中定义了一个结构体一样,当你还未用该自定义类型创建变量时,定义结构体类型这个过程并没有分配实际的内存空间来存储它。所以类在未实例化之前仅仅只是声明,并没有分配实际的空间。
- 一个类可以实例化出多个对象,实例化出的对象 占用实际的物理空间,存储类成员变量。
类是没有空间的,只有类实例化出的对象才有空间。 如下:
Date
类是没有空间的,只有Date
类实例化出的对象才有具体的日期。需要注意直接用实例化的对象调用成员变量要求成员变量是public
的(公有的)。
int main()
{
//Date._year = 2001;//error
//Date._year = 11;//error
//Date._year = 15;//error
Date d1;
d1._year = 2001;//true
d1._month = 11;//true
d1._day = 15;//true
}
- 做个比方。类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图。
设计图只设计出需要什么东西,但是并没有实体的建筑存在,同样类也只是一个设计,实例化出的对象才能实际存储数据,占用物理空间。
类对象模型
计算类对象的大小
#include<iostream>
using namespace std;
struct ListNode {
int val;
ListNode* next;
};
class List
{
public:
void Init();
void Push(int x);
void Pop();
private:
ListNode* head;
};
int main()
{
List l1;
l1.Init();
l1.Push(1);
l1.Push(2);
List l2;
l2.Init();
l2.Push(1);
cout << sizeof(l1) << endl;
cout << sizeof(l2) << endl;
return 0;
}
l1
和l2
的大小是否相同?
l1
和l2
是类实例化出来的对象,只是在栈上存储了指向堆里的指针,他们的大小相同。类本身是不占内存空间的,它在编译前是放在文件里,存在磁盘上的。编译后,变成指令存在公共代码段;类实例化出来的的对象才会占用内存空间。计算类型的大小就等同于计算类实例化出来的对象的大小。
类对象大小的计算和C语言结构体大小的计算一样,要考虑内存对齐。
类对象存储方式的猜测
- 对象中包含类的各个成员
缺陷:每个对象中成员变量是不同的,但是调用同一份函数,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次,浪费空间。
那么针对上面的缺席如何解决呢? 所以我们有了以下另外两种猜测:
- 代码只保存一份,在对象中保存存放代码的地址
- 只保存成员变量,成员函数存放在公共的代码段
对于上述几种猜测,我们可以根据下面的不同对象的大小进行判断:
// 类中既有成员变量,又有成员函数
class A1 {
public:
void f1() {}
private:
int _a;
};
// 类中仅有成员函数
class A2 {
public:
void f2() {}
};
//类中仅有成员变量
class A3 {
private:
int _a;
};
// 类中什么都没有---空类
class A4
{};
int main()
{
A1 a1;
A2 a2;
A3 a3;
A4 a4;
cout << sizeof(a1) << endl; //输出结果为4
cout << sizeof(a2) << endl; //输出结果为1
cout << sizeof(a3) << endl; //输出结果为4
cout << sizeof(a4) << endl; //输出结果为1
return 0;
}
类中没有成员变量的类的大小是1,这1byte不存储有效数据,作用是占位,标识对象被实例化定义出来了。
结论:一个类的大小,实际就是该类中”成员变量”之和,当然要注意内存对齐以及注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类的对象。
思考:为什么成员变量在对象中,成员函数不在对象中?
每个对象成员变量的内容是不一样的,需要独立存储;每个对象调用的成员函数是一样的,所以成员函数放到共享公共区域(代码段)。
结构体内存对齐规则
1.第一个成员在与结构体偏移量为0的地址处;
2.其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处;
注意:对齐数 = 编译器默认的一个对齐数与该成员大小的较小值。VS中默认的对齐数为8
3.结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍;
4.如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
this指针
this指针的引出
我们先来定义一个日期类Date
:
class Date
{
public:
//初始化日期
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
//打印日期
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
int main()
{
Date d1, d2; //实例化
d1.Init(2023, 5, 27); //初始化
d2.Init(2023, 5, 28);//初始化
d1.Print(); //打印d1
d2.Print();//打印d2
return 0;
}
对于上述类,有这样的一个问题:
Date
类中有Init
与d1
调用Init
函数时,该函数是如何知道应该设置d1
对象,而不是设置d2
对象呢?
C++中通过引入this
指针解决该问题,即:C++编译器给每个非静态的成员函数增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有成员变量的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
其实我们平时看到的形参列表和实参列表和编译器处理后的有所不同:
上图中,如果是
d1
调用,this
就是d1
的地址;如果是d2
调用,this
就是d2
的地址。这是编译器处理的,在形参或者实参不允许自己加this
指针,可以在类里面加直接转换成指令,我们可以利用下面的程序验证this
指针的存在:
class Date
{
public:
//初始化日期
void Init(int year, int month, int day)
{
this->_year = year;
this->_month = month;
this->_day = day;
}
//打印日期
void Print()
{
cout <<this-> _year << "/" << this->_month << "/" << this->_day << endl;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
int main()
{
Date d1, d2;
d1.Init(2023, 5, 27);
d2.Init(2023, 5, 28);
d1.Print();
d2.Print();
return 0;
}
this指针的特性
this
指针的类型:类类型* const
。this
指针只能在成员函数
的内部使用。this
指针本质上其实是一个成员函数的形参,是对象调用成员函数时,将对象地址作为实参传递给this
形参。所以对象中不存储this
指针。this
指针是成员函数第一个隐含的指针形参,一般情况由编译器通过ecx
寄存器自动传递,不需要用户传递。
我们通过下面这段代码更好的认识this
指针:
#include <iostream>
using namespace std;
class A
{
public:
void PrintA()
{
cout << _a << endl;
}
void Display()
{
cout << "Display()" << endl;
}
private:
int _a;
};
int main()
{
A* pa = nullptr;
//pa->Display(); //代码1
//pa->PrintA(); //代码2
}
当程序分别运行代码1和代码2时,程序运行的结果如何?是编译报错还是运行崩溃亦或正常运行?
我们可能看到指针p是一个空指针,而代码1和代码2都通过操作符
->
,间接性的执行了对p
的解引用操作,所以我们可能认为程序会崩溃。其实不然,当程序执行代码1时,程序不会崩溃,会正常输出Display()
;当程序执行代码2时,程序才会因为对空指针解引用而崩溃。
指针
p
是一个类的空指针,当执行代码1时,pa
传给Display()
函数中的this
指针,但是成员函数中并没有对空指针p
进行解引用,Display()
等成员函数地址并没有存到对象里面,成员函数的地址是存在公共代码段的,所以不用解引用就可以直接打印Display()
。当程序执行代码2时,会因为对空指针解引用而崩溃。执行代码2时,调用了成员函数
PrintA
,成员函数的地址是存在公共代码段的,所以不用解引用,但是PrintA
函数中要打印成员变量_a
,成员变量_a
只有通过对this
指针进行解引用才能访问到,而this
指针此时接收的pa
是nullptr
,对空指针解引用必然会导致程序的崩溃。
类的6个默认成员函数
在C语言中,我们在某些特定情况下需要进行初始化和销毁操作,但是有的时候会因为自己的疏忽造成程序出现错误,出现内存泄漏等问题。在C++中可以通过编译器自动调用默认成员函数自动进行初始化和销毁空间。
**如果一个类中什么成员都没有,简称为空类。空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数(构造函数、析构函数、拷贝构造、赋值重载、取地址及const
取地址操作符重载)。**当我们不写时,编译器自动生成;当我们自己定义了之后,编译器就不生成了。
默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
class Date {};
构造函数
构造函数概念
对于以下Date
类:
#include <iostream>
using namespace std;
class Date {
public:
//初始化函数
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
//打印函数
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;//实例化对象
d1.Init(2022,5,30);//初始化对象
d1.Print();//打印结果为2022/5/30
Date d2;//实例化
d2.Init(2023, 5, 30);//初始化对象
d2.Print();//打印结果为2023/5/30
return 0;
}
对于Date
类,可以通过 Init
公有方法给对象设置日期,但如果每次创建对象时都调用该方法设置信息,未免有点麻烦,那能否在对象创建时,就将信息设置进去呢?
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。
我们通过在Date
类中自己定义一个构造函数就可以实现在实例化对象时自动将信息设置进入,代码如下:
#include <iostream>
using namespace std;
class Date {
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2022, 5, 30);
d1.Print();//打印结果为2022/5/30
Date d2(2023, 5, 30);
d2.Print();//打印结果为2023/5/30
return 0;
}
那上面的代码中我们是否可以理解成构造函数的使用就是d1.Date(2022,5,30)
?
不能,因为这里的时候d1
还没有被实例化出来,构造函数不能用对象去调用。但是如果我们等对象实例化出来之后,即Date d1;d1.Date(2022,5,30);
如果这样写,和我们刚开始用Init
函数进行初始化没有区别,所以并不能这样理解。
构造函数特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
构造函数的特征:
1.函数名和类名相同;
2.无返回值,这里的无返回值并不是指返回值为void
,而是真的无返回值,如上面Date
类的构造函数就是Date(){}
;
3.对象实例化时编译器自动调用对应的构造函数;
4.构造函数可以重载,也就是说一个类可以有多个构造函数,有多种初始化方式;
#include <iostream>
using namespace std;
class Date {
public:
//1.无参构造函数
Date()
{}
//2.带参构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1; //调用无参构造函数
//通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明,
// 如果加了括号编译器就无法识别是函数声明还是调用构造函数
// 如下面这行代码就是d3函数的声明,该函数无参,返回一个日期类型的对象
//Date d3();
Date d2(2023, 5, 30);//调用带参的构造函数
return 0;
}
通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明,如果加了括号编译器就无法识别是函数声明还是调用构造函数,如
Date d3();
代码就是d3
函数的声明,该函数无参,返回一个日期类型的对象;如果是有参数的构造函数则可以这样写Date d4(2023,5,30);
,因为如果是函数声明的话要带参数类型,即Date d4(int year,int month,int day)
。
注意:无参的构造函数、全缺省的构造函数以及用户不写编译器自动生成的构造函数被称为默认构造函数。默认构造函数只能有一个,也就是说无参的构造函数和全缺省的构造函数不能同时存在,否则编译器会报错。
//无参构造函数和全缺省的构造函数不能同时存在,因为默认构造函数只能存在一个
//无参构造函数
Date()
{}
//全缺省的构造函数
Date(int year = 2022, int month = 5, int day = 30)
{
_year = year;
_month = month;
_day = day;
}
5.如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
#include <iostream>
using namespace std;
class Date {
public:
//我们自己实现的构造函数
/*Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}*/
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
//当我们将自己实现的构造函数注释掉时,编译器生成默认的构造函数,此时Date d1;代码可以编译通过
//当我们将自己实现的构造函数取消注释之后,此时编译器不会生成默认的构造函数,Date d1;代码编译失败,必须要传参,使用代码Date d2(2023, 5, 30);可以编译通过
Date d1;
//Date d2(2023, 5, 30);//调用带参的构造函数
return 0;
}
那么,既然在我们不实现构造函数的情况下,编译器会自动生成默认的构造函数,那么我们为什么要自己实现构造函数呢?还是以Date
类为例:当我们不自己实现构造函数时,使用编译器默认生成的构造函数会出现随机值的情况,具体如下:
#include <iostream>
using namespace std;
class Date {
public:
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
打印结果为:
可见,编译器默认生成的构造函数打印结果随机值,那编译器默认生成的构造函数意义在哪?
C++把类型分为了内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如
int/char/double……
;自定义类型就是使用struct/class/union
等自己定义的类型。编译器自己生成的默认构造函数有以下处理机制:
编译器自动生成的构造函数对内置类型成员不做处理;
对于自定义类型,编译器会再去调用它们自己的默认构造函数。
我们通过下面的程序来观察,下面的程序中定义了一个Time
类和一个Date
类,Date
类中定义了一个Time
类型的成员变量:
class Time
{
public:
Time()
{
cout << "Time类的构造函数" << endl;
}
};
class Date
{
private:
//C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
打印结果:
通过打印结果我们可以发现在Date
类中对于自定义类型Time
类型会去调用Time
类的默认构造函数。
当自定义类型变量和内置类型混着使用的时候,并不会对内置类型进行处理,我们可以考虑给内置类型设置缺省值。
C++11 中针对内置类型成员变量不初始化的缺陷,规定内置类型成员变量在类中声明时可以给默认值。当我们没有显示初始化的时候,使用成员变量的缺省值。
6.无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个(调用时会存在歧义,建议留全缺省的)。
注意:无参构造函数、全缺省构造函数、用户没写编译器默认生成的构造函数,都可以认为是默认构造函数。
下面程序就是同时定义了无参构造函数和全缺省构造函数,程序会报错。
#include <iostream>
using namespace std;
class Date {
public:
//无参构造函数
Date()
{}
//全缺省的构造函数
Date(int year = 2022, int month = 5, int day = 30)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1; //会报错,对重载函数的调用不明确
return 0;
}
析构函数
析构函数概念
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
如下面的Date
类:
class Date
{
public:
Date()
{
cout << "Date()" << endl;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
~Date()
{
cout << "~Date()" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
return 0;
}
当一个类对象销毁时,其中的局部变量也会随着该对象的销毁而销毁,如Date
类,当我们实例化一个对象d1
并销毁时,d1
当中的局部变量_year/_month/_day
也会被编译器销毁,局部对象销毁工作是由编译器完成的。
Date
类这样的类是不需要析构函数的,因为它内部没有什么资源需要清理,但是像栈这种涉及到申请空间资源,需要自己定义析构函数释放资源。
析构函数特性
1.析构函数的函数名是在类名前加上字符~
class Date
{
public:
~Date()
{
cout << "~Date()" << endl;
}
};
2.析构函数无参数,无返回值类型。
这里的无返回值就是没有返回值,并不是指返回值类型为void
。
3.一个类有且只有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
注意:析构函数不能重载。
编译器自动生成的析构函数的处理机制:
- 编译器自动生成的析构函数对内置类型成员不做处理;
- 对于自定义类型,编译器会再去调用它们自己的默认析构函数。
class Time
{
public:
Time()
{
cout << "Time()" << endl;
}
~Time()
{
cout << "~Time()" << endl;
}
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
程序运行输出结果:
在此程序中
main
方法中根本没有直接创建Time
类的对象,为什么最后会调用Time
类的构造函数和析构函数?因为
main
方法中创建了Date
类型对象d
,而d
中包含4个成员变量,其中_year, _month,_day
三个是内置类型成员,默认构造函数和默认析构函数对内置不做处理,内置类型成员变量销毁时不需要资源清理,最后系统直接将其内存回收即可;而_t
是Time
类对象,实例化时调用Time
类的默认构造函数。在d
销毁时,要将其内部包含的Time
类的_t
对象销毁,所以要调用Time
类的析构函数。但是main
函数中不能直接调用Time
类的析构函数,实际要释放的是Date
类对象,所以编译器会调用Date
类的析构函数,而Date
没有显式提供析构函数,则编译器会给Date
类生成一个默认的析构函数,目的是在其内部调用Time
类的析构函数,即当Date
对象销毁时,要保证其内部每个自定义对象都可以正确销毁main
函数中并没有直接调用Time
类析构函数,而是显式调用编译器为Date
类生成的默认析构函数。
总结:创建哪个类的对象则调用该类的构造函数,销毁那个类的对象则调用该类的析构函数。
4.对象生命周期结束时,C++编译器会自动调用析构函数
5.如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date
类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack
类。
6.先构造的后析构,后构造的先析构。
因为对象是定义在函数中的,函数调用会建立栈帧,栈帧中的对象构造和析构也要符合栈先进后出的原则。
拷贝构造函数
拷贝构造函数概念
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const
修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
用
const
参数做形参很多好处:传参的时候可以传不是const
类型的参数,const
类型的参数也可以传递,属于权限的缩小或者平移;不加const
可能会造成权限的放大,还有因为顺序错误,改变引用对象的值。
class Date {
public:
//构造函数
Date(int year = 2023, int month = 6, int day = 2)
{
cout << "Date()" << endl;
_year = year;
_month = month;
_day = day;
}
//拷贝构造函数
Date(const Date& d)
{
cout << "Date(const Date& d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
//打印函数
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
~Date()
{
cout << "~Date()" << endl;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1(2022, 5, 6);
d1.Print();
Date d2(d1);
//也可以使用=进行拷贝构造
//Date d2 = d1
d2.Print();
return 0;
}
打印结果:
实例化
d1
时调用构造函数,然后打印;实施化d2
时调用拷贝构造函数,然后打印,然后析构d1
和d2
两个对象。
拷贝构造函数表示方式有
Date d2(d1)/Date d2 = d1
。
拷贝构造函数特性
拷贝构造函数也是特殊的成员函数,其特征如下:
1.拷贝构造函数是构造函数的一个重载形式。
2.拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
通过前面的学习我们知道函数传值传参就是将实参内容拷贝一份给形参;引用传参中形参是实参的别名。
普通的函数类型可以直接传值传递,但是自定义类型不能随便拷贝,自定义类型的拷贝要调用拷贝构造函数。
为什么自定义类型的函数传参不能使用传值传参呢?
因为有些场景下传值拷贝会出错。对于日期类可以直接使用传值拷贝;但是对于栈这样的类,不能直接拷贝,如果按字节拷贝,那么两个指针指向同一个空间,先后调用析构函数将会对同一块空间析构两次,同时当他们push的时候,也是对同一块空间进行操作。
浅拷贝:编译器能干的事情就是浅拷贝,一个字节一个字节的拷贝。
所以对于栈这样的类型,要进行深拷贝的拷贝构造,即自己去开一个新的空间。所以规定自定义类型的拷贝要调用拷贝构造,避免出现指向同一块空间及多次析构的情况。
所以使用传值传参的时候要调用拷贝构造,使用引用传参的时候不用调用拷贝构造。
如果拷贝构造函数使用传值传参,使用传值传参的时候要调用拷贝构造,而调用拷贝构造函数又需要先传参……如此循环最终就引发了无穷递归。示例图如下:
为避免如下无穷递归的情况,所以规定拷贝构造函数不能使用传值传参,要使用引用传参。
自定义类型的对象进行函数传参时,一般推荐使用引用传参。使用传值传参也可以,但是每次传参时都要调用拷贝构造函数。
3.若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝或者值拷贝。
如下面的Date
类,我们并没有自己实现拷贝构造函数,是由编译器自动生成的拷贝构造函数完成对象的拷贝。
class Date {
public:
//构造函数
Date(int year = 2023, int month = 6, int day = 2)
{
_year = year;
_month = month;
_day = day;
}
//打印函数
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1(2022, 5, 6);
d1.Print();
Date d2(d1);
d2.Print();
return 0;
}
打印结果:
编译器自动生成的拷贝构造函数机制:
1.编译器自动生成的拷贝构造函数对内置类型会完成浅拷贝(值拷贝)。
2.对于自定义类型,编译器会再去调用它们自己的默认构造函数。
上面的Date
类已经验证了编译器自动生成的拷贝构造函数对内置类型会完成浅拷贝(值拷贝),我们再来通过下面的程序验证对于自定义类型,编译器会再去调用它们自己的默认构造函数:
class Time {
public:
Time()
{
_hour = 17;
_minute = 4;
_second = 53;
}
Time(const Time& t)
{
cout << "Time(const Time& t)" << endl;
_hour = t._hour;
_minute = t._minute;
_second = t._second;
}
private:
int _hour;
int _minute;
int _second;
};
class Date {
private:
//内置类型
int _year = 1;
int _month = 1;
int _day = 1;
//自定义类型
Time _t;
};
int main()
{
Date d1;
Date d2(d1);
return 0;
}
上面程序中用已经存在的d1
拷贝构造d2
,此时会调用Date
类的拷贝构造函数;但Date类并没有显式定义拷贝构造函数,则编译器会给Date
类生成一个默认的拷贝构造函数,在Date
类中,Time
类型的_t
是自定义类型,调用Time
类自己的拷贝构造函数。
在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其对应的拷贝构造函数完成拷贝的。
4.编译器自动生成的拷贝构造函数不能实现深拷贝。
编译器生成的默认拷贝构造函数会对内置类型完成浅拷贝。因此对于下面的Date
类,浅拷贝实际上就是将d1
的内容复制了一份拷贝给d2
,所以浅拷贝也被叫做值拷贝。
class Date {
private:
//内置类型
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1;
Date d2(d1);
return 0;
}
但是编译器生成的拷贝构造函数并不能完成深拷贝,所以在某些场景下,我们不能依赖于编译器生成的拷贝构造函数。如下面的代码我们实际只想利用栈s1
创建一个新的栈对象s2
:
class Stack {
public:
Stack(int capacity = 4)
{
int* p = (int*)malloc(sizeof(int) * capacity);
if (p == nullptr)
{
exit(0);
}
_a = p;
_top = 0;
_capacity = capacity;
}
//析构函数,如果取消注释则直接报错,原因就是对同一个空间析构两次
/*~Stack()
{
free(_a);
}*/
void Print()
{
cout << _a << endl;
}
private:
int* _a;
int _top;
int _capacity = 4;
};
int main()
{
Stack s1;
s1.Print();
Stack s2(s1);
s2.Print();
return 0;
}
打印结果如下:打印出来s1
和s2
的地址相同,不符合我们的预期。
如果使用编译器自动生成的拷贝构造函数,完成的是浅拷贝,拷贝完成之后指向的是同一块空间:
指向同一块空间存在的问题:
1.插入删除数据的时候会造成覆盖,因为top1
的改变不引起top2
的改变,指针指向的空间相同,所以会引起数据的覆盖;
2.析构两次(先构造的后析构,后构造的先析构,s2
先析构,s1
再析构),程序崩溃。
这也就意味着如果我们对其中任意一个进行操作都会影响另外一个,但是我们希望的是两个栈之间互不影响。默认的拷贝我们都叫做值拷贝或者浅拷贝,深拷贝做更深一层的拷贝,就是让他们各自有独立的空间。所以我们要进行深拷贝以达到它们拥有各自的空间。
class Stack {
public:
//构造函数
Stack(int capacity = 4)
{
int* p = (int*)malloc(sizeof(int) * capacity);
if (p == nullptr)
{
exit(0);
}
_a = p;
_top = 0;
_capacity = capacity;
}
//拷贝构造函数
Stack(const Stack& s)
{
int* _arr = (int*)malloc(sizeof(int) * s._capacity);
if (_arr == nullptr)
{
perror("malloc failed");
exit(0);
}
_a = _arr;
_top = s._top;
_capacity = s._capacity;
//memcpy函数
memcpy(_a, s._a, sizeof(int) * s._top);
}
~Stack()
{
free(_a);
}
void Print()
{
cout << _a << endl;
}
private:
int* _a;
int _top;
int _capacity = 4;
};
int main()
{
Stack s1;
s1.Print();
Stack s2(s1);
s2.Print();
return 0;
}
类中如果没有涉及资源申请时,拷贝构造函数写不写都可以;但是一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
5.拷贝构造函数典型调用场景:
使用下面的类,改变main函数来观察调用场景:
class Date {
public:
//构造函数
Date(int year = 2023, int month = 6, int day = 2)
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造函数
Date(const Date& d)
{
cout << "Date(const Date& d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
void func1(Date d)
{
}
Date func2(Date d)
{
return d;
}
- 使用已存在对象创建新对象
int main()
{
Date d1(2022, 5, 6);
//使用已存在的对象创建新对象时调用拷贝构造函数
Date d2(d1);
return 0;
}
输出结果:
- 函数参数类型为类类型对象
下面的程序中func1
函数函数参数为类类型对象,此时调用func1
函数要传值传参,所以要调用拷贝构造函数。
void func1(Date d)
{
}
int main()
{
Date d1(2022, 5, 6);
//func1函数参数类型为类类型对象
func1(d1);
return 0;
}
输出结果:
- 函数返回值类型为类类型对象
函数返回值类型为类类型对象时要先将return
的值临时拷贝到上一层栈帧中的临时变量中,所以要调用拷贝构造函数。本例中就是要将d
拷贝一份,所以要调用拷贝构造函数,函数参数类型也为类类型对象,所以要调用两次拷贝构造函数。
Date func2(Date d)
{
return d;
}
int main()
{
Date d1(2022, 5, 6);
func2(d1);
return 0;
}
输出结果:
为了提高程序效率减少拷贝,一般自定义类型对象传参时,尽量使用引用类型;返回时根据实际场景,能用引用尽量使用引用。
赋值运算符重载
运算符重载
**C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。 **
d1 == d2;//可读性强,书写简单
IsSame(d1,d2);//可读性差,书写麻烦
函数名字为:关键字operator后面接需要重载的运算符符号。如赋值运算符重载函数名为
operator==
。
函数原型:返回值类型 operator操作符(参数列表)。如重载赋值运算符函数原型为
bool operator== (const Date& d1,const Date& d2)
。
注意:
1.不能通过连接其他符号来创建新的操作符:比如operator@。
2.重载操作符必须有一个类类型或枚举类型的操作数。
3.用于内置类型的操作符,重载后其含义不能改变。
4.作为类成员的重载函数时,函数有一个默认的形参this,限定为第一个形参。
5.sizeof
::
.*
?:
.
这5个运算符不能重载。
我们通过重载==
操作符来熟悉一下:
由上面第4点我们知道当运算符重载作为类的成员函数时,函数有一个默认的形参this
,限定为第一个形参,也就是说,当是类的成员函数时,函数参数列表中需要我们写的只有右操作数,左操作数为this
指针(a == b
,其中a
为左操作数,b
为右操作数)。
class Date {
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
//==运算符重载
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
private:
int _year;
int _month;
int _day;
};
我们也可以将该运算符重载函数放在类的外面,在类外没有this
指针,所以此时函数的形参设置为两个。
但因为此时类中的成员变量为私有,外部无法访问,我们可以将类中的成员变量设置为公有的(public
),外部就可以访问该类的成员变量了(也可以使用用友元函数解决)。
class Date {
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
int _year;
int _month;
int _day;
};
//==运算符重载
bool operator==(const Date& d1,const Date& d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
赋值运算符重载
1.赋值运算符重载格式
- 参数类型:
const T&
,传递引用可以提高传参效率
赋值运算符只能是成员函数,因为赋值运算符重载就算我们不实现,编译器也会默认自动生成,所以赋值运算符重载函数的第一个形参默认是this
指针,第二个形参是赋值运算符的右操作数。
由于是自定义类型传参,我们若是使用传值传参,会额外调用一次拷贝构造函数,所以函数的第二个参数(即右操作数)最好使用引用传参(第一个参数是默认的this
指针),并且在函数体内不会对右操作数进行修改,所以最好加上const
进行修饰。
- 返回值类型:
T&
,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
如果只以d2 = d1
这种方式使用赋值运算符,赋值运算符重载函数就不用有返回值,因为在函数体内已经通过this
指针对d2
进行修改。但是为了支持连续赋值,即d3 = d2 = d1
,我们就需要为函数设置一个返回值,返回值应该是赋值运算符的左操作数,即*this
。
为了提高效率避免调用拷贝构造,最好使用引用返回,因为this
指针指向的对象出了函数作用域并没被销毁,所以可以使用引用返回。
- 检测是否自己给自己赋值
若是出现d1 = d1
这种情况,不必进行赋值操作,因为是没有意义的,所以先判断是不是自己给自己赋值。
- 返回
*this
:要符合连续赋值
为了支持连续赋值,我们返回的时候返回赋值运算符的左操作数,而作为类成员函数只能通过this
指针访问到左操作数,所以要返回左操作数就只能返回*this
。
class Date
{
public:
Date(int year = 2023, int month = 6, int day = 3)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
Date& operator=(const Date& d)
{
//检测是否自己给自己赋值
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
//返回*this,要符合连续赋值
//d3 = d2 = d1时,要返回的是d2,即*this
return *this;
}
private:
int _year;
int _month;
int _day;
};
2.赋值运算符只能重载成类的成员函数不能重载成全局函数
class Date
{
public:
Date(int year = 2023, int month = 6, int day = 3)
{
_year = year;
_month = month;
_day = day;
}
int _year;
int _month;
int _day;
};
// 赋值运算符重载成全局函数,全局函数没有this指针需要两个参数
Date& operator=(Date& left, const Date& right)
{
if (&left != &right)
{
left._year = right._year;
left._month = right._month;
left._day = right._day;
}
return left;
}
// 编译失败:
//“operator =”必须是非静态成员
原因:赋值运算符也是类的6个默认成员函数之一,默认成员函数即我们不自己实现编译器也会自动生成。所以赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突,编译失败。故赋值运算符重载只能是类的成员函数。
3.一个类如果没有显示定义赋值运算符重载,编译器也会自动生成一个,完成对象按字节序的值拷贝
注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
//Time类的赋值运算符重载
Time& operator=(const Time& t)
{
cout << "Time& operator=(const Time& t)" << endl;
if (this != &t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
}
return *this;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d1;
Date d2;
d1 = d2;
return 0;
}
打印结果:
尽管编译器会自动生成赋值运算符重载函数,但是并不代表我们可以不用实现,如果类中未涉及到资源管理,赋值运算符是否实现都可以;但是一旦涉及到资源管理则必须要实现。以下代码就涉及到资源管理:
class Stack
{
public:
Stack(size_t capacity = 10)
{
_array = (int*)malloc(capacity * sizeof(int));
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(const int& data)
{
_array[_size] = data;
_size++;
}
~Stack()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
int* _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;
}
s1
中压入1、2、3、4,然后实例化s2
,s2
中空间大小为10并没有存储元素,但是Stack
类中并未自己实现赋值运算符重载是由编译器自动生成的赋值运算符重载,所以当s2 = s1
的时候完成的是字节序拷贝,直接将s1
中的全部成员变量的值给s2
的成员变量,此时s2
也指向s1
的那块空间,s2
原来的那部分空间丢失了,存在内存泄漏;s1
和s2
共享同一份内存空间,最后销毁时会导致同一份内存空间释放两次而引起程序崩溃。
观察以下代码调用的是拷贝构造函数还是赋值运算符重载函数?
Date d1(2021, 6, 1);//代码1 构造函数 Date d2(d1);//代码2 拷贝构造 Date d3 = d1;//代码3 拷贝构造 Date d4; d4 = d1;//代码4 赋值运算符重载
代码2和代码3调用的都是拷贝构造函数;代码4调用的是赋值运算符重载函数。
拷贝构造函数:用一个已经存在的对象去构造初始化另一个即将创建的对象。
赋值运算符重载函数:在两个对象都已经存在的情况下,将一个对象赋值给另一个对象。
const成员
将const
修饰的成员函数称之为const
成员函数,const
修饰类成员函数,实际修饰该成员函数隐含的this
指针,表明在该成员函数中不能对类的任何成员进行修改。
如果成员函数声明和定义分离,都要在函数后加上
const
。
成员函数中原本的
this
类型为类类型* const this
,this指针本身不能被改变,但是指针指向的内容可以被改变,但是如果变为const
成员函数之后,this
指针类型为变为了const 类类型* const this
,指针本身和指针指向的内容都不能被改变了。
思考下面几个问题(面试题):
1.const对象可以调用非const成员函数吗?
2.非const对象可以调用const成员函数吗?
3.const成员函数内可以调用其他的非const成员函数吗?
4.非const成员函数内可以调用其他的const成员函数吗?
解答:
1.不可以,
const
对象只能调用const
成员函数,const
对象的内容不能被修改,即this
指针被const
所修饰,然而非const
成员函数的this
指针没有被const
所修饰,如果const
对象调用非const
成员函数就属于权限的放大,权限只能平移或者缩小,所以不可以调用。2.可以,非
const
对象的this
指针没有被const
修饰,调用const
成员函数属于权限的缩小。3.不可以,
const
成员函数内的this
指针已经被const
修饰成const *this
,再调用非const
函数就属于是权限的放大,所以已经不能再调用。4.可以,非
const
成员函数内的this
指针还没有被const
修饰,调用其他的const
成员函数属于权限的缩小,所以可以调用。
总结:
const
对象不可以调用非const
成员函数 (权限放大);非const
对象可以调用const
成员函数 (权限缩小)。
const
成员函数内不可以调用其它非const
成员函数;非const
成员函数内可以调用其它const
成员函数。成员函数加
const
,变成const
成员函数,const
对象可以调用,非const
对象也可以调用。
class Date
{
public:
Date(int year = 2023, int month = 6, int day = 4)
{
_year = year;
_month = month;
_day = day;
}
//Print函数构成重载函数
void Print()//void Print(Date* const this)
{
cout << "非const成员函数" << endl;
cout << _year << "/" << _month << "/" << _day << endl;
}
void Print() const//void Print(const Date* const this)
{
cout << "const成员函数" << endl;
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 6, 4);
//d1是非const对象调用非const成员函数
d1.Print();
//d2是const对象调用const成员函数
const Date d2(2023, 10, 1);
d2.Print();
return 0;
}
Print
函数构成重载,分别是非const
成员函数和const
成员函数,d1
是非const
对象调用非const
成员函数,d2
是const
对象调用const
成员函数,当只有const
成员函数的时候非const
对象也可以调用const
成员函数。
打印结果如下:
不是说所有的成员函数都要加 const
,具体要看成员函数的功能,如果成员函数是修改型 (operrato+=、Push
),那就不能加;如果是只读型 (Print、operator==
),那就最好加。
取地址及const取地址操作符重载
这两个默认成员函数一般不用重新定义 ,编译器默认会生成。
class Date
{
public:
Date* operator&()
{
return this;
}
const Date* operator&()const
{
return this;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
int main()
{
Date d1;
cout << &d1 << endl;//输出d1的地址
return 0;
}
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容。
日期类的实现
定义类时,如果函数声明和定义分离,函数声明时可以带缺省参数,函数定义时不能带缺省参数,否则会报错。
普通函数声明和定义分离时,无论声明还是定义都可以带缺省参数,但是声明和定义中不能同时出现。
日期类中我们要实现的功能函数声明和解释如下:
//Date.h头文件
//函数声明中可以带缺省参数,函数定义和声明分离,定义中不能带缺省参数
class Date{
public:
//构造函数,使用全缺省的默认构造函数
Date(int year = 2023, int month = 6, int day = 4);
//日期 += 天数
Date& operator+=(int day);
//日期 + 天数,
//不改变this指针指向的内容,所以使用const修饰,后面的函数中只要是不改变*this内容都使用const修饰,使其称为const成员函数
Date operator+(int day) const;
//日期-=天数
Date& operator-=(int day);
//日期-天数
Date operator-(int day) const;
//打印日期函数
void Print() const ;
//前置++
Date& operator++();
//后置++,当要实现后置++重载时,给定一个int类型的参数,没有其他实际意义,只是为了构成重载。
Date operator++(int);
//前置--
Date& operator--();
//后置--
Date operator--(int);
//日期类的大小关系比较
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;
//日期-日期
int operator-(const Date& d) const;
//因为日期类里面不涉及资源的申请和释放所以拷贝构造函数、赋值运算符重载函数和析构函数使用编译器自动生成的就可以,不用再对其实现。
};
//流插入 >>
//流输出 <<
构造函数
构造函数主要实现的就是日期的赋值操作,所以我们要先检查日期的合法性。
在类外实现的时候要标明类域。
// 获取本月天数
//使用内联函数,提高效率
inline int GetMonthDay(int year,int month)
{
//使用数组存储每个月的天数
int monthday[] = {0, 31,28,31,30,31,30,31,31,30,31,30,31 };
//注意闰年2月天数为29天,闰年:能被4整除且不能被100整除或者能被400整除
//判断是否是闰年
if ((year % 4 == 0 && year % 100 == 0) || (year % 400 == 0))
{
//判断是否是2月
if (month == 2)
{
//如果是闰年2月+1就是29天
return monthday[month] + 1;
}
}
//不是直接返回当月天数
return monthday[month];
}
Date::Date(int year, int month, int day)
{
//先判断年是否合法
if (year >= 0
&& month >= 1 && month < 13
&& day >= 1 && day <= GetMonthDay(year, month))
{
_year = year;
_month = month;
_day = day;
}
else
{
cout << "日期非法" << endl;
exit(-1);//直接退出程序
}
}
GetMonthDay
函数可能被多次调用,最好将其设置为内联函数;
GetMonthDay
函数中存储每月天数的数组最好是用static
修饰,存储在静态区,避免每次调用该函数都需要重新开辟数组。
日期 += 天数
先判断天数day
是正数还是负数,如果是负数,复用-=
运算符;如果是正数,我们将天数加到当前Date对象的_day
上,然后判断日期是否合法,若不合法不断调整直到日期合法为止。
调整方法:判断_day
是否合法,如果不合法则减去当前月的天数,_month
加1;如果_month
不合法,则将_year
加1,将_month
置为1。如此不断调整,直到日期合法为止。
实现+=
运算符重载要支持这样写d1 = d3 += 100
,即+=
要支持连续赋值,所以要有返回值。出了作用域,this
指针还在所以可以用引用返回。
注意不能因为要引用返回,而将tmp
设置为静态变量,因为每次改变都会影响``tmp`,而静态变量只会初始化一次。
//日期 += 天数
Date& Date::operator+=(int day)
{
if (day < 0)
{
//复用-=运算符重载函数
*this -= -day;
}
else
{
_day = day + _day;
//通过while循环判断日期是否合法
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
//注意先month++再判断
_month++;
if (_month > 13)
{
_month = 1;
_year++;
}
}
}
return *this;
}
日期 + 天数
+
运算符的重载我们可以通过复用+=
重载运算符,而且+
运算符不会修改原本的内容,所以我们可以用const
修饰成员函数。
//+不改变原本内容
Date Date::operator+(int day) const
{
Date tmp(*this);//调用拷贝构造
//const成员函数不能修改this指针指向的内容
//*this += day;
//复用+=
tmp += day;
return tmp;
}
扩展:
我们可以实现
+=
运算符重载,实现+
运算符重载时复用+=
;也可以实现+
运算符重载,实现+=
运算符重载时复用+
;那么哪种方法更好呢?我们实现
+
复用+=
程序如下:Date Date::operator+(int day) const { Date tmp(*this); if (_day < 0) { //复用-=运算符重载 tmp -= - day; } else { tmp._day += day; while (tmp._day > GetMonthDay(tmp._year,tmp._month)) { tmp._day -= GetMonthDay(tmp._year, tmp._month); //注意先month++再判断 tmp._month++; if (tmp._month > 12) { tmp._year++; tmp._month = 1; } } } Date tmp(*this); } Date& Date::operator+=(int day) { *this = *this + day; return *this; }
通过上面的程序我们可以看到在实现
+
运算符重载时,Date tmp(*this);
这里一次拷贝构造;因为是传值返回,所以Date tmp(*this);
还有一次拷贝构造,再实现+=
复用+
时,还有两次拷贝构造和*this = *this + day
这里的一次赋值。但是我们用
+=
实现+
时,+=
运算符重载函数本身没有发生任何拷贝构造行为,复用+=
实现+
运算符时有两次拷贝构造和一次赋值。所以相比较而言,实现
+
运算符时复用+=
运算符这种方式比较好。
日期 -= 天数
实现-=
运算符时需要加上上一个月的天数。同时返回类型使用引用,实现支持连续-=
。
//日期-=天数
Date& Date::operator-=(int day)
{
if (day < 0)
{
//复用+=
*this += -day;
}
else
{
_day -= day;
while (_day <= 0)
{
--_month;
if (_month == 0)
{
_year--;
_month = 12;
}
_day += GetMonthDay(_year, _month);
}
}
return *this;
}
日期 - 天数
和实现+=
和+
运算符相同,通过复用-=
实现。
//日期-天数
Date Date::operator-(int day) const
{
Date tmp(*this);
tmp -= day;
return tmp;
}
打印日期函数
//打印日期函数
void Date::Print() const
{
cout << _year << "/" << _month << "/" << _day << endl;
}
前置++
前置++
返回的是*this
,出了函数作用域不会被销毁,所以可以使用引用返回。
//前置++
Date& Date::operator++()
{
//复用+=
*this += 1;
return *this;
}
后置++
由于前置++
和后置++
的运算符均为++
,且操作数都是只有一个,为了方便区分我们给后置++
的运算符重载的参数加上一个int
类型参数,使用后置++
时不需要给这个int
参数传入实参,这里int
参数的作用只是为了跟前置++
构成函数重载。
后置++
需要返回被加之前的值,所以先用对象tmp
保存被加之前的值,然后再复用+=
将对象加一,最后返回tmp
对象。由于tmp
对象是局部对象,出了该函数作用域就被销毁了,所以后置++
只能使用传值返回。
//后置++要给定一个int类型的参数,没有其他实际意义,只是为了构成重载
Date Date::operator++(int)
{
Date tmp(*this);
//复用+=
*this += 1;
return tmp;
}
前置–
前置--
和后置--
原理和前置++
、后置++
一样。
//前置--
Date& Date::operator--()
{
//复用+=
*this -= 1;
return *this;
}
后置–
//后置--要给定一个int类型的参数,没有其他实际意义,只是为了构成重载
Date Date::operator--(int)
{
Date tmp(*this);
//复用+=
*this -= 1;
return tmp;
}
日期类的大小关系比较
日期类的大小关系比较我们可以通过实现其中的两个,其他的通过复用来实现。
进行日期的大小比较,我们并不会改变传入对象的值,所以可以将其定义为const
成员函数。
>运算符重载
bool Date::operator>(const Date& d) const
{
return (_year > d._year)
|| (_year == d._year && _month > d._month)
|| (_year == d._year && _month == d._month && _day == d._day);
}
==运算符重载
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) || (*this == d);
}
<运算符重载
bool Date::operator<(const Date& d) const
{
return !(*this >= d);
}
<=运算符重载
bool Date::operator<=(const Date& d) const
{
return !(*this > d);
}
!=运算符重载
bool Date:: operator!=(const Date& d) const
{
return !(*this == d);
}
日期 - 日期
日期-日期算出来的是两个日期之间相差的天数。
需要让较小日期的天数一直加一,直到最后和较大的日期相等即可,这个过程中较小日期所加的总天数便是这两个日期之间差值的绝对值。设置flag
用于判断是哪一个日期大哪一个日期小。若是第一个日期大于第二个日期,则返回这个差值的正值,若第一个日期小于第二个日期,则返回这个差值的负值。
下面的代码中复用了许多重载的运算符,所以不需要我们考虑底层实现,直接使用就可以。
//日期-日期
//下面的代码中复用了许多重载的运算符,所以不需要我们考虑底层实现,直接使用就可以。
int Date::operator-(const Date& d) const
{
Date max = *this;
Date min = d;
int flag = 1;
if (*this < d) //复用<
{
min = *this;
max = d;
flag = -1;
}
int n = 0;//用于记录加了多少次1
while (min != max ) //复用!=
{
++min;//复用++
++n;
}
return n * flag;
}
流插入(<<)运算符重载
我们平时喜欢用流插入<<
,其实流插入本身也是一个库里面的重载运算符,因此流插入能够自动识别类型;cin
和cout
就是一个类对象,cout
是一个ostream
的全局类对象,cin
是一个istream
的全局类对象:
在我们自己定义的类中除了赋值运算符能够直接使用外,其他的都需要重载。
我们可以自己实现流插入,而不用每次都依赖Print
函数。即实现下面的程序:
//流插入<<运算符重载
inline ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "/" << d._month << "/" << d._day << endl;
return out;
}
out
就是cout
的别名,类型是ostream
类型。但是如果我们将其实现时定义为Date
类的成员函数,那么默认第一个参数就是this
,即Date
类型,右操作数才是ostream
类型,如果我们要将其实现为成员函数,我们使用时就要d1 << cout
这样写才能调用,和我们之前经常使用的形式不一样,所以我们可以写在外面,使其成为全局函数。
但是如果实现成为全局函数,访问不了类中私有的成员变量,此时我们可以通过在类中定义函数获取成员变量或者将函数定义为友元函数。
//友元函数
friend ostream& operator<<(ostream& out, const Date& d);
同时为了支持实现连续插入cout << d1 << d2 << endl;
,所以要有一个返回值且必须返回ostream
类型,出了函数作用域没有销毁,所以可以使用引用返回。
C++中支持运算符重载,就是因为要支持自定义类型。
流提取(>>)运算符重载
流提取运算符重载时不能加Date
类型不能加const
,因为要改变,同时也要使用友元函数。in
是cin
的别名,是istream
类型的。
//流提取>>运算符重载
inline istream& operator>>(istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
我们可以将流插入运算符重载和流提取运算符重载改为内联函数。
如果一个函数直接定义在类里面,编译器可能直接当成内联函数处理,声明和定义分离的不会被当成内联函数处理。
内联函数在符号表只有函数符号,没有具体的地址,所以链接的时候找不到。我们直接写在.h文件中,内联函数如果在.h文件中只有声明没有定义就要去符号表中找地址进行链接,但是内联函数符号表中没有地址所以不能进行链接。如果在.h文件中声明和定义都存在就不用再去链接。所以内联函数只能在当前.h文件或者.cpp文件中使用。
日期类的具体代码
Date.h头文件(成员函数声明)
//Date.h头文件
#pragma once
#include <iostream>
using namespace std;
//函数声明中可以带缺省参数,函数定义和声明分离,定义中不能带缺省参数
class Date {
friend ostream& operator<<(ostream& out, const Date& d);//流插入
friend istream& operator>>(istream& in, Date& d);//流提取
public:
//构造函数,使用全缺省的默认构造函数
Date(int year = 2023, int month = 6, int day = 4);
//日期 += 天数
Date& operator+=(int day);
//日期 + 天数,
//不改变this指针指向的内容,所以使用const修饰,后面的函数中只要是不改变*this内容都使用const修饰,使其称为const成员函数
Date operator+(int day) const;
//日期-=天数
Date& operator-=(int day);
//日期-天数
Date operator-(int day) const;
//打印日期函数
void Print() const;
//前置++
Date& operator++();
//后置++,当要实现后置++重载时,给定一个int类型的参数,没有其他实际意义,只是为了构成重载。
Date operator++(int);
//前置--
Date& operator--();
//后置--
Date operator--(int);
//日期类的大小关系比较
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;
//日期-日期
int operator-(const Date& d) const;
//因为日期类里面不涉及资源的申请和释放所以拷贝构造函数、
//赋值运算符重载函数和析构函数使用编译器自动生成的就可以,不用再对其实现。
private:
int _year;
int _month;
int _day;
};
//内联函数只能在当前文件中使用
//流插入<<运算符重载
//流提取>>运算符重载
inline istream& operator>>(istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
inline ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "/" << d._month << "/" << d._day << endl;
return out;
}
Date.cpp文件(成员函数功能实现)
//Date.cpp文件
#define _CRT_SECURE_NO_WARNINGS 1
#include "Date.h"
//构造函数,使用全缺省的默认构造函数
// 获取本月天数
//GetMonthDay函数可能被多次调用,最好将其设置为内联函数;
//函数中存储每月天数的数组最好是用static修饰,存储在静态区,避免每次调用该函数都需要重新开辟数组
inline int GetMonthDay(int year,int month)
{
//使用数组存储每个月的天数
static int monthday[] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
//注意闰年2月天数为29天,闰年:能被4整除且不能被100整除或者能被400整除
//判断是否是闰年
if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0))
{
//判断是否是2月
if (month == 2)
{
//如果是闰年2月+1就是29天
return monthday[month] + 1;
}
}
//不是直接返回当月天数
return monthday[month];
}
Date::Date(int year, int month, int day)
{
//先判断年是否合法
if (year >= 0
&& month >= 1 && month < 13
&& day >= 1 && day <= GetMonthDay(year, month))
{
_year = year;
_month = month;
_day = day;
}
else
{
cout << "日期非法" << endl;
exit(-1);//直接退出程序
}
}
//日期 += 天数
Date& Date::operator+=(int day)
{
if (day < 0)
{
//复用-=
*this -= -day;
}
else
{
_day = day + _day;
//通过while循环判断日期是否合法
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
//注意先month++再判断
_month++;
if (_month > 13)
{
_month = 1;
_year++;
}
}
}
return *this;
}
//+不改变原本内容
Date Date::operator+(int day) const
{
Date tmp(*this);//调用拷贝构造
//const成员函数不能修改this指针指向的内容
//*this += day;
//复用+=
tmp += day;
return tmp;
}
//Date Date::operator+(int day) const
//{
// Date tmp(*this);
// if (_day < 0)
// {
// //复用-=
// //tmp -= - day;
// }
// else
// {
// tmp._day += day;
// while (tmp._day > GetMonthDay(tmp._year,tmp._month))
// {
// tmp._day -= GetMonthDay(tmp._year, tmp._month);
// //注意先month++再判断
// tmp._month++;
// if (tmp._month > 12)
// {
// tmp._year++;
// tmp._month = 1;
// }
// }
// }
// return tmp;
//}
//Date& Date::operator+=(int day)
//{
// *this = *this + day;
// return *this;
//}
//日期-=天数
Date& Date::operator-=(int day)
{
if (day < 0)
{
*this += -day;
}
else
{
_day -= day;
while (_day <= 0)
{
--_month;
if (_month == 0)
{
_year--;
_month = 12;
}
_day += GetMonthDay(_year, _month);
}
}
return *this;
}
//日期-天数
Date Date::operator-(int day) const
{
Date tmp(*this);
tmp -= day;
return tmp;
}
//打印日期函数
void Date::Print() const
{
cout << _year << "/" << _month << "/" << _day << endl;
}
//前置++
Date& Date::operator++()
{
//复用+=
*this += 1;
return *this;
}
//后置++要给定一个int类型的参数,没有其他实际意义,只是为了构成重载
Date Date::operator++(int)
{
Date tmp(*this);
//复用+=
*this += 1;
return tmp;
}
//前置--
Date& Date::operator--()
{
//复用+=
*this -= 1;
return *this;
}
//后置--要给定一个int类型的参数,没有其他实际意义,只是为了构成重载
Date Date::operator--(int)
{
Date tmp(*this);
//复用+=
*this -= 1;
return tmp;
}
//日期类的大小关系比较
bool Date::operator>(const Date& d) const
{
return (_year > d._year)
|| (_year == d._year && _month > d._month)
|| (_year == d._year && _month == d._month && _day == d._day);
}
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) || (*this == d);
}
bool Date::operator<(const Date& d) const
{
return !(*this >= d);
}
bool Date::operator<=(const Date& d) const
{
return !(*this > d);
}
bool Date:: operator!=(const Date& d) const
{
return !(*this == d);
}
//日期-日期
//下面的代码中复用了许多重载的运算符,所以不需要我们考虑底层实现,直接使用就可以。
int Date::operator-(const Date& d) const
{
Date max = *this;
Date min = d;
int flag = 1;
if (*this < d) //复用<
{
min = *this;
max = d;
flag = -1;
}
int n = 0;//用于记录加了多少次1
while (min != max ) //复用!=
{
++min;//复用++
++n;
}
return n * flag;
}
test.cpp文件(功能测试)
//test.cpp文件
#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
using namespace std;
#include "Date.h"
void test1()
{
//测试+= 和+
Date d1(2023, 6, 5);
d1.Print();
d1 += -300;
d1.Print();
Date d2(d1 + 60);
d2.Print();
}
void test2()
{
//测试-=和-
Date d1(2023, 6, 5);
d1.Print();
d1 -= -300;
d1.Print();
Date d2(d1 - 300);
d2.Print();
}
void test3()
{
//测试日期 - 日期
Date d1(2023, 6, 5);
d1.Print();
Date d2(d1 + 300);
d2.Print();
cout << (d2 - d1) << endl;
}
void test4()
{
//测试++和--
Date d1(2023, 6, 5);
d1.Print();
++d1;
d1.Print();
--d1;
d1.Print();
d1++;
d1.Print();
d1--;
d1.Print();
}
void test5()
{
//测试关系运算符
Date d1(2023, 6, 5);
Date d2(2024, 3, 3);
Date d3(d1);
cout << (d1 == d2) << endl; //false
cout << (d1 == d3) << endl; //true
cout << (d1 != d2) << endl; //true
cout << (d1 != d3) << endl; //false
cout << (d1 > d2) << endl; //false
cout << (d1 >= d2) << endl; // false
cout << (d1 <= d2) << endl; // true
cout << (d1 < d2) << endl; // true
}
void test6()
{
//测试流提取和流插入
Date d1;
cin >> d1;
cout << d1;
}
int main()
{
test6();
return 0;
}
再谈构造函数
构造函数体赋值
在实例化对象时,编译器会通过调用构造函数给对象中的各个成员变量一个合适的初始值:
class Date {
public:
//构造函数
Date(int year = 2023,int month = 6,int day = 5)
{
//_year可以多次赋值
_year = year;
_year = 2024;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
虽然上述构造函数调用之后,对象中已经有了一个初始值,但是不能将其称为对对象中成员变量的初始化,构造函数体中的语句只能将其称为赋初始值,而不能称作初始化。因为初始化只能初始化一次,而构造函数体内可以多次赋值。
初始化列表
初始化列表:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个成员变量后面跟一个放在括号中的初始值或表达式。
class Date {
public:
//构造函数
Date(int year = 2023,int month = 6,int day = 5)
//初始化列表
:_year(year)
,_month(month)
,_day(day)
{
}
private:
int _year;
int _month;
int _day;
};
对于对象来说,初始化列表是对象成员变量定义初始化的地方。
在定义类时,类中的成员变量只是声明并不是定义初始化。
当我们在成员变量中加上const
,程序就不能正常运行了,这是因为const
变量必须在定义的时候初始化,const
只有一次初始化的机会,所以必须给成员变量找一个定义的位置,不然像const
类型的成员不好处理。所以在C++11中规定构造函数初始化列表是成员变量定义和初始化的地方。
class Date {
public:
//构造函数
Date(int year = 2023, int month = 6, int day = 5)
//初始化列表
:_year(year)
, _month(month)
, _day(day)
,_t(20) //true,初始化列表是成员变量定义的地方
{
_t = 30;//error,不能在构造函数体内初始化
}
private:
//成员变量的声明
int _year;
int _month;
int _day;
const int _t = 20; //这里只是缺省值,并不是初始化
};
int main()
{
Date d;//这里的定义是对象整体的定义
//成员变量在构造函数的初始化列表中定义并且初始化
}
注意:
1.哪个对象调用构造函数,初始化列表就是该对象所有成员变量定义的位置,且每个成员变量在初始化列表中只能出现一次。
因为任何类型的变量初始化只能进行一次,所以同一个成员变量在初始化列表中不能多次出现。
2.不管是否显式在初始化列表定义初始化成员变量,编译器对每个成员变量都会在初始化列表中进行定义初始化;当在初始化列表显式定义初始化成员变量的时候,使用初始化列表中的值。
例如当Date类的构造函数初始化列表为空时,也会先走初始化列表再走函数体。
class Date {
public:
//构造函数
Date(int year = 2023, int month = 6, int day = 5)
//初始化列表为空时,也会先走这里在进入函数体
{
}
/*Date(int year = 2023, int month = 6, int day = 5)
//初始化列表
:_year(year)
, _month(month)
, _day(day)
{
}*/
private:
//成员变量的声明
int _year;
int _month;
int _day;
};
3.有三个变量必须在初始化列表初始化:const变量
,int& 变量名
(引用也必须在定义的地方初始化),没有默认构造的自定义类型成员。
默认构造函数:无参的构造函数、全缺省的构造函数以及编译器自动生成的构造函数。
下面的程序中B类的构造函数不是默认构造函数,A类中的自定义类型成员变量_bb
会自动调用B类的构造函数,所以在A类中的初始化列表中要定义_bb
。const
成员变量_c
和引用成员变量_ref
都要在初始化列表中定义。
class B {
public:
//B的构造函数,不是默认构造函数
B(int a)
:_a(a)
{
cout << "B(int a = 20)" << endl;
}
//B的打印函数
void Print()
{
cout << _a << endl;
}
//B的析构函数
~B()
{
cout << "~B()" << endl;
}
private:
int _a = 0;
};
class A
{
public:
A()
:_a(2)
,_b(2)
,_c(3)
//,_ref(_c)//权限的放大,不可以,_c是const类型的变量
,_ref(_a)
,_bb(30)
{}
void Print()
{
cout << _a << "/" << _b << "/" << _c << "/" << _ref << "/" << endl;
_bb.Print();
}
private:
int _a;//声明
int _b;
const int _c;
int& _ref;
B _bb;
};
int main()
{
A a;
a.Print();
return 0;
}
4.成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关。
观察下面的程序,最终的打印结果为多少呢?
class A {
public:
A(int a = 20,int b = 30)
:_a2(a)
,_a1(_a2)
{
}
//打印
void Print()
{
cout << _a1 << ":" << _a2 << endl;
}
private:
int _a1;
int _a2;
};
int main()
{
A a;
a.Print();
return 0;
}
打印结果:
为什么会这样呢?因为成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关,也就是说当我们在初始化列表中_a1(_a2)
时,_a2
的值并没有定义,并不是说_a2(a)
在前面_a2
就先定义了。我们可以将初始化列表中的顺序修改为_a1(a),_a2(_a1)
。所以建议声明顺序和初始化列表顺序保持一致,避免出现这样的问题。
5.最好在初始化列表里进行定义,在构造函数体内已经是赋值行为,即能用初始化列表尽量用初始化列表。
因为初始化列表实际上就是当你实例化一个对象时,该对象的成员变量定义的地方,所以无论你是否显示定义初始化列表,都会先走初始化列表再进入函数体。
- 对于内置类型使用初始化列表和在构造函数体内进行初始化实际上是没有差别的,如下面的代码:
int a = 10;//初始化列表
///
int a;//不使用初始化列表
a = 10;
- 对于自定义类型,使用初始化列表可以提高效率
class B {
public:
B(int a = 0)
:_a(a)
{}
private:
int _a = 0;
};
class A {
public:
//使用初始化列表
A()
:_b(20)
{
}
//不使用初始化列表
/*A()
{
B b(20);
_b = b;
}*/
private:
B _b;
};
int main()
{
A a;
return 0;
}
使用初始化列表时只需要在初始化列表那里调用一次B类的构造函数,而不使用初始化列表不仅要在初始化列表那里调用一次B类的构造函数,还要再函数体内调用一次构造函数和赋值重载运算符函数,所以使用初始化列表可以提高效率。
C++11支持非静态成员变量在声明时进行初始化赋值,但是要注意这里不是初始化,这里是给声明的成员变量一个缺省值。
class A {
private:
//非静态成员变量可以给缺省值
int _a = 10;//缺省值
int _b = 20;//缺省值
};
初始化列表是对象成员变量初始化的地方,若是显示定义了初始化列表,则成员变量按照给定值进行初始化;如果没有显示定义,则用缺省值进行初始化;如果没有给定缺省值,则内置类型成员变量就是随机值。
explicit关键字
构造函数不仅可以构造和初始化对象,对于单个参数的构造函数,还支持隐式类型转换(C++98),C++98不支持多个参数的构造函数隐式类型转换,C++11支持。
class A {
public:
A(int a)
:_a(a)
{
}
private:
int _a;
int _b;
};
int main()
{
A a1(10);
A a2 = 10;
return 0;
}
我们看上面的代码:A a1(10);
调用构造函数实例化对象;A a2 = 10
实例化对象是一个隐式类型转换。如int i = 0; double d = i
这两句代码也会发生隐式类型转换,i
不是转换给d
,而是在类型转换中间会产生一个临时变量,临时变量类型是double
类型并且具有常性。所以A a2 = 10
也是先在中间产生一个具有常性的临时变量,然后将临时变量拷贝构造给a2
。
拷贝构造也有初始化列表,因为拷贝构造也是构造函数。
A a2 = 10
相当于A a(10); A a2(a)
,先构造再拷贝构造,但是现在经过编译器的优化,直接使用一个构造函数就完成了,相当于A a2(10)
。
那我们怎么知道中间产生一个临时变量呢?可以使用A& ref = 10
尝试,发现不能通过编译,但是如果改为const A& ref = 10
就可以了,因为临时变量具有常性,只能用const
类型接收。
在C++11中支持多参数的构造函数隐式类型转换,我们可以使用{}
括起来,A b = {1,2};
此时就支持多参数的隐式类型转换了。
如果我们不想让构造函数支持隐式类型转换,可以使用explicit
关键字,使用之后就不支持隐式类型转换了。用法如下:
class A {
public:
explicit A(int a)
:_a(a)
{
}
private:
int _a;
int _b;
};
int main()
{
A a1(10);
A a2 = 10;
return 0;
}
static成员
概念
声明为static
的类成员称为类的静态成员,用static
修饰的成员变量,称之为静态成员变量;用static
修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化。
实现一个类,计算程序中创建了多少个类对象。
一个对象一定是一个构造函数或者拷贝构造函数实例化出来的。
我们可以定义一个全局变量count
来记录调用构造函数和拷贝构造函数的次数,可能和库中命名空间中的函数名或者函数符号相同,所以可以不展开命名空间以防冲突,程序如下:
#include <iostream>
int count = 0;
class A {
public:
A(int a = 10)
{
count++;
}
A(const A& a)
{
count++;
}
private:
int _a;
};
void func(A a)
{}
int main()
{
A a1;
A a2(a1);
func(a1);
std::cout << count << std::endl; //输出结果为3
return 0;
}
程序虽然可以,但是可能会出错。
我们可以增加一个静态成员变量static int count
,此时count
在静态区,不属于某个对象,属于所有对象,属于整个类。静态成员不在初始化列表初始化,在类外初始化。
使用静态成员变量计算程序中创建了多少个类对象。
#include <iostream>
class A {
public:
A(int a = 10)
{
_count++;
}
A(const A& a)
{
_count++;
}
static int GetCount()
{
return _count;
}
private:
int _a;
static int _count;
};
int A::_count = 0;
void func(A a)
{}
int main()
{
A a1;
A a2(a1);
func(a1);
std::cout << a1.GetCount() << std::endl;
return 0;
}
特性
- 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区
我们可以通过计算含有静态成员变量的类的大小来验证:
#include <iostream>
using namespace std;
class A {
private:
static int _n;
};
int main()
{
cout << sizeof(A) << endl; //输出1
}
A类的大小为1,静态成员变量_n
是存在静态区的,属于整个类,也属于类的所有对象,所以计算类的大小或是类对象的大小时,静态成员并不计入总大小之和。
- 静态成员变量必须在类外定义,定义时不添加static关键字,类中只是声明
class A {
private:
static int _n;
};
int A::_n = 0;
虽然这里的静态成员变量是私有的,但是却突破了类域可以在类外直接对其进行访问,这是一个特例,不受访问限定符的限制,否则就无法初始化静态成员变量了。
- 类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问
匿名对象:我们在实例化对象的时候不能使用
A s();
,因为这样分不清是函数的声明还是调用构造函数,但是我们可以使用A()
,这个实例化出来的对象叫做匿名对象。特点:生命周期只在实例化对象这一行,没有名字。所以有时候调用函数的时候可以使用匿名对象,即用即销毁。
当静态成员变量为公有时,有以下几种访问方式:
#include <iostream>
using namespace std;
class A {
public:
static int _n;
};
int A::_n = 0;
int main()
{
A a;
cout << a._n << endl; //通过已实例化对象调用
cout << A()._n << endl; //通过匿名对象调用
cout << A::_n << endl; // 通过类名::静态成员变量调用
}
当静态成员变量为私有时,有几下几种访问方式:
#include <iostream>
using namespace std;
class A {
public:
static int GetN()
{
return _n;
}
private:
static int _n;
};
int A::_n = 0;
int main()
{
A a;
cout << a.GetN() << endl; //通过已实例化对象调用
cout << A().GetN() << endl; //通过匿名对象调用
cout << A::GetN() << endl; // 通过类名::静态成员变量调用,这里必须用静态成员函数
}
- 静态成员函数没有隐藏的this指针,不能访问任何非静态成员
class A {
public:
static void Print()
{
cout << _a << endl;//error,静态成员函数没有this指针,无法访问非静态成员变量
cout << _n << endl; //true
}
private:
int _a;
static int _n;
};
int A::_n = 0;
含有静态成员变量的类一般都含有一个静态成员函数用来访问静态成员变量。
- 静态成员也是类的成员,受public、protected、private 访问限定符的限制
当静态成员变量设置为private
时,不能直接对其进行访问,要借助静态成员函数进行访问。
静态成员函数可以调用非静态成员函数吗?
不可以,非静态成员函数的第一个形参默认为
this
指针,而静态成员函数中没有this
指针,无法访问类对象的非静态成员变量,也无法访问类对象的非静态成员函数。非静态成员函数可以调用静态成员函数吗?
可以,因为非静态成员函数和静态成员函数都在类里,调用静态成员函数不需要
this
指针,直接调用就可以了。
我们可以看下面的题目:
求1+2+3+…+n,要求不能使用乘除法、for、while、if、else、switch、case等关键字及条件判断语句(A?B:C)
我们可以通过构造函数来计算:
代码1:
class Solution{
public:
class Sum{
public:
Sum()
{
_sum += _n;
_n++;
}
};
int Sum_Solution(int n)
{
Sum a[n];
return _sum;
}
private:
static int _n;
static int _sum;
};
int Solution::_n = 1;
int Solution::_sum = 0;
代码2:
int Solution::Sum::_i = 1;
int Solution::Sum::_sum = 0;
class Solution {
public:
class Sum{
public:
Sum()
{
_sum += _i;
_i++;
}
static int GetNum()
{
return _sum;
}
private:
static int _i;
static int _sum;
};
int Sum_Solution(int n) {
Sum a[n];//调用n次构造函数,就计算出来了,变长数组,C99支持的,我们也可以用new
//Sum* ptr = new Sum[n];
return Sum::GetNum();
}
};
友元
友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
友元可以分为友元函数和友元类。
友元函数
我们在实现日期类的时候重载了流插入(<<)和流提取(>>),但是当重载成成员函数的时候this指针是第一个参数也就是左操作数,使用的时候不符合我们平时使用习惯,在实际使用中cout需要是第一个形参对象,所以我们可以将其重载为全局函数,通过在类中将其作为日期类的友元函数就可以访问类中的成员了。
#include <iostream>
using namespace std;
class Date {
//友元函数
friend istream& operator>>(istream& in, Date& d);//流提取
friend ostream& operator<<(ostream& out, const Date& d);//流插入
public:
Date(int year = 1, int month = 1, int day = 1)
:_year(year)
,_month(month)
,_day(day)
{
}
private:
int _year;
int _month;
int _day;
};
//重载流提取
istream& operator>>(istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
//重载流插入
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "/" << d._month << "/" << d._day << endl;
return out;
}
int main()
{
Date d1;
cin >> d1;
cout << d1;
}
cout是ostream中的一个全局对象,cin是istream中的一个全局对象。
友元函数说明:
-
友元函数可访问类的私有和保护成员,但不是类的成员函数
-
友元函数不能用const修饰
-
友元函数可以在类定义的任何地方声明,不受类访问限定符限制
-
一个函数可以是多个类的友元函数
-
友元函数的调用与普通函数的调用原理相同
友元类
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员。
#include <iostream>
using namespace std;
class Date {
friend class Time;//声明时间类是日期类的友元类,在Time类中可以访问Date类的所有成员
public:
Date(int year = 1, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
{
}
private:
int _year;
int _month;
int _day;
};
class Time {
public:
void PrintDate()
{
//直接使用Date类的对象访问Date类的私有成员
cout << d._year << d._month << d._day << endl;
}
private:
int _hour;
int _min;
int _sec;
Date d; //Date类的对象
};
int main()
{
Time t;
t.PrintDate();
}
- 友元关系是单向的,不具有交换性
上述代码中,Time
类是Date
类的友元类,Time
类可以访问Date
类中的私有成员,但是Date
类不能访问Time
类中的私有成员。
- 友元关系不能传递
如果C是B的友元, B是A的友元,则不能说明C是A的友元。
- 友元关系不能继承
内部类
概念
如果一个类定义在另一个类的内部,这个内部类就叫做内部类。内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越的访问权限。
其实内部类就是外部类的友元类,即内部类可以通过外部类的对象来访问外部类中的所有成员,但是外部类不是内部类的友元类。
特性
-
内部类可以定义在外部类的public、protected、private都是可以的。
-
注意内部类可以直接访问外部类中的static成员,不需要外部类的对象/类名。
-
sizeof(外部类)=外部类,和内部类没有任何关系
#include <iostream>
using namespace std;
class A {
public:
//B天生就是A的友元类
class B {
public:
void Print(const A& a)
{
cout << _a << a._b << endl;//true
}
private:
static int _k;
int _b;
};
//这样写是错误的,A类不能访问B类中的成员
//void PrintB(const B& b)
//{
// cout << b._b;//error
//}
private:
static int _a;
int _b;
};
int A::_a = 1;//A中的静态成员变量初始化
int A::B::_k = 2;//B中的静态成员变量初始化
如果要访问内部类中的成员或者函数,要外部类::内部类::成员名
。并且要实例化内部类对象时,内部类必须在外部类的public中,private访问限定符不能实例化对象。空间独立但是某些操作受到访问限定符和类域的限制。
计算外部类的大小时,不包括内部类的大小。
编译器优化
我们通过下面的代码来认识一些编译器的优化:不同的版本有不同的优化方法,这里使用的版本是VS2022。
- 构造+拷贝构造可以优化为构造
C++98中支持单个参数的隐式类型转换,C++11中支持多个参数的隐式类型转换。
class A {
public:
A(int a)
{
cout << "A()" << endl;
}
A(const A& a)
{
cout << "A(const A& a)" << endl;
}
A& operator=(const A& a)
{
cout << "A& operator=(const A& a)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
int main()
{
A a1 = 1;//构造+拷贝构造+优化 -> 构造
}
打印结果:
- 传值传参时没有优化,直接调用拷贝构造函数
class A {
public:
A(int a)
{
cout << "A()" << endl;
}
A(const A& a)
{
cout << "A(const A& a)" << endl;
}
A& operator=(const A& a)
{
cout << "A& operator=(const A& a)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
void func1(A a)
{}
int main()
{
A a1 = 1;//构造+拷贝构造+优化 -> 构造
func1(a1);
}
打印结果:
func1(2)
和func1(A(2))
两个都是先构造一个临时对象,再把临时对象拷贝构造给func1
中的形参,编译器会进行优化。
class A {
public:
A(int a)
{
cout << "A()" << endl;
}
A(const A& a)
{
cout << "A(const A& a)" << endl;
}
A& operator=(const A& a)
{
cout << "A& operator=(const A& a)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
void func1(A a)
{}
int main()
{
//A a1 = 1;//构造+拷贝构造+优化 -> 构造
func1(2);//构造+拷贝构造+优化 -> 构造
func1(A(2)); //构造 + 拷贝构造 + 优化->构造
}
打印结果:
func2(2)
、func2(A(2))
先生成一个临时变量,引用直接使用临时对象,无优化;func2(a1)
直接引用使用,无优化。
当函数的形参为引用类型时,如果不需要改变,最好加上const。
class A {
public:
A(int a)
{
cout << "A()" << endl;
}
A(const A& a)
{
cout << "A(const A& a)" << endl;
}
A& operator=(const A& a)
{
cout << "A& operator=(const A& a)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
void func1(A a)
{}
//当函数的形参为引用类型时,如果不需要改变,最好加上const
//这里形参如果不加const会报错,因为传递的是临时变量,临时变量具有常性
void func2(const A& a)
{}
int main()
{
A a1 = 1;//构造+拷贝构造+优化 -> 构造
func2(2);//无优化
func2(A(2)); //无优化
func2(a1);//无优化
}
打印结果:
- 使用传值返回的时候也会发生优化
class A {
public:
A(int a = 20)
{
cout << "A()" << endl;
}
A(const A& a)
{
cout << "A(const A& a)" << endl;
}
A& operator=(const A& a)
{
cout << "A& operator=(const A& a)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
void func1(A a)
{}
void func2(const A& a)
{}
A func3()
{
A aa;
return aa;
}
int main()
{
A a1 = func3();//拷贝构造+拷贝构造+优化 -> 拷贝构造
}
两次拷贝构造优化为一次拷贝构造。
- 不会发生优化
class A {
public:
A(int a = 20)
{
cout << "A()" << endl;
}
A(const A& a)
{
cout << "A(const A& a)" << endl;
}
A& operator=(const A& a)
{
_a = a._a;
cout << "A& operator=(const A& a)" << endl;
return *this;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
void func1(A a)
{}
void func2(const A& a)
{}
A func3()
{
A aa;
return aa;
}
int main()
{
A a1;
a1 = func3();//不能优化
}
总结:
1.接收函数返回值时尽量用拷贝构造方式接收,不要赋值接收
2.函数中返回对象时,尽量返回匿名对象
3.尽量使用const引用传参
再次理解类和对象
C++是面向对象的语言,面向对象三大特性:封装、继承、多态。
现实生活中的实体计算机并不认识,计算机只认识二进制格式的数据。如果想要让计算机认识现实生活中的实体,用户必须通过某种面向对象的语言,对实体进行描述,然后通过编写程序,创建对象后计算机才可以认识。比如想要让计算机认识洗衣机,就需要:
-
用户先要对现实中洗衣机实体进行抽象,即在人为思想层面对洗衣机进行认识,洗衣机有什么属性,有什么功能,即对洗衣机进行抽象认知的一个过程
-
经过上一步之后,在人的头脑中已经对洗衣机有了一个清醒的认识,只不过此时计算机还不清楚,想要让计算机识别人想象中的洗衣机,就需要人通过某种面向对象的语言(比如:C++、Java、Python等)将洗衣机用类来进行描述,并输入到计算机中
-
经过上一步之后,在计算机中就有了一个洗衣机类,但是洗衣机类只是站在计算机的角度对洗衣机对象进行描述的,通过洗衣机类,可以实例化出一个个具体的洗衣机对象,此时计算机才能洗衣机是什么东西。
-
用户就可以借助计算机中洗衣机对象,来模拟现实中的洗衣机实体了。
在类和对象阶段,要体会到类是对某一类实体(对象)来进行描述的,描述该对象具有哪些属性,哪些方法,描述完成后就形成了一种新的自定义类型,才用该自定义类型就可以实例化具体的对象。
封装
C++通过类,将一个对象的属性与行为结合在一起,使其更符合人们对于一件事物的认知,将属于该对象的所有东西打包在一起。通过访问限定符的将其部分功能开放出来与其他对象进行交互,而对于对象内部的一些实现细节,外部用户不需要知道,知道了有些情况下也没用,反而增加了使用或者维护的难度,让整个事情复杂化。
比如我们乘火车,我们不需要关心票的分配机制等,只需要找到对应的座位即可,这就是封装的好处。
面向对象
可以看出,面向对象其实是在模拟抽象映射现实世界: