ANSI/ISO C++ Professional Programmer's Handbook(3)

来源:互联网 发布:心事谁人知 陈小春 编辑:程序博客网 时间:2024/05/17 17:43
  摘自:http://sttony.blogspot.com/search/label/C%2B%2B

3


运算符重载


by Danny Kalev





简介


内建运算符一样可以被扩展以支持用户定义类型。重载运算符预定义的含义是扩展而不是覆盖它。虽然普通函数可以提供相同的功能,但是运算符重载提供了一种统一的表示习惯,这比普通的函数调用语法更清楚。例如



Monday < Tuesday; //重载了<
Greater_than(Monday, Tuesday);

运算符重载的历史可以追述到早期的Fortran语言。Fortran,第一种高级程序设计语言,在50年代中后期以某种方式革命性的提出了运算符重载的概念。第一次,内建运算符如+-能适用于变量数据类型:整型、实型和复数型。到那时为止,汇编语言——它甚至不支持运算符记号——是程序员的唯一选择。Fortran的运算符重载限于一套固定的内建数据类型;他们不能被程序员扩展。基于对象的程序设计语言提供了用户定义的重载运算符。在这样的语言里,将一套运算符和用户定义的类型联系起来成为可能。面向对象的程序设计语言一般也集成运算符重载。


C++中重新定义内建运算符含义的能力是引起批评的源头。大部分向C++移植的C程序员认为运算符重载和允许程序员增加、取消、改变语言关键字一样危险。虽然可能带来混乱,运算符重载还是成为C++最基本的特性之一,并且泛型程序设计的基本要求(泛型程序设计在第十章“STL和泛型程序设计”讨论)。今天,即使试图不要运算符重载的语言也在加入这种特性。


本章探索运算符重载的好处,也讨论潜在的问题。本章也讨论适用于运算符重载的小规则。最后,演示运算符重载的特殊形式——转换运算符。


运算符重载本质上来说是一个函数,其名字是由关键字operator打头的运算符。例如



class Book
{
private:
long ISBN;
public:
//...
long get_ISBN() const { return ISBN;}
};
bool operator < (const Book& b1, const Book& b2) //重载运算符<
{
return b1.get_ISBN() < b2.get_ISBN();
}

运算符重载的唯一规则


C++对运算符重载只有很少的规则。例如,不禁止程序员重载运算符++来执行一个减少操作(但对于期望运算符++执行增量操作的用户来说,不免感到惊奇)。这样的滥用和双关可以产生几乎不能理解的神秘代码风格。一般的,重载了运算符的源代码是不贴近用户的;因此,以一种意想不到、不直观的方式重载运算符是不推荐的。


另一种极端——完全放弃运算符重载——也是不实际的选择,因为这意味着放弃这种数据抽象和泛型程序设计的重要工具。当你重载运算符以支持用户定义类型时,推荐你保持相应内建运算符的基本语义。换句话说,重载运算符对操作数和有相同接口的内建运算符都有副作用。


成员和非成员


大多数重载运算符既可以申明为非静态的类成员也可以不是成员函数。下面的例子中,运算符==作为非静态的类成员重载:



class Date
{
private:
int day;
int month;
int year;
public:
bool operator == (const Date & d ); // 1:成员函数
};

作为选择的,它也能申明成一个friend函数(选择成员函数还是友函数的标准在这一章讨论):



bool operator ==( const Date & d1, const Date& d2); // 2:非成员函数
class Date
{
private:
int day;
int month;
int year;
public:
friend bool operator ==( const Date & d1, const Date& d2);
};

虽然如此,运算符[]()=,和->只能被申明为非静态类成员;这是为了确保他们的第一个操作数是左值。


运算符的接口


当你重载运算符时,应该使其与它的内建副本有相同的接口。运算符的接口由用于的操作数组成,不管这些操作数是能被运算符改变的还是返回的结果。例如,考虑运算符==。它的内建版本可以接受广泛的类型,包括intboolfloatchar以及指针。检测运算符==的操作数是否相等的基础计算过程是它的实现细节。然而,内建==运算符检测左右操作数的一致性和返回一个bool值作为结果的过程可以被一般化。注意运算符==没有改变它的任何一个操作数是十分重要的;另外,在运算符==中操作数的顺序是无关紧要的。重载运算符==也必须遵循这些行为。


