目录
类和对象是C++学习中较难跨过的坎,里面的细节非常多,非常杂,想用一篇博客来讲清楚类和对象的所有知识是非常困难的,我会从C语言的结构体慢慢过渡到纯正的C++,一步步带大家探索类和对象,讲所有知识点尽可能的阐述清楚,希望大家能有所收获
引入
类和对象的基本思想
要讲类和对象,那必须要理解一个概念:面向对象
这个概念是对比于面向过程而出现的,C语言就是比较经典的面对过程的语言,对于面向过程,我们关注的是一个项目、一个题目的思维过程,通过函数调用的方式,一步步完成问题。
但是这种方式在面对大型的项目时,会显得十分慌乱,手足无措,找不到逻辑的线头,或者是逻辑过于复杂,无法清晰的通过一个个函数体现出来
C++之父本贾尼也发现了这一点,他认为现实世界中的问题,都是人与人,人与物,物与物之间产生的关系与交互而来的,那么我们解决问题,是否也可以基于这样的特点,来进行呢?
思维样例
汽车行驶的过程
-
面向对象的思维方式:
我们将把汽车视为一个类,汽车类将拥有属性和方法。属性可能包括汽车的颜色、制造商、型号等等,而方法可能包括加速、刹车、转弯等等。我们还可以通过继承和多态性来实现不同类型的汽车(如轿车、卡车、SUV等)之间的代码共享和复用。 -
面向过程的思维方式:
我们则更依赖于函数来完成任务。我们会将汽车行驶的过程分解成一系列的步骤,每一步都由一个函数来执行。例如,首先启动引擎,然后换挡、踩油门加速、踩刹车减速等等。这些函数通常是顺序执行的,并且需要传递参数以控制其行为。
现在是否大家心里有了对面向对象这种思想的初步认识?
下面我写一个对象的样例,让大家初步认识认识C++中的类
引例
struct Stack
{
void Init(int capacity)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (nullptr == _a)
{
perror("Init::malloc/err");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(const int& data)
{
// 扩容
_a[_size] = data;
++_size;
}
int Top()
{
return _a[_size - 1];
}
void Destroy()
{
if (_a)
{
free(_a);
_a = nullptr;
_capacity = 0;
_size = 0;
}
}
int* _a;
int _capacity;
int _size;
};
int main()
{
Stack s;
s.Init(10);
s.Push(1);
cout << s.Top() << endl;
s.Destroy();
return 0;
}
从观察的角度来说:第一眼看上去很熟悉,这不是C语言中的结构体吗?但是仔细看,里面又有一些不同,结构体内包含了一些函数,而且怎么命名上有些特别,变量前面都有一个 _ 符号,使用的时候,对于这些函数 . 后面就能直接使用。感觉上会比C语言写出来的栈更成体系,更为完整。
可能大多数人在没学过类和对象的时候来看这段代码就是这种感觉,类和对象不就是结构体里面能放函数,函数能通过 . 来直接使用吗?通过上例肯定很多人就是这么认为的,我这么写这个引例,是为了让零基础的学习者能够比较容易的看懂,而真正的C++类,不是这么轻松就能讲明白的。
那么就让我们正式开始类和对象吧
基本知识
类的定义
类是面向对象编程中的一个概念,用来描述一类具有相同特征和行为的对象。它定义了一组属性和方法,这些属性和方法可以被对象使用。类可以看作是一种模板或者蓝图,用来创建具有相同属性和行为的对象。在类中定义的属性和方法可以通过实例化对象来进行使用。
这里的特征和行为、属性和方法,可以统一成一种表达:成员变量和成员函数
我们定义类用到的关键字为class和struct
class ClassName
{
//类体
}; //注意分号不能忘
类中的内容称为类的成员:类中的变量称为类的属性后或员变量,类中的函数称为类的方法或成员函数,我在后面统一都称为成员变量和成员函数。
class和struct 与 访问限定符
对于上面的引例,我们将struct直接改成class,会发现程序报错,报错的提示 XXXX 不可访问,其实这里就涉及到了class和struct最突出的区别
要讲清楚这一点,那么就需要明白一个概念
类和对象的访问限定符
访问限定符有三种:public - 共有、protected - 保护、private - 私有
public修饰的成员在类外可以直接被访问,protected和private修饰的成员在类外不能直接被访问((protected和private我们前期学习类和对象可以认为是一个东西,后期我们会具体谈谈区别和用法。)),访问作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止,如果后面没有访问限定符,作用域就到类结束。
而class的默认访问权限为private,struct为public,struct这样的设定是为了向下兼容C中的结构体struct的特性,而对于class默认为private,可以理解为这是一种强封闭性,我们把类如果比作一个盒子,class就是一个黑色不透明的盒子,需要使用什么就将其特意放在公开域中,而其他的所有内容都密封在黑盒中,这样就具有非常良好的封闭性,使得数据能够得到很好的保护。
因此在上面的例子中,由于用的是class关键字,class默认访问权限是private,不能直接使用这些私有的成员函数和成员变量,所以我们进行修改:
此时就能够正常运行了。
具体的,我将所有函数放在了共有域,所有的变量放到了私有域,这也就是一种对数据的保护,使用类的人不需要通过 . 直接访问到_capacity、_size这些值,这些值的改变,完全取决于对类的需求或者者说是对成员函数的设计。
我们这里注意到,对于私有成员变量,我们在变量名前面加了一个 _ 这是一种规范性行为,除了在前面加 _ ,还可以在后面加 _ ,或者是在前面加m(member),比如上例的Init成员函数的传参,我们外部传入了一个初始容量大小,为了可读性,我们形参名也就取的capacity,也就是 _capacity = capacity ,有一一对应的感觉,也就有比较高的可读性。
回到class和struct,在C++我们是偏向于使用class的,其一就是前面讲到的class的强封闭性,更符合面向对象的思想,其二就是对于我们后期学习继承、多态等特性的时候,只有class才能够使用这些特性。
当然,在对于不使用继承、多态等特性的特性时,我们只要能够注意合理使用private、public,class和struct并没有什么差异。
类域
类似于局部域全局域、命名空间域,类也有类域,类域的范围默认就是在类体{}内,在类域中不添加域作用限定符:: 就能够正常使用类中的成员变量和成员函数。
class Stu
{
public:
void print()
{
cout << _name << endl;
}
void NameSet(const char* name)
{
memcpy(_name, name, strlen(name) + 1);
}
private:
char _name[20];
};
int main()
{
Stu s1;
s1.NameSet("zhangsan");
s1.print();//zhangsan
return 0;
}
这里我就在print()成员函数中直接使用了 _name 成员变量。
类域中还有个特点,类域中的成员变量和成员函数的前后顺序无影响,也就是编译器是将其看成整体,这是因为类体中的内容其实只是对象的蓝图、模板,什么时候使用,具体使用什么,都是取决于程序员。
多文件操作
在我们实际项目中,一个类中可以会几十个成员变量、函数,如果都放在一个.c文件中,会显得特别冗杂,因为我们一个类在完全写好,做足了测试后,我们只需要做的就是调用,我们此时不会关心类的实现,因此,我引入类的多文件操作。
//Stu.cpp
class Stu
{
public:
void print()
{
cout << "Name:" << _name << "\nHeight:" << _height << "\nWeight:"<< _weight << endl;
}
void NameSet(const char* name)
{
memcpy(_name, name, strlen(name) + 1);
}
void HeightSet(int height)
{
_height = height;
}
void WeightSet(int weight)
{
_weight = weight;
}
private:
char _name[20];
int _height;
int _weight;
};
以上是这个类的完整代码,我们接下来将其拆分成 一个.cpp 和 一个.h文件,并用一个test.cpp文件进行测试
//Stu.h
#pragma once
#include<iostream>
using namespace std;
class Stu
{
public:
void print();
void NameSet(const char* name);
void HeightSet(int height);
void WeightSet(int weight);
private:
char _name[20];
int _height;
int _weight;
};
//Stu.cpp
#include"Stu.h"
void Stu::print()
{
cout << _name << endl;
}
void Stu::NameSet(const char* name)
{
memcpy(_name, name, strlen(name) + 1);
}
void Stu::HeightSet(int height)
{
_height = height;
}
void Stu::WeightSet(int weight)
{
_weight = weight;
}
//test.cpp
int main()
{
Stu s1;
s1.NameSet("zhangsan");
s1.HeightSet(160);
s1.WeightSet(90);
s1.print();
return 0;
}
与数据结构时我们的多文件操作有些类似,我们将类放在.h文件中,类体中存放成员函数的声明,以及成员变量,而成员函数的真正实现放在.cpp中,做测试又是另一个.cpp文件,这样会使得工程上更加规范,更加接近于一个公司中的真实情况。
需要注意的是我们在实现类函数的定义时,需要指定这个函数是属于谁的,也就是说需要用域作用限定符来告诉编译器这个函数是属于这个类的,不是全局的函数。
面对对象的三大特性
面对对象有三个特性:封装、继承、多态
- 封装:
将 数据 和 操作数据的方法 进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
封装本质上是一种管理,让用户更方便使用类。比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。
对于计算机使用者而言,不用关心内部核心部件,比如主板上线路是如何布局的,CPU内部是如何设计的等,用户只需要知道,怎么开机、怎么通过键盘和鼠标与计算机进行交互即可。因此计算机厂商在出厂时,在外部套上壳子,将内部实现细节隐藏起来,仅仅对外提供开关机、鼠标以及键盘插孔等,让用户可以与计算机进行交互即可
在C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限(private、public、private)来隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。 - 继承:
它允许一个类(子类/派生类)从另一个类(父类/基类)继承属性和方法。子类可以使用其父类的所有公共和保护成员,包括变量、函数和操作符重载。 - 多态:
允许不同对象对同一消息作出不同的响应。
对于继承和多态的具体特性,我会在下一篇类和对象的文章中讲解
类的实例化
//类中
private:
int* _a;
int _top;
int _capacity;
思考:这一些类中的成员变量是声明还是定义?
:是声明!
对于变量来说,是声明还是定义,取决于是否开辟了空间,开了空间就是定义,没开空间就是声明。
而注意,类中的变量函数都是一种模板、一张图纸,不会有空间消耗,只有当我们外部创建了一个类的对象时,这个对象才会消耗空间,因此对于类中的这些变量来说,是声明。
class Stack
{...};
Stack s1;
Stack::top = -1;//err,其原因就是用域作用限定符拿到的top是声明,并不会开辟空间,也就无法赋值
s1.top = -1;//right,s1是具体的对象,通过 . 拿到top变量,进行赋值
因此我们将创建类对象的这种行为称为类的实例化
类的字节大小
在C语言中计算结构体的类型,为了提高访问效率,编译器会考虑内存对齐的问题,C++中也相同,一定会有人问?不考虑函数的大小?至少存一个函数指针?不考虑!编译器是将成员函数放在公共代码区,并不需要对象存储,也就是说,C++类的大小就和结构体的大小计算一模一样,按照变量声明的先后顺序,考虑内存对齐的计算出类的大小
特殊的,类中只有成员函数,没有成员变量,这种情况下,编译器为了占位,让类占一个字节
这种函数有什么用?:仿函数 博主这次不讲,这次是为了类和对象的入门,并不会讲这么深入的知识
this指针
我们上文中有讲,类中的成员函数能够直接使用成员变量,甚至是使用私有成员变量,对这个点,我们可以给出明确的解释:在C++中,每一个成员函数,都会有一个隐式的形参参数,即this指针
this指针的原型就是 Date* const this,也就是指向对象地址的指针,对比理解:如果是在C语言中,就相当于StackPushBack(&s,10)中的这个&s对应的形参,C++这么设计是为了简化操作,因为对于类中函数来说,基本都会使用到类中的成员变量,那干脆就都不用写,就默认让成员函数的第一个参数是this指针,我们这里原型中的const的对this指针本身,也就是说,不能改变指针本身的地址,可以修改指针指向的空间中的内容
简单来说,就是之前我们数据结构中是我们自己传递结构体地址,而C++中变成了编译器偷偷的传地址,偷偷的~~~
需要注意的是:不能在形参和实参列表的位置出现this指针,这个的话是规定,记住即可,但是可以在函数体内显式的使用this指针,后面也会讲到显示使用this指针的场景
下面来看一些例子:
class A
{
public:
void Print()
{
cout << this << endl;//00000000
cout << "Print()" << endl;
}
void test()
{
cout << _a << endl;//err
_a = 1;//err
}
int _b;
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();//right
p->_b = 1;//err
p->test();//err
return 0;
}
第一个p->Print()
因为在Print内部没有对this指针解引用,并且Print()本身也没有存在对象中,而是在公共代码区中,相对的p->test()
中就对this指针进行了解引用,因此在运行时程序崩掉了,p->_b = 1
,因为_b是存在对象中,而对象的地址是nullptr,对nullptr位置进行了访问操作,那就会报错
类的六个默认成员函数
这一部分也是为了减少程序员重复操作、让程序员更专注于程序的逻辑的一部分,这部分的细节特别多,希望大家能够看完后,自行实验,自行操作,多在自己的编译器上做一些测试。我对于这部分主要是通过日期类、栈、队列数据结构的例子来讲解这部分的知识。
基本概念
如果一个类中没有任何成员,即空类,编译器依旧会自动生成六个默认成员函数
初始化与销毁
先来看一个例子:
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, 7, 5);
d1.Print();
return 0;
}
对于一个对象来说,如果需要初始化,那就需要调用Init函数,每次创建都需要调用Init,如果是栈类的话,那结束时,还需要调用Destroy函数,来进行资源(堆上的资源)的清理回收。或者我们想要创建一个和已存在的对象数据完全相同的对象,这类操作是机械的,重复的,低效的,因此C++有了构造函数、析构函数、拷贝构造函数来替代Init、Destory函数等函数,来提高效率
构造函数
构造函数是一个特殊的成员函数,名字与类名相同,创建类对象时由编译器自动调用,对对象进行初始化,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
构造函数有如下特征:
- 函数名与类名相同。
- 无返回值。(不用写void)
- 对象实例化时编译器自动调用对应的构造函数。
- 构造函数可以支持函数重载
class Date
{
public:
Date()// 默认构造函数
{}
Date(int year, int month, int day)// 构造函数
{
_year = year;
_month = month;
_day = day;
}
/*
//使用缺省值合成为一个默认构造,无参时,就是1900 1 1
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
*/
private:
int _year;
int _month;
int _day;
};
void TestDate()
{
Date d1; // 调用无参构造函数 注意:对象后面不用跟括号
Date d2(2015, 1, 1); //2015 1 1 调用带参的构造函数
}
注意,构造函数需要是共有成员函数的,如果是私有的,就无法自动调用构造函数,构造函数放成私有是有引用场景的:单例模式,这篇博客不会讲。
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,标准规定:对内置类型不做处理,而对自定义类型会调用其默认构造,一旦用户显式定义默认构造函数,编译器将不再生成默认构造函数。
可以用上图来理解,编译器只会帮我们处理非叶子节点,而对于叶子节点,终究还是我们的int、double,指针(包括自定义类型的指针也是内置类型)这些内置类型,我们需要自行处理:子节点是叶子节点的根节点,也就是所有成员变量存在内置类型的自定义类型我们都需要自行处理,可能有读者在这里自行测试的时候,会发现自己的编译器下,内置类型被初始化为了0,这是编译器的个性化操作,是个例,在C++标准下,并没有这么做,所以不要懒,需要我们自己写构造函数
在给大家一个例子来理解:
该例子是两个栈实现队列,MyQueue中没有内置类型,不需要我们自己实现构造函数,Stack中有内置类型,需要实现构造函数
因此只要类中存在内置类型,就要自己写构造函数,编译器自己的默认构造函数不稳定(和编译器有关),不要依赖编译器!
应该读者会注意到我上面一会儿提到默认构造函数,一会儿提到构造函数,估计也不太明白二者的区别
我这里解释解释:
无参的构造函数 和 全缺省的构造函数 、 编译器生成的构造函数 都称为默认构造函数,并且默认构造函数是三则一的(函数重载的限制),初始化是MyQueue q1;
不带参数,不加括号,没有参数,而所有以类名为函数名、没有返回值的函数都是构造函数,初始化是Date d1(1900, 1, 1);
这种带参,只要满足函数重载的要求,构造函数根据需求可以有多个,但至少我们需要实现一个默认构造,去取代编译器生成的。
在C++11中,在成员声明时可以给缺省值,给默认构造函数使用,对于全缺省构造:全缺省中的缺省值优先级更高,无参构造:此时也没有什么意义了,也就是说,如果十分明确某个类的初始值,不会发生变化,就直接在声明处给出缺省值,就不用实现构造函数了。当然大多数情况都是使用全缺省的构造函数,不给参数就是缺省值,给参数就是给定值,自由度会大很多。
上面两种情况中,后者更优
如果涉及需要动态开辟内存的情况,我们也不会去调用默认构造函数,而是用new来进行处理,下次博客对其讲解
析构函数
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成
的。析构函数是完成对象中资源清理的工作。
析构函数的特征:
- 析构函数名是在类名前加上字符 ~。
- 无参数无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重
载 - 对象生命周期结束时,C++编译系统系统自动调用析构函数。
简单来说析构函数代替了Destory的工作
同构造函数:编译器生成的默认析构函数对内置类型不做处理,对自定义类型会去调用它的析构函数
析构函数的自动调用意义很大,可以有效的防止内存泄露,也就是说对于类,我们就不用去过多的考虑内存泄露的问题。
如果面对malloc calloc realloc出来的变量,编译器生成的默认析构,不会处理内置类型,也就是不会处理_a指针,也就是会内存泄露
因此:
一般情况下,有动态申请资源(堆上),就需要写析构函数释放资源(链栈类)
没有动态申请的资源,不需要写析构(日期类)
需要释放资源的成员都是自定义类型,不需要写析构函数(两个栈完成的队列)
拷贝构造
只有单个形参,该形参的类型是类类型,并且是类类型的const引用,由编译器自动调用。
这里的单个形参是显式的单个,隐式的还有this指针
拷贝构造的特征
- 拷贝构造函数是构造函数的一个重载形式。
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值传参编译器直接报错,因为会引发
无穷递归调用。
拷贝构造在以下情况下会进行调用
- 在用已存在的类对象创建新对象时自动调用
- 将一个对象作为实参传递给一个类 类型的形参(值传递),会自动调用拷贝构造
- 函数返回值是类类型时,会自动调用拷贝构造(实际在VS2022中测试,这种情况会被优化)
拷贝构造第二个特征中的传值情况会无穷递归的原因:
在我们调用函数让d1拷贝到d2时,d1是自定义类型,自定义类型传参需要调用拷贝构造函数,调用的拷贝构造函数又需要传参,参数又是自定义类型,又需要调用拷贝构造函数…无限递归
整体逻辑:拷贝构造需要传参,传参会调用拷贝构造,新的拷贝构造也需要传参,传参会调用拷贝构造…无限递归
因此,我们为了处理这种套娃的情况,就需要让拷贝构造的形参类型是指针或引用类型,引用用着更舒服一些,也不用关注取地址问题。
这里可能有读者对构造函数内部的逻辑有些迷糊,因此我这里也画了一个理解图
让d1的值都对应传给d2
为了提高代码的鲁棒性,防止我们意外的在拷贝构造中修改数据,我们在前面加上const
main(): Date d2(d1) -> class{}: Date(const Date\* d)
权限缩小,从可读可写的d1,让其在拷贝构造里只可读
如果我们没有自行定义拷贝构造,编译器会生成默认的拷贝构造,默认的拷贝构造函数对象按内存存储地址中的内容,以字节序,一个字节一个字节的完成拷贝(类似于memcpy),这种拷贝叫浅拷贝,或者值拷贝
- 内置类型成员完成值拷贝/浅拷贝
- 自定义类型成员会调用它的拷贝构造
简单来说,它都会处理
那是不是所有类都是用默认的拷贝构造就可以了呢?
No,默认的拷贝构造无法解决所有问题
比如需要动态申请内存的栈Stack,如果我们直接使用默认拷贝构造,那么就是浅拷贝,也就是说,我们拷贝出来的新对象中动态开辟的内存和旧对象是用的同一块堆空间,如果我们销毁了旧对象,那么新对象的堆空间也还给操作系统了,而对于类来说,编译器都会自动调用析构函数,也就是说,编译器会对同一块空间free两次,这很致命,会导致崩溃,从需求角度,这也没有达到我们栈的拷贝需求,我们是想要得到一个同样存储大小,存储内容相同,并且开辟了另一块堆空间的栈对象
不止动态内存申请的情况,对于所有类中有指针类型的类,浅拷贝都没有完成真正的拷贝,只是让新对象指向了同一块空间,这样拷贝出来的对象与旧对象的空间并不是独立的,会互相影响,在大多数情况,我们都是不希望出现互相影响的情况的。
因此对于有指针类型的情况,我们就需要自行实现深拷贝,深拷贝是指,对于非指针、非引用类型,正常进行值拷贝,对于指针类型,创建另一个指针,如果是动态开辟的,再开辟一块相同大小的空间,对指向的内容进行值拷贝,拷贝结束之后两个对象虽然存的值是一样的,但是指针变量内存地址不一样,两个对象互不影响,互不干涉。
Stack的拷贝构造的实现:
补充:
析构函数的调用顺序
建议:
如果我们的类非常大,函数的返回类型的是类类型的时候,在满足需求、以及语法的前提下,尽可能的使用引用的方式。
运算符重载
引例
int main()
{
Date d1(1930, 1, 1);
Date d2(1920, 1, 1);
if (d1.CmpDate(d1, d2))
{
cout << "d1 > d2" << endl;
}
else
{
cout << "d1 <= d2" << endl;
}
}
自定义类型不同于内置类型,因此这里的大于比较需要通过调用函数的方式进行,如果还需要比较小于,比较等于,计算两个日期的差值等情况,每次都需要我们手动去调用相应的函数,会十分麻烦,因此,我们C++中引入了运算符重载这个概念。
if (d1 > d2)
{
cout << "d1 > d2" << endl;
}
else
{
cout << "d1 <= d2" << endl;
}
通过运算符重载函数使得上述代码能够正常运行。使代码更加简洁易懂,同时也能够增强代码的可读性和可维护性。
运算符重载相关概念
运算符重载函数是一种具有特殊函数名的函数,函数名字为:关键字operator + 运算符符号,函数原型:返回值类型 operator操作符(参数列表),当我们完成了对应的运算符重载函数,在用类对象做运算时,编译器检查到运算符作用的操作数是类类型后,会去寻找对应的运算符重载函数,然后由编译器进行运算符重载函数调用
例如对于小于运算符重载:
bool operator<(const Date d1, const Date d2)
{...}
Date d1(1920, 11,12);
Date d2(1931, 3, 13);
if(d1 < d2) cout << "d1 < d2" << endl;
else cout << "d1 >= d2" << endl;
下面我会使用日期类,对运算符重载做一个比较全面的讲解。
小于运算符重载
内置类型的比较:编译器会直接转换成指令
自定义类型的比较:转换成去调用 operator<(d1, d2) operator>(d1, d2)这两个重载的函数
//这两行代码完全等价
cout << (d1 < d2) << endl;
cout << (operator<(d1, d2)) << endl;
需要强调的是:对于某一个类来说,要根据运算符对类来说是否有实际意义和用途来决定是否需要写出对应的运算符重载,比如日期对比较、减法是有意义的,但是对乘法等是没有意义的。
回顾上例,这种方式是把运算符重载用作普通函数,缺陷的话是无法直接访问到私有成员变量,只有把_year _month _day 这三个成员变量公有化,才能够正常运行,除此之外,普通函数使得所有人都可以调用这个函数,使得封闭性较差,因此我们摒弃这种普通函数的方式,而将针对Date类的运算符重载都放在类中,成为成员函数。
小于运算符重载
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;
}
return false;
}
//变成成员函数后,等价的语句就变成如下:
d1 < d2;
d1.operator(d2);//也就是说,对于二元运算符,左操作数 默认 为调用者,到运算符重载函数内部就是this指针,右操作数是参数,也就是需要用.去访问到成员变量、函数
//一元的情况,唯一的操作数就是调用者,也就是this指针
//三元运算符的话C++中只有三目运算符 con : exp1 ? exp2 ,C++是禁止它使用运算符重载的。
运算符重载需要注意的一些要点:
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有一个类参数,成员函数的第一个参数默认是this指针可以保证这一点
- 对于内置类型的运算,其含义不能改变,例如:2 + 3 = 5,我们不能重载其为 2 + 3 = 10
- 运算符对应的操作数个数不能发生变化,原来是多少,运算符重载的参数就是多少个。
- 其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this,也就是如果是二元运算,参数列表中只有一个显式的参数
- 运算符重载支持函数复用
- 函数重载和运算符重载,前者是同名函数通过参数列表判断调用的哪一个,后者是让运算符支持自定义类型的运算。
- 有五个运算符不支持重载:
域作用限定符 ::
sizeof
三目 ? :
成员访问 .
.* (访问成员函数指针?,基本不用,记住即可,容易考研、面试考到)
等于运算符重载
bool Date::operator==(const Date& d)
{
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);
}
赋值重载
是特殊的运算符重载函数——赋值运算符重载函数(是默认成员函数,我们不显示实现,编译器会自行生成一个默认赋值运算符重载)
默认生成的赋值重载和拷贝构造的行为一样:
- 对内置类型成员完成值拷贝、浅拷贝
- 对自定义类型成员会去调用它的赋值重载
void operator=(const Date&d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
int main()
{
Date d1(2021, 1, 1);
Date d2;
d2 = d1;
cout << d2._year << "-" << d2._month << "-" << d2._day << endl;//2021-1-1
return 0;
}
这里我们就需要区分区分赋值重载和拷贝构造:
用已经存在的两个对象之间赋值拷贝 —— 赋值重载
用一个已经存在的对象初始化另一个对象 —— 拷贝函数
提问:Date d3 = d1;
这种写法是赋值还是拷贝?拷贝构造!因为它符合拷贝构造的要求。
但是我们在日常使用时,会用到连等,链式的这种情况,因此我们需要对上面实现的赋值重载的返回值进行改进。
Date operator=(const Date&d)
{
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
int main()
{
Date d1(2021, 1, 1);
Date d2,d3, d4;
d3 = d2 = d1;
return 0;
}
我觉得还不够,效率太低,继续改进
Date& operator=(const Date&d)引用是防止每次调用都需要调用拷贝构造
{
if(this != &d)//防止出现d1 = d1这种无效的操作,同时这里也复用了 != 重载
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
int main()
{
Date d1(2021, 1, 1);
Date d2, d3;
d3 = d2 = d1;
return 0;
}
同拷贝构造,编译器默认生成的是值拷贝/浅拷贝,因此,遇到指针的情况,就需要自行实现(深拷贝),对于我们这里的日期类, 日期中没有指针,没有资源的申请,不需要我们自行实现赋值重载
但对于Stack栈来说,就需要自行实现赋值重载,我们的MyQueue——两个栈实现的队列,类中成员变量是类类型,调用其赋值重载,也就不需要实现MyQueue的赋值重载。
赋值运算符对于运算符重载函数来说有一个特殊的地方,只能在类中实现,其原因,如果我们把赋值运算符重载函数弄成普通函数,那么在赋值的情况时,编译器不知道该调用类中的默认赋值重载,还是我们自行实现的赋值重载。
同理,其他的默认成员函数,比如,构造、析构,也不能变成普通函数,理由也是冲突问题
日期加减相关重载
日期类,我们是有需求知道:某一个日期后n天的日期的、前n天的日期,也想知道两个日期的差值是多少
对于这几个运算符重载,其难度在于闰年、闰月的把握,还有该运算符操作后是否会影响到原数据(+= +),以及需要注意n的正负问题,而不是运算符语法、思想本身的问题,因此我在这里就把我的代码放在这里,不懂的可以评论区问我。
bool Date::is_Leap(int year)
{
return (year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0);
}
int Date::GetMonthDay(int year, int month)//得到每个月的天数
{
//这个函数会被频繁调用,因此我们就将这个函数控制为static,让其不需要每次都创建,提高效率
static int MonArr[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (month == 2 && is_Leap(_year))//注意这里是先判断是否是二月,再判断闰年,反过来效率会下降
{
return 29;
}
else
{
return MonArr[month];
}
}
Date& Date::operator+=(int day)//日期+天数 重载
{
//防止用户输入的是负数,出现非法日期
if (day < 0)
{
//注意 *this -= day; 那么进入-=重载后,就是_day -= day; 也就是负负得正,为加,就是错的。所以需要*this -= -day,abs(day)也可以
return *this -= -day;
}
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month == 13)
{
_year++;
_month = 1;
}
}
return *this;
}
Date Date::operator+(int day)//日期+天数 重载 不改变原数据
{
//因为不改变原数据,所以我们拷贝构造一份新的,又由于tmp是局部变量,所以出了作用域tmp不存在了,不能引用返回
Date tmp(*this);
tmp += day;//复用 += 重载
return tmp;
}
//对于+= + 来说,如果是先实现 日期 + 天数,然后让 += 去复用 + ,这种效率比较低,因为,+的重载的返回这只能是Date,也就是每次复用,都会调用拷贝构造,我们这种实现就相对较优
int Date::operator-(const Date& d)//日期 - 日期
{
Date max = *this;//*this可以拿到第一个操作数,复用赋值重载
Date min = d;
int flag = 1;//结果可以为负,也就是说能反映出 大日期 - 小日期 和 小日期 - 大日期
if (*this < d)
{
max = d;
min = *this;
flag = -1;
}
int n = 0;
while (min != max)//这里不用min < max ——效率没有 != 高
{
++min;//效率比后置++高
++n;
}
return n * flag;//用flag反映正负
}
//这里operator-构成函数重载,函数重载和运算符重载没有任何关系
Date Date::operator-(int day)//日期 - 天数
{
Date tmp(*this);
tmp -= day;
return tmp;
}
Date& Date::operator-=(int day)// 日期 - 天数
{
//防止用户输入的是负数,防止非法日期
if (day < 0)
{
//同+=中注释,也可abs(day)
return *this += -day;
}
_day -= day;
while (_day <= 0)
{
方法一:(优)
day小于0,先处理month
_month--;
if (_month == 0)
{
_year--;
_month = 12;
}
//此时再处理day的时候就不需要考虑_month的月份了
_day += GetMonthDay(_year, _month);
方法二
//if (_month != 1)//非一月
// _day += GetMonthDay(_year, _month - 1);
//else
// _day += GetMonthDay(_year, 12);
//_month--;
//if (_month == 0)
//{
// _year--;
// _month = 12;
//}
}
return *this;
}
比较特殊的++ – 这俩可以拧出来提一提
这两个操作符涉及到前置后置,其结果不完全相同,因此C++为了处理,做了一些规定:
Date& operator++();//这是前置++)
Date operator++(int) ;//(这是后置++)
这里的int默认编译器可能传个1,传个0,这是为了用函数重载的性质,来区分前者后者
Date& Date::operator++()//前置++ 重载
{
*this += 1;
return *this;
}
Date Date::operator++(int)//后置++重载
{
//我们是需要模拟返回 *this += 1 前的值,又不能在返回后在函数内 *this += 1 ,所以就先用tmp存储+1前的值,然后先 *this += 1 ,再用tmp返回 += 1 前的值
Date tmp(*this);
//Date tmp = *this;
*this += 1;//这里不要用(*this)++;
return tmp;
}
Date& Date::operator--()//-- 重载(这是前置--)
{
*this -= 1;
return *this;
}
Date Date::operator--(int)//-- 重载(这是后置--)
{
Date tmp(*this);
*this -= 1;
return tmp;
}
const成员函数
Date d1(2023, 4, 4);
d1.Print();
const Date d2(2023, 5, 5);
d2.Print();//err
这是因为d2是const修饰的,但是Print中的this指针是Date const* this类型的(这里的const的保证this指针的地址不被改变),也就是说,这里使得d2的权限放大(d1是权限平移),那么C++为了支持const对象能够正常调用成员函数,支持我们在参数列表后的位置添加const,,这个const修饰的是 *this,也就使得对于const Date d2这种const对象,调用添加了const的成员函数时,从权限放大变成了权限平移,使得能够正常调用。
当然这样的操作使得在成员函数内部无法改变this指针指向的空间中的数据,因此需要根据需求来决定是否添加const,让成员函数变成const修饰的成员函数。
所以我们回顾前面所有成员函数:
这里我再提四个问:
- const对象可以调用非const成员函数吗?不可以 权限放大
- 非const对象可以调用const成员函数吗?可以 权限缩小
- const成员函数内可以调用其他的非const成员函数吗?不可以 权限放大
- 非const成员函数内可以调用其他的const成员函数吗?可以 权限缩小
普通取地址重载 和 const取地址重载
其实这俩基本没有什么可说的,因为他们是默认生成的,所以就还是为他俩列了一个标题。
//取地址重载,这两个函数构成函数重载
Date* operator&(){ return this; }
const Date* operator&()const{ return this; }
基本是不需要管这两个重载的,因为默认的可以应对几乎所有情况
只有对于有特殊的需要,比如,不想让用户得到函数的地址,此时我们实现这两个运算符重载时,就把返回值写成return nullptr;或者其他特殊的地址即可。
小结
这就是本篇博客的全部知识了,类和对象的意义重大,是C++被广泛使用的原因,希望大家都能够掌握类和对象的知识点,多多联系,多多测试