第7章 继承与派生
7.1 继承与派生
1、基本概念
派生类从基类继承了各种成员的关系就称为继承。
类的继承是新的类从已有类那里得到已有的特性。从已有的类产生新类的过程就是类的派生。在继承过程中,原有的类或已经存在的用来派生新类的类称为基类或父类,而由已经存在的类派生出的新类则称为派生类或子类。
从派生类的角度,根据它所拥有的基类数目不同,可以分为单继承和多继承。一个类只有一个直接基类时,称为单继承;而一个类同时有多个直接基类时,则称为多继承。
从上面的描述可知,任何一个类都可以派生出一个新类,派生类也可以再派生出新类,因此,基类和派生类是相对而言的,一个基类可以是另一个基类的派生类,从而形成了复杂的继承结构,出现了类的层次。基类与派生类之间的关系如下:
(1)基类是对派生类的抽象,派生类是对基类的具体化。基类抽取了它的派生类的公共特征,而派生类通过增加信息将抽象的基类变为某种有用的类型,派生类是基类定义的延续。
(2)派生类是基类的组合。多继承可以看作是多个单继承的简单组合。
(3)公有派生类的对象可以作为基类的对象处理。这一点与类聚集(成员对象)是不同的,在类聚集(成员对象)中,一个类的对象只能拥有作为其成员的其他类的对象,但不能作为其他类对象而使用。
2、派生类的定义与构成
定义派生类的一般格式如下:
class<派生类名>:<继承方式1><基类名1>,
<继承方式2><基类名2>,
……,
<继承方式n><基类名n>
{
<派生类新定义成员>
};
其中,<基类名>是已有的类的名称,<派生类名>是继承原有类的特性而生成的新类的名称。单继承时,只需定义一个基类;多继承时,需同时定义多个基类。
<继承方式>即派生类的访问控制方式,用于控制基类中声明的成员在多大的范围内能被派生类的用户访问。每一个继承方式,只对紧随其后的基类进行限定。继承方式包括3种:公有继承(public)、私有继承(private)和保护继承(protected)。如果不显式地给出继承方式,缺省的类继承方式是私有继承private.
7.2 派生类的构造函数和析构函数
1、派生类构造函数的一般格式如下:
<派生类名>::<派生类名>(<总参数表>):<基类名1>(参数表1),
……
<基类名n>(<参数表n>),
<成员对象名1>(<参数表n+1>),
……,
<成员对象名m>(<参数表n+m>)
{
<派生类构造函数体>
}
派生类的构造函数名与类名相同。在构造函数的参数表中,给出了初始化基类数据、成员对象数据以及新增的其他数据成员所需要的全部参数。在参数表之后,列出需要使用参数进行初始化的基类名和成员对象名以及各自的参数名,各项之间使用逗号分隔。注意对基类成员和新增成员对象的初始化必须在成员初始化列表中进行。
当派生类有多个基类时,处于同一层次的各个基类的构造函数的调用顺序取决于定义派生类时声明的顺序(自左向右),而与在派生类构造函数的成员初始化列表中给出的顺序无关。如果派生类的基类也是一个派生类,则每个派生类只需负责它的直接基类的构造,依次上溯。
当派生类中有多个成员对象时,各个成员对象构造函数的调用顺序也取决于在派生类中定义的顺序(自上而下),而与在派生类构造函数的成员初始化列表中给出的顺序无关。
建立派生类对象时,构造函数的执行顺序如下:
(1)执行基类的构造函数,调用顺序按照各个基类被继承时声明的顺序(自左向右);
(2)执行成员对象的构造函数,调用顺序按照各个成员对象在类中声明的顺序(自上而下);
(3)执行派生类的构造函数。
派生类的构造函数只有在需要的时候才必须定义。派生类构造函数提供了将参数传递给基类构造函数的途径,以保证在基类进行初始化时能够获得必要的数据。因此,如果基类的构造函数定义了一个或多个参数时,派生类必须定义构造函数。
如果基类中定义了缺省构造函数或根本没有定义任何一个构造函数(此时,由编译器自动生成缺省构造函数)时,在派生类构造函数的定义中可以省略对基类构造函数的调用,即省略“<基类名>(<参数表>)”.成员对象的情况与基类相同。
当所有的基类和成员对象的构造函数都可以省略,并且也可以不在成员初始化列表中对其他数据成员进行初始化时,可以省略派生类构造函数的成员初始化列表。
2、派生类的析构函数
与构造函数相同,派生类的析构函数在执行过程中也要对基类和成员对象进行操作,但它的执行过程与构造函数严格相反,即:
(1)对派生类新增普通成员进行清理。
(2)调用成员对象析构函数,对派生类新增的成员对象进行清理。
(3)调用基类析构函数,对基类进行清理。
派生类析构函数的定义与基类无关,与没有继承关系的类中的析构函数的定义完全相同。它只负责对新增普通成员的清理工作,系统会自己调用基类及成员对象的析构函数进行相应的清理工作。
7.3 多继承与虚基类
1、多继承中的二义性问题
在派生类中对基类成员的访问应该是唯一的。但是,在多继承情况下,可能造成对基类中某个成员的访问出现了不唯一的情况,这时就称对基类成员的访问产生了二义性。
要解决这一问题,有两种方法:
(1)通过作用域运算符(::)明确指出访问的是基类Basel的fun()函数,还是基类Base2的fun()函数。
使用作用域运算符进行限定的一般格式是:
<对象名>.<基类名>::<成员名>
//数据成员
<对象名>.<基类名>::<成员名>(参数表>)
//成员函数
(2)在类中定义同名成员
对于在不同的作用域中声明的标识符的可见性原则是:如果存在两个或多个具有包含关系的作用域,外层声明的标识符如果在内层没有声明同名标识符,那么它在内层可见;如果内层声明了同名标识符,则外层标识符在内层不可见,这时称内层变量覆盖了外层同名变量。
在类的继承层次结构中,基类的成员和派生类新增的成员都具有类作用域,二者的作用范围不同,是相互包含的两个层,派生类在内层。这时,如果派生类定义了一个和某个基类成员同名的新成员(如果是成员函数,则参数表也要相同,参数不同的情况属于重载),派生的新成员就覆盖了外层同名成员,直接使用成员名只能访问到派生类的成员。
2、虚基类的定义
当一个派生类从多个基类派生,而这些基类又有一个共同的基类,当对该基类中说明的成员进行访问时,可能出现二义性。虚基类就是为了解决这种二义性问题提出来的。
在产生二义性问题的第二种情况中,产生二义性的最主要的原因是基类Base在派生类Derived2中产生了两个基类子对象,从而导致了对基类Base的成员data访问的不唯一性。要解决这个问题,只需使这个公共基类Base在派生类中只产生一个子对象即可。虚基类就可以完成这个任务。
虚基类的说明格式如下:
class<类名>:virtual<继承方式><基类名>
其中,关键字virtual与继承方式的位置无关,但必须位于虚基类名之前,且virtual只对紧随其后的基类名起作用。
3、虚基类的构造函数
使用虚基类解决二义性问题的关键是在派生类中只产生一个虚基类子对象。为初始化基类子对象,派生类的构造函数要调用基类的构造函数。对于虚基类,由于派生类的对象中只有一个虚基类子对象,所以,在建立派生类的一个对象时,为保证虚基类子对象只被初始化一次,这个虚基类构造函数必须只被调用一次。虽然继承结构的层次可能很深,但要建立的对象所属的类只是这个继承结构中间的某个类,因此将在建立对象时所指定的类称为最派生类。虚基类子对象由最派生类的构造函数通过调用虚基类的构造函数进行初始化。所以,最派生类的构造函数的成员初始化列表中必须列出对虚基类构造函数的调用;如果未列出,则表示使用该虚基类的缺省构造函数。
由于最派生类总是相对的,因此,从虚基类直接或间接派生的派生类中的构造函数的成员初始化列表中都要列出对虚基类构造函数的调用。但只有用于建立对象的最派生类的构造函数才调用虚基类的构造函数,此时最派生类的所有基类中列出的对虚基类的构造函数的调用在执行过程中都被忽略,从而保证对虚基类子对象只初始化一次。
当在一个成员初始化列表中同时出现对虚基类和非虚基类构造函数的调用时,虚基类的构造函数先于非虚基类的构造函数执行。
7.4 子类型关系
公有继承时,派生类的对象可以作为基类的对象处理,派生类是基类的子类型。
子类型关系使得在需要基类对象的任何地方都可以使用公有派生类的对象来替代,从而可以使用相同的函数统一处理基类对象和公有派生类对象(形参为基类对象时,实参可以是派生类对象),而不必为每一个类设计单独的处理程序,大大提高了程序的效率。它是实现多态性的重要基础之一。
子类型关系的定义如下:
有一个特定的类型S,当且仅当它提供了类型T的行为时,称类型S是类型T的子类型。
公有派生类的对象可以赋值给基类的对象。实际上不仅如此,具有子类型关系的基类和派生类的对象之间满足如下赋值兼容规则:
(1)公有派生类的对象可以赋值给基类的对象,即用公有派生类对象中从基类继承来的成员,逐个赋值给基类对象的成员。
(2)公有派生类的对象可以初始化基类的引用。
(3)公有派生类的对象的地址可以赋值给指向基类的指针。
7.5 虚函数与多态性
1、多态性的概念
一个面向对象的系统常常要求一组具有相同基本语义的方法能在同一接口下为不同的对象服务,这就是所谓多态性(polymorphism)。
在C+ +语言中,多态性可分为两类:编译时的多态性和运行时的多态性。
编译时的多态性是通过函数重载和模板体现的。利用函数重载机制,在调用同名的函数时,编译系统可根据实参的具体情况确定所调用的是同名函数中的哪一个。利用函数模板,编译系统可根据模板实参以及模板函数实参的具体情况确定所要调用的是哪个函数,并生成相应的函数实例;利用类模板,编译系统可根据模板实参的具体情况确定所要定义的是哪个类的对象,并生成相应的类实例。由于有关操作所针对的具体目标(函数或类)的确定都是在编译时完成的,与运行时的动态环境无关,“编译时的多态性”因此而得名,其实现机制则和为静态绑定(static binding,也译作静态联编)。函数重载是“函数”一章中已经学习过的内容,但其中没有包含函数重载的一种特殊情况:运算符重载。
2、虚函数
在成员函数声明的前面加上virtual修饰,即把该函数声明为虚函数。虚函数可以是另一个类的友元函数,但不得是静态成员函数。
在派生类中可以重新定义从基类继承下来的虚函数,从而提供该函数的适用于派生类的专门版本。也可能并不需要重新定义,在这种情况下,继承下来的虚函数仍然保持其在基类中的定义,即派生类和基类使用同一函数版本。除少数特殊情况外,在派生类中重定义虚函数时,函数名、形参表和返回值类型必须保持不变。
虚函数在派生类被重定义后,重定义的函数仍然是一个虚函数,可以在其派生类中再次被重定义。注意,对于虚函数的重定义函数,无论是否用virtual修饰都是虚函数。当然,最好不要省略virtual修饰,以免削弱程序的可读性。
对虚函数的调用有两种方式:非多态调用和多态调用。非多态调用是指不借助于指针或引用的直接调用。非多态调用总是通过成员访问运算符 .进行的。与通常的成员函数调用类似,非多态调用是建立在静态绑定机制的基础之上的,不具备多态性特征。多态调用是指借助于指向基类的指针或引用的调用。在C+ +中,一个基类指针(或引用)可以用于指向它的派生类对象,而且通过这样的指针(或引用)调用虚函数时,被调用的是该指针(或引用)实际所指向的对象类的那个重定义版本。
基类中的实函数也可以在派生类中重定义,但重定义的函数仍然是实函数。在实函数的情况下,通过基类指针(或引用)所调用的只能是基类的那个函数版本,无法调用到派生类中的重定义函数。也就是说,尽管调用的语法形式可能是相同的,但对实函数的任何形式的调用都是非多态的。注意,无论是虚函数还是实函数,在派生类中被重定义后,原来的函数版本即被隐藏,在通过成员访问运算符 .直接调用该函数时,所调用的是重定义版本。但原来的版本依然存在,仍然可以通过在函数名前加域修饰(即:<类名>::)来调用它们。
3、虚析构函数
析构函数也可以通过virtual修饰而声明为虚函数。虚析构函数与一般虚函数的不同之处在于:
(1)重定义函数就是派生类的析构函数,不要求同名。
(2)一个虚析构函数的版本被调用执行后,接着就要调用执行基类版本,依次类推,直到调用执行了派生序列的最开始的那个虚析构函数版本为止。
通常,只要派生类中包含有虚函数的重定义(从而有可能被多态调用),而且对析函数进行了专门的声明(而不是不做任何声明,从而采用默认的析构函数),其基类的析构函数就应当声明为虚函数,否则就可能出问题。
4、纯虚函数与抽象类
在某些情况下,基类无法确定(或无法完全确定)一个虚函数的具体操作方式或内容,只能靠派生类来提供各个具体的实现版本。基类中的这种必须靠派生类提供重定义版本的虚函数称为纯虚函数。为了将一个虚函数声明为纯虚函数,需要在虚函数原形的语句结束符 ;之前加上=0.
拥有纯虚函数的类称为抽象类,抽象类不能用来定义对象。如果一抽象类的派生类没有重定义来自基类的某个纯虚函数,则该函数在派生类中仍然是纯虚函数,这就使得该派生类也成为抽象类。也就是说,一个派生类可以把重定义纯虚函数的任务进一步转交给它自己的派生类。
可以在将一个函数声明为纯虚函数的同时,为该函数提供实现版本。换句话说,一个函数是否为纯虚函数,取决于其原形的尾部是否为“=0”,与实现版本的有无没有什么关系。拥有实现版本的纯虚函数仍然有赖于派生类提供重定义版本。纯虚函数的实现版本通常是不完善的版本,但包含了一些共有操作,供各个派生类在重定义函数中调用。派生类在重定义一个纯虚函数时,可以继续将之声明为纯虚函数。另外,纯虚函数不得声明为内联函数。
北京 | 天津 | 上海 | 江苏 | 山东 |
安徽 | 浙江 | 江西 | 福建 | 深圳 |
广东 | 河北 | 湖南 | 广西 | 河南 |
海南 | 湖北 | 四川 | 重庆 | 云南 |
贵州 | 西藏 | 新疆 | 陕西 | 山西 |
宁夏 | 甘肃 | 青海 | 辽宁 | 吉林 |
黑龙江 | 内蒙古 |