运算符的一致性


运算符==是二元和对称的。==的重载版本也应该遵循这种限制。它接受两个有相同类型的参数。实际上,常使用运算符==测试两个有截然不同基础类型的操作数的一致性,例如charint。但是,在这种情况下C++自动的对操作数应用integral promotion(整数提升);结果,在比较之前表面上不同的类型被提升成了相同的类型对称性限制意味着运算==定义成函数而不是成员函数。为了使你看清为什么,这儿有对两种版本重载运算符 ==的比较:



class Date
{
private:
int day;
int month;
int year;
public:
Date();
bool operator == (const Date & d) const; // 1 不对称的
friend bool operator ==(const Date& d1, const Date& d2); //2 对称的
};
bool operator ==(const Date& d1, const Date& d2);

(1)中将重载运算符==申明为成员函数与内建运算符==不一致,因为它接受两个不同类型的实参。编译器将成员运算符==翻译成下列形式:



bool Date::operator == (const Date *const, const Date&) const;

第一个参数是指向const对象的const this指针(记住,this总是一const指针;当成员函数也是const时它指向一个const对象)。第二个参数是const Date的引用。很明显,两种类型之间没有标准的类型转换存在。另一方面,友函数 版本接受两个同类型的参数。支持友函数版本有实际的意义。STL算法依赖对称版本的重载运算符 ==。例如,存储不支持对称版本运算符==对象的容器不能排序。


另一个例子是内建运算符+=,其也接受两个参数,并改变左参数而不改变右参数。重载+=的接口需要反映它改变它的一个对象而保持另一个对象的事实。这通过申明函数为const来反映,函数自己是非const成员函数。例如



class Date
{
private:
int day;
int month;
int year;
public:
Date();
//内建+=改变它的左操作数而不改变右操作数
//同样的行为在这里保持
Date & operator += (const Date & d);
};

总的来说,每一个重载运算符都必须实现与它的内建运算符相同的接口。如何实现在定义基础操作和隐藏细节方面是自由的——只要遵循其接口。


运算符重载的限制


就象前面提到的,重载运算符是一个函数,这个函数申明时以关键字operator开头并紧接一个运算符id。运算符可以是下列之一:



new delete new[] delete[]
+ - * / % ^ & | ~
! = < > += -= *= /= %=
^= &= |= << >> >>= <<= == !=
<= >= && || ++ -- , ->* ->
() []

另外。下列运算符的一元和二元形式都能被重载:



+ - * &

重载运算符以同样的方式从基类继承。注意。这个规则不适用类隐含申明的而不是用户申明的赋值运算符。 因此,基类赋值运算符总是被派生类的拷贝赋值运算符隐藏。(赋值运算符在第四章“特殊成员函数:默认构造器,拷贝构造器,销毁器和分配运算符特殊成员函数:默认构造器,拷贝构造器,销毁器和赋值运算符”讨论)


重载运算符只能用于用户自定义类型


重载运算符必须至少接受一个用户自定义类型(运算符newdelete是例外——详细参见第十一章“内存管理”)。这条规则保证只包含基本类型的表达式的含义不能被用户改变。例如



int i,j,k;
k = i + j; //总是使用内建的=和+


创造新的运算符是不允许的


重载运算符扩展内建的运算符,所以你不能引入新的运算符到语言(转换运算符不同于普通的运算符)。下面的例子试图重载@运算符,但是因为上面的原因不能编译:



void operator @ (int); //非法,@不是内建运算符或类型名

优先级和参数个数


运算符的优先级和参数的个数都不能改变。例如,重载&&只能有两个参数——和内建运算符&&一样。另外,当运算符重载时它的优先级也不能改变。两个或多个重载运算符的序列,例如t2<t1/t2,按内建运算符的优先级规则求解。因为除法运算符的级别高于小于号,表达式总是被解释成t2<(t1/t2)


