在明白如何构造对象和访问对象中的内容后,我们已经可以定义一些类来使我们的程序结构化,但是我们对于基本类型再能使用的运算符”+”,”-“,”*”,”/“并不能直接用在我们定义的类上。例如我们之前定义的Complex类,显然它也有比较良好的四则运算的性质,如果我们不能直接使用而是必须定义一些函数就过于繁琐了。本章中,我们就详细介绍一下在自己定义的类中,如何将运算符进行重载,从而使之可以在对象上直接使用。

主要内容:+-*/的重载;=重载=>深度拷贝;通过友元函数进行运算符的重载;流插入运算符和流提取算符的重载;类型强制转换运算符的重载;自增自减算符的重载。

运算符重载的原理和基本方法

运算符重载实际上就是编写以运算符作为名称的函数,可以把这种函数当成接收一个,两个或者多个参数的函数,不过这些参数不再以参数表的形式出现,而是按照运算符自身的特性安排其位置。其基本的定义格式如下:

1
2
3
返回值类型 operator 运算符(形参表){
...
}

包含被重载的运算符的表达式被编译后变成运算函数的调用,函数实参就是具体调用时规定位置的对象,运算的结果是就是函数的返回值。运算符可以被多次重载。注意,运算符可以被重载为全局函数,也可以被重载为成员函数,一般而言更加倾向于声明为成员函数,但是也有一些很难被声明为成员函数的情况,例如后面的流插入和流提取运算,我们后面会提及。下面我们通过一个例子,讲一下需要注意的细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include<iostream>
using namespace std;
class Complex{
public:
double real,image;
Complex(double r,double i=0):real(r),image(i){}
Complex operator-(const Complex& c);
friend ostream& operator<<(ostream& os,const Complex& c){
if(c.image<0){
os<<c.real<<c.image<<'i';
}
else if(c.image==0){
os<<c.real;
}
else{
os<<c.real<<'+'<<c.image<<'i';
}
return os;
}//先别管这个,我只是想让输出更好看一些QWQ
};
Complex operator+(const Complex& c1,const Complex& c2){
return Complex(c1.real+c2.real,c1.image+c2.image);//返回一个临时对象
}
Complex Complex::operator-(const Complex& c){
return Complex(real-c.real,image-c.image);
}
int main(){
Complex c1(3,4),c2(2,5);
cout<<c1+c2<<endl;
cout<<c1-c2<<endl;
return 0;
}

输出为:

1
2
5+9i
1-1i

程序中演示了对于Complex类型重载+和-的方法,其中对于+算符的重载是通过全局函数的形式进行的,-算符则是通过成员函数定义,可以看出,相对而言,成员函数会比全局函数少一个参数,这是因为成员函数编译时会增加一个this指针,使参数表编程和算符规定一致的情况。如果你想把定义参数表中的&删去,并不会影响结果,但是后续多态等特性还是更倾向于定义一个引用类型的参数表。另外,如果你真的特别想把一个全局函数放在class的定义中去声明,可以通过friend友元声明的方法使编译器知道这是一个全局函数,否则编译器仍然会加入this指针,变成三个参数表的函数,这样就会报错。最后要注意算符的执行位置,例如c1-c2实际上是运行operator-(c1,c2)对应的参数表是operator-(*this,c),当然对于全局函数,就是直接对应参数表中的顺序了。

重载赋值运算符 “=”

重载 “=” 使不兼容的值也可以进行赋值

C++中,赋值算符=要求作用两个的操作数是匹配的,至少是兼容的,如果我们希望两侧的类型不兼容时,也可以进行赋值运算,这时就需要对=进行重载。不同于前面+-*/的打打闹闹,=的重载返回值不能是一个临时对象,因为它的作用是必须要实打实的改变一个对象的值,所以C++要求,=的重载必须是一个成员函数,函数吸收一个参数,在函数中通过操作可以改变本类的成员变量进行赋值,最后可以返回一个本类的引用,一般会选择*this。举一个字符串类的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include<iostream>
#include<cstring>
using namespace std;
class String{
char* str;
public:
String():str(NULL){}
const char* show_str() const{
return str;
}//为了安全,我们会定义const类型来防止其被可修改的指针指向
String& operator=(const char* s){
if(str){
delete [] str;
}
if(s){
str=new char[strlen(s)+1];
strcpy(str,s);
}
else{
str=NULL;
}
return *this;
}
~String(){
if(str){
delete [] str;
}
}
};
int main(){
String s;
s="Hello";
cout<<s.show_str()<<endl;
s="hi";
cout<<s.show_str()<<endl;
return 0;
}

