C++运算符重载详解
C++语言的一个很有意思的特性就是除了支持函数重载外还支持运算符重载,原因就是在C++看来运算符也算是一种函数。比如一个 a + b
的加法表达式也可以用函数的形式:operator + (a, b)
来表达。这里的operator +
代表的就是加法函数。高级语言中的表达式和数学表达式非常相似,在一定的程度上通过运算符来描述表达式会比通过函数来描述表达式更加利于理解和阅读。一般情况下在重载某个运算符的实现时最好要和运算符本身的数学表示意义相似,当然你也可以完全实现一个和运算符本身意义无关的功能或者相反的功能(比如对某个+运算符实现为相减)。运算符函数和类的成员函数以及普通函数一样,同样可分为类运算符和普通运算符。要定义一个运算符函数总是按如下的格式来定义和申明:
返回类型 operator 运算符(参数类型1 [,参数类型2] [,参数类型3] [, 参数类型N]);
运算符重载需要在运算符前面加上关键字operator。一般情况下参数的个数不会超过2个,因为运算符大多只是一元或者二元运算,而只有函数运算符()以及new和delete这三个运算符才支持超过2个参数的情况。
可重载的运算符的种类
并不是所有C++中的运算符都可以支持重载,我们也不能创建一个新的运算符出来(比如Σ)。有的运算符只能作为类成员函数被重载,而有的运算符则只能当做普通函数来使用。
- 不能被重载的运算符有:. .* :: ?: sizeof
- 只能作为类成员函数重载的运算符有:() [] -> =
下面我将会对各种运算符重载的方法进行详细的介绍。同时为了更加表现通用性,我这边对参数类型的定义都采用模板的形式,并给出运算符的一些大体实现的逻辑。实际中进行重载时则需要根据具体的类型来进行定义和声明。
1. 流运算符
描述 | 值 |
---|---|
运算符种类 | >> << |
是否支持类成员 | YES |
是否支持普通函数 | YES |
运算单元 | 二元 |
返回类型 | 左值引用 |
流运算符是C++特有的一种运算符。C++的标准库里面的iostream类就支持了流运算符并提供了读取流>>和插入流<<两种运算符,它们分别用来进行输入和输出操作,而且可以连续的进行输入输出,正是因为流运算符的这些特性使得函数的返回值类型必须是引用类型,而且对于普通函数来说第一个参数也必须是引用类型。下面的例子说明了对流运算符的声明和定义方法:
//普通流运算符函数模板
template<class LeftType, class RightType>
LeftType& operator << (LeftType& left, const RightType& right)
{
//...
return left;
}
template<class LeftType, class RightType>
LeftType& operator >> (LeftType& left, RightType& right)
{
//...
return left;
}
//类成员函数
class CA
{
public:
template<class RightType>
CA& operator << (const RightType& right)
{
//...
return *this;
}
template<class RightType>
CA& operator >>(RightType& right)
{
//...
return *this;
}
};
从上面的例子里面可以看出:
- 流运算符的返回总是引用类型的,目的是返回值可以做左值并且进行连续的流运算操作。
- 对于输入流运算符>>来说我们要求右边的参数必须是引用类型的,原因就是输入流会修改右边参数变量的内容。如果右边参数是普通的值类型则不会起到输入内容被改变的效果。当然右边参数类型除了采用引用之外,还可以设置为指针类型。
- 对于输出流运算符<<来说因为并不会改变右边参数的内容,所以我们建议右边参数类型为常量引用类型,目的是为了防止函数内部对右边参数的修改以及产生数据的副本或者产生多余的构造拷贝函数的调用。
- 一般对流运算符进行重载可以采用普通函数也可以采用类成员函数的形式。二者的差别就是普通函数不能访问类的私有变量。当然解决的方法是将普通函数设置为类的友元函数即可。
2. 算术表达式运算符
描述 | 值 |
---|---|
运算符种类 | + - * / % ^ & | ~ >> << |
是否支持类成员 | YES |
是否支持普通函数 | YES |
运算单元 | 除~是一元之外其他都是二元 |
返回类型 | 普通值类型 |
算术表达式是最常见的数学运算符号,上面分别定义的是加(+)、减(-)、乘(*)、除(/)、取余(%)、异或(^)、与(&)、或(|)、非()、算术右移(>>)、逻辑左移(<<)几个运算符。除运算符外其他运算符都是二元运算符,而且运算的结果和原来的值无关,并且不能做左值引用。下面是这些运算符重载的例子代码:
//普通算术运算符函数模板
template<class ReturnType, class LeftType, class RightType>
ReturnType operator + (const LeftType& left, const RightType& right)
{
//...
return 返回一个ReturnType类型的值
}
//取反运算符是一个一元运算符。
template<class ReturnType, class LeftType>
ReturnType operator ~(const LeftType& left)
{
//...
return 返回一个ReturnType类型的值
}
//类成员函数
class CA
{
public:
template<class ReturnType, class RightType>
ReturnType operator + (const RightType& right) const
{
//...
return 一个新的ReturnType类型对象。
}
//取反运算符是一个一元运算符。
template<class ReturnType>
ReturnType operator ~ () const
{
//...
return 一个新的ReturnType类型对象。
}
};
从上面的例子可以看出:
- 函数的返回都是普通类型而不是引用类型是因为这些运算符计算出来的结果都和输入的数据并不是相同的对象而是一个临时对象,因此不能返回引用类型,也就是不能再作为左值使用。
- 正是因为返回的值和输入参数是不同的对象,因此函数里面的入参都用常量引用来表示,这样数据既不会被修改又可以减少构造拷贝的产生。
- 函数的返回类型可以和函数的入参类型不一致,但在实际中最好是所有参数的类型保持一致。
- 除了~运算符是一元运算符外其他的都是二元运算符,你可以看到上面的例子里面一元和二元运算符定义的差异性。
- 这里面的<<和>>分别是表示位移运算而不是流运算。所以可以看出其实我们可以完全来自定义运算符的意义,也就是实现的结果可以和真实的数学运算符的意义完全不一致。
3. 算术赋值表达式运算符
描述 | 值 |
---|---|
运算符种类 | += -= *= /= %= ^= &= |= >>= <<= |
是否支持类成员 | YES |
是否支持普通函数 | YES |
运算单元 | 二元 |
返回类型 | 左值引用 |
算术赋值表达式除了具有上面说的算术运算的功能之外,还有保存结果的作用,也就是会将运算的结果保存起来。因此这种运算符函数的第一个参数必须是引用类型,而不能是常量,同时返回类型要和第一个参数的类型一致。下面的例子说明了运算符的声明和定义方法:
//普通运算符函数模板
template<class LeftType, class RightType>
LeftType& operator += (LeftType& left, const RightType& right)
{
//...
return left;
}
//类成员函数
class CA
{
public:
template<class RightType>
CA& operator += (const RightType& right)
{
//...
return *this;
}
template<class RightType>
CA& operator +=(RightType& right)
{
//...
return *this;
}
};
从上面的例子里面可以看出:
- 算术赋值运算符的返回总是引用类型,而且要和运算符左边的参数类型保持一致。
- 函数的右边因为并不会改变右边参数的内容,所以我们建议右边参数类型为常量引用类型,目的是为了防止函数内部对右边参数的修改以及产生数据的副本或者产生多余的构造拷贝函数的调用。
4. 比较运算符
描述 | 值 |
---|---|
运算符种类 | == != < > <= >= && || ! |
是否支持类成员 | YES |
是否支持普通函数 | YES |
运算单元 | 除!外其他的都是二元 |
返回类型 | bool |
比较运算符主要用于进行逻辑判断,返回的是bool类型的值。这些运算符并不会改变数据的内容,因此参数都设置为常量引用最佳。下面的例子说明了运算符的声明和定义方法:
//普通算术运算符函数模板
template<class LeftType, class RightType>
bool operator == (const LeftType& left, const RightType& right)
{
//...
return true or false
}
//非运算符是一个一元运算符。
template<class LeftType>
bool operator !(const LeftType& left)
{
//...
return true or false
}
//类成员函数
class CA
{
public:
template<class RightType>
bool operator == (const RightType& right) const
{
//...
return true or false
}
//取反运算符是一个一元运算符。
bool operator ! () const
{
//...
return true or false
}
};
从上面的例子可以看出:
- 条件运算符返回的一般是固定的bool类型,因为不会改变数据的值所以无论参数还是成员函数都用常量来修饰。
5. 自增自减运算符
描述 | 值 |
---|---|
运算符种类 | ++ -- |
是否支持类成员 | YES |
是否支持普通函数 | YES |
运算单元 | 一元 |
返回类型 | 普通类型,和左值引用 |
自增和自减运算符都是一元运算符,而且都会改变自身的内容,因此左边参数不能是常量而只能是引用类型。又因为自增分为后缀i++和前缀++i两种形式(自减也一样,下面就只举自增的例子了)。后缀自增返回的值不能做左值而前缀自增返回的值则可以做左值。为了区分前自增和后自增,系统规定对前缀自增的运算符函数上添加一个int类型的参数作为区分的标志。下面的例子说明了运算符的声明和定义方法:
//普通函数运算符函数模板
//++i
template<class LeftType>
LeftType& operator ++ (LeftType& left, int)
{
//...
return left
}
//i++
template<class LeftType>
LeftType operator ++ (LeftType& left)
{
//...
return 新的LeftType值
}
//类成员函数
class CA
{
public:
CA& operator ++ (int)
{
//...
return *this;
}
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& operator =(const CA& other) //赋值运算符重载
{
//..
return *this;
}
~CA(){} //析构函数
}
从上面的定义可以看出:
- 赋值运算符要求返回的是类的引用类型,因为赋值后的结果是可以做左值引用的。
- 赋值运算符函数参数是常量引用表明不会修改入参的值。
7. 下标索引运算符
描述 | 值 |
---|---|
运算符种类 | [] |
是否支持类成员 | YES |
是否支持普通函数 | NO |
运算单元 | 二元 |
返回类型 | 引用 |
我们知道在数组中我们可以通过下标索引的方式来读取和设置某个元素的值比如:
int array[10] = {0};
int a = array[0];
array[0] = 10;
在实际中我们的有些类也具备集合的特性,我们也希望获取这个集合类中的数据元素通过下标来实现,为了解决这个问题我们可以对在类中实现下标索引运算符。这个运算符只支持在类中定义,并且索引的下标一般是整数类型,当然你可以定义为其他类型以便实现类似于字典或者映射表的功能。具体的代码如下:
class CA
{
public:
//只用于常量对象的读取操作
template<class ReturnType, class IndexType>
const ReturnType& operator [](IndexType index) const
{
return 某个returnType的引用
}
//用于一般对象的读取和写入操作
template<class ReturnType, class 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<class Type>
operator Type()
{
return Type类型的数据。
}
};
从上面的代码中可以看出:
- 类型转换运算符重载是不需要指定返回类型的,同时也不需要指定其他的入参,而只需要指定转换的类型作为运算符即可。
- 类型转换运算符重载是可以用于任何的数据类型的,通过类型转换运算符的使用我们就可以很简单的解决这种类型不匹配的问题了,下面的代码我们来看通过类型转换运算符重载的解决方案:
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)
{
int ret = pfn(a);
std::cout << ret << std::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)
{
std::cout << arg1 + arg2 + m_a << std::endl;
}
};
void main()
{
foo2(10, &foo1); //普通函数作为参数传递。
CA a(20);
foo2(10, a); //将对象传递给foo2当做普通函数来用。
a(20, 30); //这里将对象当做一个普通的函数来用。
}
上面的代码可以看出来,因为CA类实现了2个函数运算符,所以我们可以将CA的对象当做普通的函数来用,在使用时就像是普通的函数调用一样。我们称这种实现了函数运算符的类的对象为函数对象。那么为什么要让对象来提供函数的能力呢?答案就是我们可以在对象的函数运算符内部访问一些对象本身具有的其他属性或者其他成员函数,而普通的函数则不具备这些特性。上面的例子也说明了这个问题,在类的函数运算符内部还可以使用数据成员。一个类中可以使用多个函数运算符的重载,而且函数运算符重载时的参数个数以及返回类型都可以完全自定义。 我们知道C++中不支持闭包机制,但是在某种程度上来说我们可以借助函数运算符重载的方式来实现这种类似闭包的能力。
10. 复引用运算符、地址运算符、成员访问运算符
描述 | 值 |
---|---|
运算符种类 | * & -> |
是否支持类成员 | YES |
是否支持普通函数 | 除了* &支持外,->不支持 |
运算单元 | 1元 |
返回类型 | 任意 |
在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);}
};
关于对new和delete运算符的详细介绍请参考文章:C++的new和delete详解