C++程序设计:继承与派生
在客观世界中,很多事物都不是孤立存在的,它们之间有着千丝万缕的联系,继承便是其中一种。比如,孩子会继承父母的特点,同时又会拥有自己的特点。面向对象程序设计提供了继承机制,可在原有类的基础上,构造功能强大的新类,实现代码重用,从而提高软件开发效率。
一、继承
所谓继承,就是从“先辈”处获得特性,它是客观世界中,事物之间的一种重要关系。例如,脊椎动物和无脊椎动物都属于动物,在程序中便可以描述为:脊椎动物和无脊椎动物继承自动物;同时,哺乳动物和两栖动物继承自脊椎动物,而节肢动物和软体动物继承自无脊椎动物。这些动物之间会形成一个继承体系,如下图所示。
在C++中,继承就是在原有类的基础上产生出新类,新类会继承原有类的所有属性和方法。原有的类称为基类或父类,新类称为派生类或子类。派生类同样可以作为基类派生出新类。在多层次继承结构中,派生类上一层的基类称为直接基类,隔层次的基类称为间接基类。例如,在上图中,哺乳动物的直接基类是脊椎动物,间接基类是动物。
1.1、继承的定义方式
在C++中,声明一个类继承另一个类的格式如下所示:
1 |
|
从上述格式可以看出,派生类的定义方法与普通类基本相同,只是在派生类名称后,添加了冒号、继承方式和基类名称。在类的继承中,有以下几点需要注意。
- 基类的构造函数与析构函数不能被继承。
- 对基类成员的继承没有选择权,不能选择继承或不继承某些成员。
- 派生类中可以增加新的成员,用于实现新功能,保证派生类的功能在基类基础上有所扩展。
- 一个基类可以派生出多个派生类;一个派生类也可以继承自多个基类。
通过继承,基类中的所有成员(构造函数和析构函数除外)被派生类继承,成为派生类成员。在此基础上,派生类还可以增加新的成员。基类和派生类之间的关系,如下图所示。
为了更好地理解和掌握继承的概念,下面通过案例,演示派生类的定义与调用,代码如下。
1 |
|
上述代码,第5~10行代码定义了一个动物类Animal,该类中有一个成员函数move(),用于表示动物的行为;第19~29行代码定义了一个猫类Cat,该类公有继承自Animal类;第43行代码,在main()函数中创建了猫类对象cat;第48行代码,通过对象cat调用基类成员函数move();第50行代码,通过对象cat调用Cat类成员函数walk()。
在上述代码中,Cat类中并没有定义move()函数,但是Cat类继承了Animal类,它会继承Animal类的move()函数,因此Cat类对象能够调用move()函数。Cat类与Animal类的继承关系,如下图所示。
注:上图中,空心箭头表示继承关系;“+”号表示成员访问权限为public,“-”号表示成员访问权限为private。如果成员访问权限为protected或友元,则用“#”号表示。
1.2、继承方式与访问权限
从基类继承的成员,其访问属性除了成员自身的访问属性,还受继承方式的影响。类的继承方式主要有三种:public(公有继承)、protected(保护继承)和private(私有继承)。不同的继承方式会影响基类成员在派生类中的访问权限。下面分别介绍这三种继承方式。
1.2.1、public(公有继承)
采用公有继承方式时,基类的公有成员和保护成员在派生类中仍然是公有成员和保护成员,其访问属性不变,可以使用派生类对象访问基类公有成员和保护成员。但是,基类的私有成员在派生类中变成了不可访问成员。如果基类中有从上层基类继承过来的不可访问成员,则基类的不可访问成员在它的派生类中同样是不可访问的。
公有继承,对派生类所继承成员的访问权限的影响,如下表所示。
注:不可访问成员,是指无论在类内还是在类外均不可访问的成员。它与私有成员的区别是,私有成员在类外不可访问,只能通过类的成员进行访问。不可访问成员完全是由类的派生形成的。对于顶层类,不存在不可访问成员,但是通过继承,基类的私有成员在派生类中就成为不可访问成员。
1.2.2、protected(保护继承)
采用保护继承方式时,基类的公有成员和保护成员在派生类中全部变成保护成员,派生类的其他成员可以直接访问它们,在派生类外无法访问。基类的私有成员和不可访问成员在派生类中的访问属性是不可访问。
保护继承,对派生类所继承成员的访问权限的影响,如下表所示。
1.2.3、private(私有继承)
采用私有继承方式时,基类的公有成员和保护成员在派生类中全部变成私有成员,派生类的其他成员可以直接访问它们,在派生类外无法访问。基类的私有成员和不可访问成员在派生类中的访问属性是不可访问。
私有继承,对派生类所继承成员的访问权限的影响,如下表所示。
与保护继承相比,在直接派生类中,私有继承与保护继承的作用实际上是相同的,在派生类外,不可访问任何基类成员;在派生类内,可以通过其他成员访问继承的基类公有成员和保护成员。但是,如果再以派生类为基类派生新类,对于保护继承方式,派生类中的保护成员在新类中仍然是保护成员,类内的其他成员可以访问;对于私有继承方式,派生类中的私有成员在新类中变成了不可访问成员,实际上就终止了基类功能在派生类中的延伸。
1.2.4、案例
下面通过案例,演示不同继承方式对派生类所继承成员的访问权限的影响,代码如下。
1 |
|
上述代码,第5~22行代码定义了学生类Student,该类声明了私有成员变量_name表示姓名,保护成员变量_grade表示年级。Student类还定义了4个公有成员函数,分别用于设置、获取学生姓名和年级。第49~49行代码定义大学生类Undergraduate公有继承Student类。Undergraduate类定义了私有成员变量_major表示学生专业,此外,还定义了构造函数和显示学生信息的show()函数。第78~86行代码,在main()函数中创建Undergraduate类对象stu,并通过对象stu调用基类的setGrade()函数、setName()函数,用来设置学生的年级和姓名。第88行代码通过对象stu调用show()函数显示学生信息。
需要注意的是,上述代码,第73行代码,在Undergraduate类的show()函数内部直接访问了从基类继承过来的保护成员_grade,因为Undergraduate类是公有继承Student类,_grade在派生类Undergraduate中也是保护成员,所以可以通过成员函数show()访问。但是,show()函数无法直接访问从基类继承过来的_name成员,因为_name是基类的私有成员,在派生类中,_name变成了派生类的不可访问成员。所以在show()函数中只能通过基类的公有成员函数getName()访问_name成员。如果在show()函数中直接访问从基类继承过来的_name成员,程序会报错。例如,若在show()函数中添加如下代码:
1 |
|
再次运行程序,编译器会报错,如下图所示。
Undergraduate类与Student类之间的公有继承关系,可以用下图表示。
若将上述代码中的Undergraduate类,改为保护继承Student类,再次运行程序,此时编译器会报错,如下图所示。
由图可知,Undergraduate保护继承Student类,Student类的setGrade()函数和setNam e()函数就变成了Undergraduate类的保护成员,保护成员在类外不能访问,因此编译器会报错。此时,Undergraduate类与Student类之间的保护继承关系,可以用下图表示。
1.3、继承与类型兼容
不同类型的数据,在一定条件下可以进行转换。比如,int n='a'
是将字符’a’赋值给整型变量n,在赋值过程中发生了隐式类型转换,字符类型数据被转换为整型数据。这种现象称为类型转换,也称为类型兼容。
在C++中,基类与派生类之间也存在类型兼容。通过公有继承,派生类获得了基类除构造函数、析构函数之外的所有成员。公有派生类实际上就继承了基类所有公有成员。因此,在语法上,公有派生类对象总是可以充当基类对象,即可以将公有派生类对象赋值给基类对象,在用到基类对象的地方可以用其公有派生类对象代替。
C++中的类型兼容,主要有以下几种使用场景:
- 使用公有派生类对象为基类对象赋值。
- 使用公有派生类对象为基类对象的引用赋值。
- 使用公有派生类对象的指针为基类指针赋值。
- 如果函数的参数是基类对象、基类对象的引用、基类指针,则函数在调用时,可以使用公有派生类对象、公有派生类对象的地址作为实参。
为了更深入地理解C++类型兼容规则,下面通过案例,演示基类与派生类之间的类型兼容,代码如下。
1 |
|
上述代码,第5~15行代码定义了Base类,该类有一个保护成员变量_name;此外Base类还定义了构造函数和普通成员函数show()。第31~38行代码定义了Derive类,Derive类公有继承Base类;Derive类中定义了构造函数和普通成员函数display()。第55~58行代码定义了一个函数func(),该函数有一个Base类的指针作为参数,在函数内部,通过Base类指针调用show()函数。
第64行代码,在main()函数中创建了Derive类对象derive;第67行代码创建Base类对象base,使用对象derive为其赋值;第69行代码创建Base类对象的引用,使用derive对象为其赋值;第71行代码定义Base类指针,取对象derive的地址为其赋值。第74~78行代码分别通过Base类对象、Base类对象的引用、Base类指针调用show()函数;第80行代码调用func()函数,并取对象derive的地址作为实参传递。
Derive类与Base类的继承关系,如下图所示。
需要注意的是,虽然可以使用公有派生类对象代替基类对象,但是通过基类对象只能访问基类的成员,无法访问派生类的新增成员。在上述代码中,如果通过基类对象base、基类对象的引用qbase、基类指针pbase访问display()函数,示例代码如下:
1 |
|
添加上述代码之后,再次运行程序,编译器会报错。
二、派生类
在继承过程中,派生类不会继承基类的构造函数与析构函数,为了完成派生类对象的创建和析构,需要在派生类中定义自己的构造函数和析构函数。除了构造函数和析构函数,派生类会继承基类其他所有成员,但派生类还会新增成员,当派生类新增的成员函数与从基类继承的成员函数重名时,派生类的成员函数会覆盖基类的成员函数。
2.1、派生类的构造函数与析构函数
派生类的成员变量包括从基类继承的成员变量和新增的成员变量,因此,派生类的构造函数除了要初始化派生类中新增的成员变量,还要初始化基类的成员变量,即派生类的构造函数要负责调用基类的构造函数。派生类的构造函数,定义格式如下所示:
1 |
|
在定义派生类构造函数时,通过在“:”运算符后完成基类构造函数的调用。基类构造函数的参数,从派生类构造函数的参数列表中获取。关于派生类构造函数的定义,有以下几点需要注意。
-
如果基类定义了有参构造函数,派生类必须定义构造函数,提供基类构造函数的参数,完成基类成员变量的初始化。
-
如果基类没有构造函数或仅存在无参构造函数,则在定义派生类构造函数时可以省略对基类构造函数的调用。
-
派生类构造函数与基类构造函数的调用顺序是,先调用基类构造函数,再调用派生类构造函数。
-
在派生类构造函数的参数列表中,需要包含派生类新增成员变量和基类成员变量的参数值。调用基类构造函数时,基类构造函数从派生类的参数列表中获取实参,因此不需要类型名。
当派生类含有成员对象时,派生类构造函数除了负责基类成员变量的初始化和本类新增成员变量的初始化,还要负责成员对象的初始化,其定义格式如下所示:
1 |
|
当创建派生类对象时,各个构造函数的调用顺序为:先调用基类构造函数,再调用成员对象的构造函数,最后调用派生类构造函数。注:基类构造函数与成员对象的构造函数的先后顺序(书写顺序),不影响各个构造函数的调用顺序。
除了构造函数,派生类还需要定义析构函数,以完成派生类中新增成员变量的内存资源释放。基类对象和成员对象的析构工作由基类析构函数和成员对象的析构函数完成。如果派生类中没有定义析构函数,编译器会提供一个默认的析构函数。在继承中,析构函数的调用顺序与构造函数相反,在析构时,先调用派生类的析构函数,再调用成员对象的析构函数,最后调用基类的析构函数。
下面通过案例,演示派生类构造函数与析构函数的定义与调用,代码如下。
1 |
|
上述代码,第5~19行代码定义了发动机类Engine,该类定义了两个私有成员变量_type和_power,分别表示发动机型号和功率;此外,Engine类还声明了构造函数、普通成员函数show()和析构函数。其中,show()函数用于显示发动机信息。第22~39行代码,在Engine类外实现各个函数。
第43~57行代码定义了交通工具类Vehicle,该类有一个私有成员变量_name,用于表示交通工具的名称;此外,Vehicle类还声明了构造函数、普通成员函数run()、普通成员函数getName()和析构函数。第60~82行代码在Vehicle类外实现各个函数。
第86~104行代码定义小汽车类Car,Car类公有继承Vehicle类。Car类定义了两个私有成员变量_seats和_color,分别表示小汽车的座位数量和颜色。此外,Car类还包含Engine类对象engine,该成员对象为公有成员变量。除了成员变量,Car类还声明了构造函数、普通成员函数brake()、普通成员函数display()和析构函数。第107~131行代码在Car类外实现各个函数。其中,第107~113行代码实现Car类的构造函数,Car类的构造函数有5个参数,用于初始化成员对象engine、基类Vehicle对象和本类对象。
第137行代码,在main()函数中创建Car类对象car,传入5个参数。第139~143行代码通过对象car调用基类的run()函数、本类的brake()函数和display()函数实现小汽车各种功能。第145行代码通过对象car中的公有成员对象engine调用Engine类的show()函数,显示小汽车发动机信息。
注:虽然公有派生类的构造函数可以直接访问基类的公有成员变量和保护成员变量,甚至可以在构造函数中对它们进行初始化,但一般不这样做,而是通过调用基类的构造函数对它们进行初始化,再调用基类接口(普通成员函数)访问它们。这样可以降低类之间的耦合性。
2.2、在派生类中隐藏基类成员函数
有时,派生类需要根据自身的特点改写从基类继承的成员函数。例如,交通工具都可以行驶,在交通工具类中可以定义run()函数,但是,不同的交通工具其行驶方式、速度等会不同,比如小汽车需要燃烧汽油、行驶速度比较快;自行车需要人力脚蹬、行驶速度比较慢。如果定义小汽车类,该类从交通工具类继承了run()函数,但需要改写run()函数,使其更贴切地描述小汽车的行驶功能。
在派生类中,定义和基类同名的函数,基类同名函数在派生类中就会被隐藏,通过派生类对象调用同名函数时,调用的是改写后的派生类成员函数,基类同名函数不会被调用。如果想通过派生类对象调用基类的同名函数,需要使用作用域限定符“::”指定要调用的函数,或者根据类型兼容规则,通过基类指针调用同名成员函数。
下面通过案例,演示在派生类中隐藏基类成员函数的方法,代码如下。
1 |
|
上述代码,第5~10行代码定义了交通工具类Vehicle,该类声明了普通成员函数run(),用于实现交通工具的行驶功能。第13~16行代码在类外实现run()函数。
第20~25行代码定义了小汽车类Car公有继承交通工具类Vehicle,该类也定义了run()函数,对基类的run()函数进行改写。第28~31行代码实现Car类的run()函数。
第37行代码,在main()函数中创建Car类对象car。第39行代码,通过对象car调用run()函数,此次调用的是Car类改写后的run()函数。第41行代码,通过作用域限定符“::”调用基类的run()函数。第43~44行代码,定义Vehicle类指针pv,取对象car的地址为其赋值。通过pv指针调用run()函数,只能调用Vehicle类的run()函数,无法调用派生类Car改写的run()函数。
需要注意的是,只要是同名函数,无论参数列表和返回值类型是否相同,基类同名函数都会被隐藏。若基类中有多个重载函数,派生类中有同名函数,则基类中所有同名函数在派生类中都会被隐藏。
三、多继承
在实际开发应用中,一个派生类往往会有多个基类,派生类从多个基类中获取所需要的属性,这种继承方式称为多继承。例如水鸟,既具有鸟的特性,能在天空飞翔,又具有鱼的特性,能在水里游泳。
3.1、多继承的定义方式
多继承是单继承的扩展,在多继承中,派生类的定义与单继承类似,其语法格式如下所示:
1 |
|
通过多继承,派生类会从多个基类中继承成员。在定义派生类对象时,派生类对象中成员变量的排列规则是:按照基类的继承顺序,将基类成员依次排列,然后再存放派生类中的新增成员。多继承的示例代码如下所示:
1 |
|
在上述代码中,派生类Derive公有继承Base1类和Base2类,如果定义Derive类对象,则Derive类对象中成员变量的排列方式如下图所示。
3.2、多继承派生类的构造函数与析构函数
与单继承中派生类构造函数类似,多继承中派生类的构造函数除了要初始化派生类中新增的成员变量,还要初始化基类的成员变量。在多继承中,由于派生类继承了多个基类,因此派生类构造函数要负责调用多个基类的构造函数。
在多继承中,派生类构造函数的定义格式如下所示:
1 |
|
在上述格式中,派生类构造函数的参数列表包含了新增成员变量和各个基类成员变量需要的所有参数。定义派生类对象时,构造函数的调用顺序是:首先按照基类继承顺序,依次调用基类构造函数,然后调用派生类构造函数。如果派生类中有成员对象,构造函数的调用顺序是:首先按照继承顺序依次调用基类构造函数,然后调用成员对象的构造函数,最后调用派生类构造函数。
除了构造函数,在派生类中还需要定义析构函数以完成派生类中新增成员的资源释放。析构函数的调用顺序与构造函数的调用顺序相反。如果派生类中没有定义析构函数,编译器会提供一个默认的析构函数。
下面通过案例,演示多继承派生类构造函数与析构函数的定义与调用,代码如下。
1 |
|
上述代码,第5~10行代码定义了木材类Wood,该类定义了构造函数与析构函数。第13~19行代码定义了沙发类Sofa,该类定义了构造函数、析构函数和普通成员函数sit()。第22~28行代码定义了床类Bed,该类定义了构造函数、析构函数和普通成员函数sleep()。
第31~38行代码定义了沙发床类Sofabed,该类公有继承Sofa类和Bed类。Sofabed类中包含Wood类对象pearwood;此外,Sofabed类还定义了构造函数与析构函数。
第43行代码,在main()函数中创建了Sofabed类对象sbed;第46行代码通过对象sbed调用基类Sofa的sit()函数;第48行代码通过对象sbed调用基类Bed的sleep()函数。
对象sbed在创建和析构的过程中,构造函数的调用顺序如下:按照基类的继承顺序,先调用Sofa类构造函数,再调用Bed类构造函数;调用完基类构造函数之后,调用派生类Sofabed中的成员对象(Wood类)的构造函数,最后调用派生类Sofabed的构造函数。在析构时,析构函数的调用顺序与构造函数相反。
3.3、多继承二义性问题
相比单继承,多继承能够有效地处理一些比较复杂的问题,更好地实现代码复用,提高编程效率,但是多继承增加了程序的复杂度,使程序的编写容易出错,维护变得困难。最常见的就是继承过程中,由于多个基类成员同名而产生的二义性问题。多继承的二义性问题包括两种情况,下面分别进行介绍。
3.3.1、不同基类有同名成员函数
在多继承中,如果多个基类中出现同名成员函数,通过派生类对象访问基类中的同名成员函数时就会出现二义性,导致程序运行错误。
下面通过案例,演示派生类对象访问基类同名成员函数时产生的二义性问题,代码如下。
1 |
|
上述代码,第5~9行代码定义了沙发类Sofa,该类定义了公有成员函数rest()。第12~16行代码定义了床类Bed,该类也定义了公有成员函数rest()。第19~23行代码定义了沙发床类Sofabed,该类公有继承Sofa类和Bed类。
第28行代码,在main()函数中创建Sofabed类对象sbed。第31行代码通过对象sbed调用基类的rest()函数,由于基类Sofa和基类Bed中都定义了rest()函数,因此对象sbed调用rest()函数时,会产生二义性问题。
Sofabed类与Sofa类、Bed类的继承关系,如下图所示。
由图可知,在派生类Sofabed中有两个rest()函数,因此在调用时产生了歧义。多继承的这种二义性可以通过作用域限定符“::”,明确指定调用的是哪个基类的函数,可将上述代码的第31行替换为如下代码:
1 |
|
通过上述方式,明确了所调用的函数,即可消除二义性。这需要程序设计者了解类的继承层次结构,增加了开发难度。
3.3.2、间接基类的成员变量在派生类中有多份拷贝
在多继承中,派生类有多个基类,这些基类可能由同一个基类派生。例如,派生类Derive继承自Base1类和Base2类,而Base1类和Base2类又继承自Base类。在这种继承方式中,间接基类的成员变量在底层的派生类中会存在多份拷贝,通过底层派生类对象访问间接基类的成员变量时,会出现访问二义性。
下面通过案例,演示多重继承中成员变量产生的访问二义性问题,代码如下。
1 |
|
上述代码,第5~13行代码定义了家具类Furniture,该类定义了保护成员变量_wood,表示家具材质,还定义了构造函数。第16~19行代码在Furniture类外实现构造函数。
第23~31行代码定义了沙发类Sofa公有继承Furniture类,Sofa类定义了保护成员变量_length,表示沙发长度。此外,Sofa类还定义了构造函数。第34~37行代码在Sofa类外实现构造函数。
第41~49行代码定义了床类Bed公有继承Furniture类,Bed类定义了保护成员变量_width,表示床的宽度;此外,Bed还定义了构造函数。第52~55行代码在Bed类外实现构造函数。
第59~66行代码定义了沙发床类Sofabed,该类公有继承Sofa类和Bed类。Sofabed类、Sofa类、Bed类和Furniture类之间的继承关系,如下图所示。
由图可知,基类Furniture的成员变量_wood在Sofabed类中有两份拷贝,分别通过继承Sofa类和Bed类获得。创建Sofabed类对象时,两份拷贝都获得数据。
在上述代码中,第86行代码创建Sofabed类对象sbed,第89行代码通过对象sbed调用getSize()函数获取沙发床信息。在getSize()函数中,第78行代码通过cout输出_wood成员值,由于sbed对象中有两个_wood成员值,在访问时出现了二义性,因此编译器报错。
为了避免访问_wood成员产生的二义性,必须通过作用域限定符“::”指定访问的是哪个基类的_wood成员。可以将上述代码中的第78行代码替换为如下两行代码:
1 |
|
四、虚继承
在程序设计过程中,通常希望间接基类的成员变量在底层派生类中只有一份拷贝,从而避免成员访问的二义性。通过虚继承可以达到这样的目的。
虚继承,是指派生类在继承基类时,在权限控制符前加上virtual关键字,其格式如下所示:
1 |
|
在上述格式中,在权限控制符前面添加了virtual关键字,就表明派生类虚继承了基类。(注:采用虚继承方式,基类通常称为虚基类。虚基类只针对虚继承,而不针对基类本身。在普通继承中,该基类并不称为虚基类。)
下面通过案例,演示虚继承的作用,代码如下。
1 |
|
上述代码,第23~31行代码定义了沙发类Sofa,Sofa类虚继承Furniture类。第41~49行代码定义床类Bed,Bed类虚继承Furniture类。第59~66行代码定义沙发床类Sofabed,Sofabed公有继承Sofa类和Bed类。
第85行代码创建Sofabed类对象sbed,第88行代码通过对象sbed调用getSize()函数获取沙发床大小。在Sofabed类的getSize()函数中,第78行代码直接访问了_wood成员,但编译器并没有报错。这是因为在对象sbed中只有一个_wood成员数据。
这里需要注意的是,在虚继承中,底层派生类的构造函数不仅负责调用直接基类的构造函数,还负责调用间接基类的构造函数。在整个对象的创建过程中,间接基类的构造函数只会调用一次。
在虚继承中,每个采用虚继承的派生类都会增加一个虚基类指针vbptr,该指针位于派生类对象的顶部。vbptr指针指向一个虚基类表vbtable(不占对象内存),虚基类表中记录了基类成员变量相对于vbptr指针的偏移量,根据偏移量就可以找到基类成员变量。当虚基类的派生类被当作基类继承时,虚基类指针vbptr也会被继承,因此底层派生类对象中成员变量的排列方式与普通继承有所不同。例如,在上述代码中,对象sbed的逻辑存储如下图所示。
在上图中,对象sbed顶部是基类Sofa的虚基类指针和成员变量;紧接着是基类Bed的虚基类指针和成员变量。间接基类Furniture的成员变量_wood,在对象sbed中只有一份拷贝,放在最下面。Sofa类的虚基类指针Sofa::vbptr指向了Sofa类的虚基类表,该虚基类表中记录了_wood与Sofa::vbptr的距离,为16字节;同样,Bed类虚基类表记录了_wood与Bed::vbptr的距离,为8字节。通过偏移量就可以快速找到基类的成员变量。
五、参考
[《C++程序设计教程》](《C++程序设计教程(第2版)》电子书在线阅读-黑马程序员 编著-得到APP (dedao.cn))