输出为:

1
2
Hello
hi

需要注意两点:首先还是顺序问题,注意我们经过上述重载,可以做到的使String=char*,但是反过来就是错误的,这时你可能会困惑,那我要是非要写一个char*=String的赋值咋办,或者在极端一点,想获得一个int=String的赋值,将一个整形数府城String中的字符串长度,这时我们肯定做不到修改char*int这些基本类型的源码,这时我们就可以请出友元函数(全局函数)进行定义了,后面我们还会介绍。

第二点是赋值函数的返回值,实际上这个返回值并不重要,C++对它没有特别的要求,实测使用void也是可以的,但是再对算符重载时使其保持原有的基本特性是必要的。例如赋值运算是可以连用的,即a=b=c是合法的,实际上的操作就是先将c赋值给b,并返回一个和c值相同的值,然后再对a进行赋值。大家可以尝试分析一些(a=b)=c的执行结果是什么,实际上最后b的值不变,而a被赋值成c,这也是为何最好返回的是引用,因为我们可能还希望对返回值进行修改。

最后需要注意,如果你将=的返回值写成本类的对象,即删去了&,在上面的程序中就会出现问题,第一次将会输出乱码,主函数执行第34行时,=函数会卡在第13行。我的分析是:主函数在第31行使用无参构造函数生成,第32行返回一个String类型的对象,需要调用复制构造函数生成一个临时对象,由于我们没有特别的定义复制构造函数,所以主函数中的s仅仅是str指向了临时对象的str指向的位置,随后=函数结束,执行析构函数将临时对象析构,这时我们就制造出来一个不是空指针,但是指向一片无意义存储空间的指针,这时再执行第33行就会输出乱码。此时再执行第34行进行赋值,if(str)为True,因为str并不是空指针,但是他有没有指向通过new动态分配的空间,再执行第13行时就会卡住,出现错误。如果想要修改就需要重构复制构造函数,实现复制构造的深拷贝。综上所述,你就返回类型的引用就挺好的,不容易出错,还更标准

深拷贝和浅拷贝

同类对象间可以直接通过=进行赋值,如果不进行任何重载,程序将执行将右侧对象逐字节拷贝到左侧对象中的工作,这种行为称为浅拷贝。因为如果一个类中存在字符串这种指针类的成员变量,浅拷贝赋值时并不会将右侧对象指针指向的内容复制到左侧对象所指向的空间中,而是使左侧对象的指针指向右侧指针指向的位置。这样的操作有时对于两个独立的对象时就会出现问题,更严重的是,当一个对象被析构时,如果我们使用delete删除了动态存储空间,另一个对象再析构时就会出现问题,就像我们刚刚提到的那样(不过刚刚那个需要的是重构复制构造函数)。所以,重构=实现指针成员变量指向不用位置但内容相同的深拷贝是十分重要的。仍然以上面的String类型为例,在其基础上增加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
String& operator=(const String& s){
if(str==s.str){
return *this;
}
if(str){
delete [] str;
}
if(s.str){
str=new char[strlen(s.str)+1];
strcpy(str,s.str);
}
else{
str=NULL;
}
return *this;
}

放在public成员函数中就可以实现深拷贝了,可以看出被赋值的对象是使用new开了一片新的存储空间来进行赋值的。对于2~4行的代码,主要是为了防止有人才写出s1=s1的语句,不写的话s1的内容就会被直接删掉。当然也可以防止出现s1=s2s2s1的引用的情况。当然通过上述代码我们仍然没有解决上一小节最后提出的问题,所以我们还应该编写赋值构造函数,这样我们才可以正常使用复制构造函数来生成对象,才可以正常使用返回值是本类的函数。String类的完整代码如下,这样才是安全的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class String{   //有指针成员变量的类尽可能实现深拷贝,要重载复制构造函数和=
char* str;
public:
String():str(NULL){}
String(const String& s){
if(s.str){
str=new char[strlen(s.str)+1];
strcpy(str,s.str);
}
else{
str=NULL;
}
}
const char* show_str() const{
return str;
}
String& operator=(const char* s){
if(str){
delete [] str;
}
if(s){
str=new char[strlen(s)+1];
strcpy(str,s);
}
else{
str=NULL;
}
return *this;
}
~String(){
if(str){
delete [] str;
}
}
String& operator=(const String& s){
if(str==s.str){
return *this;
}
if(str){
delete [] str;
}
if(s.str){
str=new char[strlen(s.str)+1];
strcpy(str,s.str);
}
else{
str=NULL;
}
return *this;
}
};