默认参数


不象普通函数。重载运算符不能申明一个有默认值的参数(运算符()是这条规则的例外;稍后讨论)。



class Date
{
private:
int day;
int month;
int year;
public:
Date & operator += (const Date & d = Date() ); //error,默认参数
};

这条规则可能很武断,但这来源于内建运算符的行为,他们从没有操作数。


不能被重载的运算符


有一些运算符不能被重载。 因为他们接受一个名字而不是一个对象作为右值。这些运算符是:



  • 直接成员访问,运算符.



  • 指向类指针成员,.*




  • 范围决定,运算符::


  • 求占内存大小,运算符sizeof


条件运算符?:也不能被重载。


另外,新的类型转换运算符——static_cast<>dynamic_cast<>reinterpret_cast<>,和const_cast<>——和###预处理记号不能重载。


转换运算符


发现C++代码和C代码一起使用是常见的。例如,以前用C语言写的系统可以通过面向对象的接口重新包装。这种用两种语言构造的系统常常需要同时支持双重的接口——一种适应面向对象环境,另一种满足C的环境。实现特殊数字实体——比如复数和二进制数——和不足一字节数据的类也倾向于使用转换运算符来使自己和基本类型平滑的交互。


字符串是一个需要双接口的典型例子。字符串对象可能不得不用在仅仅支持以NULL结尾char数组 的上下文中。例如



class Mystring
{
private:
char *s;
int size;
public:
Mystring(const char *);
Mystring();
//...
};
#include <cstring>
#include "Mystring.h"
using namespace std;
int main()
{
Mystring str("hello world");
int n = strcmp(str, "Hello"); //编译期错误:
//str不是const char *类型
return 0;
}

C++为这种情况提供一种自动的类型转换。转换运算符可以被认为是用户自定义的类型转换运算符;在需要特定类型的上下文中将这种对象转换成不同的类型。转换是自动的。例如



class Mystring //现在有转换运算符
{
private:
char *s;
int size;
public:
Mystring();
operator const char * () {return s; } //将Mystring转换成C的字符串
//...
};
int n = strcmp(str, "Hello"); //OK,自动将str转换成const char *

转换运算符与普通的重载运算符相比有两点不同。第一,转换运算符不需要返回值(void也不需要)。返回值由运算符的名字推出。


第二,转换运算符不需要参数。


转换运算符可以将对象转换成任何类型,基本类型和用户定义类型都行:



struct DateRep //以前的C代码
{
char day;
char month;
short year;
};
class Date //面向对象包装
{
private:
DateRep dr;
public:
operator DateRep () const { return dr;} //自动转换成DateRep
};
extern "C" int transmit_date(DateRep); //基于C的通讯API
int main()
{
Date d;
//...使用d
//将数据对象作为二进制流传送到遥远的客户方
int ret_stat = transmit_date; //使用以前的通讯API
return 0;
}

标准转换VS.用户定义转换


用户定义转换和标准转换的交互能带来不受欢迎的惊奇和副作用,因此必须谨慎使用。测试下面具体的例子。


显式的只带一个参数的构造器也是一个转换运算符,它将参数转换成这个类的对象。但编译器必须解决一个重载函数调用时,它除了考虑标准的以外还要考虑象这样用户定义的。例如



class Numeric
{
private:
float f;
public:
Numeric(float ff): f(ff) {} //构造器也是一个float到Numeric的
//转换运算符
};
void f(Numeric);
Numeric num(0.05);
f(5.f); //OK,调用void f(Numeric)。Numeric的构造器将
//参数转换成Numeric对象

假设你稍后又增加了一个重载版本的f()



void f (double);

现在相同的函数调用有不同的解决:



f(5.f); //现在调用f(double)而不是f(Numeric)

这是因为float被自动提升为double,以匹配一个函数特征。这是一个标准类型转换。另一方面,floatNumeric的转换是一个用户定义转换。用户定义转换级别低于标准转换——在重载函数确定中;结果函数调用解决不同。


