C++一个很有意思的特性就是除了支持函数重载之外还支持运算符重载,这是因为在C++看来运算符也是函数。如 a + b 可以用operator + (a, b)表示,其中operator +就是加法运算符重载函数。高级语言中的表达式和数学表达式非常相似,从一定程度上看,通过运算符来描述表达式会比通过函数来描述表达式更便于理解和阅读。通常在重载某个运算符时最好和运算符原本的功能相似,当然也可以实现一个和运算符原本功能无关甚至截然相反的功能。运算符重载函数通常按照如下格式声明和定义:
返回类型 operator 运算符(参数类型1 [,参数类型2] [,参数类型3] [, 参数类型N]);
注意:重载运算符时需要在运算符前加上关键字operator。通常运算符重载函数的参数个数不会超过2个,因为运算符大多执行的是一元或者二元运算,只有()、new和delete这三个运算符才支持超过2个参数的情况。
可重载运算符的种类
并不是所有的C++运算符都可以重载,当然也不能凭空创造一个新的运算符出来。且有的运算符只能作为类成员函数被重载,而有的运算符则只能作为友元函数被重载。
不能重载的运算符有:. .* :: ?: sizeof
只能作为类成员函数重载的运算符有:() [] -> =
下文将对各种运算符的重载方法进行详细介绍。为了更具通用性,运算符重载函数使用了模板机制(真正进行重载时需要根据具体的类型进行定义和声明),并给出了重载函数的大体实现逻辑。
1. 流运算符
描述 | 值 |
运算符种类 | >> << |
是否支持类成员函数重载 | NO |
是否支持友元函数重载 | YES |
运算单元 | 二元 |
返回类型 | 左值引用 |
流运算符是C++特有的一种运算符。C++标准库中的iostream类实现了流提取运算符>>和流插入运算符<<的重载,分别用于执行输入和输出操作,而且可以进行连续的输入输出。正是流运算符的这些特性,使得流操作符重载函数的返回值类型必须是引用,且与第一个参数的数据类型一致。下面的例子说明了流运算符重载函数的声明和定义方法:
//友元函数
template<typedef RightType>
LeftType& operator <<(ostream& os, const RightType& right){
//...
return left;
}
template<typedef RightType>
LeftType& operator >>(istream& is, RightType& right){
//...
return left;
}
从上面的例子可以看出:
-
流运算符重载函数的第一个参数为ostream或istream类型,而类成员函数的第一个形参默认为this指针,因此不能使用类成员函数重载流运算符。
-
流运算符重载函数的返回值为引用类型,目的是返回值可以用作左值并且进行连续的输入输出。
-
对于流插入运算符>>来说,第二个参数必须是引用类型的,这是因为输入流会修改这个参数的内容。当然除了引用之外,还可以将第二个参数设置为指针类型。
-
对于输出流运算符<<来说,由于函数内部不会修改第二个参数的内容,因此通常将其设为常引用类型,以防重载函数内部篡改参数的内容同时减少不必要的拷贝构造。
2. 算术运算符
描述 | 值 |
运算符种类 | + - * / % ^ & | ~ >> << |
是否支持类成员函数重载 | YES |
是否支持友元函数重载 | YES |
运算单元 | 除了~是一元之外其他都是二元 |
返回类型 | 普通值类型 |
算术运算符是最常见的数学运算符,上面分别定义的是加(+)、减(-)、乘(*)、除(/)、取余(%)、异或(^)、与(&)、或(|)、非(~)、算术右移(>>)、逻辑左移(<<)运算符。除取非运算符以外的其他运算符都是二元运算符,算术运算符的运算结果和入参无关,且不能用作左值引用。下面是这些运算符重载函数的示例代码:
//友元函数
template<typedef ReturnType, typedef LeftType, typedef RightType>
ReturnType operator +(const LeftType& left, const RightType& right)
{
//...
return 返回一个ReturnType类型的值
}
template<typedef ReturnType, typedef LeftType>
ReturnType operator ~(const LeftType& left)
{
//...
return 返回一个ReturnType类型的值
}
//类成员函数
class CA {
public:
template<typedef ReturnType, typedef RightType>
ReturnType operator +(const RightType& right) const
{
//...
return 一个新的ReturnType类型对象。
}
template<typedef ReturnType>
ReturnType operator ~() const
{
//...
return 一个新的ReturnType类型对象。
}
};
从上面的例子可以看出:
-
算术运算符重载函数的返回值是值类型而非引用类型,这是因为这些运算符重载函数返回的是一个临时对象,而非函数参数,因此不能返回引用,也就不能作为左值使用。
-
由于函数返回值和函数参数是不同的对象,因此算术运算符重载函数的参数类型均为常引用,这样数据既不会被篡改又可以减少拷贝构造的次数。
-
算术运算符重载函数的返回值类型可以和参数类型不一致,但通常返回值和参数的类型都保持一致。
-
除了~运算符是一元运算符外,其他的都是二元运算符,你可以从上面的例子中看到一元和二元运算符重载时的差异。
-
这里面的<<和>>表示位移运算而不是流运算。也就是说,我们可以自定义运算符的功能,使得它的功能与其原本的功能完全不一致。
3. 算术赋值运算符
描述 | 值 |
运算符种类 | += -= *= /= %= ^= &= |= >>= <<= |
是否支持类成员函数重载 | YES |
是否支持友元函数重载 | YES |
运算单元 | 二元 |
返回类型 | 左值引用 |
算术赋值运算符除了具有算术运算的功能之外,还能保存运算结果,因此此类运算符重载函数的第一个参数必须为引用类型,而且不能为常引用,同时返回值类型要和函数第一个参数的类型保持一致。下面的例子说明了算术赋值运算符重载函数的声明和定义方法:
//友元函数
template<typedef LeftType, typedef RightType>
LeftType& operator +=(LeftType& left, const RightType& right)
{
//...
return left;
}
//类成员函数
class CA {
public:
template<typedef RightType>
CA& operator +=(const RightType& right)
{
//...
return *this;
}
};
从上面的例子可以看出:
-
算术赋值运算符重载函数的返回值为引用类型,和函数第一个参数类型保持一致。
-
函数第二个参数的内容不会发生改变,因此通常将其设为常引用类型,以防止函数内部对其进行篡改并减少不必要的拷贝构造。
4. 比较运算符
描述 | 值 |
运算符种类 | == != < > <= >= && || ! |
是否支持类成员函数重载 | YES |
是否支持友元函数重载 | YES |
运算单元 | 除!外其他的都是二元 |
返回类型 | bool |
比较运算符主要用于进行逻辑判断,返回的是bool类型的值。此类运算符重载函数不会修改参数的内容,因此通常将函数参数设为常引用类型。下面的例子说明了比较运算符重载函数的声明和定义方法:
//友元函数
template<typedef LeftType, typedef RightType>
bool operator ==(const LeftType& left, const RightType& right)
{
//...
return true or false
}
template<typedef LeftType>
bool operator !(const LeftType& left)
{
//...
return true or false
}
//类成员函数
class CA {
public:
template<typedef RightType>
bool operator ==(const RightType& right) const
{
//...
return true or false
}
bool operator !() const
{
//...
return true or false
}
};
从上面的例子可以看出:
-
条件运算符重载函数的返回值固定为bool类型,函数内部不会修改函数参数的内容,因此无论重载为成员函数还是友元函数,函数参数均为常引用类型。
5. 自增/自减运算符
描述 | 值 |
运算符种类 | ++ -- |
是否支持类成员函数重载 | YES |
是否支持友元函数重载 | YES |
运算单元 | 一元 |
返回类型 | 普通类型 && 左值引用 |
自增和自减运算符都是一元运算符,而且都会修改自身的内容,因此第一个参数不能为常引用而只能是普通引用类型。又因为自增/自减运算分为后缀和前缀两种,后缀自增/自减返回的值不能用作左值,而前缀自增/自减返回的值则可以作左值。为了区分前缀自增/自减和后缀自增/自减,系统规定为后缀自增/自减运算符重载函数添加一个int类型的参数作为区分标志。下面以自增运算符重载函数为例说明此类运算符的声明和定义方法:
//友元函数
//i++
template<typedef LeftType>
LeftType& operator ++(LeftType& left, int)
{
//...
return left
}
//++i
template<typedef LeftType>
LeftType operator ++(LeftType& left)
{
//...
return 新的LeftType值
}
//类成员函数
class CA {
public:
//i++
CA& operator ++(int) {
//...
return *this;
}
//++i
CA operator ++() {
//...
return 新的CA类型值
}
};
从上面的函数定义可以看出:
-
自增/自减运算符重载函数的参数、返回值及函数修饰都不能携带const关键字。
-
前缀自增/自减运算符重载函数的返回值为引用类型,可以用作左值,而后缀自增/自减运算符重载函数的返回值为值类型,不能用作左值。
-
参数中有int声明的是后缀自增/自减运算符重载函数,没有int声明的是前缀自增/自减运算符重载函数。
6.赋值运算符
描述 | 值 |
运算符种类 | = |
是否支持类成员函数重载 | YES |
是否支持友元函数重载 | NO |
运算单元 | 二元 |
返回类型 | 左值引用 |
赋值运算符只能重载为类成员函数。
重载赋值运算符通常是为了解决对象的浅拷贝问题。我们知道C++中对于对象赋值的默认处理机制是逐字节拷贝,这种拷贝对于只有值类型成员变量的对象来说没有问题,但如果对象中有指针类型的成员变量则可能出现内存重复释放的问题。如以下的代码片段:
class CA {
public:
int *m_a;
~CA(){ delete m_a; }
};
void main() {
CA a, b;
a.m_a = new int;
b = a; //这里执行赋值操作,但是有危险!
}
从上面的代码可以看出,对象a、b的生命期结束后,析构函数会释放成员变量m_a所占用的内存,但由于浅拷贝的存在,这部分内存将被释放两次,进而导致程序崩溃。这种情况下我们就需要对类的赋值运算符进行重载来解决对象的浅拷贝问题。另外,上面的情况除了要对一个类的赋值运算符进行重载,还要为这个类提供拷贝构造函数。这里面有一个著名的构造类的大三原则:
大三原则:如果一个类需要拷贝构造、赋值运算符、析构函数这三个成员函数中的任何一个,则三者都要实现。
工程实践中,大多数情况只要遵循“大二规则”即可,也就是说通常只要实现拷贝构造、赋值操作符,析构函数并不总是必需的。
大三原则主要是为了解决浅拷贝问题。这里拷贝构造函数的实现和赋值运算符重载函数的实现类似,二者的区别在于拷贝构造函数一般用在对象初始化场景中,如对象类型的函数参数传递以及对象类型的值的返回,而赋值运算符则用于对象构造完成后的重新赋值更新。如以下代码:
class CA {
//...
};
CA foo(CA a) { return a; }
void main() {
CA a, c; //构造函数
CA b = foo(a); //a在传递给foo时会调用拷贝构造,foo在返回数据给b时也会调用拷贝构造,即使这里出现了赋值运算符。
c = b; //赋值运算符
}
从上面的代码中可以看到构造函数、拷贝构造函数、赋值运算符重载函数调用的时机和差异。下面我们来对赋值运算符重载函数以及大三原则进行定义:
class CA {
public:
CA() {} //构造函数
CA(const CA &other) {} //拷贝构造函数
~CA() {} //析构函数
CA &operator =(const CA& other) //赋值运算符重载函数
{
//..
return *this;
}
};
从上面的定义可以看出:
-
赋值运算符重载函数的返回值为引用类型,可用作左值引用。
-
赋值运算符重载函数的参数为常引用类型,函数内部不会修改入参的内容。
7. 下标索引运算符
描述 | 值 |
运算符种类 | [] |
是否支持类成员函数重载 | YES |
是否支持友元函数重载 | NO |
运算单元 | 二元 |
返回类型 | 引用 |
在数组中我们可以通过下标索引的方式来读取和设置某个元素的值,如 int array[10] = {0}; int a = array[0]; array[0] = 10;
有些类也具备集合的特性,我们也希望通过下标获取这个集合类中的某个数据元素。为了解决这个问题,我们可以以类成员函数的方式重载下标索引运算符。索引下标通常为整数类型,当然也可以定义为其他的类型,以实现类似字典或者映射表的功能。具体的代码如下:
class CA {
public:
//只用于常量对象的读取操作
template<typedef ReturnType, typedef IndexType>
const ReturnType& operator [](IndexType index) const
{
return 某个returnType的引用
}
//用于一般对象的读取和写入操作
template<typedef ReturnType, typedef IndexType>
ReturnType& operator[](IndexType index)
{
return 某个returnType的引用
}
};
从上面的代码可以看出:
-
这里定义了两个函数:前者用于通过下标索引对常量集合对象数据进行读取,而后者则是为了对非常量集合对象进行下标数据读取和写入。
-
第一个函数的返回值为常引用类型,这主要是为了保证读取到的数据不被篡改,并减少因为读取操作而产生的不必要的拷贝构造,而写入操作则必须使用引用类型。
8. 类型转换运算符
描述 | 值 |
运算符种类 | 各种数据类型 |
是否支持类成员函数重载 | YES |
是否支持友元函数重载 | NO |
运算单元 | 一元 |
返回类型 | 各种数据类型 |
有些函数只接受特定类型的参数。而对于一个类来说,如果这个类的对象并不是那个特定的类型则无法将这个对象作为参数进行传递。为了解决这个问题,我们为类创建一个特殊的类型转换函数,如:
void foo(int a) { cout << a << endl; }
class CA {
private:
int m_a;
public:
CA(int a): m_a(a) {}
int toInt() { return m_a; }
};
void main() {
CA a(10);
foo(a); // wrong!!! a是CA类型而非整数,编译时报错。
foo(a.toInt()); // ok!!
}
可以看到,为了进行有效的参数传递,CA类必须要通过函数toInt来获取整数并传递给foo函数。而类型转换运算符则可以更加方便的解决此类问题,通过重载类型转换运算符,我们的代码在进行参数传递时就不再需要借助多余的函数,而是直接进行参数传递即可。类型转换运算符重载其实是一种适配器模式,我们可以通过类型转换运算符实现不同类型数据的转换和传递操作。类型转换运算符重载函数的声明和定义方法如下:
class CA {
public:
template<typedef Type>
operator Type() {
return Type类型的数据。
}
};
从上面的代码可以看出:
-
类型转换运算符重载函数不需要指定返回值类型,同时也不需要指定入参,只需要将待转换的类型作为运算符即可。
-
类型转换运算符重载函数可以用于任何数据类型,通过类型转换运算符我们可以解决这种类型不匹配的问题。
下面的代码我们来看通过重载类型转换运算符的解决方案:
void foo(int a) { cout << a << endl; }
class CA {
private:
int m_a;
public:
CA(int a): m_a(a){}
operator int() { return m_a; }
};
void main() {
CA a(10);
foo(a); // ok! 在进行参数传递是a会调用类型转换运算符进行类型的转换。
}
9. 函数运算符
描述 | 值 |
运算符种类 | () |
是否支持类成员函数重载 | YES |
是否支持友元函数重载 | NO |
运算单元 | N元 |
返回类型 | 任意 |
函数运算符被广泛应用在STL的算法库中。函数运算符可以理解为C++对闭包的支持和实现。 通过函数运算符,我们可以将一个对象当作普通函数使用,这就是说我们可以在某些接收函数地址作为参数的方法中传递一个对象,只要这个类重载了函数运算符并且它的参数签名和待接收的函数参数签名一致即可。我们先来看下面一段代码:
//定义一个模板fn, 可以接收普通函数, 也可以接收重载了函数运算符的对象
template<class fn>
void foo2(int a, fn pfn) {
cout << pfn(a) << endl;
}
int foo1(int arg) { return arg + 1; }
class CA {
private:
int m_a;
public:
CA(int a): m_a(a){} //定义一个函数运算符
int operator()(int arg) { return arg + m_a; } //定义另外一个函数运算符
void operator()(int arg1, int arg2) {
cout << arg1 + arg2 + m_a << endl;
}
};
void main() {
foo2(10, &foo1); //普通函数作为参数传递。
CA a(20);
foo2(10, a); //将对象传递给foo2当做普通函数来用。
a(20, 30); //这里将对象当做一个普通的函数来用。
}
从上面的代码可以看出,CA类实现了2个函数运算符,因此我们可以将CA类型的对象当做普通的函数来用,在使用时就像是普通的函数调用一样。我们称这种重载了函数运算符的类对象为函数对象。那为什么要让对象来提供函数的能力呢?答案是我们可以在对象的函数运算符内部访问一些对象本身具有的其他属性或者其他成员函数,而普通函数不具备这些特性。上面的例子也说明了这个问题,在类的函数运算符内部还可以使用数据成员。一个类中可以使用多个函数运算符的重载,而且重载函数运算符时,参数个数以及返回值类型都可以自定义。 我们知道C++中不支持闭包机制,但是在某种程度上来说我们可以借助函数运算符重载的方式来实现这种类似闭包的功能。
10. 解引用运算符、取地址运算符、成员访问运算符
描述 | 值 |
运算符种类 | * & -> |
是否支持类成员函数重载 | YES |
是否支持友元函数重载 | * &支持,->不支持 |
运算单元 | 一元 |
返回类型 | 任意 |
在C++中我可以可以对一个指针对象使用*运算符来实现解引用操作,也就是得到这个指针所指向的对象;对一个对象使用&运算符则可以得到对象的地址;而对于一个对象指针,我们可以使用->运算符来访问对象成员。因此这里的*运算符表示的是解引用运算符、&表示的是取地址运算符、->表示的是成员访问运算符。
class CA {
public:
int m_a;
};
void main() {
CA a;
CA *p = &a; //取地址运算符
cout << *p << endl; //取值运算符
p->m_a = 10; //成员访问运算符
}
可以看出,上述三个运算主要用于进行指针相关的操作,如实现智能指针以及代理。这也用在C++从语言级别上对某些设计模式的实现中,如,在编程中有时候我们会构造一个类,用于对另外一个类进行管理,除了自身的一些方法外,所有其他的方法调用都会委托给被管理类,这样我们就要在管理类中实现所有被管理类的方法,比如下面的例子:
class CA{
public:
void foo1();
void foo2();
void foo3();
};
class CB{
private:
CA *m_p;
public:
CB(CA *p): m_p(p){}
~CB() { delete m_p;} //负责销毁对象
CA *getCA() { return m_p; }
void foo1() { m_p->foo1(); }
void foo2() { m_p->foo2(); }
void foo3() { m_p->foo3(); }
};
void fn(CA *p) { p->foo1(); }
void main() {
CB b(new CA);
b.foo1();
b.foo2();
b.foo3(); //因为fn只接受CA类型所以这里CB要提供一个方法来转化为CA对象。
fn(b.getCA());
}
上面的代码可以看出CB类是CA类的管理类,他会负责对CA类对象的生命周期的管理。除了这些管理外CB类还实现了所有CA类的方法。当CA类的方法有很多时,这种实现的方式是非常低效的,那怎么解决这个问题呢?答案就是本小节中所讲的3个运算符重载。我们来看如何实现这三个运算符的重载:
class CA{
public:
void foo1();
void foo2();
void foo3();
};
class CB {
private:
CA *m_p;
public:
CB(CA *p): m_p(p) {}
~CB() { delete m_p; } //负责销毁对象
public: //解引用和取地址运算符是互逆的两个操作
CA &operator *() { return *m_p; }
CA *operator &() {return m_p; } //成员访问的运算符和&运算符的实现机制非常相似
CA *operator ->() { return m_p; }
};
void fn1(CA *p) { p->foo1(); }
void fn2(CA &r) { r.foo2(); }
void main() {
CB b(new CA);
b->foo1();
b->foo2(); //这两个调用了->运算符重载
fn1(&b); //调用&运算符重载
fn2(*b); //调用*运算符重载
}
从上面的代码可以看出,正是因为实现了对三个运算符的重载,我们不再需要在CB类中重写foo1-foo3的实现,且不需要提供特殊的类型转换方法,而是直接通过运算符的方式就可以转化为CA对象使用。当然一个完整的智能指针的封装不仅仅是对三个运算符的重载,我们还需要对构造函数、拷贝构造、赋值运算符、类型转化运算符、析构函数进行处理。如果你要想更加深入的了解智能指针,请阅读STL中auto_ptr类的源码。
11. 内存分配和销毁运算符
描述 | 值 |
运算符种类 | new delete |
是否支持类成员函数重载 | YES |
是否支持友元函数重载 | YES |
运算单元 | N元 |
返回类型 | new返回指针, delete不返回 |
是的,你没有看错C++中对内存分配new以及内存销毁delete也是支持重载的,也就是说new和delete也是一种运算符。默认情况下C++中的new和delete都是在堆中进行内存分配和销毁,有时候我们想对某个类的内存分配方式进行定制,这时候就需要通过对new和delete进行重载处理了。系统规定如果实现了new的重载就必须实现delete的重载处理。关于对内存分配和销毁部分我想单独开辟一篇文章来进行详细介绍。这里面就只简单了举例如何来实现new和delete的重载:
class CA {
public:
CA *operator new(size_t t { return malloc(t); }
void operator delete(void *p) { free(p);}
};