运算符重载为友元函数

重载运算符时,之所以更建议使用成员函数而非全局函数的原因,因为全局函数不能访问私有成员变量,在实际使用中这个特性就会使定义函数十分不方便。但是有时我们不得不使用全局函数重载算符,例如我们前面提到的Complex类对象c实现5+c的运算,如果定义成员函数,我们需要再int这个类中写成员函数,这显然有点不太可能,再如我们后面要讲的<<>>算符,如果不适用全局函数,我们就需要再ostreamistream类中写成员函数,这都是不现实的。如果我们必须写全局函数,但又想访问私有成员变量,这时我们前面学习的友元就可以帮助我们实现这一功能了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include<iostream>
using namespace std;
class Complex{
public:
double real,image;
Complex(double r,double i=0):real(r),image(i){}
Complex operator+(Complex c){
return Complex(real+c.real,image+c.image);
}
friend ostream& operator<<(ostream& os,const Complex& c){
if(c.image<0){
os<<c.real<<c.image<<'i';
}
else if(c.image==0){
os<<c.real;
}
else{
os<<c.real<<'+'<<c.image<<'i';
}
return os;
}
friend Complex operator+(int r,Complex c);
};
Complex operator+(int r,Complex c){
return Complex(r+c.real,c.image);
}
int main(){
Complex c1(3,4);
cout<<2+c1<<endl;
cout<<c1+2<<endl;
return 0;
}

友元声明的全局函数可以在class内直接定义,也可以在class外定义,但是在class外就不要再写friend声明了。加入friend声明的+函数后,就可以执行输出2+c1的Complex值了。

流插入和流提取运算符的重载

在C++中,我们常使用<<左移算符和cout一起用于输出,>>右移算符和cin一起用于输入,所以<<>>分别被称为流插入算符和流提取算符。实际上他们本身没有输出和输入的功能,能和cout一起使用,是因为coutostream类的对象,ostream类对<<算符进行了重载,多次重载为成员函数,是基本的数据类型都是可以输出的,例如为了使cout<<5运行,ostream类中需要有:

1
2
3
4
5
ostream& ostream::operator<<(int n){
//输出n的代码
printf("%d",n);
return *this
}

但是对于我们自己写的class,在使用cincout就不能正常运行,因为没有我们自定类的参数表使>><<可以被执行,这时就需要重载。我们不可能在ostreamistream中修改,所以新的定义只能写成全局函数,如果自定义的类中需要打印私有成员,这是我们就需要使用友元函数进行定义,例如我们前面写过的Complex类,想实现丝滑的输入和输出,其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include<iostream>
#include<string>
#include<cstdlib>
using namespace std;
class Complex{
double real,image;//私有成员全局函数访问必须声明友元
public:
Complex(){}
Complex(double r,double i=0):real(r),image(i){}
Complex operator+(Complex c){
return Complex(real+c.real,image+c.image);
}
friend ostream& operator<<(ostream& os,const Complex& c){
if(c.image<0){
os<<c.real<<c.image<<'i';
}
else if(c.image==0){
os<<c.real;
}
else{
os<<c.real<<'+'<<c.image<<'i';
}
return os;
}//声明友元的全局函数也可以在class中直接定义
friend istream& operator>>(istream& is,Complex& c);//当然在类外定义才是最合理的结构
};
istream& operator>>(istream& is,Complex& c){
string s;
is>>s;
int pos=s.find("+",0);
string sTmp=s.substr(0,pos);
c.real=atof(sTmp.c_str());
sTmp=s.substr(pos+1,s.length()-pos-2);
c.image=atof(sTmp.c_str());
return is;
}
int main(){
Complex c1(3,4);
cout<<c1<<endl;
Complex c2;
cin>>c2;
cout<<c2<<endl;
return 0;
}

cin的功能我没有写全,这样并不能输入带有-的复数以及纯实纯虚的复数,请读者自行补充,当作练习(绝对不是我懒QWQ)。

重载类型强制转换运算符

C++中,类的名字本身也是一种运算符,表示类型强制转换。注意这里的强制转换是将本类对象强制转换成别的类型,将别的类型尤其使基本类型转换为本类的强制转换一般通过构造函数来完成(第二章我们讲过单参数表构造函数作为强制转换函数)。类型强制转换使单目运算符,可以被重载,但只能重载为成员函数,不能写成全局函数,它的声明方法就是operator 类型名(),例如在Complex中强制转换成double型,就是提取其实部:

