1.类的6个默认成员函数
平时有没有过这样的经历:比如括号匹配问题,有时候忘记初始化;有时候直接return,忘记销毁,导致内存泄漏;有些地方return比较繁琐。祖师爷也面临同样的困境:1.初始化和销毁经常忘记。2.有些地方写起来很繁琐,不能直接return。所以祖师爷就创建了下面的东西:C++里面增加了6个默认成员函数
重点是前四个。那祖师爷怎么考虑这里的问题的呢:这里能不能自动一下。什么自动呢?就是为了不容易忘记,能不能写的时候默认不调了,只要自动就不需要自己再初始化和销毁那些了,刚才的问题也就没有了。所以祖师爷就弄出了构造函数和解析函数,构造函数看名字好像在创建对象,其实不是,而是在初始化。析构函数看名字好像在对对象本身销毁,其实不是,而是在做清理工作。为什么这样说,因为对象不需要某个函数去创建,对象在栈里面,栈里面的变量是自动创建的,它是跟着栈帧走的,栈帧结束这些变量这些变量随着栈帧的销毁也就销毁了。可以理解为构造对象和析构对象时要调函数,只是这个函数不是做创建和销毁对象工作,而是做初始化和清理工作。那构造函数和析构函数分别是怎么说的呢:构造函数是一个特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。那特殊在哪些地方呢:1. 函数名与类名相同。2. 无返回值(也不写void)。3. 对象实例化时编译器自动调用对应的构造函数。下面是一个栈:
typedef int DataType;
class Stack
{
public:
void Init()
{
_array = (DataType*)malloc(sizeof(DataType) * 4);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = 4;
_size = 0;
}
void Push(DataType data)
{
CheckCapacity();
_array[_size] = data;
_size++;
}
void Pop()
{
if (Empty())
return;
_size--;
}
DataType Top() { return _array[_size - 1]; }
int Empty() { return 0 == _size; }
int Size() { return _size; }
void Destroy()
{
if (_array)
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
}
private:
void CheckCapacity()
{
if (_size == _capacity)
{
int newcapacity = _capacity * 2;
DataType* temp = (DataType*)realloc(_array, newcapacity * sizeof(DataType));
if (temp == NULL)
{
perror("realloc申请空间失败!!!");
return;
}
_array = temp;
_capacity = newcapacity;
}
}
private:
DataType* _array;
int _capacity;
int _size;
};
int main()
{
Stack s;
s.Init();
s.Push(1);
s.Push(2);
s.Push(3);
cout << s.Top() << endl;
return 0;
}
以前不调用初始化函数就会报错并异常退出。
现在写个构造函数就可以了,原来的Init也可以存在,但没有必要了,它的功能就是替代了Init,此时没有调用初始化含函数,程序可以正常运行,因为自动调用了构造函数。有人说构造函数可以设为私有吗?语法上可以是私有的,但是就调不动了。前面的栈还有内存泄漏问题,因为没有调用Destroy,祖师爷是要把这些问题都搞定的,所以还有析构函数。析构函数与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作,类比Destroy。它的特殊在:1. 析构函数名是在类名前加上字符 ~(反过来)。2. 无参数无返回值类型。3. 对象生命周期结束时,C++编译系统系统自动调用析构函数。
以前不调用Destroy没办法,就只能内存泄漏了,现在可以自动调用。有了构造函数和析构函数,以后就不怕忘记调用了。
下面来看看构造函数和析构函数所涉及的繁琐问题:有没有这样一种场景,有些地方有多种初始化的方式。
在栈类实例化的不同对象里面,有时想这样初始化,有时想那样初始化。如有时候想直接开4个大小的空间;有时候还有一种可能就是已经知道一上来就有一组数据想插入进去,所以要用合适的大小来初始化。这是就引出了下面的事:构造函数可以重载。为什么支持重载,因为可能有多种初始化的方式。下面设计出了点岔子:
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2;
d1.Print();
return 0;
}
这里面也是有构造函数的,那是什么样子呢?有这样一个点:如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
那自动生成的构造函数干了什么事情?怎么运行后是这样的值?不是自动生成了构造函数但看结果感觉什么都没有干,怎么知道有没有自动生成?这个其实是祖师爷当初的一个失误,C++标准没有规定要初始化,这里严格来说应该初始化为0。编译器也做了一些事,只是我们没有看到。C++把类型分为两类:1.内置类型/基本类型,语言本身定义的基础类型int/char/double/指针等等。2.自定义类型,用struct/class等等定义的类型。我们不写,编译器默认生成构造函数,内置类型不做处理(有些编译器也会处理,但那是个性化行为,不是所有编译器都会处理),自定义类型会去调用它的默认构造函数。比如定义一个自定义类型:
内置类型不一定处理,自定义类型祖师爷规定了必须处理。那日常过程中只要有内置类型需不需要写构造函数呢?能不能让编译器默认生成?理论而言是不可以的,综上得出分析:1.一般情况下,有内置类型成员,就需要自己写构造函数,不能用编译器自己生成。2.如果全部都是自定义类型成员,可以考虑让编译器自己生成。
C++里面觉得前面处理不够妥当(内置不处理,自定义处理),所以C++11标准发布的时候,打了个补丁(不是改了之前的语法),在成员函数声明的时候可以给缺省值,注意这里不是初始化,只是声明,这里给的是默认的缺省值,给编译器生成默认构造函数用。
因为严格给0有些场景也不合理,比如没有0年0月0日。如果显示写了构造函数,缺省值就不会用了。
下面看看关于构造函数调用问题,写两个构造函数:
一个没有参数,一个带参数,这里除了函数定义特殊,调用跟普通函数也是不一样的,也非常特殊。普通函数的调用都是函数名加参数列表,它是对象加参数列表或对象不加列表。但不加参数也不能带括号,因为会跟函数声明有点冲突,编译器不好识别。也不能Data d1; d1.Data();如果这样写为什么不直接叫Init,就不合理了。之前说了全缺省的,构造函数写成全缺省后,语法上上图两个Data构造函数可以同时存在,它们两构成重载。但问题是无参调用存在歧义,因为不确定是无参还是缺省,所以也不要同时存在,一般存在全缺省的就可以。
在看这样一个点:无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数,这三个有且仅有一个。再区分一下,默认成员函数是我们不写编译器会自己生成,默认构造函数是不传参就可以调用的函数。
补充析构函数没说的点,析构函数不写也会自动生成默认的析构函数,它也对内置类型不做处理,对自定义类型会做处理,析构函数不支持重载,因为没有参数。
这样对括号匹配问题就很方便。
下面再来梳理一下上述类容,比如main函数中有Stack st1;和Stack st2(10);,如果我们没有写默认构造函数,那编译器自动生成的默认构造函数意义是什么?是在任何场景下什么事情都不做吗?我们不写编译器会自动生成一个默认构造函数,这个默认构造函数对内置类型成员不会做处理(有些编译器会处理且C++11打了个补丁,声明可以给缺省值给了会用缺省值初始化),对自定义类型成员会去调用他的默认构造函数。因此一般情况下,构造函数都需要我们自己写。什么情况下可以不自己写呢?内置类型成员都有缺省值且初始化符合我们的要求或全是自定义类的构造且这些类都定义了默认构造,这些类型没定义默认构造也是不行的。下面看这样的例子:
比如简单实现一个树,最开始有根结点,这可以不写它的构造函数吗?直接不写是不可以的,要么Tree里面写一个构造,要么直接给缺省值nullptr。对于不写析构函数编译器会自动生成默认的析构函数,自动生成默认的析构函数对内置类型成员不做处理,对自定义类型会去调用它的析构函数。什么情况下可以不写析构函数:
ts下面看一个函数叫拷贝构造:
有些场景下对对象有拷贝的需求,什么是拷贝的需求?比如说这有一个日期类对象,假设对当前对象不能改变,要拷贝一个对象算100天以后是多少。当前是Data d1(2025, 2, 26),不想改变当前日期,想算一个新日期出来,新日期是在当前日期之上算100天之后是多少天。不想改变当前日期又想在当前对象的基础上加100天,那就需要拷贝一下。那怎么拷贝呢?就跟我们要ctrl + c或v一样都很像,它在这里定义了一个函数,这个函数也是一个特殊的成员函数,这个特殊的成员函数叫拷贝构造。
拷贝构造函数是特殊的成员函数,它是构造函数的一个重载形式,所以它就是一个构造函数,所以函数名和类名相同,只是用同类型的对象来进行构造。比如说Data d2(d1),d2是d1的拷贝。Data (Data d),用同类型的对象构造,说明d2(d1)相当于d2是要用d1构造出来的。那this就是d2,d就是d1,把d1的类容给this就可以了,这就是拷贝构造。
但这里的拷贝构造有一些问题,拷贝构造要像上图写是编不过的,因为这里有个规定,拷贝构造有且仅有一个参数并且这个参数必须是引用,用传值方式编译器会报错,不报错会引发无穷递归。就是编译器在这里强制检查了,如果不强制检查其实是会无穷递归的。运行后会报错,就是祖师爷规定了必须用引用。
这是一个难以理解的点:我们要调用拷贝构造函数那我们要干嘛?要先传参,但是自定义类型的实参传给形参又形成一个拷贝构造,现在要调用一个函数,要调用这个函数呢需要先传参数,传参又会形成一个新的拷贝构造,要调这个拷贝构造呢又要形成新的拷贝构造要调用之前又要传参,传参又形成新的拷贝构造函数,每次调这个函数都没有调到,都要先传参,传参又是一个新的拷贝构造。C++祖师爷规定了自定义类型传参是必须调拷贝构造的,如果是传值传参,如Data d2(d1),还没有调到函数就要先传参,d1传给d,但d1传给d又是一个拷贝构造,这样会无限递归下去。
看到这里一定感觉懵懵的,不知道怎么理解,没事,先按照祖师爷规定的写出来:
运行后发现可以,下面看这样的例子理解一下:
对于正常函数的调用,要调用函数func怎么办?要先传参,对于内置类型没有这样的限制,但是自定义类型C++有规定。内置类型没有这样的规定,内置类型就是直接拷贝,func(10)就是把这4个字节拷贝给int i的这4个字节就可以了。这里日期类对象是12个字节,按内置类型或C语言理解会认为把实参12字节直接拷贝给形参就可以了。但C++规定不是这样的,它规定自定义类型相当于用实参的对象初始化形参,规定了不能直接拷贝,必须要调用拷贝构造去完成。
在func(d1)打断点,调试时发现没有调用func函数,而是去调用了拷贝构造。因为调用这个函数自定义类型要先传参,传参就是一个拷贝构造。为啥传参是拷贝构造,先理解C++规定,它规定了内置类型直接拷贝,自定义类型必须调用拷贝构造完成拷贝,不仅仅在传参里,正常赋值也是一样的。func(d1)打断点调试时,先调用拷贝构造,相当于传参过程,调完拷贝构造回到原处继续走发现才能进入func函数。
如果这里使用传值调用,用日期类初始化日期类。Data d1用年月日初始化就直接调日期类构造函数,用自己类型对象初始化调用拷贝构造函数,那能不能调到拷贝构造函数呢?调拷贝构造函数前要先传参,传参就形成了新的拷贝构造,新的拷贝构造相当于d要用d1初始化,Data d(d1),这个新的拷贝构造函数又要先传参又会形成一个新的拷贝构造函数,这样会不断的无穷下去,幸亏编译器强制检查了,要不然这是一条没有尽头的路。这里有两个方法可以解决这个问题,一个是引用,一个是指针。
指针是内置类型,哪怕是自定义类型的指针也是内置类型,所以指针把地址拷贝过去就可以了,可以解决问题。但不建议用指针,因为感觉调用时写法很别扭,看着不是很好。用引用会比较好,调用拷贝构造前要先传参,这时不会形成新的拷贝构造了,因为d是d1的别名,语法上来说都没开空间,d就是d1,this就是d2,在函数中把d1的内容给d2就可以了。这里也传的自定义类型,但传值的自定义类型才会继续有新的拷贝构造,这里是传引用不是传值,传引用d就是d1的别名,直接调拷贝构造就可以了。
拷贝构造这里一般还会建议加个const,因为不加怕有人写成上图右边那样,这样不仅没有拷贝成功,还把原来的数据给改了,给引用加了const后权限就缩小了,缩小后写反可以检查出来。有时候到这里可能有点混乱会有疑问说私有不是不能访问怎么这里可以访问?一定记得私有是对类外面的不能访问,类里面是可以访问的,类中的year就是一个声明,实例化的类的year是各自实在的year。
拷贝构造还有一些新的条款:它们都是默认构造函数,我们不写编译器会自动生成,那默认生成的构造函数是怎么做的呢?1.内置类型成员完成值拷贝或浅拷贝。2.自定义类型成员会调用它的拷贝构造。
那上图不写拷贝构造可以完成拷贝吗?根据上面说的可以,因为它会完成值拷贝,值拷贝就像memcpy一样的拷贝。
比如日期类是12字节,左边的12个字节会一个一个字节的拷贝到右边去。既然默认生成的构造函数也可以完成拷贝,那以后干脆不写用默认的不就好了?这里的日期类可以不写,默认生成的拷贝构造就可以用,但再看下面这个:
这有个栈,这也要完成拷贝,但运行后发现结果会崩溃。
为什么不可以呢?把st1的内容拷贝给st2,值拷贝会把值依次拷过来,对于top和capacity没有问题,但a都指向了同一块空间,这样st2出作用域的时候析构一次,st1也会析构一次,同一块空间就被析构了两次,因为不能析构两次所以就崩了。栈里面符合后进先出,后定义的会先析构,所以st2先析构,st1后析构。这种拷贝也叫浅拷贝,这时编译器自己写的就不行了,所以必须自己实现,实现深拷贝。怎么实现呢?两个a不能指向同一块空间,所以st2中的a也开一样大的空间,有一样的值,如上图有1234,开一样大的空间还把这些值拷贝下来,这样完成的就是深拷贝。下面先来简单看看深拷贝,以后会详细说:
现在就可以完成拷贝了,此时是否明白祖师爷在这里要求调拷贝构造函数而不是C语言的方式?如果像C一样把值都拷贝过来就会有挂了的情况,只要一传值导致两个对象指向一块空间,析构一自动调用就崩了。除了有析构两次的问题,给st1尾插值也会影响st2。所以浅拷贝有两个问题:1.析构两次,报错。2.一个修改会影响另一个。拷贝构造是为自定义类型的深拷贝的对象而产生的。
对于MyQueue这样的例子也不用自己写拷贝构造,因为自定义类型会调用它的拷贝构造函数。下面再来感受一下祖师爷为什么设计引用:
上图例子中,左半边func用类型接受,这样写会调用拷贝构造拷贝12个字节,按照右边例子写,d是d1的别名,语法上没有开空间,这样更快捷,12个字节还是可以容忍的,再看下面的例子:
假设栈实现的是深拷贝,要开空间传数据,如果func是传值传参,假设栈有一万个数字那开空间再传数组代价非常大,如果用引用就能很轻松的解决问题。再看有这样一个场景:
这里左边例子func返回st时不会直接返回,它会形成拷贝构造而且是深拷贝,所以如果用引用返回就可以减少拷贝。
注意如果这里不是静态的是不能返回局部引用的,因为出func作用域会调用一次析构,此时如果有ret接受返回值,里面的_a深拷贝到的也是析构后的类容,因此这种就不能用引用,只能硬着头皮传值返回。
下面看个新玩法:
这有两个日期类,假设想比较一下日期大小怎么比较?正常情况下都是去写一个函数:
先假设上图成员变量都是公有的,这里命名为Less,就是让人知道如果前一个比后一个小就返回真,反之返回假。
那如果给这个函数取名为func,就不太好辨认这个函数到底是比较大的还是比较小的,况且还没有注释,所以最好的方式是可以这样:
如果可以这样用就一下子知道一个是比较的小于,一个是比较的大于。但大于小于号可以比较自定义类型不能比较内置类型,因为内置类型是祖师爷自己定义的,他们知道int怎么比、double怎么比较等,自定义类型是自己定义的,祖师爷不知道我们要怎么比,只有我们自己知道怎样比。那怎样可以让我们自己用这个运算符呢?C++有个方式叫运算符重载,这时一个新增的关键字叫operator,就算运算符默认不支持,重载了就可以支持了。
这样就可以了。因为流插入的优先级高,所以加括号。那为啥这样一操作就支持写大于小于号了?因为编译器做了特殊处理,它比较时看到是内置类型知道该怎么做,就会转化为对应的指令;看到是自定义类型就会转化为去调相应的函数。
相当于上面的转化成了下面的样子,这里也可以直接像下面一样显示调用,但没有必要,这样就没有可读性了,毕竟他们是等价的。
再次说一下上述类容,现在有个日期类对象:
想比较这两个类的大小,可以用函数完成,但最好的方式是用运算符重载来完成,因为这样可读性更强。那运算符重载是什么?可以这样理解:C++期望自定义类型也能像内置类型那样去用运算符,内置类型是它直接就知道,比如整型就直接知道怎么去比较,自定义类型需要自己去控制。但并不是所有的运算符在这里都有意义,拿日期类来说:d1和d2比较大小是有意义的,日期减日期是天数也是有意义的,日期加日期就没有什么意义。所以是否要重载运算符取决于这个运算符对这个类是否有意义,那怎么去重载呢?怎么去控制这些行为?因为自定义类型是偏复杂的,不是说简单的加减就能搞定的,所以最好是用一个函数去实现,所以C++规定了如想实现小于或大于可转化为去调用一个函数,比较完的结果是一个bool值。
这里一个全局的小于函数就实现了(这里先暂时把成员变量放成共有,要不然会报错),这个小于究竟怎样比较呢是根据类设计的意义来比较的。
d1<d2本质被转换成去调用operator<(d1,d2);显示调用也可以,那d1<d2怎么就变成去调用operator去了呢?
因为编译器编译后它们都会变为指令,看汇编发现底层都是一样的,所以转化不是d1<d2直接变成operator,而是底层一样编译器一看就知道是等价的。
平时成员变量都是私有的,现在想想类外面不能访问私有和保护怎么办?可以想到类里面是可以访问私有和保护的,所以把operator<放为成员函数就可以了。
但这个代码还是编不过,编译报错说参数太多了,这是因为作为成员函数有一个隐含的this指针。这里先插入一个小话题:C++为了增强程序的可读性引入了运算符重载,它有一些要求:1.不能创建一个新的运算符,比如operator@中@这个符号C和C++中都没有,没有的话就不能重载一个这个符号来用。2.不能去增加操作符的操作数的个数,作为类成员函数重载时,其形参看起来比操作数数目少1个,因为成员函数的第一个参数为隐藏的this。3.必须有一个参数是类(自定义)类型参数,这表达的意思是不能对纯内置类型去重载,如不能重载一个这个出来:bool operator<(const int& x,const int& y)。
再回过头看报错问题,因为操作符有几个操作数,重载函数就有几个参数,所以应该有两个参数就可以了,改为上图就可以了。
此时就d1<d2就不是转化为operator<(d1, d2),而是转化为d1.operator<(d2),相当于调成员函数,d1传给了隐藏的this,x是d2的别名,它们底层是一样的。所以有全局函数调全局函数,没有全局函数调成员函数,也就是自定义类型的运算符重载的本质是在调用函数,作为成员函数时参数看起来像少了一个一样。了解上述后还有一个注意点:用过的运算符中有5个运算符不能重载:.* :: sizeof ?: . 。
还记得前面说过有4个重点的默认成员函数,到目前已经了解了3个,还有一个没了解的是赋值运算符重载,也就是赋值重载。那什么场景下会用赋值重载呢?
比如上图有两个类,想把d2的值赋值给d1,就用=运算符,这个运算符在C++里面叫赋值运算符。既然是运算符重载所以规定转化为去调一个函数,如果自己实现这个函数,就是简单的拷贝就可以了:
注意这里不是拷贝构造,这里是用已经存在的两个对象之间的复制拷贝,拷贝构造是用一个已经存在的对象初始化另一个对象,比如d1=d2本质是运算符重载函数,Date d3(d1)本质是构造函数。现在看上面写的简单的运算符重载,赋值符号有两个操作数所以应该有2个参数,那赋值可以完成吗
通过调试看到可以完成,上面写的是最简单版本的赋值,这个赋值是存在一些问题的。赋值有的时候是有这样一些场景的:
C语言中是支持连续赋值的,那连续赋值是怎么执行的?0先给k,k=0这个表达式有个返回值是k,k做了k=0表达式的返回值又去赋值给j做右操作数,依次这样下去。理解上述后再来看看日期类是否支持连续赋值:
这里编不过,有个报错信息,因为自定义类型转化成了对应的函数调用,上图连续赋值中先调用d4=d1,返回值是void就没法往后进行了,正确做法应该是返回d4,d4再去做右操作数赋值给d5。那怎样让d4做返回值?
在简单实现的函数中d就是d1,this就是d4的地址,这里要返回d4所以void换为Date,this不能在形参和实参的位置显示加但可以在函数内部显示的给,所以return *this就返回d4了。但这个程序还有一些不完美,传值返回这里是不会返回*this这个对象的,会返回*this这个对象的拷贝。所以d5=d4=d1中d4=d1相当于没有返回d4,返回d4的拷贝作为d5的右操作数。
这里在类中写个拷贝构造,发现拷贝构造调用了两次,因为有两次返回。
因为赋值重载出了作用域*this还在,所以这里可以用引用返回,此时运行就没有拷贝构造了,提高了效率。
上述问题解决完后还有一些问题要解决,有没有可能有这样的赋值:d1=d2
如果这样写代码是可以通过的,this是d1的地址,d是d1的别名。如果不想要d1=d1可以像上图右边那样写,这样处理后遇到相同就可以不用赋值了。再看这样一个问题,这个函数叫赋值运算符重载,它是一个默认成员函数,默认成员函数如果没有显示定义编译器会自动生成一个默认的,默认的会完成值拷贝,它和拷贝构造行为是一样的:1.对内置类型成员完成值拷贝或浅拷贝。2.对自定义类型成员会去调用它的赋值重载。意味着我们不写编译器也会默认生成一个,默认生成的也是可以完成连续赋值的,因为要符合原生类型的行为。这里不写来观察一下:
发现确实可以完成,这里日期类不自己写默认生成的赋值重载就够用了,所以像Data或MyQueue这样的类是不需要我们自己实现赋值重载的,因为它们各有各的玩法。像Stack这样的就需要我们自己实现,因为默认生成的是浅拷贝。其他的运算符重载可以写成全局的也可以写成成员函数,赋值重载是不可以写成全局的,因为赋值重载是默认成员函数,它和大于那些不是一个性质的。赋值不能重载成全局函数,如果写道全局函数会和默认生成的发生冲突,会报错。
2.日期类的实现
新建立Date.cpp和Date.h,把Test.cpp中的日期类拷贝到Date.h中,下面来说日期类的各自玩法:现在想实现一个相对完整的日期类,怎么去实现呢?
这里就写的正式一些,声明和定义全部分离,假设这里全都分离了:
那拷贝构造不需要写,赋值重载不需要写,析构也不需要写,因为默认生成的都可以完成相应的工作,唯一要写的就是构造函数。其实构造函数严格来说不会写成分离,因为放类里面正好让它变为内联,这里就放出来感受就可以,声明和定义分离不能同时给缺省参数,一般在声明中给,同时记得在Date.cpp中要指定类域。Print就放在类中了,因为Print实在太短了,做个内联刚刚好,类中的直接就是内联,把operator<也和上面一样定义和声明分离。下面来实现一个operator>:
根据实现小于的经验,实现大于就把小于函数中的小于号变为大于号就可以了,以这样的思路也可以实现等于、大于等于…,这种方法可以实现,但还有更好的方法。这里推荐先写一个小于和等于或大于和等于,这样其他的玩法就会得心应手。那就写一个等于:
等于比较好实现,就是依次相等就可以了。那现在怎么实现<=呢?以前肯定会照着实现小于的思路写,现在就不用从小于的基础上改了,这里<=可以这样理解,小于等于就是满足小于或等于,所以可以这样实现:
如果写d1 <= d2,d1就是*this,d2是x,直接分别返回就可以了。也就是可以想办法去复用,只是复用没直接调<和==的函数而是间接调的,直接用期望的运算符去调用别的函数,这就非常轻松。
<=实现完后要实现>就直接是<=取反;实现>=也一样复用,可以是>或==,但更好的写法是<取反;还有一个!=,直接==取反就可以了。这里除了前面学过的类的知识外还完美的使用了复用。这样的方法不仅仅是针对日期类,可以用于任意一个要写比较有关的运算符重载的类。
日期类还有一些东西:前面说日期加日期没有价值,但日期加天数是有价值的,运算符重载并么有说必须是两个同类型的进行相加,只是其中一个特点是必须有一个自定义类型。
这里有d1,想算100天以后是多少天,这样的加法也是有价值的。
所以在.h中重载一个运算符为operator+,对于d1+100这个双操作符中,左操作数通常是第一个参数,右操作数通常是第二个参数,有些地方不是很区分左右,但像减法就比较区分,所以还是要注意一下。重载函数的参数是个整型,日期加天数还是日期,所以返回类型先暂时写成Date,能不能用引用返回一会再说。现在比如有一个日期,如果给这个日期加5天怎么加?加20天又怎么加?其实日期加天数就是一个进位法,这不能单纯的满30就进位,因为一个月是多少天不是固定的,况且2月在平年和闰年不一样,所以获取某年某月的天数还是一个挺复杂的事,所以最好实现一个子函数。
在.h中声明,在.cpp中定义,那怎么获取某年某月的天数呢?可以定义一个数组,开13个大小的空间,因为没有0月,这样就和月份对应了,把每个月是多少天放进去,这里先给2月给28天,因为平年多一些,一会再考虑特殊情况,这里直接return月份所对应的天数就可以了,这种方法和计数排序绝对映射的感觉很像。现在上图代码中还差一点东西就是闰年,闰年的2月是29天,那怎么判断闰年?四年一润百年不润,四百年再一润,就是能被4整除且不能被100整除或能被400整除的都是闰年。如果是闰年且是二月就返回29,否则返回数组中的内容。
那上图这个函数有没有优化的空间呢?比如我们传了某年5月,先判断了半天看是不是闰年,如果是闰年结果一看又不是二月就走else了,那前面白判断了,所以发现这里有些麻烦,那有没有什么方法可以避免这里的麻烦呢?
可以先判断是不是二月,之前是不管三七二十一先判断闰年,现在如果不是2月就直接不判闰年了,这是第一个优化。第二个优化是数组前加static,因为这个函数会被频繁的调用,+要频繁的获取每个月的天数,每次调用这个函数都要创建一个数组太费劲了,直接放到静态区就不用每次都创建了。这里函数没有必要返回引用,因为内置类型传值返回拷贝一下就行了,况且这里有return 29,29是常量,除非const int&,但内置类型就不要考虑引用了,考虑会画蛇添足。现在回归到+,这里怎么加呢?
比如2023年4月26日,这里先加2天,就是直接在天数上加,加上后看有没有超过4月的天数,没有超过不用管,超过了就要进位;比如加20天,先直接在天数上加变为2023年4月46日,这里超过了4月的天数,此时要把4月的天数减掉然后进位变为2023年5月16日,进位后看有没有超过5月,如果超过了继续把5月天数减掉然后进位;所以这里的逻辑就是持续进位,天满了往月进,月满了往年进。比如加100天,直接在天数上加变为2023年4月126日,天数超过该月的天数就减去该月的天数然后进位,这里要一个月一个月的进,因为每个月的天数不一样不能一把算满了。如果往12月进位,月满了就往年进,然后月直接变为1月就行。现在就开始实现:
先不管什么先把天算上_day = day,天加上后小于当前月的天数不用管,大于的话就减掉,现在不用关心某年某月有多少天交给函数处理就可以了,进位后看如果月等于13就让年++,月置为1,这样不断往复,最终返回*this。这个加实现的其实有一些问题:
4月26日加100天是8月4号没问题,但有没有感觉到哪里不对劲:比如有int i = 10;i + 100;那i加100后i的值是不会变的,是这个表达式有个返回值,所以不能对上图代码中的_day这些进行加加减减,这样实现的其实不是加而是加等。
这里*this不是局部的,可以用引用返回。那为什么+=还有返回值?因为+=和赋值一样也是支持连续的,如j = i += 10。那+怎么写呢?
+不能改变自己,d1+100需要有个对象和d1一样但是不是d1,不能改变d1,所以拷贝构造一个,此时改的不是d1而是d1的拷贝,最后返回拷贝出的对象就可以了。这里tmp出了作用域就销毁了,所以不能用引用返回,如果用引用返回tmp都销毁了,数据可能是一些脏数据或随机值。那传值返回不是多了一层拷贝构造?这里没有办法,在正确性和效率前要选择正确性。
现在测验一下发现算出来了日期并且d1也没有变。
有没有感觉左边的写法很别扭,可以像右边这样写,看起来好看也方便理解。d1+100是个函数,函数调用有个返回值,返回值再次给d2,那这里Date d2 = 返回值 是赋值还是拷贝构造呢?看这样一个例子:
这里Date d2 = d1是拷贝构造,这是用一个已存在的对象初始化另一个对象,这里就等价于Date d4(d2)。而下面的d2 = d1是赋值,这是已经存在的两个对象之间的复制拷贝,相当于是运算符重载函数。所以上面那里是拷贝构造,因此平时不要被表象的写法迷惑了,看到看它的本质。
再来看一个点:
operator+=和operator+感觉有些重复,能不能复用一下?
可以,这里可以让+复用+=,也可以让+=复用+。左边+=本来就把值改了,这刚好把tmp的值改了;右边加不改变自己就在复制给自己。
至于哪个好没有区别,都是创建了2个对象。
最后再看日期类有些地方是支持++的,++也是运算符。
++是个单运算符,因为只有一个操作数,++的实现也比较简单。但真正的问题是有前置++和后置++,前置加加和后置加加都要加加,但前置++返回++以后的对象,后置++返回++之前的对象。这两个函数内部一样都+1,但它们的返回值不一样,所以不能用一个运算符重载就代替了。不管怎么样先实现前置加加,加完后返回++后的值:
现在最大的问题是后置++怎么实现呢?
可以想到的是既然要返回++之前的值,因此保存之前的结果,加完后返回保存的对象。这里不是重点,主要是operator++()能不能同时存在?这里有个概念梳理一下:运算符重载和函数重载都用来重载这个词,但运算符重载是为了让自定义类型用内置类型,函数重载是支持函数名相同参数不同,它们是两个概念只是共用了一个词。所以两个重载的运算符可以构成函数重载,因为它们本来就是两个函数,只是名字特殊罢了。但这里不能构成函数重载,因为名字和参数都相同。
为了构成函数重载规定后置++加一个参数,加int参数不是为了接受具体的值,仅仅是占位,跟前置++构成重载,接受不用可以不写形参。那调用时编译器怎么解决?
会进行上图的转化,后置++传的整型由编译器决定。对于内置类型前置++和后置++没区别,自定义类型有区别,用前置比较好,因为后置++会凭空多创建2个对象(tmp和返回时)。
下面再来看看日期减天数:
比如有个日期是2023年5月5日,想看50天以前是哪一天怎么算呢?
还是和以前的思路一样,先把天数减出来变为2023年5月-45日,下一步加法是进位,那减法就是借位,比如看2天前是哪一天就是5-2变为2023年5月3日,5月3号不需要再借位,但是减成负数就需要借位了。那需要借的是哪个月的呢?这里借4月的,不是借5月的,因为5月的都已经减没有了,所以向4月借30天过来变为2023年4月-15日。此时不合法还要继续借,借3月31天变为2023年3月16日,此时就合法没问题了。加法和减法的实现就是进到年月日合法或借到年月日合法就完成了。实现日期加天数时按之前的逻辑优先实现的是+=,因为+=不需要创建额外的对象,返回+=以后的值就是返回*this,出作用域变量还在可以用引用返回。所以这里优先实现-=比较好:
_day这上来先把天数减去,减掉后判断_day是不是小于等于0,如果是的话就要继续做处理。因为不借5月的天数借4月的天数,所以优先--month,减月涉及的问题可能是1月,本来是1月就要向上一年的12月借,所以月减完后如果月变为0就要让年--,月变为12。现在就有一个借多少天的问题,多少天涉及到平年闰年这些问题,所以直接交给前面实现好的获取天数的函数就可以了,最后_day满足要求后出循环返回*this就可以了。下面测试看一下:
继续来看减的实现,减要求返回减之后的值但自己不能改变:
这里就拷贝构造一下,用构造出的值复用-=函数,最后返回减完后的就可以。
比如现在的日期是2023年5月5日,想看50天后是哪一天:
这样是没问题的,那如果想看50天以前是哪一天这样写呢:
此时结果就有问题了,一个日期+正数是合法的,日期+负数也是合法的,正数就是看以后的日期,负数就是看以前的日期,所以这里还要考虑的更完善一点。这里控制好+=,+也就控制好了,+=一个负数相当于-=一个正数,所以要完成复用:
这样写还是有问题,调试发现因为day的值是-50,调用-=函数相当于是-=一个负数,这样与变为-=一个正数不一样,所以可以用abs求绝对值或给day加一个负号来解决:
现在就对了,-=也是有这样的问题的:
-=一个负数相当于+=一个正数,和上面同样的道理继续改进:
下面再来看看前置--和后置--的实现:
这里还是一样有参数只是为了和前置区分。
现在还想看看某个日期到某个日期有多少天,所以写一个日期-日期:
日期不能单纯的减日期,比如2023年5月1日减去2000年1月1日,一般认为天相减,月相减,年相减就可以了,但是有2月、平年闰年的问题,这里不建议直接相减算,比较麻烦。
如果非要算可以采用这样的方法,先算2000年1月1日到2023年1月1日有多年,算出多少年后再乘365,然后考虑有有几个闰年,有几个就再加几天,再算1月1号到5月1号有多少天,两者一相加天数就出来了。这里是分两段算,要不然直接算比较难,就是先让小的日期算到大的日期年月日一样的那个算出整年后再算其他。再如2023年5月5日减2000年10月9日有多少天,同样算2023年10月9日减2000年10月9日有多少年,处理完再看10月9日与5月5日差多少天,把多的天数减掉就算出来了。这是一种方式,但还要处理平年闰年的问题,比较麻烦,这里介绍一个更简单的方式,这种方式通过对前面的复用就可以搞定:比如有两个日期d1和d2,判断一下d1和d2谁大谁小,然后让小的不断++,++到和大的日期相等,++了多少次就和大的日期相差了多少天。
d1-d2中如果d1小算出来是负的,d1大算出来是正的,所以还需要分开控制。默认第一个大第二个小,给flag=1,但也有可能默认错了,所以通过if进行改正,改完后减出来是负的,所以把flag改为-1,此时若小的不等于大的(也可以复用小于,但不等于更方便)就不断++小的,有2月等的情况时复用的++都会做处理,再用n不断记录,最后返回n*flag就可以了。前大后小为正,前小后大为负,这里不断++看起来比直接算要慢,但其实对于计算机这里消耗可以几乎忽略不计。
现在还有个用起来不舒服的地方,如:
这里打印时要自己写函数调用,感觉不太舒服,printf能不能解决这里的问题?肯定不能,printf只能打印整型等那些内置类型,不能解决自定义类型,因为自定义类型是自己定义的,编译器不知道怎么打印,所以C++支持了流插入,cout << d1。我们说内置类型支持流插入和printf那些是因为编译器知道内置类型应该如何打印,自定义类型是不知道的,所以这时要实现一个东西叫运算符重载:operator<<。流插入这个运算符是一个双操作数,一个是日期类对象,一个是cout。
cout是一个类对象,是ostream的类对象,ostream是库定义的,是iostream这个头文件中定义的,所以包iostream再把命名空间展开就可以用它们了。在iostream里面库里面定义的类然后用ostream类去定义了一个全局的cout,cin是istream类的对象。库里面为什么直接支持内置类型呢?并且用的时候还自动识别类型?不像C需要用占位符去指定。
因为转化为去调用相应的函数了,其实是函数重载支持自动识别类型。这有两个点:1.可以直接支持内置类型是库里面实现了。2.可以直接支持自动识别类型是因为函数重载。那日期类想支持就要自己实现,内置类型支持是因库里面实现的,为什么库中要实现是因为这是一个新运算符,C中没有流插入流提取,所以内置类型重载了。现在看看自定义类型如何实现:
这里只能按照上图第一种去实现,如果按照第二种去实现,只能去库里面改源代码,在库中把日期类定义加上然后再ostream这个类中去加新的重载,这样第二个参数就可以是Date,第一个参数是cout也就是this,但是不可以改库。
按照第一种实现时因为内置类型的已经实现好了,所以函数内直接复用就可以。
这样实现完后直接cout<<d1这样写是会报错的,因为如果是两个操作数那么参数要分顺序,会转化为上图第二种情况去调用,按照实现来看应该是d1<<cout这样去调用,转化为上图第一种情况去调用。但这样写怪怪的,正常的流插入是右边的对象流向控制台,但现在是控制台流向对象。不能改库中的内容那怎样才能写为cout<<d1去正常调用呢?反正流插入写成成员函数注定是不可以的,因为Date对象默认占用第一个参数,就是做了左操作数,写出来就一定是d1<<cout,不符合使用习惯。其实就是想让cout在第一个参数,那怎样让cout去第一个参数呢?如果写成成员函数第一个位置就被悄悄占领了,所以写到全局就可以了:
这里就不存在抢占的问题了,cout在第一个参数,日期类在第二个参数刚刚好,但是还有一个问题,全局是不能访问私有的,这里有两个方法:1.在类中分别写获取年月日的函数,通过返回值来获取数据。2.用友元函数。这里用第二个方式,这里想在全局访问私有,用友元函数相当于我是你的朋友,被你邀请了,就可以去你家里玩,就是类中加friend声明一下就可以了:
现在就可以cout<<d1去调用了,也可以转化为operator<<(cout,d1)显示调用,但没有意义。解决完上述问题后还有一个问题:对于上图写到全局的函数声明,后面的日期对象加const了,那前面的out需不需要加const修饰呢?前面的ostream类是不能加const修饰的,out是cout的别名,因为流插入就是在往out里面写东西,如果加const修饰就不能往里面写入东西,d加const是因为不会改变日期类,不改变就尽量加const,防止它被修改。这还有一些小问题,有时候是这样写的:
有时候会有连续的流插入,上面说过连续赋值d1=d2=d3,连续赋值是从右往左执行的,d3给d2,返回值d2做右操作数给d1。这里可以从右往左执行吗?不可以,它和赋值不一样,这是运算符的特性决定的,它是从左往右执行的,先d1流向cout,流向cout调函数,为了支持连续要有一个返回值,这个返回值应该是cout,cout再去做左操作数支持d2流插入,返回值再去支持d3,所以变为:
有流插入还有流提取,cin是istream类型的对象,内置类型实现好了所以用复用去实现:
这里d不能加const,因为提取的类容要往里面写,in也不能加const,因为流要读写必须取它里面的内容也要改它里面的状态值。cin>>d1,cout<<d1就是输入的东西先给到终端,从终端把输入的东西提取出来,再把东西插入到终端。现在再来体会C++为什么弄出自定义类型后还有弄出流,因为有了流一切皆可输入输出,printf和scanf只能支持内置类型,C++的流可以支持内置类型也可以支持自定义类型。
还有一个点:
比如定义了d1再输出d1,乍一看没什么问题,但是没有这样的日期,所以在有些地方要加检查:首先所有对象都是构造出来的,所以可以在构造的时候加一些检查:
直接断言可以但比较长,这里就用if来判断,年可以不用判断,年是负可以代表公元前,月必须是大于0且小于13,天必须大于0小于对应月份的天数,如果满足条件才赋值。走else说明有问题,可以打印和断言提示。那平时输入日期也会有类似的问题,比如:
所以也可以在operator>>加检查:
这里单独创建变量输入值,判断合适后再给到d的变量中。这里if里面不能直接调用GetMonthDay函数,因为是定义在全局的,所以要用对象去调用,写可以将.h中的GetMonthDay函数写成静态的直接用类域去调用,这个先了解,后面详细介绍。
再看一个问题:
d1去调用print可以调动,d2去调用print发现调不动会报错,这是为什么呢?核心原因是第一个会转化为d1.print(&d1),第二个会转化为d2.print(&d2),然后传给printf函数里的this,this的类型是Date* this(这里const没有影响,暂时去除看起来方便),这里的区别就是d1传的是Date*,d2传的是const Date*,说明指针指向的内容不能变。
d2传相当于权限的放大,d1传相当于相当于权限的平移。那怎样就可以去传呢?把this改为const Date*就可以了:
现在d2传相当于权限平移,d1传相当于权限缩小,那怎样把this指针改为const Date*呢?这里没办法直接改动this的类型,因为它是隐含的,所以C++中给了这样的方法:
意味着这个位置加const修饰的是*this,不是this。成员函数后面加const以后普通和const对象都可以调用。那能不能所有成员函数都加上const?不能,要修改成员变量的函数不能加,比如说+=不可以加,加了后_day += day就不能改变了;+可以加const,因为又不改变自己。所以在不改变成员变量的地方都加上,否则按上面的例子d1<d2可以比较,但d2<d1就不可以了,如果声明的地方不加const第一个都编不过去,所以只有成员函数内部不修改成员变量,都应该加const,这样const对象和普通对象都可以调用:
最后再看还有两个默认成员函数,这两个默认成员函数叫取地址运算符重载。我们说过自定义类型只要用运算符都是要重载的,因为自定义类型不能用相关的运算符,内置类型可以用相关的运算符。
那上面为什么可以用呢?因为不写编译器会自动生成,那要是自己写呢?
这里就要写两个,第一个是普通对象取地址返回的其实就是this;第二个是const对象取地址返回的就是带const的this。上图两个类取地址时会分别调用对应的函数(有些vs版本中要求const对象必须初始化,因为const对象只有一次初始化的机会,可以给缺省值用默认构造来解决),这两个函数构成函数重载。我们不写编译器会生成默认的,所以这两个函数价值很小,就是为了说的过去,日常都不用管它们。所以之前说有6个默认成员函数,最重要的是前4个后两个基本不用管,它的出现可以理解为为了把规则圆上,因为前面说过一个问题就是自定义类型使用运算符是需要重载的,因为自定义类型比较复杂它不知道用这个运算符怎么比较、加等等。但取地址不一样,取地址很好弄,取这个对象开始那一个字节的地址就可以了,所以平时用默认实现的就可以了,不用自己写。除非只有这样一种场景下需要自己写:
就是不想让别人取到可修改的或普通对象的地址就可以改写为上图的写法。