因为这些现象,转换运算符收到强烈的批评。一些程序设计课禁止使用他们。但是,转换运算符对在双接口的程序中是——有时不可避免——一个有用的工具,就象你看到的。


后缀和前缀运算符


对于早期的类型,C++区别++x;x++;,也区别--x;x--;。在这些情况下,对象也必须区别前缀和后缀重载运算符(例如,作为优化的度量。参见第十二章“优化你的代码”)。后缀运算符申明了一个虚假的int参数,而他们的前缀版本没有。例如



class Date
{
public:
Date& operator++(); //前缀
Date& operator--(); //前缀
Date& operator++(int unused); //后缀
Date& operator--(int unused); //后缀
};
void f()
{
Date d, d1;
d1 = ++d;//前缀:先增加d然后赋值给d1
d1 = d++; //后缀:先赋值然后增加d
}

使用函数调用语法


重载运算符的调用只是普通函数调用的一种变形。你可以使用显式的函数调用来调用重载运算符,例如:



bool operator==(const Date& d1, const Date& d2);
void f()
{
Date d, d1;
bool equal;
d1.operator++(0); //等价于:d1++;
d1.operator++(); //等价于:++d1;
equal = operator==(d, d1);//等价于:d==d1;
Date&(Date::*pmf) (); //指向成员函数的指针
pmf = & Date::operator++;
}

和谐的运算符重载


无论什么时候你重载了象+-的运算符,你也必须支持相应的+=-=运算符。编译器不会自动的替你做。考虑下面的例子:



class Date
{
public:
Date& operator + (const Date& d); //注意:运算符+=未定义
};
Date d1, d2;
d1 = d1 + d2; //正确;使用重载的+和默认的赋值运算符
d1 += d2; //编译期错误:“没有用户为类Date定义运算符+=”

理论上讲,编译器能通过合并赋值运算符和重载运算符+来合成运算符+=,这样表达式d1 += d2;被自动的扩展成d1 = d1+d2。但是,这是不受欢迎的,因为自动扩展比一个用户自定义版本低效。自动扩展版本建立了一个临时对象,而用户定义版本避免了这一点。而且,不能想象,一个有重载运算符+的类故意没有运算符+=


通过值返回对象


为了效率,大型对象常常通过引用或指针来传递给函数——或从函数返回。但是,仍然有少数情况最好的选择是通过值返回对象。运算符+是这种情况的例子。它必须返回一个对象,但不能改变它的任何一个操作数。表面上自然的选择是,分配一个对象返回它的地址。然而,这不是一个好主意。动态内存分配比本地存储要慢的多。动态分配也可能失败并抛出异常,异常再被处理程序截获。更糟的是,这个解决方案是一种错误倾向,因为谁删除这个对象成了不清楚的问题——创建者还是用户?


另一个解决方法是使用静态对象并返回引用。例如



class Year
{
private:
int year;
public:
Year(int y = 0) : year(y) {}
Year& operator + (const Year& other) const; //返回本地静态Year对象的
//引用
int getYear() const;
void setYear(int y);
};
Year& Year::operator + (const Year& other) const
{
static Year result;
result = Year(this->getYear() + other.getYear() );
return result;
}

静态对象解决了所有权问题,但它仍是有问题的:在重载运算符的每一个调用中,静态对象相同的实例被修改并返回给调用者。相同的对象能通过引用返回给许多用户。这些用户不知道他们保存的是一个共享的实例,这个实例在他们使用之后就被修改了。


最后,最安全和最有效的的解决方法仍然是通过值返回:



class Year
{
private:
int year;
public:
Year(int y = 0) : year(y) {}
Year operator + (const Year& other) const; //通过值返回Year对象
int getYear() const;
void setYear(int y);
};
Year Year::operator + (const Year& other) const
{
return Year(this->getYear() + other.getYear() );
}

多重重载


重载运算符服从函数重载的规则。因此,运算符 可以被重载多次。什么时候有用呢?考虑下面的Month类和它关联的运算符 ==



class Month
{
private:
int m;
public:
Month(int m = 0);
};
bool operator == (const Month& m1, const Month &m2);

