480 likes | 730 Views
第 6 章 运算符重载、虚函数与多态性. 运算符重载是指用户可以重新定义一个系统已有的运算符,以便可以用于新的数据类型的操作。例如,我们可以重载“ +” 运算符,使之在系统已有的“加”运算符的基础上,可以用于字符串的连接和复数数据类型的“加”的操作。. 而虚函数是指在其类中定义一个函数的空壳,在各派生类中定义其函数体的具体内容,从而实现执行同样的代码但运行结果不同。. 6.1 多态性概述. 多态性的英文单词是 polymorphism ,意为多个形态或多种状态,简称多态性。在面向对象中,多态性是指不同的对象收到相同的消息时,产生不同的动作。.
E N D
第6章 运算符重载、虚函数与多态性 运算符重载是指用户可以重新定义一个系统已有的运算符,以便可以用于新的数据类型的操作。例如,我们可以重载“+”运算符,使之在系统已有的“加”运算符的基础上,可以用于字符串的连接和复数数据类型的“加”的操作。 而虚函数是指在其类中定义一个函数的空壳,在各派生类中定义其函数体的具体内容,从而实现执行同样的代码但运行结果不同。
6.1 多态性概述 多态性的英文单词是polymorphism,意为多个形态或多种状态,简称多态性。在面向对象中,多态性是指不同的对象收到相同的消息时,产生不同的动作。 在C++中,多态性是指一个函数名有多种函数体内容,有函数重载、运算符重载和虚函数方式。其中,函数重载、运算符重载是在编译时就确定调用哪一个函数体内容,称为静态多态性。而虚函数是在运行时才确定哪一个函数体内容,因此称为动态多态性。
6.2 运算符重载 运算符重载的目的是为了使系统已有的运算符能够用于新的数据类型上。 实际上在C语言中, 有许多系统预定义的运算符例如“+”, 它可以用于整型值, 也可以用于实型值, 虽然使用相同的运算符, 但生成的代码不相同。因此可以说, 像“+”这样的运算符在C语言中已被重载了。C++语言扩充了这个功能, 允许已存在的运算符由用户在不同的上下文中作出不同的解释。
运算符重载是通过定义运算符函数来完成的。在C++中,运算符重载通过一个运算符函数来进行。运算符函数可以被设计为成员函数,也可以被设计为友元函数。运算符重载是通过定义运算符函数来完成的。在C++中,运算符重载通过一个运算符函数来进行。运算符函数可以被设计为成员函数,也可以被设计为友元函数。 运算符函数的定义格式为: 结果类型名 operator运算符(操作数表) { ...... //相对于该类而重新定义的运算符含义 } 6.2.1 运算符函数的定义 上述定义中“operator运算符”就是所谓的运算符函数的名字。
运算符一般分为一元和二元运算符,一元运算符需要一个操作数,二元运算符需要二个操作数。操作数表中罗列的就是该运算符所需要的操作数。运算符一般分为一元和二元运算符,一元运算符需要一个操作数,二元运算符需要二个操作数。操作数表中罗列的就是该运算符所需要的操作数。 运算符函数体对重载的运算符的含义作出新的解释。这里,所解释的含义只限定在重载该运算符的类范围内。当在该类对象的上下文中,该运算符的含义由这个函数体进行解释(或称执行), 否则, 该运算符具有系统原有的含义。因此,当一个运算符被重载时,它所有原先的含义并未失去,只是在一个特定类的范围内重新定义了该运算符的含义。
下面我们重载运算符+和-,以便用于复数complex类的操作(例cpp6-1)。 在进行运算符重载时必须注意以下几个问题: ①重载的运算符必须是系统已定义的运算符, 如不能定义一个这样的运算符函数: int operator$(...); ②C++中大多数的运算符是可以重载的,但下列运算符是不能重载的: . 成员运算符 .* 成员指针运算符 :: 作用域运算符 # 预编译运算符 ?: 条件运算符 sizeof 求字节数运算符
③重载这些运算符时,不能改变它们的优先级,不能改变它们的操作数; ④运算符函数既可被设计为成员函数,也可被设计为友元函数,但以下四种运算符的运算符函数只能被设计为成员函数: = 赋值运算符 ( ) 函数运算符 [ ] 下标运算符 -> 指向运算符 ⑤重载的运算符至少必须作用于一个以上用户定义的数据类型。例如下面的定义是错误的: int operator+(int a,int b) { return(a+b); }
⑥除了“=”以外,重载的运算符可以被任何派生类所继承。如果每个类都需要一个“=”运算符,那么每个类就要明确地定义自己的“=”运算符。 由于能够对旧运算符定义新含义,有可能使编出来的程序几乎不可理解。例如,在一个程序中把运算符“+”用来表示减运算,而“*”用来表示除运算,那么该程序的阅读将是非常的困难。因此,运算符重载主要用来模仿运算符的习惯用法,用于解决新数据类型的类似运算功能,不应该标新立异。
重载运算符时,运算符函数可被设计为成员函数,也可被设计为友元函数。这两种方式非常类似,但还有许多差别,关键原因在于成员函数属于某个类,具有this指针,而友元函数不属于类范围,没有this指针。 6.2.2 用成员函数与用友元函数 重载运算符的区别 以下假定用“@”表示任意可重载的运算符。
一元运算符: 对于一元运算符,不论是前缀还是后缀,都需要一个操作数: aa@或@aa 对任意一元运算符@, 都可有如下两种解释: aa.operator@( ) (1) 或 operator@(aa) (2) 在式(1)的解释下,一元运算符函数operator@所需的一个操作数由对象aa通过this指针隐含地传递。因此,它的参数表为空。这时,运算符函数用类的成员函数来表示。
在式(2)的解释下,一元运算符函数operator@所需的一个操作数在参数表中由对象aa显式地提供,这适合没有this指针的情况。这时,运算符函数用类的友元函数来表示。 不管是用成员函数还是友元函数重载运算符,该运算符的使用方法是相同的,都是这样使用的: aa@或@aa 这里,aa是重载运算符@的那个类的对象。
二元运算符: 同样地,对任意的二元运算符@: aa@bb 可解释为 aa.operator@(bb) (3) 或 operator@(aa,bb) (4) 在式(3)的解释下,二元运算符函数operator@所需的一个操作数由对象aa通过this指针隐含地传递。因此,它只有一个参数bb。这时,运算符函数用类的成员函数来表示。
在式(4)的解释下,二元运算符函数operator@所需的两个操作数都在参数表中由对象aa和bb显式地提供,这适合没有this指针的情况。这时,运算符函数用类的友元函数来表示。 不管是用成员函数还是友元函数重载运算符,该运算符的使用方法是相同的,都是这样使用的: aa@bb 用成员函数重载运算符和使用友元函数重载运算符,它们传递参数的方法不一样,也就导致了它们的实现代码不相同。
下面仔细谈谈它们的差别。 创建一个名为threed的简单类,它包含三维空间中一点的x,y,z坐标值。重载“++”、“+”和“=”运算符,以便能进行三维点对象的相加和赋值。分别用成员函数和友元函数来实现,然后再来对比它们的不同。 例cpp6-2。 从两个程序的输出结果完全相同可以看出,无论用那种方法重载运算符,使用方法是完全相同的。但两者的声明和定义是有差别的:
(1)在类的声明中,友元函数除了多一个friend关键字外, 最主要的是多了一个操作数, 如下所示: 成员函数: threed operator+(threed t); 友元函数: friend threed operator+(threed op1, threed op2); (2)在运算符函数的类外定义中,成员函数需要加“类名::”来限定其范围: 成员函数:threed threed::operator+(threed t) 友元函数:threed operator+(threed op1, threed op2)
(3)在函数体内,成员函数有this指针,所以x,y,z可以直接引用,而在友元函数中两个操作数都必须指明其对象: { three_d temp; { three_d temp; temp.x=x+t.x; temp.x=op1.x+op2.x; temp.y=y+t.y; temp.y=op1.y+op2.y; temp.z=z+t.z; temp.z=op1.z+op2.z; return temp; return temp; } } (4)当需要把修改后的结果返回给类对象时,用友元函数重载运算符时操作数表必须用引用参数。
例如,上例中的“++”运算符,采用的就是引用参数:例如,上例中的“++”运算符,采用的就是引用参数: three_d operator++(three_d &op1) (5)对于大多数情况下,用成员函数重载运算符或用友元函数重载运算符都是可行的,仅仅是实现方法不同而已。但在有些情况下,两者是不能相互替代的。如前所述,不能用友元函数重载“=、( )、[ ]、->”这四个运算符;而当二元运算符中的第一个操作数不是对象,只是一般数据时用成员函数重载的运算符会出现问题,而用友元函数重载的运算符不会出现问题。例cpp6-3。
在成员函数重载的+运算符中,表达式z+27可被解释为: z.operator+(27) z是一个复数类的对象,系统由此知道现在用的是复数类中“+”的重载版本,所需参数应为复数,于是系统将27自动进行类型转换,再与z相加。因此表达式能正确地工作。 但是,表达式27+z被解释为:27.operator+(z) 这个式子毫无意义。因为27不是用户定义的对象,系统不知道用户要重载运算符“+”,把它解释为一般的整型值,这样再与后面的complex类对象z相加时就出现错误。
如果用友元函数来重载该运算符就可以避免这种错误。由于友元函数所需的两个操作数都必须在参数表中明确地说明,因此很容易对此情况进行处理。如果用友元函数来重载该运算符就可以避免这种错误。由于友元函数所需的两个操作数都必须在参数表中明确地说明,因此很容易对此情况进行处理。 表达式27+z被解释为:operator +(27,z); 由于z是一个复数类的对象,系统由此知道现在用的是复数类中“+”的重载版本,所需参数应为复数,于是系统将27自动进行类型转换,它通过调用构造函数complex(int a)版本将整型值27变为complex类27,再与z相加。因此表达式能正确地工作。 下面再看一个用友元函数重载的例子cpp6-4。
在C和C++中,运算符++和--有两种方式,即前缀方式:在C和C++中,运算符++和--有两种方式,即前缀方式: ++i; 或 --i; 和后缀方式: i++; 或 i--; 6.2.3 重载++和-- 早期的C++在重载符++或--时,不能显式地区分是前缀方式还是后缀方式。但在新的C++标准中,对此作了一些约定。
对于前缀方式++i,可以用一个一元运算符函数重载为:对于前缀方式++i,可以用一个一元运算符函数重载为: aa.operator++(); //成员函数重载 或 operator++(X &aa); //友元函数重载 对于后缀方式i++,可以用一个二元运算符函数重载为: aa.operator++(int); //成员函数重载 或 operator++(X &aa, int); //友元函数重载
这时,第二个参数(int)一般设置为0,例如: i++; 等价于 i++(0); 类似地,也可重载为友元函数。 例cpp6-5。 6.2.4 重载[ ]运算符 重载运算符--也用类似的方法。 C++中数组下标运算符的一般使用格式为: 数组名[表达式]
在重载“[ ]”时,C++也把它看成双目运算符,其操作数为“数组名”和“表达式”,相应的运算符函数为operator[ ]。 设a是类A的对象,类A中定义了重载“[]”的operator函数,则表达式a[3]; 可被解释为: a.operator[ ](3); 对下标运算符重载定义只能使用成员函数,其形式如下: 类型类名::operator[](形参) {函数体 }
这里,形参只能有一个。 下面的例子给出重载下标运算符的用法。例6.5 分析:在这个程序中,重载“[]”的operator函数被定义为: char &operator[](int i) { return *(str+i);} 则 word[n-1]=word[n-1]-32; 相当于: word.operator[](n-1)=word.operator[](n-1)-32; 它的功能是将小写字母转换成大写字母。
函数调用运算符可以带零个或多个参数。下面的例子就是重载函数调用运算符“( )”来实现: f(x,y)= (x2-5)(y+8) 例6.6(P166) 分析:该程序中,定义了F类的两个对象f1和f2。在程序中出现的表达式 f1(2.7,6.2) 和 f2(3.3,7.8) 分别被编译程序解释为: f1.operater( )(2.7,6.2) 和 f2.operater( )(3.3,7.8) 6.2.5 重载运算符( )
6.3 派生类与基类的转换 派生类与基类之间的转换主要涉及三个方面:派生类与基类对象之间的转换、派生类与基类对象指针之间的转换、派生类与基类对象成员指针之间的转换。 6.3.1 派生类对象与基类对象之间的转换 在继承的关系下,派生类的对象可以直接赋值给其public基类的对象,而不须经过任何转换。 如:
class Base { ... }; class Derived: public Base { ... }; void main() { Derived devobj; Base basobj=devobj; //将派生类对象直接赋给 基类的对象 ... } 此项转换只限于当基类为public时,对于private的基类则不适用:
class PubBase { ... }; class PriBase { ... }; class Derived: public PubBase, private PriBase { ... }; void main( ) { Derived devobj; PubBase basobj=devobj; //正确 PriBase priobj=devobj; //错误 ... }
C++之所以允许这样的赋值法乃是因为每一个派生类对象中皆含有一个public基类的对象,使得这样的赋值语句是安全的: Base basobj = devobj; 上述赋值说明对象basobj永远可以使用到派生类所指定过来的public基类部分。相反地,若将一基类的对象赋值给派生类对象,则程序员必须明确使用强制类型转换方式来实现赋值,如: devobj=(Derived )basobj;
事实上,不只是派生类的对象可以直接赋值给其public对象,派生类对象的引用和指针也可以直接赋值给其指向public基类对象的引用和指针变量。 指向基类和派生类的指针是相关的,假设B_class是基类,D_class是从B_class公有派生出来的派生类,在C++中,任何被说明为指向B_class的指针也可以指向D_class。 6.3.2 派生类对象指针与基类对象指针之间的转换 例如:B_class *p,b_obj; D_class d_obj; p=&b_obj; p=&d_obj;
可以用一个指向基类的指针指向其公有派生类的对象。但是相反却不正确,即不能用指向派生类的指针指向一个基类的对象。如果希望用指向基类的指针访问其公有派生类对象的特定成员,必须将基类指针用强制类型转换方式转换为派生类指针。可以用一个指向基类的指针指向其公有派生类的对象。但是相反却不正确,即不能用指向派生类的指针指向一个基类的对象。如果希望用指向基类的指针访问其公有派生类对象的特定成员,必须将基类指针用强制类型转换方式转换为派生类指针。 下面看一个实例cpp6-6。 一个指向基类的指针可用来指向从基类公有派生的任何对象,这是C++实现虚函数与多态性的关键途径。
6.4 虚函数与多态性 在前面的介绍中,对于某一个类的普通成员函数的重载,可以通过以下三个方面进行区分: ①若成员函数的参数类型和个数不同,则可以根据参数的特征来区分, 这是一般的函数重载。如: show(int, char); show(char * , float); ②使用类名和作用域运算符来区分。如: Circle::show(); Message::show();
③通过对象来区分。如: mcobj.show(); 所有上述这些函数的区分都是在编译时进行的,称为早期匹配或静态联编。 除此之外,C++还提供了一种更为灵活的机制来解决函数匹配问题,它允许show()函数的使用与show()的实现版本之间的联系推迟到程序运行时进行,称为晚期匹配或动态联编(滞后联编)。 虚函数正是解决这些问题的关键概念。
在继承体系中,如果在派生类中要对所继承的成员函数重新定义其功能时,该函数应在基类中被定义为虚函数,即在成员函数定义时在其前面加上关键字Virtual。当调用此成员函数时,C++系统能自动判别应该调用哪一个类对象的成员函数。在继承体系中,如果在派生类中要对所继承的成员函数重新定义其功能时,该函数应在基类中被定义为虚函数,即在成员函数定义时在其前面加上关键字Virtual。当调用此成员函数时,C++系统能自动判别应该调用哪一个类对象的成员函数。 6.4.1 虚函数的概念 因此,虚函数是一种单界面多实现版本的方法,即函数名、返回类型、参数类型和个数及顺序完全相同,但函数体内容可以完全不同。
前面我们说过,如果在派生类中重新定义了一个与基类中成员函数同名的成员函数,则基类的成员函数将被隐藏起来,但它仍然存在,且可以通过基类名和作用域运算符来使用。前面我们说过,如果在派生类中重新定义了一个与基类中成员函数同名的成员函数,则基类的成员函数将被隐藏起来,但它仍然存在,且可以通过基类名和作用域运算符来使用。 如果采用指向对象的指针(或引用)来调用,则指向基类的指针永远只能调用属于基类的成员函数,而指向派生类的指针永远只能调用属于派生类自己的成员函数。 看下面的例子cpp6-7。
从运行结果来看: (1) 指向基类的指针p,不管赋给它的是基类对象bobj的地址还是派生类对象fobj和sobj的地址,p->who()调用的始终是基类中定义的版本。 (2) 指向派生类的指针fp和sp,它们调用的who()是各自的派生类内重新定义的who()版本。 (3) 无论是指向基类的指针还是指向派生类的指针,当用强制类型转换后,所调用的who()函数就变成了转换后的类类型的who()版本。
前面我们说过,一个指向基类的指针可直接用来指向从基类公有派生的任何对象,因此我们能不能用一个指向基类的指针变量,通过赋给它的对象地址的改变,达到同一个调用语句实现不同版本的函数调用? 例如上例中,(4)和(6)语句能够调用派生类First_d和Second_d各自的who()版本。 为了实现这个愿望,C++提出了虚函数的概念,它是在基类中成员函数的定义时在其前面加上virtual关键字(在派生类中重新定义该函数时不需加关键字virtual)。
加上virtual后,基类成员函数的版本在派生类中就不再存在了(原来是被隐藏起来),因而在派生类就只有一个成员函数的版本了,那就是那个属于自己的重新定义的版本,并且当把派生类对象的地址(或引用)赋给指向基类的指针(或引用)变量时,用指向基类的指针变量来调用虚函数,则执行的就是派生类自己的函数版本。加上virtual后,基类成员函数的版本在派生类中就不再存在了(原来是被隐藏起来),因而在派生类就只有一个成员函数的版本了,那就是那个属于自己的重新定义的版本,并且当把派生类对象的地址(或引用)赋给指向基类的指针(或引用)变量时,用指向基类的指针变量来调用虚函数,则执行的就是派生类自己的函数版本。 为了验证,我们把上面的例子中的who()函数定义为虚函数,然后重新运行程序,看看结果怎样?
从前三行的结果说明,通过虚函数和指向不同对象的基类指针,C++系统能自动判别应该调用哪一个类对象的成员函数,即同一个语句“p->who(); ”,由于赋给p的对象地址不同,使得能够实现调用不同who()函数版本的方法。由于所调用函数who()的版本是在程序运行时确定的,因此我们称为晚期匹配,也称为运行时的多态性。 6.4.2 多态性的实现 由上可见,在继承体系下,用虚函数实现运行时的多态性的关键之处有三(可称为三要素):
①在基类定义中,必须把成员函数定义为虚函数,即在正常函数定义之前加关键字“virtual”; ②在派生类定义中,对虚函数的重新定义只能修改函数体内容,而函数名、返回类型、参数个数、参数类型及参数顺序必须与基类的定义完全相同。若是返回类型不同,则C++认为是错误的;若是参数不同,则C++认为是一般的函数重载。 ③必须用指向基类的指针(或引用)访问虚函数。其他方式都不能实现运行时的多态性。
注意,在虚函数下,仍然可以用“对象名. ”或“类名::”来调用该虚函数,但此时虚特性丢失。 下述例子cpp6-8说明了虚函数的三要素及虚特性问题。 我们说虚函数与多态性是一种界面多种实现,是C++面向对象程序设计的关键, 它的实现过程是:在基类中用虚函数提供一个界面即定义一个函数原型,这是该类公有派生的对象都具有的共同界面, 在派生类中重新定义该虚函数的函数体内容(实现版本), 然后通过指向基类的指针(或引用)指向不同的派生类对象, 达到访问虚函数的不同实现版本。 下面再看一个虚函数的实例cpp6-9。
虚函数必须是类的成员函数,不能将虚函数说明为全局(非成员的)函数, 也不能说明为静态成员函数。不能把友元函数说明为虚函数,但虚函数可以是另一个类的友元(友元类)。析构函数可以是虚函数, 但构造函数不能为虚函数。另外, 当在构造函数和析构函数中调用虚函数时, 虚函数的虚特性将丢失, 即不能实现动态联编。 一旦一个函数被说明为虚函数,不管经历了多少派生类层次, 都将保持其虚特性。当一个派生类没有重新定义虚函数时,则使用其直接基类的虚函数版本。
6.5 纯虚函数与抽象类 有时我们设计一个基类只是为了作为其他派生类的基类,提供各派生类一个公共的界面,并不需要为这些虚函数提供有实际意义的函数内容,真正的内容是在各派生类中定义的,为此,C++引入了纯虚函数的概念。 6.5.1 纯虚函数 纯虚函数是一个在基类中说明的虚函数,它在该基类中设定为0,要求任何派生类都必须定义自己的函数体内容。纯虚函数的原型为: virtual 返回类型 函数名(参数表)=0;
具有一个以上纯虚函数的类称为抽象类。抽象类只能作为其他派生类的基类,不能建立自己的对象。抽象类也不能作为参数类型、函数返回值类型或显式转换的类型。但可以声明抽象类的指针和引用。具有一个以上纯虚函数的类称为抽象类。抽象类只能作为其他派生类的基类,不能建立自己的对象。抽象类也不能作为参数类型、函数返回值类型或显式转换的类型。但可以声明抽象类的指针和引用。 6.5.2 抽象类 下面看一下实例cpp6-10。 将一虚函数说明为纯虚函数,就要求派生类都重新定义该函数的实现版本。如果不对其定义,则在派生类中该虚函数仍为纯虚函数,因此该派生类也就仍是抽象类。
例如: class ab_circle: public shape { private: int radius; public: void rotate(int){ } }; 由于shape::draw()是一个纯虚函数,缺省的ab_circle::draw()也是一个纯虚函数,所以ab_circle仍为抽象类。 要使ab_circle类为非抽象的,必须作如下说明:
class ab_circle: public shape { private: int radius; public: void rotate(int); void draw(); }; 并提供ab_circle::draw()和ab_circle::rotate(int)的定义。
作业: 1 用虚函数和多态性改写第5章的两个作业。 2 设计一个时间类Time: 有三个成员变量hour、 minute、second, 定义一个Show成员函数能按 “hh:mm:ss”格式输出时间, 定义一个没有参数的 构造函数和一个有三个参数的构造函数, 重载 +、-、++和=运算符。
实例:应用派生类和虚函数设计一个面向对象程序: (1)定义一个抽象基类Circle, 它有一个实型数据成员radius,代表圆的半径;定义并实现一个构造函数Circle (double r1),定义一个求圆面积的虚函数double area()。 (2)从Circle类公有派生一个球体类Sphere,定义并实现一个有一个参数的构造函数Sphere (double r2),实现其求表面积函数area()的具体代码(球体的表面积=4πR2)。 (3)从Circle类公有派生一个圆柱体类Cylinder,定义一个代表高度的实型数据成员height,定义并实现一个有二个参数的构造函数Cylinder (double r1, double h),实现其求表面积函数area()的具体代码(圆柱体的表面积=2πRh)。 (4)在main函数中,定义一个Circle的指针p、一个Circle的对象、一个Sphere对象和一个Cylinder对象,用p指针实现虚函数的多态性,计算并打印出圆、球体和圆柱体的面积。