重载运算符
- 运算符重载的意义是使得程序员可以重新定义一个运算符的行为。
基本规则
可以被重载的运算符
程序员几乎可以重载 C++ 的所有运算符,包括以下这些:
+ - * / % ^ & | ~
= < > += -= *= /= %=
^= &= |= << >> <<= >>= ==
!= <= >= ! && || ++ --
, ->* -> () []
new delete
new[] delete[]
但是仍有一些不能重载
. .* :: ?:
sizeof typeid
static_cast dynamic_cast const_cast
reinterpret_cast
注意:
- 只有已经存在的运算符可以被重载,无法自创一个运算符表达某种运算。
- 不能重载基本类(比如整数、浮点数等等)的运算符。
- 重载运算符之后参数的数量和重载之前保持一致(比如乘法要有两个参数
a * b
,那么重载之后也必须有两个参数而不能使用默认参数) - 重载运算符之后运算优先级和之前保持一致。
重载运算符的写法
-
重载运算符可以是一个成员函数,也可以是一个全局函数。
当重载运算符是一个成员函数的话,默认其中一个参数是
*this
,可以少给一个参数。当重载运算符是一个全局函数的话,需要给出全部参数。
如下:
class A
{
public:
const A A::operator + (const A& that) const;
//函数后的 const 保证这个操作不会改变参数的值
};
const A operator + (const A& r, const A& l);
另外有一些细节,我们实现重载运算的时候,要尽量与未重载之前的运算性质一致,如下:
class Integer
{
public:
Integer(int n = 0) : i(n) {}
const Integer Integer::operator + (const Integer& that) const
{
return Integer(i + that.i);
}
private:
int i;
};
- 加法原来的返回值是一个常量类型,函数的返回值是一个
const Integer
类型。这保证了重载之后与重载之前的加法的返回值均不可以做左值。 - 加法不会改变两个参数的值,因此我们在参数表和函数后加上 const,表示
*this, that
两个参数均不可修改。
使用重载运算符
Integer x(1), y(5), z;
x + y; ====> x.operator + (y);
当我们执行 x + y 的时候,编译器发现运算符左边的变量是 Integer 类型,因此它会调用 Integer 类里的重载加法。
编译器对每一个运算符都会做类似检查,根据检查到的对象的数据类型选择对应的(重载)运算规则操作。
我们把这个受到检查的对象成为此次运算的 receiver,它决定了此次运算采用何种运算规则。
现在有三次运算操作:
z = x + y;
z = x + 3;
z = 3 + y;
- 第一次运算中,毫无疑问,调用了重载 + 运算符,将新对象赋值给 z 。
- 第二次运算中,receiver 是对象 x,它要求调用重载 + 运算符,但是传进去的第二个参数是一个 int 类型的数。这时候编译器发现 Integer 包含一个需求一个 int 类型参数的构造函数,它就调用这个构造函数把 int 转化为 Integer 类的一个对象,再执行 1 中的操作。
- 第三次运算中,receiver 是一个 int 类型的数,它要求调用 int 类型 + 运算符,但是传进去的参数不存在一种方式(类型转换函数)转化为 int 类型,于是无法执行。
上面是重载运算符是成员函数的情况,假设重载运算符是一个全局函数,情况又有所不同;
/* 在 Integer 类内要声明友元函数,否则无法访问私有成员 i */
friend const Integer operator + (const Integer& a, const Integer& b);
/* 在 Integer 类外 */
const Integer operator + (const Integer& a, const Integer& b)
{
return Integer(a.i + b.i);
}
这时我们发现可以执行 z = 3 + x;
了。因为这次的重载运算符不是成员函数,(成员函数默认第一个参数是自己,如果没有类型转换函数,就无法自动转换)所以第一个参数也可以发生自动类型转换。
当编译器尝试调用 int 类型的加法无果后,它会尝试寻找别的可能性。这时发现可以把 3 通过构造函数转换为 Integer 类从而调用重载运算符,于是它就去做了这件事,程序顺利编译。
总结一下
根据上面的概述,我们做如下约定:
- 对于一元运算符,建议把它们写成成员函数。
- 重载的
= () [] -> ->*
运算符,必须是成员函数。 - 所有其他二元运算符,建议把它们写成类的友元全局函数。(否则会出现一些错误,比如上面的
z = 3 + x;
那样的错误)
函数原型
前面我们说过,重载的运算符要与重载之前的参数特性和返回值一致,所以我们需要关心这些运算符的原型是怎样的。
除此之外,如果一个重载运算符函数不是太复杂,那么它就应该是内联的,并且建议在函数的声明处就加上 inline
关键字来表示这个函数将要被内联以方便阅读代码。
+ - * / % ^ & | ~
const T operator X (const T& l, const T& r);
(全局友元函数)
const T operator X (const T& r) const;
(成员函数)
这些运算符不会修改参数的值,所以参数是 const T&
的类型,运算结果是一个新的不能做左值的对象,所以返回值是 const T
。
! && || < <= == >= >
bool operator X (const T& l, const T& r);
(全局友元函数)
bool operator X (const T& r) const;
(成员函数)
这些运算符不会修改参数的值,所以参数是 const T&
的类型,运算结果是一个 bool 类型的变量(真或假)。
[]
T& operator [] (int index);
中括号运算符需要一个 int 类型的参数,返回的结果是可以做左值的对象,所以返回值是 T&
。
- 自增/自减运算符
const T& operator ++ ();
(前置自增)
const T operator ++ (int);
(后置自增)
const T& operator -- ();
(前置自减)
const T operator -- (int)
(后置自减)
因为要区分自增/自减运算符在变量前面还是后面,需要在后面填充一个 (int) 来让编译器区分它们,这个 (int) 并无其他特殊意义。
前置的自增/自减符号返回了一个引用,因为要先自增/自减再返回,返回值就是自增/自减后的变量。
后置的自增/自减符号返回了一个新对象,因为要返回变量自增/自减以前的值,这就只能把它以前的值当作一个新对象返回,然后让变量自增/自减。
例:实现重载 ++ 运算符
主体还是上面的那个 Integer 类,为了方便阅读,这里再放一下全部的源代码。
class Integer
{
public:
Integer(int i = 0){ this -> i = i; }
friend inline const Integer operator + (const Integer& a, const Integer& b);
inline const Integer& operator ++ ();
inline const Integer operator ++ (int);
private:
int i;
};
inline const Integer operator + (const Integer& a, const Integer& b)
{
return Integer(a.i + b.i);
}
//重载前置 ++ 运算符
inline const Integer& Integer::operator ++ ()
{
this -> i += 1;
return *this;
}
// 重载后置 ++ 运算符
inline const Integer Integer::operator ++ (int)
{
Integer old(*this);
//先创建旧版本的对象,拷贝构造发生
++ (*this);
//调用前置 ++
return old;
//返回旧版本的对象,不能返回引用,因为 old 是一个局部变量
}
可以看到,后置 ++ 是基于前置 ++ 来实现的,这样以后如果有需要修改 ++ 运算符的含义,可以只修改前置的而不用修改后置的,会方便一些。
例:重载逻辑运算符
类似上面的依赖做法,我们只需要重载逻辑运算的 == <
运算符,就可以基于它们派生出所有的逻辑运算了。这样只用修改 == <
的逻辑运算函数就可以实现对所有逻辑运算函数的修改。
我们假定下面的函数已经在类内做过如友元声明。
//友元声明
friend inline bool operator X (const Integer& lhs, const Integer& rhs);
//因为返回的是原始类型,它们本身的类型就是 const T&,所以返回类型不用加 const 了。
inline bool operator == (const Integer& lhs, const Integer& rhs)
{
return lhs.i == rhs.i;
}
inline bool operator != (const Integer& lhs, const Integer& rhs)
{
return !(lhs == rhs);
}
inline bool operator < (const Integer& lhs, const Integer& rhs)
{
return lhs.i < rhs.i;
}
inline bool operator > (const Integer& lhs, const Integer& rhs)
{
return rhs < lhs;
}
inline bool operator <= (const Integer& lhs, const Integer& rhs)
{
return !(lhs > rhs);
}
inline bool operator >= (const Integer& lhs, const Integer& rhs)
{
return !(lhs < rhs);
}
例:重载 [ ] 运算符
当一个类代表了某种容器,而我们需要提供下标访问的功能时,就需要重载 []
运算符。
/* In Vector.h */
#ifndef _VECTOR_H_
#define _VECTOR_H_
class Vector
{
public:
Vector(int);
~Vector();
inline int& operator [] (int index)
{
return *(v_array + index);//返回 v_array 后的第 index 个元素
}
private:
int v_size;
int* v_array;
};
#endif
/* In Vector.cpp */
#include "vector.h"
Vector::Vector(int size) : v_size(size)
{
v_array = new int[size];//动态申请内存
}
Vector::~Vector()
{
delete [] v_array;// 内存回收
}
/* In main.cpp */
#include "vector.h"
#include <iostream>
using namespace std;
int main()
{
Vector v(100);
v[45] = 6;//直接给对象赋值,被重载为给 v_array[index] 赋值
cout << v[45];//直接调用 v_array[45] 也可以
return 0;
}
重载赋值运算符
在「拷贝构造」一节中,我们强调「初始化」和「赋值」是不同的操作。
用对象初始化对象将会调用拷贝构造函数,用对象给对象赋值将会调用赋值运算。
目前只考虑本类的对象给本类的对象赋值的情况,至于别的类的对象赋值给本类或者本类对象赋值给别的类,则涉及到重载类型转换,我们将在下一节中介绍。
先来设计一个简单的类 Person,它具有记录一个人的姓名,年龄以及输出它们的功能。
我们在这些函数上加上一些输出,以便整个程序的运行过程清晰可见:
class Person
{
public:
Person(char* name = "David", int age = 18) : Age(age)
{
this -> name = new char [strlen(name)];
strcpy(this -> name, name);
cout << "Constructor called" << endl;
}//构造函数
~Person()
{
cout << "Distructor called, object " << name << " has been deleted." << endl;
delete [] name;
}//析构函数
Person (Person& that) : Age(that.Age)
{
this -> name = new char [strlen(that.name)];
strcpy(this -> name, that.name);
cout << "Copy constructor called" << endl;
}//拷贝构造
void ShowID()
{
cout << "Name: " << name << "\nAge: " << Age << '\n';
}//打印信息
private:
char* name;
int Age;
};
不妨先来试试编译器为我们补充的默认赋值运算符,它执行的是成员到成员的赋值:
int main()
{
Person a("Jack", 18);
Person b = a, c;
a.ShowID();
b.ShowID();
c.ShowID();
c = a;
c.ShowID();
return 0;
}
结果不负众望的炸了:
这个报错我们似曾相识,实际上就是有一块内存被重复释放了。
我们的拷贝构造函数一定是安全的,这一点在之前「拷贝构造」一文中已经证明过了,这说明变量 a, b 都是没问题的。
那就只可能是系统自带的赋值运算出了问题——它赋值的时候直接把 c 的 char* name
指向 b 的 char* name
所指向的位置,而没有重新开辟一片内存来存放 c 的 name。
而当 b 被析构掉,c 的 char* name
指向的内存实际上已经被析构了,当 c 执行析构时,就会出现重复释放的错误。
现在我们来重写一下赋值运算符来修正这个错误:
inline Person& operator = (const Person& that)
// 因为是修改自己,所以参数可以加 const,括号后不能加 const
{
strcpy(name, that.name);
Age = that.Age;
return *this;
//其实上面已经完成修改了,用不上返回值,这里加上 return *this; 是方便连续赋值
//这样就支持 a = b = c; 这种赋值方法了
//返回值也可以加上 const,加上 const 之后就不可以做左值了
//譬如不支持 (a = b) = c;(尽管这看上去是脱裤子放屁的一件事)
//因为函数原型支持这种写法,所以重载函数也要支持,返回值就不加 const 了
}
但是这个程序仍然是不完美的。
假设有对象 a ,它里面的 char* name
指向了 10000 个字符的字符串,另一个对象 b 里面的 char* name
指向了一个 10 个字符的字符串。
当我们执行 a = b;
根据上面的代码,程序实际上会用 10000 个 char 的内存存 10 个字符,这无疑是大炮打苍蝇的行为。
为了避免这种浪费,应该这么写:
inline Person& operator = (const Person& that)
{
delete [] name;
name = new char [strlen(that.name)];
strcpy(name, that.name);
Age = that.Age;
return *this;
}
总体意思上就是把原来的内存释放掉,然后新建一块大小合适的内存再做字符串的拷贝。
但是这样写还有问题,假如执行 a = a;
呢?
我们会发现进来第一步我们先把 a 的字符串 delete 掉了,然后不管是调用 strlen(that.name)
或者 strcpy(name, that.name)
都不行了,因为这里 that.name
就是被 delete 掉的 name。
所以我们应该再做一次特判:
inline Person& operator = (const Person& that)
{
if(this != &that)//不是自己给自己赋值,再执行下面的
//注意要用指针判断,如果写成 if(*this != that) 则需要重载 != 运算符
{
delete [] name;
name = new char [strlen(that.name)];
strcpy(name, that.name);
Age = that.Age;
}
return *this;
}
这样就很优雅且完美了。
重载类型转换函数
基本类型的自动类型转换
C++ 会做一些简单的自动类型转换(如需要),对于基本类型来说,通常是窄的 -> 宽的的转换,比如下面的这些转换。
(图源自翁恺教授网课的 PPT)
这里说的窄 -> 宽是指安全的转换,即不损失精度,不改变原数据的值的情况下可以做出的类型转换。
我们把这种自动的类型转换称为“隐式类型转换”。编译器会拒绝宽 -> 窄的隐式类型转换,因为这是不安全的。
对于上图:
-
Primitive 指基本类型中可以进行的隐式自动转换。
-
Implicit 指任何类型都可以进行的隐式自动转换。
当然,如果一定要完成宽 -> 窄的转换,则需要用“显式类型转换”,比如 int a; f((double)a);
就是一个显式的类型转换。显式类型转换是程序员通过代码指明要求编译器去做的。这表示程序员知道此处不安全,但仍选择这样做。这样一来,可能产生的错误结果就由程序员自己负责。
构造函数的自动类型转换
除了基本类型的自动类型转换,构造函数也可以完成类型转换的工作:
class A
{
A(){}
};
class B
{
B(const A&){}
};
f(B){}
int main()
{
A a;
f(a);// 通过 B 的构造函数使用 a 构造了一个 B 类的对象作为参数
}
具体的,当一个类具有只有一个参数的构造函数的时候,可以通过构造函数隐式地把参数转化为该类型的一个对象(即完成参数类型到类类型的类型转换)。
如果你不希望让编译器用构造函数做上面那件事(因为它是隐式的,程序员可能不知道这个地方发生了非程序员本意的类型转换(实际上就是不小心写错了)),可以在构造函数之前加上 explicit
关键字,这表示“构造函数就是构造函数,不要用它做类型转换”。
具体写法是这样的:
explicit B(const A& a){/* do sth. */}
B b = a;//OK!
b = a;//Error!
重载的类型转换
重载函数形式如下,可以把一个 A 类型的对象转换为 B 类型的对象,该函数必须是成员函数,并且没有返回类型但有返回值。
class A
{
public:
operator B() const;
//把 *this 转化为 B 类的一个对象,不改变 *this 的值,加 const 限定
//没有返回类型
};
A::operator B() const
{
/* do something */
return b;//b 是一个 B 类型的对象
}
这样说可能不太清楚,我们来实现一个分数类:
class Frac
{
public:
Frac(int nume, int deno) : numerator(nume), denominator(deno) {}
~Frac(){}
inline void Print(){ cout << numerator << '/' << denominator << '\n'; }
operator double() const;
private:
int numerator, denominator;
//分子,分母
};
Frac::operator double() const
{
return (double)numerator/(double)denominator;
}//在这个类型转换中,分数分子除以分母转换为 double 类型的小数
void f(double x)// f需求一个 double
{
cout << x << endl;
}
int main()
{
Frac x(1, 3);
f(x);//给了一个 Frac 类型的对象
return 0;
}
// 输出结果 0.333333 可知发生了类型转换
另外,一个类 A 中不能存在多种从 A 到 B 的转换,否则编译器会不知道使用哪种转换方式,譬如这段代码:
class A
{
public:
operator B() const;
};
class B
{
public:
B(A);
};
void f(B){}
int main()
{
A a;
f(a);
return 0;
}
存在一种从 A 到 B 的类型转换函数,而 B 的构造函数也可以将 A 转换为 B,此时程序无法编译。同样的,也不能写出多个从 A 到 B 的类型转换函数。
其中一种解决方案就是给 B 的构造函数加上 explicit 关键字,B 的构造函数就不能完成类型转换了。
总结
- 总的来说,不建议使用类型转换函数。因为自动的东西可能会导致一些没有意识到的类型转换,这时候编译器不会提醒我们发生了类型转换。
- 建议另写一个函数专门来做类型转换这件事,而不是重载类型转换函数。这样一来,所有的类型转换都必须我们亲自去调用那个自定义的函数,我们也就会知道——这里发生了类型转换,而且是我们亲自批准的。