由于隐含intMonth转换运算符,可以用重载运算符==来比较简单的 int值和Month对象。例如



void f()
{
int n = 7;
Month June(6);
bool same =
(June == n); //calls bool operator == (const Month& m1, const Month &m2);
}

这工作的很好,但这是低效的:参数n先被转换成一个临时的Month对象,然后于 June对象比较。你可以通过定义运算符==的另外的重载版本来避免临时对象的构造:



bool operator == (int m, const Month& month);
bool operator == (const Month& month, int m);

因此,表达式June == n将调用下面的重载运算符:



bool operator == (const Month& month, int m);

这个重载版本不会创造临时对象,所以它是更有效的。同样是关于性能的考虑导致C++标准化委员会为std::string和其他的标准库中的类定义了三种不同版本的运算符==(参见第十章“STL和泛型程序设计”)。


用户定义类型的重载运算符


你也可以为enum类型定义重载运算符。例如,象++和--这样的运算符是很有用的,因此他们可以通过给定enum类型的枚举值来重复。你可以这样做:



#include <iostream>
using namespace std;
enum Days
{
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday
};
Days& operator++(Days& d, int) //后缀++
{
if (d == Sunday)
return d = Monday; //转回去
int temp = d; //转换成一个int
return d = static_cast<Days> (++temp);
}
int main()
{
Days day = Monday;
for (;;) //将days作为整数显示
{
cout<< day <<endl;
day++;
if (day == Sunday)
break;
}
return 0;
}

如果你想以符号而不是整数来显示枚举值,重载运算符<<也可以:



ostream& operator<<(ostream& os, Days d) //以符号形式显示Days
{
switch
{
case Monday:
return os<<"Monday";
case Tuesday:
return os<<"Tuesday";
case Wednesday:
return os<<"Wednesday";
case Thursday:
return os<<"Thursady";
case Friday:
return os<<"Friday";
case Saturday:
return os<<"Satrurday";
case Sunday:
return os<<"Sunday";
default:
return os<<"Unknown";
}
}

重载下标运算符


对于包含数组元素的多样的类,可以通过重载下标运算符来方便的访问单一元素。记住,永远定义两个版本的下标运算符:const版本和非const版本。例如



class Ptr_collection
{
private :
void **ptr_array;
int elements;
public:
Ptr_collection() {}
//...
void * operator [] (int index) { return ptr_array[index];}
const void * operator [] (int index) const { return ptr_array[index];}
};
void f(const Ptr_collection & pointers)
{
const void *p = pointers[0]; //调用const版本的运算符[]
if ( p == 0)
return;
else
{
//...使用p
}
}

函数对象


函数对象是作为一个包含函数调用运算符重载版本的类来实现的。这样一个类的实例可以象一个函数一样使用。普通函数可以有任意数目的参数;因此,运算符()在其他运算符中是一个例外。因为它可以接受任意数目的参数。另外,它可以接受默认参数。在下面的例子里,一个函数对象实现了一个泛型的增量函数:



#include <iostream>
using namespace std;
class increment
{
//一个泛型的增量函数
public : template < class T > T operator() (T t) const { return ++t;}
};
void f(int n, const increment& incr)
{
cout << incr(n); //输出1
}
int main()
{
int i = 0;
increment incr;
f(i, incr);
return 0;
}

总结


运算符重载的概念并非新也不是C++独有。它是实现数据抽象和混合类的最基本的工具。在许多方面,C++中重载运算符象普通函数:他们被继承,他们可以被重载多次,他们即可申明为非静态成员也可申明为非成员函数。但是,有一些限制适用与重载运算符。重载运算符有确定的参数数目,而且不能有默认参数。另外,运算符的一致性和优先级不能被改变。内建运算符有一个由运算符适用的操作数和运算符返回的结果组成的接口。当你重载运算符时,建议你保持和内建运算符相同的接口。


转换运算符是一种特殊类型的重载运算符。他们与普通重载运算符相比在两个地方需要关注:他们不返回值,也不需要任何参数。


原创粉丝点击