目录
前言:
上回我们主要谈及了C++里面的类和对象以及类内部的部分成员函数,今天我们继续学习剩下的两个成员函数,以及类的一些其它的应用场景。
一、运算符重载
1)比较、赋值运算符重载
以往在进行比较的时候,一定是基于像:int、double..类型来进行比较的,这些也被称为内置类型,如果我们需要自定义类型进行比较,那些运算符还会不会有用呢?还是以日期类为例:
#include<iostream>
using namespace std;
class Date{
public:
Date(int year = 2023, int month = 11, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << "year:" << _year << " month:" << _month << " day:" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
void Test()
{
Date d1;
Date d2(2023, 11, 7);
d1 == d2;
d1 > d2;
d1 < d2;
//..
return;
}
int main()
{
Test();
return 0;
}
通过编译我们可以看到这些运算符对自定义类型是不能识别的,当然这也符合我们的常理,也在意料之中,但是很多时候我们就是需要对内置类型进行比较,该如何实现?
这个问题很关键,解决这个关键的问题就在于问题的关键...其实很简单,我们自己实现一个符合需求的比较函数不就行了吗,传的参数是内置类型参数,返回值按照需求进行设置:
bool Equal(Date d1, Date d2)
{
return d1._year == d2._year
&&d1._month == d2._month
&&d1._day == d2._day;
}
这样就可以对日期类内置类型进行比较了啊,没错,这样就可以对日期类进行比较了,但是这里要注意的是,这种函数需要放在类内部,因为 私有成员变量不能被外界访问。
此时我们再次进行测试:
这个时候我们发现,这样还是不对啊,大家可千万别忘记了,在类内是存在this指针的,成员函数第一个参数为this指针,这是隐藏参数,所以在类内我们要这样写:
bool Equal(Date d2)
{
return _year == d2._year
&&month == d2._month
&&_day == d2._day;
}
照此,我们趁热打铁,来试试日期类大于操作该怎么写?最好多思考一下在往下看,我们其实可以这么实现:我们把大于的情况全部写出来,剩下的就是false了。
bool Greater(Date y)
{
if(_year > y._year)
{
return true;
}
else if(_year == y._year && month > y._month)
{
return true;
}
else if(_year == y._year && month == y._month && day > y._day)
{
return true;
}
return false;
}
现在实现对内置类型的比较运算已经不算大问题了,可是在我们都知道,运算符是我们经常使用的东西,在与别人合作的过程中,命名叒会引发一系列问题,不同国家不同的文化,对命名使用是不一样的,这样可能别人就不知道你这个是干嘛用的,于是祖师爷规定了一个特殊的名字来做函数名————operator+运算符 ,例如:
//Equal
bool operator==(Date d1, Date d2)
{
return d1._year == d2._year
&&d1._month == d2._month
&&d1._day == d2._day;
}
//Greater
bool operator>(Date x, Date y)
{
if(x._year > y._year)
{
return true;
}
else if(x._year == y._year && x._month > y._month)
{
return true;
}
else if(x._year == y._year && x._month == y._month && x.day > y._day)
{
return true;
}
return false;
}
虽然这样方便了很多,但是祖师爷觉得还是不方便,于是祖师爷让编译器可以给我们像比较自定义类型一样直接比较:
Date s1(2022,10,1);
Date s2(2023,10,1);
int ret1 = s1 > s2;
int ret2 = s1 == s2;
这种以operator进行内置类型比较的函数叫做——运算符重载,实际上:
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有 特殊函数名 的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通函数类似。
注意事项:
1、不能通过其他符号来创建新的操作符,比如operator@
2、重载操作符必须有一个为类的类型参数
3、用于内置类型的运算符,其含义不能改变,比如operator>但是实现却是小于
4、作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数第一个参数为隐藏的this
5、.* :: sizeof ?: . 以上五个运算符是不能重载的
函数重载与运算符重载并没有什么关系,函数重载是可以允许参数不同的同名函数,而运算符重载是自定义类型可以使用运算符。
其实我们上面的运算符重载还是有些问题,运算符重载函数传参使用的是类的类型,所以在调用运算符重载函数的时候,会先调用拷贝构造。所以我们需要在类型前面使用引用类型,而且我们只是进行比较,加上const更加保险:
bool operator==(Const Date &d2)
{
return _year == d2._year
&&_month == d2._month
&&_day == d2._day;
}
bool operator>(Const Date &y)
{
if(_year > y._year)
{
return true;
}
else if(_year == y._year && _month > y._month)
{
return true;
}
else if(_year == y._year && _month == y._month && day > y._day)
{
return true;
}
return false;
}
我们需要重载哪些运算符是根据这个运算符重载是否有意义。有意义就可以实现,没意义就不用实现,比如日期类相加,没有什么太大的意义就不用实现。像是两个日期相减表示差了多少天就是有意义的。
我们可以实现过了多少天之后的日期等等..我们不妨来实现一下过了多少天之后的日期。
过了多少天也就是加了多少天,返回值应该是加上这些天之后的日期,所以返回类型应为类的类型的引用(避免拷贝构造):
Date &operator+= (int day)
{
//...具体实现过程
}
我们想要知道加了多少天,加满了需要对月份进行操作,那我们就必须要知道这个月到底有多少天,我们不妨设置一个GetMonthDay函数:
int GetMonthday(int year, int month)
{
int Monthday[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if(month == 2 && ((year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0)))
{
return 29;
}
return Monthday[month];
}
这样每个月的天数就能得出来了, 那么我们如何将得到的天数转换成年和月呢?其实很简单:
首先,需要先将天数累加到当前天数上,再调用本类的GetMonthday来获得当前月的天数,如果本类的天数大于本月的天数,那我们就减去本月的天数,再将月份+1,如果此时月份超过12变成13,我们直接将将月份重置为1,年份+1就行了。
但是可能会出现减完一次天数还要大于当前月份,所以我们在外层设置一个while循环只要当前天数大于当月天数就一直重复上述操作,所以我们可以得到:
Date& operator+=(int day)
{
_day += day;
while(_day > GetMonthday(_year, _month))
{
_day -= GetMonthday(_year, _month);
++_month;
if(_month == 13)
{
++_year;
_month = 1;
}
}
return *this;
}
我们既然要返回日期类的类型,那么我们的返回值其实就是看不见的this指针所指向的对象,返回本类对象只需要返回this指针的解引用就行了,让我们来测试一下实际的效果:
void Test()
{
Date d1(2023, 10, 1);
d1.Print();
d1 += 100;
d1.Print();
d1 += 50;
d1.Print();
}
如果你照着日历来查看的话,会发现是完全符合日期的,这也就说明了我们写的是没有问题的。
上面我们实现的是对本类对象的改变,我们每次想要看到多少天之后是什么日期的时候,我们都是直接将这个对象内容改变,如果我们仅仅想看看n天之后的日期而不改变原类对象呢?这个时候其实就是单纯的加法,而不是加等。
如果想实现这个操作,我们有必要在实现一个operator+的运算符重载,首先,我们需要保证返回的日期类是不会影响调用者的对象的,其实我们只需要在原有的operator+=的函数内部创建一个临时对象,对本对象进行拷贝,用临时对象进行+=操作,这样就不会对本类对象进行改变了。
这里要注意的是,我们返回值类型不应该再用引用返回做处理,因为这是个临时对象,出了作用域就会销毁,所以返回类型应该为本类类型,返回值为临时对象。
Date operator+(int day)
{
Date tmp(*this);//创建临时对象对本对象进行拷贝
//用临时对象实现+=操作,这样就不会对本类对象进行改变了
tmp._day += day;
while(tmp._day > GetMonthDay(tmp._year, tmp._month))
{
tmp._day -= GetMonthDay(tmp._year, tmp._month);
++tmp._month;
if(tmp._month == 13)
{
++tmp._year;
tmp._month = 1;
}
}
return tmp;//返回临时对象,临时对象销毁,本类对象不改变
}
//测试信息
/*void Test()
{
Date d1(2023, 10, 1);
Date s = d1 + 100;
s.Print();
d1.Print();
return;
}*/
由测试信息可以看到,我们确实并没有对本类对象进行改变,但是operator+这个操作其实是有些冗余的,为什么这样说呢?我们来看下面代码:
Date operator+(int day)
{
Date tmp(*this);
tmp += day;
return tmp;
}
直接创建临时对象对本类对象进行拷贝,直接复用operator+=的操作实际上是和上面写的代码完全一致,所以我才会说上面的代码比较冗余,因为没有必要在将+=给实现一遍了,你不信?来看一下完整的过程以及运行结果:
#include<iostream>
using namespace std;
class Date{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
int GetMonthDay(int year, int month)
{
int Monthday[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if(month == 2 && ((year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0)))
{
return 29;
}
return Monthday[month];
}
Date &operator+=(int day)
{
_day += day;
while(_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
++_month;
if(_month == 13)
{
++_year;
_month = 1;
}
}
return *this;
}
Date operator+(int day)
{
Date tmp(*this);
tmp += day;
return tmp;
}
void Print()
{
cout << "year: " << _year << " month: " << _month << " day: " << _day << endl;
return;
}
private:
int _year;
int _month;
int _day;
};
void Test()
{
Date d1(2023, 10, 1);
Date s = d1 + 100;
s.Print();
d1.Print();
return;
}
int main()
{
Test();
return 0;
}
所以我们在写代码的时候可以复用的尽量来复用,尽量避免冗余的代码,可以减少很多的工作量。
于此类似,我们可以进行其他的有意义的运算符重载,例如实现<,<=,>,>=...其实这些运算符都可以由>来进行复用,你可以尝试着自己复用一下如何进行实现这些重载吧,下面为具体的复用代码:
bool operator!=(const Date &d)
{
return !(*this == d);
}
bool operator<(const Date &d)
{
return !(*this > d) && (*this != d);
}
bool operator<=(const Date &d)
{
return !(*this > d);
}
bool operator>=(const Date &d)
{
return (*this > d) || (*this == d);
}
除了比较操作,其实我们还有常用的运算符——自增自减运算符,这些运算符实现其实也有复用:
Date& operator++()
{
_day += 1;
return *this;
}
与前面相同,我们天数自增只需要复用前面的+=,再返回本类的类型就可以了。但是不知道你发现没有,我们的自增自减运算符还细分为前置自增自减与后置自增自减。那么我们如何区分前置自增还是后置自增呢?难道++operator吗?并不存在这种写法。
我们的祖师爷开始并没有想到这个问题,但是后面他又补了自己挖的坑,operator++()就表示前置++,operator++(int)就表示后置++ 运算符,只能说祖师爷这招太妙了,我们在参数列表位置放置一个类型,也不需要具体形参,因为用不到,这种方法表示后置++再好不过。
注意:这里后置++的形参列表只能是int类型,这是祖师爷规定死的。
Date operator++(int)
{
Date tmp(*this);
_day += 1;
return tmp;
}
后置++改变本类对象,但是返回值却是改变之前的对象,所以这里也是可以用一个临时对象拷贝本类对象,本类对象自增,返回之前的临时对象,这样就完成了后置++的作用了。类似的还有前置后置自减运算符的重载,方法类似,你可以尝试自己实现,结果在下面:
Date& operator--()
{
_day -= 1;
return *this;
}
Date operator--(int)
{
Date tmp(*this);
_day -= 1;
return tmp;
}
我们已经实现了大部分的运算符重载,但是我们相差的天数还没有实现,也就是operator-和operator-=这两个运算符重载,这两个重载和前面的+和+=实现的不太一样,尤其是operator-,它并不是返回类的类型,而是返回天数,因为日期的相减的意义是差几天。
我们可以根据年月日进位来进行相差天数的计算,但是这样实现会变得很麻烦,如果有想尝试的小伙伴可以自己尝试,下面我介绍一种更加简单的方法:
我们直接省去年和月的进位,我们用两个临时对象(max,min)记录本对象日期和传参对象日期,比较他俩的大小,将max和min进行调整,创建一个计数器(count)记录天数,只要小的那个日期类(min)不等于大的日期类(max),那就将min自增,count自增。循环结束时,count就是所差的天数:
int operator-(const Date &d)
{
int flag = 1;
Date max = *this;
Date min = d;
if(*this < d)
{
flag = -1;
max = d;
min = *this;
}
int count = 0;
while(min != max)
{
++min;
++count;
}
return count * flag;
}
这里注意,我使用了flag是为了保证结果一定为正数,防止类拷贝不对的情况而造成返回值为负数,所以我们在函数内对flag值进行调整,防止有负数的情况出现。
operator-我们知道是相差了几天,而operator-=的意义和前面的+=类似,表示减去n天之后的日期,所以在实现的层面也和+=差不多,只有操作是相反的,你可以先尝试自己实现一下。
Date& operator-=(int day)
{
_day -= day;
while(_day <= 0)
{
_day += GetMonthday(_year, _month);
--_month;
if(_month == 0)
{
--_year;
_month = 12;
}
}
return *this;
}
operator-=这个运算符重载以我们实现的意义来看,是要传入要减去的天数,从而得到减完之后的日期,所以传的天数默认为正数,但是不能保证有负数不会被传进来,这样日期就乱套了,安全性就变得很差,所以我们需要在操作之前先判断一下天数day是否小于0,如果小于0还要进行-=操作,那么这个意义就变成了加上day之后返回的日期。
所以我们遇到负数不必直接终止退出函数,这个负数可以直接转换为加上的天数,直接复用operator+=的操作:
if(day < 0)
{
return *this += (-day);
}
同样,在实现+=的运算符重载里我们也要判断天数day是否小于0,如果小于0表示的含义就不再是+=而应该是-=的含义,所以也要在+=的前面加上同样的判断:
if(day < 0)
{
return *this -= (-day);
}
安全问题在日常中尤为重要,还有哪里是可能会发生安全问题?在日期类的最开始,构造函数那里,可能会传入非法的日期,所以我们也要在那里进行判断:
if(year < 0 || month < 0 ||
month > 12 || day < 0 ||
day > GetMonthday(year, month))
{
cout << "非法输入 :";
Print();
cout << endl;
exit(-1);
}
以下为日期类的运算符重载实现的完整代码:
namespace MyDate{
class Date{
public:
Date(const int year = 2023, const int month = 10, const int day = 1)
{
if(year < 0 || month < 0 ||
month > 12 || day < 0 ||
day > GetMonthday(year, month))
{
cout << "非法输入 :";
Print();
cout << endl;
exit(-1);
}
_year = year;
_month = month;
_day = day;
}
Date(const Date &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
int GetMonthday(int year, int month)
{
int Monthday[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if(month == 2 && ((year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0)))
{
return 29;
}
return Monthday[month];
}
bool operator>(const Date &d)
{
if(_year > d._year)
{
return true;
}
else if(_year == d._year && _month > d._month)
{
return true;
}
else if(_year == d._year && _month == d._month && _day > d._day)
{
return true;
}
return false;
}
bool operator==(const Date &d)
{
if(_year == d._year &&
_month == d._month &&
_day == d._day)
{
return true;
}
return false;
}
Date operator+(int day)
{
Date tmp(*this);
_day += day;
return tmp;
}
Date& operator+=(int day)
{
if(day < 0)
{
return *this -= (-day);
}
_day += day;
while(_day > GetMonthday(_year, _month))
{
_day -= GetMonthday(_year, _month);
++_month;
if(_month == 13)
{
++_year;
_month = 1;
}
}
return *this;
}
bool operator!=(const Date &d)
{
return !(*this == d);
}
bool operator<(const Date &d)
{
return !(*this > d) && (*this != d);
}
bool operator<=(const Date &d)
{
return !(*this > d);
}
bool operator>=(const Date &d)
{
return (*this > d) || (*this == d);
}
Date& operator++()
{
_day += 1;
return *this;
}
Date operator++(int)
{
Date tmp(*this);
_day += 1;
return tmp;
}
Date& operator--()
{
_day -= 1;
return *this;
}
Date operator--(int)
{
Date tmp(*this);
_day -= 1;
return tmp;
}
int operator-(const Date &d)
{
int flag = 1;
Date max = *this;
Date min = d;
if(*this < d)
{
flag = -1;
max = d;
min = *this;
}
int count = 0;
while(min != max)
{
++min;
++count;
}
return count * flag;
}
Date& operator-=(int day)
{
if(day < 0)
{
return *this += (-day);
}
_day -= day;
while(_day <= 0)
{
_day += GetMonthday(_year, _month);
--_month;
if(_month == 0)
{
--_year;
_month = 12;
}
}
return *this;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
~Date()
{
_year = 2000;
_month = 1;
_day = 1;
}
private:
int _year;
int _month;
int _day;
};
}
2) 流插入留提取运算符重载
说到运算符,在C++里面就不得不提到我们在接触第一个C++程序时,如何打印出hello world的,使用了流插入运算符,与之相对的还有流提取运算符,它们也是运算符,是不是也可以重载?
我们先来思考,通常我们如何使用流插入流提取运算符的,有哪些注意事项?还记得在【上】我们说的吗?流插入与流提取运算符是自动识别类型的,其实这与我们的头文件<iostream>有关,iostream表示输出输出流,分为:
我们用红圈圈出来的部分以外我们目前不需要了解很多,cout、cerr、clog这些也不需要过多了解,这与后面多态继承有关,我们具体看看istream里面到底包含了什么?
我们可以看到,哪里存在什么自动识别类型,全部都是运算符重载罢了,“哪里有什么岁月静好,不过是有人替你负重前行”,我们仔细看一下,这些全都是内置类型,对于自定义类型是需要自己实现流插入与流提取运算符的。
那么我们该如何实现?operator <<(),上面我们看到流插入的 类型是ostream,除了ostream还需要类的类型,比如实现日期类:
void operator >>(Date const &d, ostream &out)
我们把运算符重载放在类内部,会有隐藏的this指针传入,所以我们不需要第一个参数:
void operator>> (ostream &out);//这里的out就是cout,名字不同但是都是同一个ostream类
将声明和定义分离:
class Date{
public:
Date(int year = 2023, int month = 11, int day = 11)
{
_year = year;
_month = month;
_day = day;
}
void operator <<(ostream &out);
~Date()
{
_year = 1;
_month = 1;
_day = 1;
}
private:
int _year;
int _month;
int _day;
};
void Date::operator <<(ostream &out)
{
out << _year << "年" << _month << "月" << _day << "日" << endl;
}
int main()
{
Date d1;
cout << d1;
return 0;
}
这样却报错了?我们的逻辑看起来也没问题啊。这时候如果我们将cout << d1反过来:
d1 << cout;
这样居然可以运行,这不是乱套了吗?很奇怪的写法。其实在C++中,双操作数的运算符,第一个参数是左操作数,第二个参数时右操作数。
//其实可以这样看:d1 << cout ——————> d1.operator<<(&d1, cout);
如果在类里面写的话一定要有本类的this指针,Date对象会默认占据第一个参数列表 的位置,operator<<实现成成员函数就是不好的写法,所以我们考虑在全局范围写流插入重载:
class Date{
public:
Date(int year = 2023, int month = 11, int day = 11)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
void operator <<(ostream &out, Date const &d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
}
我们再次编译,发现还是编译不通过,看报错信息,哦,原来是类的成员是私有的,那我们先将类私有成员开放成公有的试试:
这个时候就能正常运行起来了, 但是我们能得到结果的前提是将私有成员变量给开放为公有,得不偿失啊老铁,有没有什么办法来让我能访问到类的私有成员变量呢?
我们可以使用在本章稍后面要说的——友元,我们只需要在类的内部加上:
friend void operator<<(ostream &out, Date const &d);
friend声明这个operator<<函数是这个类的友元,那么作为你的朋友我就可以访问这个类的私有成员变量(具体可以往后翻到友元那一节),这样就不需要担心私有成员不能访问的问题了。
我们通常在使用流插入时会经常用到连续插入:
int a = 1, b = 2;
cout << a << b << endl;//多次流插入操作
而我们上面实现的流插入操作只能执行一次,我们仔细分析这里流插入的特点,为什么能进行连续流插入?其实我们在流插入的时候从右往左是依次将返回值传给左值的,endl返回给b,b在返回给a,最后打印出来,所以我们只需要将返回类型改为流插入类型的引用就行了:
#include<iostream>
using namespace std;
class Date{
public:
Date(int year = 2023, int month = 11, int day = 11)
{
_year = year;
_month = month;
_day = day;
}
friend ostream& operator<<(ostream &out, Date const &d);
private:
int _year;
int _month;
int _day;
};
ostream& operator <<(ostream &out, Date const &d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
int main()
{
Date d1;
Date d2(2023, 1, 1);
cout << d1 << d2 << endl;
return 0;
}
流插入我们已经实现完成了,接下来就是流提取运算符了,实现的过程和流插入几乎没什么区别,可以自己动手实现一下,实在懒得写就看下面现成的吧:
class Date{
public:
Date(int year = 2023, int month = 11, int day = 11)
{
_year = year;
_month = month;
_day = day;
}
friend ostream& operator<<(ostream &out, Date const &d);
friend istream& operator>>(istream &in, Date const &d);
private:
int _year;
int _month;
int _day;
};
ostream& operator <<(ostream &out, Date const &d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
istream& operator >>(istream &in, Date const &d)
{
in >> d._year >> d._month >> d._day;
return in;
}
总结:
其他的运算符重载一般是实现成成员函数,而<< >> 必须实现在全局,这样才能让流对象做第一个参数,才符合可读性。
流的本质是为了解决C语言中不能支持自定义类型输入输出问题,使用 面向对象+运算符重载 解决这类问题。
以上就是运算符重载的相关知识了,理解或许很简单,但是多加练习才能打牢根基。所以要勤加练习,很多问题是自己在写的时候才会注意到的。
二、剩下的默认成员函数
1)赋值运算符重载
我们前面学习了拷贝构造函数,拷贝构造实际上是一个已经存在的对象去拷贝初始化另一个对象。如果是两个都已经存在的对象呢?我们来看下面代码:
#include<iostream>
using namespace std;
class Date{
public:
Date(int year = 2023, int month = 11, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << "year:" << _year << " month:" << _month << " day:" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
void Test()
{
Date s1(2023, 11, 6);
Date s2(2023, 10, 24);
Date s3(s1);
Date s4 = s2;
s3.Print();
s4.Print();
return;
}
int main()
{
Test();
return 0;
}
在Test函数里,毫无疑问s3是会调用拷贝构造来初始化自己的,但是下面s4呢?按我们前面的学习来说,内置类型运算符是不会对自定义类型处理的啊,那这里是拷贝构造吗?其实这里调用的是六大默认成员函数之一————赋值运算符重载
赋值运算符重载的实际效果是这样的:
Date& operator =(const Date &d)
{
if(this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day
}
return *this;
}
这里的 返回类型是Date&,因为我们可能要进行一个表达式 多次赋值 运算,返回类的类型可以让左边被赋值对象连续接收参数。这里的使用if是为了防止自己对自己的拷贝。
可能你会问赋值运算符重载有什么用?我们直接用拷贝构造函数不就行了,其实我们很多时候调用的并不是拷贝构造,而是运算符重载。
两个角度来理解(本质还是一回事):
1、我们上面也说了,拷贝构造的应用场景是一个已存在的对象去拷贝初始化另一个对象,而赋值运算符的应用场景是两个已存在的对象进行拷贝。
2、初始化和赋值是两个概念。初始化是指在定义的时候进行赋值,而在定义完成之后再进行赋值的操作叫做赋值。程序以拷贝的方式初始化时,是会调用拷贝构造的。程序给一个对象赋值时是会调用赋值运算符的。
既然编译器能帮助我们实现,我们不写不也行吗?在日期类中当然可以,但是如果是在栈(stack)、二叉树(BinaryTree)或者打开文件等需要申请资源的类当中使用编译器默认生成的赋值重载则会造成跟拷贝构造相同的错误:
所以像栈类似的需要申请资源的类,拷贝构造与赋值运算符都要自己来写。
总结:
1、参数类型为const+引用对象,可以避免调用拷贝构造,同时可接收const类型与非const类型的对象,权限高。
2、返回值类型为类的引用返回,同样避免拷贝构造的问题,同时还能保证连续赋值:s3 = s2 = s1;
3、检查是否进行了自我赋值。
4、如果没有将赋值重载显示地写出来,编译器会默认生成一个赋值重载,而且对申请资源的类无用。
2)const成员函数
我们先来看这样一段代码:
#include<iostream>
using namespace std;
class Date{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Display()
{
cout << "year: " << _year << " month:" << _month << " day:" << endl;
return;
}
private:
int _year;
int _month;
int _day;
};
void Test()
{
const Date s(2023, 10, 1);
s.Display();
}
int main()
{
Test();
return 0;
}
这里因为某些原因,我需要将创建的对象s加上const, 这个时候我再想调用类内部函数会发生什么?
居然不让访问?!这是为什么? 还记得我们前面说的this指针吗?实际上this指针在Display这个函数里是这样:
this指针的完整写法是 const Date* this 这个const作用的是指针,而不是指针指向的内容, 而在Test函数里,我们给s对象加了const,也就是说我们权限只能进行读取操作,不可修改对象。
在C++中,对const修饰的成员取地址是非法的,因为这样可能会改变修饰内容。而对象在调用的时候实际上会对该对象取地址传入this指针,也就是之前说的权限放大问题,所以造成程序错误。
为了使被const修饰的对象可以访问成员函数,C++规定了可以用const来修饰类的成员函数。在类中,这些const成员函数本质上是修饰成员函数隐藏的this指针的,这样就保证了this指针在该成员函数内部不可修改,只可读取。
我们这在成员函数后面加上const测试一下:
这下就能操作类的成员函数了。
由此,我们来思考下面四个问题:
1、const 对象可以调用非const成员函数吗?
2、非const 对象可以调用const成员函数吗?
3、const 成员函数内可以调用其他的非const成员函数吗?
4、非const 成员函数内可以调用其他的const成员函数吗?
我们上面的例子也算是解释了第1个问题,const对象不能调用非const成员函数,因为权限不能被放大调用,只能平移或者缩小。
第二个问题,非const对象调用const成员函数明显是权限缩小,是可以调用的。
第三个问题,const 成员函数不可调用非const成员函数,权限被放大了当然不行。 第四个问题,非const成员函数调用const成员函数也是一种权限缩小的情况,可以调用。
建议在不改变类内容的成员函数后面都加上const,把权限降到最低,那么任何权限参数就都能接收,同时又保证安全性,以及代码的健壮性。
3)取地址及const取地址操作符重载
取地址也算是运算符,所以就一定有取地址运算符重载,这里分为const和非const取地址运算符重载:
class Date
{
public:
//两个取地址的权限不同
Date* operator&()//this指针为Date *const this
{
return this;
}
const Date* operator&()const //this指针为const Date *const this
{
return this;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
一般这两个运算符重载不需要自己实现,除非你不想让别人得到你的地址,返回个假地址糊弄过去...这两个的区别就是取const修饰变量的地址与取非const修饰变量的地址,编译器默认生成的和我们上面写的没什么区别。
三、再谈构造函数
还记得我们的构造函数吗,我们前面说构造函数是为了进行初始化,给各个对象中各个成员变量一个合适的初始值。
class Date{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
但实际上,这严格来说并不能叫初始化,虽然在调用构造的时候会给一个初始值,但不能将其称为类对象成员的初始化,构造函数体中的语句只能将其称为赋初值,而不能称为初始化,因为 初始化只能初始化一次,而构造函数体内 可以多次赋值。
我们来看一看C++中初始化的方式吧。
1)初始化列表
初始化列表:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。
class Date{
public:
Date(int year, int month, int day)
:_year(year)
,_month(month)
,_day(day)
{}
private:
int _year;
int _month;
int _day;
};
其实,在类中私有(private)部分,是对变量的声明,并不是定义,我们之前所有的定义全都是在类的实例化的时候对 对象整体定义。
上面是类的整体定义,如果想要在细一点,对类的成员变量分开定义该如何做呢?这就是初始化列表的用处了,当然更大的用处是为了给引用变量,常变量来初始化的,例如:
#include<iostream>
using namespace std;
int main()
{
const int i;//声明常变量
int &p = i;//声明引用
return 0;
}
我们之前也说过,const修饰的变量需要再声明的同时初始化,引用也必须要在声明的时候初始化,不能将声明与定义分离,如果这两个变量出现在类内,仅仅用构造函数势必会报错的,也就是说也用类型与const修饰类型必须用初始化列表来初始化。
class Date{
public:
Date(int year, int month, int day)
:_year(year)
,_month(month)
,_day(day)
,_a(1)
,_aa(year)
{}
private:
int _year;
int _month;
int _day;
const int _a;
int &_aa;
};
当然也可以这么写:
class Date{
public:
Date(int year, int month, int day)
:_a(1)
,_aa(year)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
const int _a;
int &_aa;
};
初始化列表可以与函数体内赋值一起使用。每个成员变量在初始化列表只能初始化一次,多次初始化会报错:
Date(int year, int month, int day)
:_year(year)
,_month(month)
,_year(year)//_year第二次出现在初始化列表
,_day(day)
,_a(1)
,_aa(year)
{}
初始化列表还有一个妙用,给没有默认构造的自定义成员变量进行初始化。我们前面提到过,内置类型如果没初始化编译器会使用默认构造初始化随机值,自定义类型会调用它的默认构造。
#include<iostream>
using namespace std;
class A{
public:
A(int a)
:_a(a)
{}
private:
int _a;
};
class Date{
public:
Date(int year, int month, int day)
:_year(year)
,_month(month)
,_day(day)
,_aa(1)
{}
private:
int _year;
int _month;
A _aa;
int _day;
};
int main()
{
Date a(2023, 11, 11);
return 0;
}
总结:
1、每个成员变量在初始化列表中只能出现一次(初始化只能初始化一次)
2、类中包含以下成员,必须放在初始化列表进行初始化:
(1)引用成员变量
(2)const成员变量
(3)自定义类型成员(该类没有默认构造)
我们能使用初始化列表来初始化就尽量用它,因为你不管是否使用初始化列表,对于自定义类型成员变量,一定会先使用初始化列表初始化。
#include<iostream>
using namespace std;
class Time{
public:
Time(int hour = 0)
:_hour(hour)
{
cout << _hour << endl;
}
private:
int _hour;
};
class Date{
public:
Date(int day)//没有对_t这个内置类型初始化,调用Time类的构造函数
{}
private:
int _day;
Time _t;
};
int main()
{
Date d(1);
return 0;
}
可以看到main函数中Test的初始化是不成功的,如果不使用初始化列表,仅仅用构造函数进行赋值操作。
那么在Date类内想要对_t这个属于Time类的成员函数进行初始化就必须要调用Time(没构造函数,所以调用拷贝构造)类的拷贝构造,然后再调用赋值运算符对自定义成员变量赋值。
#include<iostream>
using namespace std;
class Time{
public:
Time(int hour = 0)
:_hour(hour)
{
cout << _hour << endl;
}
private:
int _hour;
};
class Date{
public:
Date(int day, int h)
{
_day = day;
Time t(h);
_t = t;
}
private:
int _day;
Time _t;
};
void Test()
{
Date d(1, 1);
return;
}
int main()
{
Test();
return 0;
}
这里虽然能成功初始化,但是却调用了一次构造,一次拷贝构造与一次赋值运算符重载,第一次构造是d进行实例化的时候,Time _t同时会被定义从而调用Time的构造函数。对于数据量大的类来说这样的开销实在不小,如果直接用初始化列表:
class Date{
public:
Date(int day, int h)
:_day(day)
,_t(h)
{}
private:
int _day;
Time _t;
};
这样初始化的开销就会小很多。对于初始化列表还有要注意的一点是,初始化列表的初始化顺序是成员变量在类中声明的次序,与其在初始化列表的先后顺序无关:
#include<iostream>
using namespace std;
class Date{
public:
Date(int year, int month, int day)
:_day(day)
,_month(month)
,_year(year)
{}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d(2023, 11, 11);
return 0;
}
可以看到这里我是把_day和_month放在初始化列表最前面,但是_year却先被初始化。
初始化列表的成员变量可以直接使用缺省值,在成员变量的后面直接使用缺省值并不是初始化,而是给参数列表的初值:
class Date{
public:
Date(int year, int month, int day)
:_day(day)
,_month(month)
,_year(year)
{}
private:
int _year = 1;//在成员变量给缺省值表示给参数列表的值
int _month = 1;
int _day = 1;
};
注意:这里在声明处给缺省值是C++11之后才有的规定。
2)explicit关键字
不知道你了不了解C/C++中的隐式类型转换,比如:
int a = 1;
double b = a;
这里double和int不匹配,那么就会发生隐式类型转换:
可以看到发生隐式转换之后a的类型并没有被改变,实际上a对b进行赋值时发生隐式类型转换是生成一个临时变量,b改变的是临时变量的类型,而不是a的类型。
这里有一种特殊情况,当b为引用类型的时候,必须在引用前加上const,因为临时变量具有常性,不加const就会造成权限放大的问题,所以要加上const,让b成为常变量,使得权限平移。
int a = 1;
const double &b = a;//临时变量具有常性要加const
在C++类(单参数的类)中是支持隐式类型转换的:
#include<iostream>
using namespace std;
class A{
public:
A(int a)
:_a(a)
{}
private:
int _a;
};
int main()
{
A a = 1;
//先调用构造函数A tmp(1)
//在调拷贝构造A a(tmp)
return 0;
}
这就是类的隐式类型转换,中途也会生成临时对象在调用拷贝构造赋值。这是一件开销很大的事情,那我们如何避免这种隐式类型转换呢?C++提供了一个关键字 explicit关键字 可以很好解决这个问题:
class A{
public:
explicit A(int a)
:_a(a)
{}
private:
int _a;
};
用explicit 关键字修饰构造函数就将隐式类型变成显示类型,这样就可以避免不小心发生隐式类型转换了。
3)静态成员
在类的成员里还有这样一类特殊的成员——static成员:
声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰的 成员函数,称之为静态成员函数。静态的成员变量一定要在类外进行初始化。
现在我想实现一个类,来计算调用这个类会创建了多少个对象。其中有一个·问题是我们如何将创建了几次对象给记录下来,如果用普通成员变量(计数器)来记录创建了多少个对象肯定是不可行的,因为在创建多次对象的时候,每个对象都会给自己的计数器自增。
既然我们不能使用普通成员变量,那我们不妨使用全局变量count来对类创建对象进行计数,创建对象在类中无非是调用构造函数与拷贝构造,我们只需要在构造和拷贝构造函数内将count+1就行了。
#include<iostream>
using namespace std;
namespace byte {
int count = 0;
};
class A {
public:
A() { ++byte::count; }
A(const A& t) { ++byte::count; }
~A() {}
private:
};
A func()
{
A aa;
return aa;
}
void Test()
{
++byte::count;
A aa;
func();
cout << byte::count << endl;
return;
}
int main()
{
Test();
return 0;
}
因为使用count会与std库里的的函数有冲突,所以这里使用了命名空间,使用func函数是为了测试返回临时对象会不会创建对象,在Test函数里我们自己创建了一个对象,所以count要+1,接着调用func函数,最后打印出来记录着的count值。
由此可见,我们出了在Test函数里创建的对象与func里创建的对象,func返回对象时也会创建一个临时对象。
虽然说用全局变量可以记录函数创建对象的次数,但是如果在之前就有人调用,或者中途有人恶意+1,这里也不太好辨别,用全局变量终究是不安全的,有没有别的办法来获取创建对象的次数呢?
这个时候我们的静态成员变量就出场了,我们知道,静态成员只能初始化一次,因为存在于静态区,所以不论创建多少个对象,他们都会共用同一个静态成员变量。
class A{
public:
A() { ++count; }
A(const A& t) { ++count; }
~A() {}
private:
static int count;
};
int A::count = 0;
你肯定注意到了,为什么我们静态成员要在类外边初始化,我们为什么不能再类中声明的时候给个缺省值?
注意,我们前面成员变量可以给缺省值的条件是这个成员变量必须属于这个类,静态成员变量是所有类都可访问的,属于静态变量,所以不能在声明的时候给缺省值。这也就解释了为什么静态成员变量一定要在类外初始化。
但是这个时候我们就能直接使用这个成员函数了吗?
void Test()
{
++A::count;
A a;
func();
cout << a.count << endl;
return;
}
我们可以看到并不能直接使用静态成员变量,因为目前静态成员变量是私有的,那么我们将私有成员变量暂时变为公有:
这个时候我们就可以看到,静态成员就可以使用了,不知道你注意到没有?这里我用了两种使用方式:
A::count;
A.count;
在类成员公有的情况下这两种调用方式都是正确的。
但是将私有成员给改为公有这样的开销似乎很大,我们有没有什么别的办法来获取count的值呢?当然有,我们可以再类内部编写一个GetCount的函数:
class A{
public:
A() { ++count; }
A(const A& t) { ++count; }
~A() {}
int GetCount()//返回count的值
{
return count;
}
private:
static int count;
};
这样我们直接调用成员函数就可以获取count的值了,这个函数并不会被改写 :
#include<iostream>
using namespace std;
class A
{
public:
A() {
++count;
}
A(const A& t) {
++count;
}
~A() {}
int GetCount()
{
return count;
}
private:
// 声明
static int count;
};
// 定义
int A::count = 0;
A func()
{
A aa;
return aa;
}
int main()
{
A aa;
func();
func();
func();
cout << aa.GetCount() - 1 << endl;
return 0;
}
还有另外一种写的方式:
cout << A().GetCount - 1 << endl;//匿名对象,生命周期仅仅为这条语句,语句结束调用析构销毁
若不想创建对象可以使用匿名对象来调用函数,只不过匿名对象调用的时候也是会发生构造的,所以还是要-1,匿名对象顾名思义就是没有名字的对象,写法就是上面的写法。
其实上面的写法都不能算是好的写法,好的写法是将GetCount函数加上static成为静态成员函数,这样就不需要再创建一个对象来调用GetCount函数了,直接使用:
A::GetCount();//静态成员函数在类的域里,所以要使用域作用限定符
问题:
1、静态成员函数可以调用非静态成员函数吗?
2、非静态成员函数可以调用类的静态成员函数吗?
其实我们前面也算是解释了第二个问题,我们甚至都能通过类域直接访问到类的静态成员函数,那么类内部的非静态成员函数也是能访问类的静态成员函数(静态成员有全局性)。
那我们来看第一个问题:
class A
{
public:
void Print()
{
cout << "you can see me" << endl;
}
static int GetCount()
{
Print();
return count;
}
private:
static int count;
};
这里我想要用类的静态成员函数来调用类的非静态成员函数,我们编译会发现:
可以发现类的静态成员函数是不能调用类的非静态成员函数的。其实这是因为静态成员函数、静态成员变量本质上和全局变量没区别,在全局范围内访问类的成员函数是非法的,所以会报错。
总结:
1. 静态成员为所有 类对象所共享,不属于某个具体的实例2. 静态成员变量必须在 类外定义,定义时不添加static关键字3. 类静态成员即可用类名::静态成员或者对象.静态成员来访问4. 静态成员函数 没有隐藏的 this 指针,不能访问任何非静态成员5. 静态成员和类的普通成员一样,也有public、protected、private3种访问级别,也可以具有返回值
四、友元
1)友元函数
我们在前面学习的时候,有时候有函数需要用到类的私有成员变量,但是我们又不希望这个函数出现在类内部,那样我们就只能用Get方法来返回类的私有成员变量,但是我们在C++中并不经常使用Get和Set方法,而是使用另一个东西来访问类的私有成员变量/函数——友元
友元分为:友元类 和 友元函数 友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
我们前面在流插入与流提取就用到了友元函数,声明流操作符函数为类的友元函数,则这个函数就可以访问这个类的私有成员变量:
friend void operator<<(ostream &out, Date const &d);
友元函数可以 直接访问类的 私有成员,它是 定义在类外部的 普通函数,不属于任何类,但需要在类的内部声 明,声明时需要加 friend关键字
注意:
1、友元函数可访问类的私有和保护成员,但不是类的成员函数
2、友元函数可以在类定义的任何地方声明,不受类访问限定符限制
3、友元函数不能用const修饰
4、一个函数可以是多个类的友元函数
5、友元函数的调用与普通函数的调用和原理相同
2)友元类
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员。
友元类就好比你跟你的好友小王,你家比较有钱,有个篮球场,对外是不开放的,但是你认为小王是你的亲密好友,所以他可以来你家的篮球场打篮球,就像下面的代码:
#include<iostream>
using namespace std;
class A{
public:
friend class B;//声明B是A的友元类,则在B中就可以直接访问到A的私有成员
A(int a)
:_a(a)
{}
private:
int _a;
B _bb;
};
class B{
public:
private:
int _s;
double _p;
A _aa;
};
B类是A类的友元,所以在B类当中,可以访问A类的私有成员变量,但是反过来A类不可以访问B类的私有成员变量。真是哥们跟你心连心,你跟哥们玩脑筋。
除此之外,友元类并没有传递性,如果类B是类A的友元,类C是类B的友元,但是并不能说明类C是类A的友元:
#include<iostream>
using namespace std;
class A{
public:
friend class B;
private:
int _a;
};
class B{
public:
friend class C;
private:
int _b;
};
class C{
public:
private:
int _c;
A _aa;
};
int main()
{
C c;
c._aa._a = 1;
return 0;
}
这说明了友元类不具有传递性。
总结:
1、友元关系是单向的,不具有交换性。
2、友元关系不能传递如果B是A的友元,C是B的友元,则不能说明C时A的友元。
五、内部类
我们经常使用类来进行封装,但是有时候单纯的类并不能满足我们的需要,类的内部还需要再进行封装成一个小的类:
概念:如果一个类定义在另一个类的内部,这个内部类就叫做内部类。注意此时这个内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去调用内部类。外部类对内部类没有任何优越的访问权限。
#include<iostream>
using namespace std;
class A{
private:
static int k;
int h;
public:
class B
{
public:
void foo(const A& a)
{
cout << k << endl;//OK
cout << a.h << endl;//OK
}
};
};
int A::k = 1;
int main()
{
A::B b;
b.foo(A());
return 0;
}
这里B类就是A类的内部类,B不仅仅可以在public可以在A类的任意位置。执行结果:
这里说明了,内部类是可以访问外部类的静态成员变量的,而且不需要外部类的对象/类名。那么你可能会问,内部类和外部类很关系一定很密切了。
我们不妨对外部类sizeof:
sizeof(A);
可以看到与内部类其实并没有什么关系,这个4是外部类的成员变量。
总结:
1. 内部类可以定义在外部类的public、protected、private都是可以的。2. 注意内部类可以直接访问外部类中的static、枚举成员,不需要外部类的对象/类名。3. sizeof(外部类)=外部类,和内部类没有任何关系
呼~~大概24000字,真的很累,如果觉得这篇文章对你有帮助的话,希望可以三连来支持一下下~