1
2
3
Complex::operator double(){
return real;
}

这样我们就可以使用double(c)实现Complex c => double的转换了。由于是单目运算符,请不要为其生命任何参数表,另外再次提醒不要写成全局函数。有了这个强制转换的函数后,在需要出现double的位置如果出现了Complex对象,编译器会自动执行强制转换函数,例如当我们写:

1
2
Complex c(1,3);
double n=2+c;

此时打印n的结果使3,因为编译器执行的是:

1
double n=2+c.operator double();

再结合前面构造函数的强制转换特性,会让C++的一些定义显得没有条理,如果你想让你的类加减乘除更有条理,就不要乱写这种强制转换函数,不然总会出现什么+操作与多个函数匹配的情况,比较容易红温(你猜我为啥没有给示例)。

自增自减运算符的重载

自增运算符++和自减运算符--都是可以被重载的,但是他们有前置和后置之分,自然定义也不太一样。一般而言,obj++++obj都会实现obj=obj+1,但是++obj返回值是修改后的值,而obj++返回的是修改之前的值。如果实在记不住,不妨想想老师总是推荐是使用++i的原因就是它不用存储原值,所以执行更快。

当然在定义obj++++obj时,都等价于obj.operator++(),无法体现出差别啊,怎么办呢,为了解决这个问题,C++规定重载++--时可以写一个增加了无用int类型形参的版本,前置时就调用参数个数正常的函数,后置就执行参数个数多一个的函数,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class number{
int n;
public:
number(int i=0):n(i){}
number& operator++(){//对于前置的,++number
++n;
return *this;
}
number operator++(int){//对于后置的,number++
number tmp=*this;
++n;
return tmp;
}
};

如果你没有写后置++的情况而使用后置++的函数,大部分比较先进(Dev C++除外)的编译器都会执行前置++的函数,那要是你前置的也没写你就是神人,编译器也救不了你。

一些注意事项和一个例题

  1. 在进行算符重载时,尽可能地保持原有算符的性质时必要的,例如不要用+去定义一个现实中的-运算,这时反人类的。
  2. C++中运算符即使被重载,运算的优先级不发生变化。
  3. ..*::?:sizeof不能被重载。
  4. 重载()[]->=时,不能将他们重载为全局函数,只能写成成员函数。

用一个例子结束这场闹剧,编写一个二维整形数组 (来自oj程序填空题):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <cstring>
using namespace std;

class Array2 {
// 在此处补充你的代码
};

int main() {
Array2 a(3,4);
int i,j;
for( i = 0;i < 3; ++i )
for( j = 0; j < 4; j ++ )
a[i][j] = i * 4 + j;
for( i = 0;i < 3; ++i ) {
for( j = 0; j < 4; j ++ ) {
cout << a(i,j) << ",";
}
cout << endl;
}
cout << "next" << endl;
Array2 b; b = a;
for( i = 0;i < 3; ++i ) {
for( j = 0; j < 4; j ++ ) {
cout << b[i][j] << ",";
}
cout << endl;
}
return 0;
}

要使输出为:

1
2
3
4
5
6
7
0,1,2,3,
4,5,6,7,
8,9,10,11,
next
0,1,2,3,
4,5,6,7,
8,9,10,11,

示例答案是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class Array2 {
// 在此处补充你的代码
int **p;
int m,n;
public:
Array2(){}
Array2(int m_,int n_):m(m_),n(n_){
if(m_*n_==0){
p=nullptr;
}
else{
p=new int*[m_];
for(int i=0;i<m_;i++){
p[i]=new int[n];
}
}
}
Array2(const Array2& a){//复制构造函数的深拷贝,不过好像没啥用
m=a.m;
n=a.n;
p=new int* [a.m];
for(int i=0;i<a.m;i++){
p[i]=new int[a.n];
for(int j=0;j<a.n;j++){
p[i][j]=a.p[i][j];
}
}
}
~Array2(){
if(p){
delete [] p;
}
}
int*& operator[](int i){
return p[i];
}
int operator()(int i,int j){
return p[i][j];
}
Array2& operator=(const Array2& a){//重构=的深拷贝
if(p==a.p){
return *this;
}
if(p){
delete [] p;
}
if(a.p){
p=new int* [a.m];
for(int i=0;i<a.m;i++){
p[i]=new int[a.n];
for(int j=0;j<a.n;j++){
p[i][j]=a.p[i][j];
}
}
}
else{
p=nullptr;
}
return *this;
}
};