本文将以日期类为基础,去探寻运算符重载的特性与使用方法,下面先给出日期类的基础定义:
class Date
{
public:
Date::Date(int year, int month, int day)
{
if (month > 0 && month <= 12
&& day > 0 && day <= GetDay(year, month))
{
_year = year;
_month = month;
_day = day;
}
else
{
cout << "非法日期" << endl;
assert(false);
}
}
private:
int _year;//年
int _month;//月
int _day;//日
};
备注:拷贝构造函数和析构函数,均可以不写,因为当前日期类的三个成员变量都是内置类型,没有动态申请空间,使用浅拷贝就可以。
如何比较两个日期的大小?
int main()
{
Date d1(2023, 7, 21);
Date d2(2023, 6, 21);
return 0;
}
现如今,定义了两个日期类的对象d1
和d2
,该如何比较这两个对现象的大小呢?首先想到的是,写一个函数来比较他俩的大小,向下面这样:
//以小于比较为例
bool Less(const Date& x, const Date& y)
{
if (x._year > y._year)
{
return false;
}
else if (x._year == y._year && x._month > y._month)
{
return false;
}
else if (x._year == y._year && x._month == y._month && x._day > y._day)
{
return false;
}
else
{
return true;
}
}
存在的问题:首先这个函数是写在类外面的,意味着,日期类的成员变量如果是private
私有的话,在类外面就无法访问,所以在这个函数里面是访问不到对象的年、月、日这三个成员变量,即x._year
等都是非法的,要想实现该函数的功能,日期类的成员变量必须是public
公有。
其次,在比较两个日期类对象大小的时候,需要写成Less(d1, d2)
,这和我们平时直接用<
符号比较大小,比起来不够直观。
为什么日期类不能直接使用<
因为日期类是我们自己定义的,属于一种自定义类型,它的大小比较方式,只有定义它的人知道,而像int
、double
等内置类型,是祖师爷创造C++语言时就定好的,祖师爷当然知道该如何比较两个内置类型变量的大小,所以提前帮我们设置好了,我们可以直接用<
去比较两个内置类型变量的大小,而至于祖师爷是怎么设置的,这里先埋一个伏笔。
运算符重载
为了解决上面Less
函数存在的问题,C++引入了运算符重载,它可以让我们直接使用<
来比较两个日期类的大小。
运算符重载是具有特殊函数名的函数,也具有返回值类型,函数名字、参数列表、返回值类型都和普通函数类似。
operator
后面接需要重载的运算符符号。bool operator<(const Date& x, const Date& y)
{
if (x._year > y._year)
{
return false;
}
else if (x._year == y._year && x._month > y._month)
{
return false;
}
else if (x._year == y._year && x._month == y._month && x._day > y._day)
{
return false;
}
else
{
return true;
}
}
上面就是对<
运算符的一个重载,它的两个形参是Data
类型的引用,此时两个日期类对象就可以直接用<
来比较大小啦,d1 < d2
本质上就是调用运算符重载函数,但是由于上面的运算符重载函数还是写在类外面,所以当日期类的成员变量是private
私有的时候,该运算符重载函数还是用不了。
//下面两条语句是等价的本质都是调用运算符重载函数
d1 < d2;
operator<(d1, d2);//d1 < d2的本质
将运算符重载函数写成成员函数
为了解决上面的私有成员变量在类外面无法访问的问题,可以把运算符重载函数写成类的成员函数或者友元,这样就能访问到私有的成员变量,但是友元一般不建议使用,因为友元会破坏封装。
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;
}
else
{
return false;
}
}
上面就是把<
运算符重载成类的成员函数,此时参数只有一个,因为<
是一个双目运算符,类的非静态成员函数有一个隐藏的形参this
指针,所以形参就只需要一个。
//它们俩是等价的
d1 < d2;
d1.operator<(d2);//d1 < d2的本质
小Tips:一个双目运算符如果重载成类的成员函数,会把它的左操作数传给第一个形参,把右操作数传给第二个形参。以上面为例,this
指针接收的是d1
的地址,d
接收的是d2
。
注意事项:
operator@
。+
,不能改变其含义。this
。.*
、::
、sizeof
、? :
、.
这五个运算符不能重载。区分赋值运算符重载和拷贝构造
Date d1(2020, 5, 21);
Date d2(2023, 6, 21);
d1 = d2;//需要调用赋值运算符重载
Date d3 = d1;//这里是调用拷贝构造函数
//Date d3(d1);//和上一行等价调用拷贝构造
要区分赋值运算符重载和拷贝构造,前者是针对两个已存在的对象,将一个对象的值,赋值给另一个,而后者是用一个已存在的对象去初始化创建一个新对象。
赋值运算符重载格式:
const T&
(T是类型),传引用返回可以提高效率。T&
,返回引用可以提高效率,有返回值目的是为了支持连续赋值。*this
:要符合连续赋值的含义。Date& operator=(const Data& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;//出了作用域*this还在,所以可以用引用返回
}
只能是类的成员函数
上面的<
运算符,最开始我们是在类外面把它重载成全局的,后来为了保证类的封装性,才把它重载成类的成员函数,而赋值运算符天生只能重载成类的成员函数,因为赋值运算符重载属于类的默认成员函数,我们不写,编译器会自动生成,所以,如果我们把赋值运算符重载写在类外面,就会和编译器生成的默认赋值运算符重载发生冲突。
编译器生成的干了些什么工作?
用户没有显式实现时,编译器生成的默认赋值运算符重载,对内置类型的成员变量是以值的方式逐字节进行拷贝(浅拷贝),对自定义类型的成员变量,调用其对应类的赋值运算符重载。
有了上面的基础,接下来完善一下日期类,重载其他的运算符。
关系运算符有<
、>
、==
、<=
、>=
、!=
,由于它们之间存在的逻辑关系,可以通过复用来实现,即:要想知道a
是否大于b
,可以通过判断a
是否小于等于b
来实现。因此,我们只要写一个<
和==
的比较逻辑,其他的直接复用即可。
重载<
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;
}
else
{
return false;
}
}
重载==
bool Date::operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
重载<=
bool Date::operator<=(const Date& d)
{
return *this < d || *this == d;
}
重载>
bool Date::operator>(const Date& d)
{
return !(*this <= d);
}
重载>=
bool Date::operator>=(const Date& d)
{
return !(*this < d);
}
重载!=
bool Date::operator!=(const Date& d)
{
return !(*this == d);
}
+
、+=
有时我们需要知道几天之后的日期,比如我想知道100天后的日期,此时就需要用当前的日期加上100,但是一个日期类型和一个整型可以相加嘛?答案是肯定的,可以通过重载+
来实现。运算符重载只规定必须有一个类类型参数,并没有说重载双目操作符必须要两个类型一样的参数。
获取某月的天数
日期加天数,要实现日期的进位,即:当当前日期是这个月的最后一天时,再加一天月份就要进一,当当前的日期是12月31日时,再加一天年份就要进一,因此可以先实现一个函数,用来获取当前月份的天数,在每加一天后,判断月份是否需要进位。
int GetDay(int year, int month)//获取某一月的天数
{
static int arr[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 arr[month];
}
除了2月,每个月的天数都是固定的,因此可以设置一个数组来存放每个月的天数,并且以月份作为下标,对应存储该月的天数,这种方法类似于哈希映射。这里还有两个小细节,第一个:把数组设置成静态,因为这个函数会重复调用多次,把数组设置成静态,它第一次创建之后,一直到程序结束都还在,可以避免函数调用时重复的创建数组。第二点:把month == 2
放在前面判断,因为只有当2月的时候才需要判断是否是闰年,如果不是2月就不用判断是不是闰年。
重载+
Date Date::operator+(int x)
{
if(x < 0)//天数为负的时候
{
return *this - (-x);//复用-
}
/Date tmp = *this;
//Date tmp(*this);//和上面等价,都是调用拷贝构造函数
tmp._day = _day + x;
while (tmp._day > GetDay(tmp._year, tmp._month))
{
tmp._day = tmp._day - GetDay(tmp._year, tmp._month);
tmp._month++;
if (tmp._month == 13)
{
tmp._year++;
tmp._month = 1;
}
}
return tmp;//
}
注意:要计算a+b
的结果,a
是不能改变的,因此一个日期加天数,不能改变原本的日期,也就是不能修改this
指针指向的内容,所以我们要先利用拷贝构造函数创建一个和*this
一模一样的对象,对应上面代码中的tmp
,在该对象的基础上去加天数。出了作用域tmp
对象会销毁,所以不能传引用返回。
重载+=
+=
和+
很像,区别在于+=
是在原来是日期上进行修改,即直接对this
指针指向的日期做修改,所以我们对上面的代码稍作修改就可以得到+=
。
Date& Date::operator+=(int x)
{
if (x < 0)//当天数为负
{
return *this -= -x;//复用-=
}
_day += x;
while (_day > GetDay(_year, _month))
{
_day = _day - GetDay(_year, _month);
_month++;
if (_month == 13)
{
_year++;
_month = 1;
}
}
return *this;
}
小Tips:加一个负的天数,就是算多少天以前的日期,所以,当天数为负的时候,可以复用下面的-=
。
+
和+=
之间的复用
可以发现,+
和+=
的实现方法十分相似,那是否可以考虑复用呢?答案是肯定的,他俩其中的一方都可以去复用另一方。
+
去复用+=
:
Date Date::operator+(int x)
{
/Date tmp = *this;
//Date tmp(*this);//和上面等价,都是调用拷贝构造函数
tmp += x;
return tmp;//
}
+=
去复用+
:
Date& Date::operator+=(int x)
{
*this = *this + x;//这里是调用赋值运算符重载
return *this;
}
注意:上面的两种复用,只能存在一个,不能同时都去复用,同时存在会出现你调用我,我调用你的死穴。
既然只能存在一个,那到底该让谁去复用呢?答案是:让+
去复用+=
。因为,+=
原本的实现过程中并没有调用拷贝构造去创建新的对象,而+
原本的实现过程中,会去调用拷贝构造函数创建新的对象,并且是以值传递的方式返回的,期间又会调用拷贝构造。如果让+=
去复用+
,原本还无需调用拷贝构造,复用后反而还要调用拷贝构造创建新对象,造成了没必要的浪费。
-
、-=
有时我们也需要知道,多少天以前的日期,此时就需要重载-
,它的两个操作数分别是日期和天数,其次,我们有时还想知道两个日期之间隔了多少天,这也需要重载-
,但此时的两个操作数都是日期。两个-
重载构成了函数重载。
重载日期-
天数
有了上面的经验,我们可以先重载-=
,再让-
去复用-=
即可,日期减天数,就是要实现日期的借位。
Date Date::operator-(int x)
{
Date tmp(*this);
return tmp -= x;//复用-=
}
重载-=
Date& operator-=(int x)
{
if (x < 0)//天数天数小于0
{
return *this += -x;//复用+=
}
_day -= x;
while (_day <= 0)
{
_month--;
if (_month == 0)
{
_month = 12;
_year--;
}
_day += GetDay(_year, _month);
}
return *this;
}
重载日期-
日期
日期-
日期,它的形参是一个日期对象,计算的结果是两个日期之间的天数,所以返回值是int
,要像知道两个日期之间相隔的天数,可以设置一个计数器,让小日期一直加到大日期,就可以知道两个日期之间相隔的天数。
int operator-(const Date& d)
{
Date max = *this;//存放大日期
Date min = d;//存放小日期
int flag = 1;
if (*this < d)
{
max = d;
min = *this;
flag = -1;
}
int n = 0;
while (max != min)
{
--max;
++n;
}
return n * flag;
}
++
、--
++
、--
操作符,无论前置还是后置,都是一元运算符,为了让前置和后置形成正确的重载,C++规定:后置重载的时候多增加一个int
类型的参数,但是当使用后置,调用运算符重载函数时该参数不用传递,编译器自动传递。
重载前置++
//前置++,返回++之后的值
Date& Date::operator++()
{
return *this += 1;//直接复用+=
}
重载后置++
//后置++,返回加之前的值
Date Date::operator++(int)//编译器会把有int的视为后置++
{
Date tmp(*this);
*this += 1;//复用+=
return tmp;
}
重载前置--
Date& operator--()
{
return *this -= 1;//复用了-=
}
重载后置--
Date operator--(int)
{
Date tmp(*this);
*this -= 1;//复用了-=
return tmp;
}
对比前置和后置可以发现,后置会调用两次拷贝构造函数,一次在创建tmp
的时候,另一次在函数返回的时候。而前置则没有调用拷贝构造,所以前置的效率相比后置会高那么一点。
<<
、>>
同理,对于自定义类型,编译器仍然不知道如何打印,所以要想通过<<
去直接打印日期类对象,需要我们对<<
运算符进行重载。
重识cout
、cin
我们在使用C++进行输入输出的时候,会用到cin
和cout
,它们俩本质上都是对象,cin
是istream
类实例化的对象,cout
是ostream
类实例化的对象。
内置类型可以直接使用<<
、>>
,本质上是因为库中进行运算符重载。而<<
、>>
不用像C语言的printf
和scanf
那样,int
对应%d
,float
对应%f
,是因为运算符重载本质上是函数,对这些不同的内置类型,分别进行了封装,在运算符重载的基础上又实现了函数重载,所以<<
、>>
支持自动识别类型。
<<
为什么不能重载成成员函数
要实现对日期类的<<
,要对<<
进行重载。但是<<
和其他的运算符有所不同,上面重载的所有运算符,为了保证类的封装性,都重载成了类的成员函数,但是<<
不行,因为我们平时的使用习惯是cout << d1
,前面说过,对于一个双目运算符的重载,它的左操作数会传递给运算符重载函数的第一个形参,右操作数会传递给运算符重载函数的第二个形参,也就是说cout
会传递给第一个形参,日期类对象d2
会传递给第二个形参,如果运算符重载函数是类的成员函数的话,那么它的第一个形参是默认的this
指针,该指针是日期类类型的指针,和cout
的类型不匹配,当然也有解决办法,那就是输出一个日期类对象的时候,写成d1 << cout
,此时就相当于d1.operator(cout)
,会把d1
的地址传给this
指针,形参再用一个ostream
类型的对象来接收cout
即可,但是这样的使用方式,显然是不合常理的。
将<<
重载成全局函数
正确的做法是,把<<
重载成全局函数,此时函数形参就没有默认的this
指针,我们可以根据需要来设置形参的顺序,第一个形参用ostream
类对象来接收cout
,第二个形参用Date
日期类对象来接收d1
。
//重载成全局的
ostream& operator<< (ostream& out, const Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
注意:形参out
不能加const
修饰,因为我们就是要往out
里面写东西,加了const
意味着out
不能修改。其次为了实现连续的输出,返回值是ostream
类型的对象out
,因为此时出了作用域out
还在,所以可以用引用返回。
因为该运算符重载函数写在全局,默认情况下,在该函数内部是无法访问到日期类的私有成员变量,为了解决这个问题,可以把该运算符重载函数设置成友元函数,或者在类里面写私有成员变量的Get
方法(Java常用)。
friend ostream& operator<< (ostream& out, Date& d);
友元函数只需要配合上friend
关键字,在日期类里面加上一条声明即可,此时在该函数体就可以使用对象中的私有成员变量。该声明不受类中访问限定符的限制。
重载>>
同理,>>
也应该重载成全局的。
istream& operator>> (istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
注意:两个形参in
和d
都不能用const
修饰,前者是因为in
本质上是一个对象,在进行流插入的时候,会改变对象里面的一些状态值,而后者是因为,我们就是希望通过流插入往d
里面写入数据,所以也不能加const
修饰。
小Tips:C++中的流插入和流提取可以完美的支持自定义类型的输入输出,而C语言的scanf
和printf
只能支持内置类型,这就是C++相较于C语言的一个优势。
将const修饰的成员函数称为const成员函数,const修饰类的成员函数,实际上修饰的是该成员函数隐含的*this
,表明该成员函数中不能修改调用该函数的对象中的任何成员。这样一来,不仅普通对象可以调用该成员函数(权限的缩小),const
对象也能调用该成员函数(权限的平移)。经过const
修饰的成员函数,它的形参this
的类型就是:const T* const this
。
bool Date::operator<(const Date& d) const//用const修饰
{
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;
}
else
{
return false;
}
}
对于所有的关系运算符重载函数,都应该加const
修饰,因为它们不会改变对象本身。
总结:
并不是所有的成员函数都要加const
修饰,要修改对象成员变量的函数,是不能加const
修饰的,例如:重载的+=
、-=
等,而成员函数中如果没有修改对象的成员变量,可以考虑加上const
修饰,这样不仅普通对象可以调用该成员函数(权限的缩小),const
对象也能调用该成员函数(权限的平移)。
Date* operator&()
{
cout << "Date* operator&()" << endl;
return this;
}
const Date* operator&() const
{
cout << "const Date* operator&() const" << endl;
return this;
}
int main()
{
Date d1(2023, 7, 22);
const Date d2(2023, 7, 22);
cout << &d1 << endl;
cout << "--------" << endl;
cout << &d2 << endl;
return 0;
}
这俩取地址运算符重载函数,又构成函数重载,因为它们的默认形参this
指针的类型不同,一个用const
修饰了,另一个没有。const
对象会去调用const
修饰的取地址运算符重载函数。
小Tips:这两个&
重载,属于类的默认成员函数,我们不写编译器会自动生成,所以这两个运算符重载一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容。
结语:
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,您的支持就是春人前进的动力!