5.赋值运算符重载
接下来我们要来学习赋值运算符重载,那赋值运算符重载呢是属于运算符重载的,所以在学习之前,我们要先来了解一下C++的运算符重载。
5.1 运算符重载
我们还来看上面实现过的那个日期Date类:
class Date { public: //构造函数 Date(int year = 1, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; };
那我们现在用Date类实例化出两个对象:
int main() { Date d1(2023, 4, 13); Date d2(2023, 4, 12); return 0; }
现在有两个对象d1,d2,大家思考一个问题,现在我们想比较这两个对象是否相等,要怎么搞?
🆗,那我们是不是可以考虑实现一个函数来判断两个对象是否相等:
bool Equal(Date x1, Date x2) { //... }
大家看该函数的参数这样写好不好,是不是不太好啊。
这里是传值传参,形参是实参的拷贝,那对象的拷贝还要调用拷贝构造。
所以这里我们是不是可以考虑传引用啊,这样就不用拷贝了,另外呢,这里只是去比较两个对象,我们并不想改变它们,所以是不是再加一个const比较好:
bool Equal(const Date& x1, const Date& x2) { //... }
写一个函数,这是一种方法。
那C++引入了运算符重载之后呢,就使得我们能够这样去玩:
比较两个日期类对象d1,d2是否相等,直接这样:
d1==d2
但是我们首先要知道自定义类型是不能直接作为这些操作符的操作数的。
不像我们的内置类型可以直接进行加减乘除比较相等这些运算,为什么自定义类型不可以啊?
因为自定义自定义,是不是我们自己写的啊,就比如我们实现的这个日期类,是我们按照自己的想法实现出来的,编译器肯定不知道比较这样两个对象应该怎么做。
而且,有些自定义类型不是进行所有的运算都有意义的,就比如日期类,两个日期对象如果相加,有意义吗,是不是没啥意义啊,如果两个日期相减还有点意义,可以理解为两个日期之间差了多少天。
所以这个是由我们自己决定的,我们觉得它可以进行什么样的运算有意义,然后去实现。
那我们要怎么做才能让我们的自定义类型像这样d1==d2直接进行一些运算和比较呢?
这就需要我们对这些运算符进行重载。
概念
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号
函数原型:返回值类型 operator操作符(参数列表)
那我们接下来就来练习一下:
上面我们不是相比较两个日期类对象是否相等嘛,那我们就来重载一下==运算符。
根据上面的概念,我们可以写出:
bool operator==(const Date& x1, const Date& x2) { }
那函数体的实现,即比较的逻辑,其实也很简单:
只要两个对象的三个属性(成员变量)_year,_month,_day
全部相同,就说明两个对象相等。
bool operator==(const Date& d1, const Date& d2) { return d1._year == d2._year && d1._month == d2._month && d1.day == d2.day; }
这样是不是就行了,但是现在有一个问题:
什么原因呢?
因为我们Date类的这3个成员变量是私有的(private),所以在类外面是不能访问的。
那怎么解决?
我们可以在类里写一个Get方法(函数),通过Get方法来访问,或者呢,直接把private访问限定符去掉。
我们这里先把private注释一下:
然后就不报错了。
那重载好,我们就可以直接用了:
当然,我们也可以像普通函数那样去调用:
当然正常情况下我们不会像普通函数那样去调用,因为我们重载就是为了可以直接d1 == d2这样用。
所以我们直接写成这样就行:
d1 == d2
剩下的工作就由编译器去做,编译器看到这样的代码,就会去看你有没有重载,如果进行了重载,就会转化成去调用这个函数operator==(d1, d2)。
那我们可以打印一下这个结果:
cout << d1 == d2 << endl; cout << operator==(d1, d2) << endl;
但是我们会发现又报错球了:
cout << d1 == d2 << endl;
这一句报错了。
什么原因呢?
🆗,是因为这里<<
的优先级比==
高,所以加个括号就行了:
int main() { Date d1(2023, 4, 13); Date d2(2023, 4, 12); cout << (d1 == d2) << endl; cout << operator==(d1, d2) << endl; return 0; }
0为假,而这两个对象也确实是不相等的。
那我们就把==重载好了,但是:
刚才我们是直接重载到了全局,我们把成员变量变成了共有的才能这样的。
那么问题又来了:我们把成员变量全部公有了,封装性又如何体现呢?
那当然是有办法解决的,我们刚才上面已经提了一种,就是提供一些共有的get方法,那除此之外呢,我们还可以用友元函数解决,但是我们还没学,而且不推荐用这个。
所以这里比较好的一种方法是:
我们直接重载到类里面,即重载成成员函数。
但是呢?我们直接把它放到类里面的话:
嗯???又报错了,说此运算符函数的参数太多。
怎么回事啊?
🆗,这里我们重载的是==运算符,正常情况下只有两个操作数,所以只需要两个参数就够了。
那大家可能会疑惑了,这里不就是两个参数嘛?
那大家不要忘了,这里是不是还有一个隐藏参数啊。
什么隐藏参数,是不是就是this指针啊。
这是不是我们上一篇文章学习的知识啊。
C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象)
所以我们这里只需给一个参数就够了。
bool operator==(const Date& d) { return _year == d._year && _month == d._month && _day == d._day; }
那调用的时候,this指针接收d1的地址,形参d就是d2(引用传参)。
注意
下面我们一起来看一下,在运算符重载这一块,需要注意的一些内容:
不能连接其他符号来创建新的操作符:比如operator@
重载操作符至少有一个类类型的参数
用于内置类型的运算符,其含义不能重载改变,例如:内置的整型+,不能改变其含义
作为类成员函数重载时,其形参看起来比操作数数目少1个,因为成员函数的第一个参数为隐藏的this
.* :: sizeof ?: .注意这5个运算符不能重载,这个经常在笔试选择题中出现。
.*其中这个运算符大家可能都没见过也没用过,没关系,大家可以记一下就行了。
练习
那上面我们对==运算符进行了重载,接下来我们再来练习几个。
那就还是上面那个日期类,现在我们来尝试重载一下<好吧:
那其实逻辑也不难,就是判断两个日期的大小嘛。
我们可以只判断小于的情况返回true,其它情况一律false:
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) { return *this == d || *this < d; }
是不是就搞定了,我们的小于等于。
那>
呢:
bool operator>(const Date& d) { return !(*this <= d); }
再来,>=
呢:
bool operator>=(const Date& d) { return !(*this < d); }
不等于!=
呢:
bool operator!=(const Date& d) { return !(*this == d); }
这样搞是不是很爽啊。
5.2 赋值重载
赋值运算符重载呢 是属于运算符重载的一种,但是,它还是我们类的6个默认成员函数的其中一个。
实现
那我们就先来重载一下赋值=
运算符吧:
那经过了刚才的学习,重载一个
=
,是不是简简单单啊。
//d1=d2(this就是d1,d就是d2) void operator=(const Date& d) { _year = d._year; _month = d._month; _day = d._day; }
这是不是就好了啊,测试一下:
可以完成赋值。
但是呢,我们当前的这个实现还有一些缺陷:
什么缺陷呢?
大家回忆一下,我们之前用内置类型进行赋值操作时是不是支持像这样的连续赋值啊:
i = j = k;
这句代码怎么执行的,是不是从右向左啊,先把k赋给j,然后再把表达式 j = k的结果,就是k赋给i。
当然还可以连续的更多。
而对于我们刚才对日期类重载的=,可以支持连续赋值吗:
额,是不行的,这里直接报错了。
那这里为啥报错了啊:
因为正常情况下d2赋给d1是不是应该有一个结果啊,然后把这个结果再赋给d3。
但是我们这里d1 = d2
是不是调了我们重载的函数,而我们上面实现的函数并没有返回值。
所以我们要加一个返回值来支持连续赋值:
那我们返回的话是不是还是返回对象的引用比较好啊:
//d1=d2(this就是d1,d就是d2) Date& operator=(const Date& d) { _year = d._year; _month = d._month; _day = d._day; return *this; }
那这下我们的连续赋值就可以了。
但是有时候呢不排除有人可能会写出这样的代码:
把自己赋给自己。
这样可以吗?
可以当然是可以的,但是它调用函数是不是白白进行了一次拷贝啊,所以呢,我们一般还会加一点东西:
Date& operator=(const Date& d) { if (this != &d) { _year = d._year; _month = d._month; _day = d._day; } return *this; }
加一个判断,如果它们是同一个对象,就不用进行拷贝了。
🆗,那我们来简单总结一下赋值运算符重载:
参数类型:const 类对象的引用,传递引用可以提高传参效率
返回值类型:类类型&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
最好检测一下是否是自己给自己赋值,并进行一下处理
返回*this:返回的结果用于支持连续赋值
那我们说了赋值运算符重载是属于6个类默认成员函数的其中一个,所以它还有一些属于自己的特性。
赋值重载的特性
用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝(浅拷贝)。
注意:默认生成的赋值重载对于内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用其对应类的赋值运算符重载完成赋值。
那有了这个特性的话,对于我们上面的日期类,我们还需要自己写赋值重载吗?
是不是不用啊,用编译器自动生成的是不是就可以完成啊。
因为日期类的成员变量是不是都是内置类型啊,而且赋值不涉及深拷贝的问题,浅拷贝就可以完成。
那我们试一下,把我们自己写的赋值重载注释掉:
然后运行:
是不是可以啊。
那这里的问题是不是就和拷贝构造一样了:
编译器生成的默认赋值运算符重载函数已经可以完成浅拷贝赋值了,所以像日期类这样的我们就没必要自己实现赋值重载了,因为默认生成的就可以帮我们搞定了。
那同样,如果涉及深拷贝的问题,像栈Stack这样的类,是不是就得我们自己实现去完成深拷贝了。
和拷贝构造一样,如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要自己实现。
然后我们再来看一个代码:
大家看这里会调用拷贝构造还是赋值重载?
这里是不是拷贝构造啊,这个我们上见过的嘛:
那为啥这里用了赋值=,但是是拷贝构造呢?
🆗,我们来简单总结一下:
什么时候是调赋值重载呢?
是我们用已经实例化出来的对象进行相互赋值的时候,调用赋值重载。
而当我们用一个已经实例化出来的对象去初始化一个新对象的时候,调的是拷贝构造。
赋值运算符只能重载成类的成员函数不能重载成全局函数
我们上面重载的一些什么等于、大于、小于、大于等于之类的运算符是不是可以重载到类外也可以重载到类里面啊。
那赋值重载也是运算符重载,我们刚才是定义在类里面的,那它可以重载到外面吗?
我们试一下:
先把成员变量的private注释掉,确保在类外能访问。
然后我们在类外实现一下赋值重载:
Date& operator=(Date& left, const Date& right) { if (&left != &right) { left._year = right._year; left._month = right._month; left._day = right._day; } return left; }
重载成全局函数,注意重载成全局函数时没有this指针了,需要给两个参数
那这就实现好了。
行不行呢:
还没运行直接就看到报错了,说必须是成员函数。
为什么这样不行呢?解释一下:
赋值重载如果在类里不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
6. const成员函数
我们来看这样一个类:
class A { public: void Print() { cout << _a << endl; } private: int _a = 10; };
然后:
int main() { A a; a.Print(); return 0; }
定义一个对象a,并调用成员函数Print。
没有什么问题。
那这样呢?
加一个const修饰对象a。
然后我们发现调用Print就出错了。
那为什么呢?
其实呢是因为这里存在了一个权限放大的问题。
这也是我们之前学习过的:对于引用,还有指针来说,对它们进行赋值和初始化时,权限可以缩小,但不能放大。
我们来分析一下:
对于我们的成员函数Print,虽然看起来没有参数,但是是不是有一个隐藏参数,就是我们熟悉的this指针嘛。
那this指针的类型是啥?
this指针的类型:类类型* const
那对于当前这个类来说就是A* const this,const 修饰的是指针this,即指针this不能被修改,但this指向的内容可以被修改。
那我们传过来的参数是啥,是调用函数的对象的地址,即a的地址,但我们的对象a是const修饰的,所以传过来的地址的是const A* &a,const修饰的是该地址指向的内容,即对象a不能被修改。
那这样的话,传给this,this可以修改其指向的内容即对象a,所以就是权限放大了。
所以这里报错了。
那怎么解决呢?
🆗,如果我们可以把this指针的类型也变成const A*是不是就可以了啊。
但是this指针的类型是我们想改变就能改变的吗?
this指针是类成员函数中的一个隐藏参数,我们是没法直接改变它的。
那就没有办法了吗?
办法肯定是有的:
我们只需在对应成员函数的括号后面加一个const 就行了。
这就是我们要学的const成员函数:
const修饰的“成员函数”称之为const成员函数。
const修饰类成员函数,实际修饰的是
*this
,这样this指向的对象将不能被修改。
那这样this指针的类型就也变成了const A*
了,这样就可以传了。
但是我们平时定义一个对象好像一般也不会在前面加一个const,那这个用处是不是不大啊?
🆗,虽然定义对象时我们一般不加const,但是我们是不是可能经常会这样搞:
void Func(const A& x) { x.Print(); }
首先这里传引用与传值相比减少拷贝,然后如果我们不想对象被改变的话,不是一般会加一个const嘛。
那当前这种情况:
class A { public: void Print() { cout << _a << endl; } private: int _a = 10; }; void Func(const A& x) { x.Print(); } int main() { A a; Func(a); return 0; }
x是a的引用(别名),a没有被const修饰,然后在Func
里,x是被cosnt修饰的,x去调用Print
,这里是不是也是权限放大了。
那这是不是跟我们开始讲的那个例子一样啊,怎么解决?
把Print变成const成员函数就行了:
像这种情况其实还是比较常见的。
所以说:
对于类的成员函数,如果在成员函数内部不需要改变调用它的对象,最好呢都可以把它写成const成员函数。
另外,如果const成员函数的声明和定义是分开的,声明和定义都要加const。
7. 取地址及const取地址操作符重载
类的6个成员函数呢,比较重要的前4个我已经学完了,最后还剩两个。
我们一起来看一下:
那剩下的两个默认成员函数呢都是取地址重载,包括对普通对象的取地址和对const对象取地址。
这两个默认成员函数呢一般不需要我们自己去实现,编译器会自动生成,绝大多数情况下我们用编译器自动生成的就行了。
我们可以试一下:
对普通对象取地址
对const对象取地址
所以这两个默认成员函数一般不需要我们自己写,用编译器默认生成的取地址的重载即可
但是,如果你想自己去重载一下的话当然也是可以的:
你可以自己指定一个地址返回。
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!
🆗,那我们这篇文章的内容就先到这里,欢迎大家指正!!!