第六章多态性与虚函数 面向对象的封装性、继承性和多态性是OOP的三大基本支柱。本章将集中讨论类与对 象的多态性概念、设计方法和技巧,这是软件系统能否控制给定对象完成所要求各种动作的 能力问题 本章目的 理解抽象类与多态性 掌握虚函数概念和用法 掌握重载概念和用法 了解系统的编译多态性与运行多态性 多态性( polymorphism)这个词来自希腊语。是指“多形态”的意思。在某些程序设计 语言中,多态性指相同的语言结构可以代表不同类型的实体(如同一变量可以匹配各种类型 的语法结构)或者对不同类型的实体进行操作(如同一个函数可以对不同结构的表进行操 作)。在强类型语言中,多态性表现为重载( overloading)和类属( genericity),又称为参数 化多态( parameterized polymorphism)。在面向对象的概念中,多态性则是指不同对象收到 相同消息时,根据对象类不同产生不同动作。C++允许程序员发送相同消息到不同的相关对 象,而由对象决定如何完成该动作,并且支持软件选择实现决策的时间。其中运行时的多态 是面向对象的程序设计语言所独有的,有人认为,只有与动态联编相结合的多态才是真正的 面向对象的多态。在此,仍然取多态性的广泛含义,但概念重点放在运行多态上 多态性提供了把接口与实现分开的另外一个方法。多态性提高了代码的组织性和可读 性,更重要的是它使软件的可扩充性有了充分的提高,使得软件可以较容易地增加新的特性 和功能。正如在上一章讲的,可以把基类对象和不同的派生类对象在某些时候视为同一类型, 再加上动态联编,就使同一个接口可以在不同的情况下有不同的实现,而实现的增多也不会 影响到接口的形态。 6.1重载与程序设计的多态性设计 具体讲,C++支持两种多态性:编译时的多态性和运行时的多态性。编译时的多态主要 体现为函数重载以及特殊的函数重载—一运算符重载,运行时的多态则由虚函数来完成。在 分别讨论这两种多态性之前,首先要进一步搞清重载的概念和用法 6.1.1函数重载 (1)为什么要重载函数 在自然语言中,除了一词多义之外,即使是同一个动词在不同的情况下,也有细微的判 别,如洗衣服和洗车中的“洗”。这在人类语言中基本上不会引起误会。但在计算机语言中, 每个名称在计算机内部必须是唯一的标识符。如打印数就必须根据需要打印的数来定义不同 的打印函数。这种定义一方面使得程序的可读性变差,使程序员的工作变得复杂,另一方面 也没有反映不同的打印函数之间的共同点 幸运的是,C++提供了函数重载的机制,通过函数的参数数目或类型所建立的附加定义 使同一函数名在计算机内部具有不同的标识符,从而能够表现不同行为。如打印数可以表示 void print(int)
第六章 多态性与虚函数 面向对象的封装性、继承性和多态性是 OOP 的三大基本支柱。本章将集中讨论类与对 象的多态性概念、设计方法和技巧,这是软件系统能否控制给定对象完成所要求各种动作的 能力问题。 本章目的: .理解抽象类与多态性 .掌握虚函数概念和用法 .掌握重载概念和用法 .了解系统的编译多态性与运行多态性 多态性(polymorphism)这个词来自希腊语。是指“多形态”的意思。在某些程序设计 语言中,多态性指相同的语言结构可以代表不同类型的实体(如同一变量可以匹配各种类型 的语法结构)或者对不同类型的实体进行操作(如同一个函数可以对不同结构的表进行操 作)。在强类型语言中,多态性表现为重载(overloading)和类属(genericity),又称为参数 化多态(parameterized polymorphism)。在面向对象的概念中,多态性则是指不同对象收到 相同消息时,根据对象类不同产生不同动作。C++允许程序员发送相同消息到不同的相关对 象,而由对象决定如何完成该动作,并且支持软件选择实现决策的时间。其中运行时的多态 是面向对象的程序设计语言所独有的,有人认为,只有与动态联编相结合的多态才是真正的 面向对象的多态。在此,仍然取多态性的广泛含义,但概念重点放在运行多态上。 多态性提供了把接口与实现分开的另外一个方法。多态性提高了代码的组织性和可读 性,更重要的是它使软件的可扩充性有了充分的提高,使得软件可以较容易地增加新的特性 和功能。正如在上一章讲的,可以把基类对象和不同的派生类对象在某些时候视为同一类型, 再加上动态联编,就使同一个接口可以在不同的情况下有不同的实现,而实现的增多也不会 影响到接口的形态。 6.1 重载与程序设计的多态性设计 具体讲,C++支持两种多态性:编译时的多态性和运行时的多态性。编译时的多态主要 体现为函数重载以及特殊的函数重载——运算符重载,运行时的多态则由虚函数来完成。在 分别讨论这两种多态性之前,首先要进一步搞清重载的概念和用法。 6.1.1 函数重载 (1)为什么要重载函数 在自然语言中,除了一词多义之外,即使是同一个动词在不同的情况下,也有细微的判 别,如洗衣服和洗车中的“洗”。这在人类语言中基本上不会引起误会。但在计算机语言中, 每个名称在计算机内部必须是唯一的标识符。如打印数就必须根据需要打印的数来定义不同 的打印函数。这种定义一方面使得程序的可读性变差,使程序员的工作变得复杂,另一方面 也没有反映不同的打印函数之间的共同点。 幸运的是,C++提供了函数重载的机制,通过函数的参数数目或类型所建立的附加定义, 使同一函数名在计算机内部具有不同的标识符,从而能够表现不同行为。如打印数可以表示 为: void print(int);
void print(float ) void print( char) 函数重载是一种方便的语言机制,它既可以运用于成员函数,也可以运用于一般函数, 成尤其是类构造函数,一般都有是重载的 (2)重载的方法 定义函数重载,函数名字相同,但所带的参数的个数或类型不同,编译器能够根据参数 来调用不同的同名函数。 如果函数的参数类型完全一致,仅仅是返回类型不同,则编译器认为出错。其原因有二 ①当函数的返回值不赋给某一个变量时,系统无法判断应调用哪一个函数:②即使当函数返 回值赋给一个变量时,系统也无法判断这一赋值是否进行了类型转换。如 int hello(; float helloo 当在程序中以下面的方式调用时,就会出现二义性问题 float f hello(; 如果存在着两个完全一致的函数定义,则认为第二个定义覆盖了第一个定义 (3)重载的注意点 函数重载提供了便利,但何时重载函数名收益最大却始终是一个问题。当一组函数执行 相似的操作时,要慎重考虑,是否有其他更好的处理方法。程序员在编写程序时不应着眼于 语言的特征,而应重点解决实际问题。在使用语言的特性时,应遵循应用程序的逻辑结 绝不可滥用语言所拥有的特性 (4)函数重载的例子 构造函数的重载,可以使系统的几个不同的对象初始化方式。 类成员函数的重载,使成员变量处理更有效率。 类外的一般函数也允许重载,常常用于一种多态性。如乘法运算。 [例6.1最大值函数max的重载版本程序EX6_1.CPP。 6.1.2运算符重载 运算符重载是对系统已有预定义的运算符赋予新的含义,用自然的方式将其发展到特殊 应用领域 重载一个运算符,要求满足两个条件:第一,不能改变运算符的初始意义:第二不能改 变运算符的操作的参数数目。运算符重载只是增加了一些与定义它的类相关的附加意义。 表6.1C+中可能重载的运算符 山_+士- i newdelete 注意:(1).::#*?:不能重载
void print(float); void print(char); 函数重载是一种方便的语言机制,它既可以运用于成员函数,也可以运用于一般函数, 成尤其是类构造函数,一般都有是重载的。 (2)重载的方法 定义函数重载,函数名字相同,但所带的参数的个数或类型不同,编译器能够根据参数 来调用不同的同名函数。 如果函数的参数类型完全一致,仅仅是返回类型不同,则编译器认为出错。其原因有二: ①当函数的返回值不赋给某一个变量时,系统无法判断应调用哪一个函数;②即使当函数返 回值赋给一个变量时,系统也无法判断这一赋值是否进行了类型转换。如 int hello(); float hello(); 当在程序中以下面的方式调用时,就会出现二义性问题: float f; f=hello(); 如果存在着两个完全一致的函数定义,则认为第二个定义覆盖了第一个定义。 (3)重载的注意点 函数重载提供了便利,但何时重载函数名收益最大却始终是一个问题。当一组函数执行 相似的操作时,要慎重考虑,是否有其他更好的处理方法。程序员在编写程序时不应着眼于 语言的特征,而应重点解决实际问题。在使用语言的特性时,应遵循应用程序的逻辑结构, 绝不可滥用语言所拥有的特性。 (4)函数重载的例子 构造函数的重载,可以使系统的几个不同的对象初始化方式。 类成员函数的重载,使成员变量处理更有效率。 类外的一般函数也允许重载,常常用于一种多态性。如乘法运算。 [例 6.1]最大值函数 max 的重载版本程序 EX6_1.CPP。 6.1.2 运算符重载 运算符重载是对系统已有预定义的运算符赋予新的含义,用自然的方式将其发展到特殊 应用领域。 重载一个运算符,要求满足两个条件:第一,不能改变运算符的初始意义;第二不能改 变运算符的操作的参数数目。运算符重载只是增加了一些与定义它的类相关的附加意义。 表 6.1 C++中可能重载的运算符 + - * / % ^ & | ~ ! = += -= *= /= %= ^= &= != > >>= = && || ++ -- [ ] ( ) -> new delete 注意:(1). ::# * ?:不能重载
(2)除赋值运算符外,其他运算符函数都可以由派生类继承。并且派生类还可以 有选择地重载自己所需要的运算符(包括基类重载的运算符) 要重载一个运算符,先要创建一个运算符函数( operator function)。一般将运算符函数 定义成类的成员函数或友元函数 定义成员运算符函数的格式如下: return type class name operator @(arg list) operation to per formed 其中, class name是重载运算符的类名,opρ erator是运算符重载的关键字,@是要重载 的运算符符号, arg list是该运算符所需的操作数。 Operator@是函数名,函数的返回类型是 return type 在类说明体内声明运算符函数用如下形式: 例6.2]二元运算符重载程序EX62.CP 6.1.3各种运算符重载设计的问题讨论 下面进一步讨论运算符重载的各类情形,分析它们在使用中应注意的技术内涵和方法上 的问题 6.1.3.1若重载运算符是向对象加上一个数的情况 例6.2a]EX62a.CPP。 6.1.3.2关于重载一和=的方法与上述类似与不同处 [例6.2b]EX62b.CPP 6.1.3.3关系运算符和逻辑运算符重载 重载运算的返回值不是定义该重载运算符的对象,而是一个代表true或 false的整数值。 例6.2c]EX6_2c.CPP 元运算符的重载 重载一元运算符的成员函数没有参数 [例6.2d]EX62d.CPP 6.1.3.5友元运算符重载 可以用友元函数重载运算符,但它与成员运算符函数的主要区别在于: (1)参数个数不同。 (2)成员函数通过thi指针传递运算符左边的操作数,而友元函数则没有该指针,必 须显示传递所有参数。 注意:不能用友元函数重载赋值运算符,而只能用友元函数。 友元函数重载运算符,能够在运算中用对象激活C++固有数据类型 [例6.2e]EX6_2e.CPPc 6.1.3.6存储分配 通过对new和 delete的重载,能够在其外部定义库函数的通用算法的基础上,提高特
(2)除赋值运算符外,其他运算符函数都可以由派生类继承。并且派生类还可以 有选择地重载自己所需要的运算符(包括基类重载的运算符)。 要重载一个运算符,先要创建一个运算符函数(operator function)。一般将运算符函数 定义成类的成员函数或友元函数。 定义成员运算符函数的格式如下: return_type class_name::operator@(arg_list) { //operation to per formed } 其中,class_name 是重载运算符的类名,operator 是运算符重载的关键字,@是要重载 的运算符符号,arg_list 是该运算符所需的操作数。Operator@是函数名,函数的返回类型是 return_type. 在类说明体内声明运算符函数用如下形式: [例 6.2] 二元运算符重载程序 EX6_2.CPP。 6.1.3 各种运算符重载设计的问题讨论 下面进一步讨论运算符重载的各类情形,分析它们在使用中应注意的技术内涵和方法上 的问题。 6.1.3.1 若重载运算符是向对象加上一个数的情况 [例 6.2a] EX6_2a.CPP。 6.1.3.2 关于重载—和=的方法与上述类似与不同处 [例 6.2b] EX6_2b.CPP。 6.1.3.3 关系运算符和逻辑运算符重载 重载运算的返回值不是定义该重载运算符的对象,而是一个代表 true 或 false 的整数值。 [例 6.2c] EX6_2c.CPP。 6.1.3.4 一元运算符的重载 重载一元运算符的成员函数没有参数。如++,--。 [例 6.2d] EX6_2d.CPP。 6.1.3.5 友元运算符重载 可以用友元函数重载运算符,但它与成员运算符函数的主要区别在于: (1) 参数个数不同。 (2) 成员函数通过 this 指针传递运算符左边的操作数,而友元函数则没有该指针,必 须显示传递所有参数。 注意:不能用友元函数重载赋值运算符,而只能用友元函数。 友元函数重载运算符,能够在运算中用对象激活 C++固有数据类型。 [例 6.2e] EX6_2e.CPP。 6.1.3.6 存储分配 通过对 new 和 delete 的重载,能够在其外部定义库函数的通用算法的基础上,提高特
定情况下的效率,用户可以定制自己的内存分配方案。一般的格式是 void*operator new(unsigned int size) ∥size的值是存放对象所需的字节数 return pointer to memor }∥为对象分配空间,会自动调用构造函数 void operator delete( void *p) (//free memory p }∥释放P指向的空间,对象失效会自动调用析构函数 new和 delete重载存在两种方式:全局重载方式和一个类的局部重载方式。但一般都是 对一个类重载,也就是把重载运算符函授数说明为类的成员函数。 [例6.2]new和 delete的重载EX6_2f.CPP 6.1.3.7对象赋值与特殊按位拷贝运算 对于一般情况下的类赋值,使用缺省赋值运算符就可以了。但在如带有指针问题的赋值 的情况下,使用普通赋值运算符会出现指针悬挂错误,需要在类中重载赋值运算符,实行严 格的按位拷贝,将传递内容和传送地址一起进行 例6.2g]对象赋值与特殊按位拷贝运算EX62g.CPP。(?) 6.1.3.8运算符[]和()的重载 运算符()为函数调用算符,运算符[]是下标运算符(变址),这两个运算符不能用友 元函数重载,只能采用成员函数重载 定义形式分别为 type class name operatorI(int index) type class name operator(arg list) 例6.2h下标运算符的重载EX62h.CPP 例6.2]下标运算符的重载EX6_2i.CPP。 6.1.3.9类类型转换函数 当要将类类型转换为基本数据类型时,需要采用显式转换机制,定义类类型转换函数 定义一个类的类型转换函数的语法形式是: class name: operator type eturn type obj/返回type类型对象 使用类类型转换函数,是一个函数调用过程。 例6.2]类类型转换函数EX62j.CPP。 6.1.4编译时的多态 多态性是OOP的一个重要特征。它一方面提供了丰富的逻辑关系清晰的描述对象方法
定情况下的效率,用户可以定制自己的内存分配方案。一般的格式是: void *operator new(unsigned int size) { //size 的值是存放对象所需的字节数 …… return pointer_to_memory; }//为对象分配空间,会自动调用构造函数 void operator delete(void *p) {//free memory p }//释放 P 指向的空间,对象失效会自动调用析构函数 new 和 delete 重载存在两种方式:全局重载方式和一个类的局部重载方式。但一般都是 对一个类重载,也就是把重载运算符函授数说明为类的成员函数。 [例 6.2f] new 和 delete 的重载 EX6_2f.CPP。 6.1.3.7 对象赋值与特殊按位拷贝运算 对于一般情况下的类赋值,使用缺省赋值运算符就可以了。但在如带有指针问题的赋值 的情况下,使用普通赋值运算符会出现指针悬挂错误,需要在类中重载赋值运算符,实行严 格的按位拷贝,将传递内容和传送地址一起进行。 [例 6.2g] 对象赋值与特殊按位拷贝运算 EX6_2g.CPP。(?) 6.1.3.8 运算符[ ]和( )的重载 运算符()为函数调用算符,运算符[ ]是下标运算符(变址),这两个运算符不能用友 元函数重载,只能采用成员函数重载。 定义形式分别为: type class_name::operator[](int index) {……} type class_name::operator()(arg_list) {……} [例 6.2h] 下标运算符的重载 EX6_2h.CPP。 [例 6.2i] 下标运算符的重载 EX6_2i.CPP。 6.1.3.9 类类型转换函数 当要将类类型转换为基本数据类型时,需要采用显式转换机制,定义类类型转换函数。 定义一个类的类型转换函数的语法形式是: class_name::operator type() { …… return type_obj;//返回 type 类型对象 } 使用类类型转换函数,是一个函数调用过程。 [例 6.2j] 类类型转换函数 EX6_2j.CPP。 6.1.4 编译时的多态 多态性是 OOP 的一个重要特征。它一方面提供了丰富的逻辑关系清晰的描述对象方法
的手段,另一方面提高了软件功能和版本进化的设计维护能力。 函数重载和运算符重载函数,构成了支持C++编译多态性的表达基础。在讨论两种不同 的多态性之前,首先了解一下函数绑定( function call binding) 将函数调用与函数体连接起来叫绑定。如果绑定在程序运行之前进行(由编译器和连接 器执行),则称为预绑定( early binding),也叫静绑定。如C语言就只有预绑定。它意味着 绑定基于的信息都是静态的。而面向对象的多态性设计要求能够在运行时,根据对象类型的 不同来选择合适的函数调用,这些类型信息在编译时是不可知的。解决这一问题的绑定是后 绑定( late binding)。 例6.3a]了解预绑定的工作过程EX63a.CPP 预绑定时,编译系统根据指针(或引用)本身的类型,而不是它所指向的对象的类型来 进行绑定。 从以上的分析可见,编译时的多态的实现,取决于程序的静态信息是否足够为相同的程 序实体(指程序代码中的各种名称和代码段)确定不同的标识符。编译时的多态,表现为以 下几方面 (1)对于在一个类中说明的重载,编译系统根据重载函数的参数个数、类型以及顺序的 差别,来分别调用相应的函数。 (2)对于在基类和派生类中的重载函数,即使所带的参数完全相同,由于它们属于不同 的类,在编译时可以根据对象名前缀来加以区别:另一种方法是使用“类名::”前缀,也可 以指示编译器分辨出应该调用哪个类的成员函数 预绑定的实体包括一般函数、重载函数、非虚成员函数和非虚友元函数。调用编译时绑 定的函数,优点是高效率(因为代码优化)。缺点是缺少灵活性,不能满足程序的可扩充性 要求 6.1.5运行时的多态 运行时的多态性是在程序运行时发生的事件,编译器在编译时未确定要调用的函数,必 须根据程序运行所产生的信息来通知调用哪一个函数。这称为后绑定( late binding),是动 态联编方式。 例6.3]建立一个数组,可以处理平面图形(2 D graph)类的对象或其派生类( polygon. circle、line)的对象。这个数组可以通过如下声明实现 2D graph& g[10] 现在的要求是让数组指向的对象在屏幕上显示,即调用成员函数 drawL 如果语言支持的只有预绑定,由需要使用大量的 swicth case语句,并在代码级上进行 类型判别。实现相当麻烦。 如果能用一种简单的表达:g[draw(),而让系统去操心运行时类型的判别,使用起来 就相当方便。要做到这一点,必须有语言机制的支持一一虚函数。C++的虚函数是一种后绑 定的实体。 后绑定的主要优点是提供了程序的灵活性,主要缺点是速度较慢,效率较低
的手段,另一方面提高了软件功能和版本进化的设计维护能力。 函数重载和运算符重载函数,构成了支持 C++编译多态性的表达基础。在讨论两种不同 的多态性之前,首先了解一下函数绑定(function call binding)。 将函数调用与函数体连接起来叫绑定。如果绑定在程序运行之前进行(由编译器和连接 器执行),则称为预绑定(early binding),也叫静绑定。如 C 语言就只有预绑定。它意味着 绑定基于的信息都是静态的。而面向对象的多态性设计要求能够在运行时,根据对象类型的 不同来选择合适的函数调用,这些类型信息在编译时是不可知的。解决这一问题的绑定是后 绑定(late binding)。 [例 6.3a] 了解预绑定的工作过程 EX6_3a.CPP。 预绑定时,编译系统根据指针(或引用)本身的类型,而不是它所指向的对象的类型来 进行绑定。 从以上的分析可见,编译时的多态的实现,取决于程序的静态信息是否足够为相同的程 序实体(指程序代码中的各种名称和代码段)确定不同的标识符。编译时的多态,表现为以 下几方面: (1)对于在一个类中说明的重载,编译系统根据重载函数的参数个数、类型以及顺序的 差别,来分别调用相应的函数。 (2)对于在基类和派生类中的重载函数,即使所带的参数完全相同,由于它们属于不同 的类,在编译时可以根据对象名前缀来加以区别;另一种方法是使用“类名::”前缀,也可 以指示编译器分辨出应该调用哪个类的成员函数。 预绑定的实体包括一般函数、重载函数、非虚成员函数和非虚友元函数。调用编译时绑 定的函数,优点是高效率(因为代码优化)。缺点是缺少灵活性,不能满足程序的可扩充性 要求。 6.1.5 运行时的多态 运行时的多态性是在程序运行时发生的事件,编译器在编译时未确定要调用的函数,必 须根据程序运行所产生的信息来通知调用哪一个函数。这称为后绑定(late binding),是动 态联编方式。 [例 6.3] 建立一个数组,可以处理平面图形(2D_graph)类的对象或其派生类(polygon、 circle、line)的对象。这个数组可以通过如下声明实现: 2D_graph& g[10]; 现在的要求是让数组指向的对象在屏幕上显示,即调用成员函数 draw()。 如果语言支持的只有预绑定,由需要使用大量的 swicth_case 语句,并在代码级上进行 类型判别。实现相当麻烦。 如果能用一种简单的表达:g[i].draw(),而让系统去操心运行时类型的判别,使用起来 就相当方便。要做到这一点,必须有语言机制的支持——虚函数。C++的虚函数是一种后绑 定的实体。 后绑定的主要优点是提供了程序的灵活性,主要缺点是速度较慢,效率较低