【编程语言】C++继承和派生类、虚基类

时间:2022-09-08 08:17:58

从已有的对象类型出发建立一种新的对象类型,使它部分或全部继承原对象的特点和功能,这是面向对象设计方法中的基本特性之一。继承不仅简化了程序设计方法,显著提高了软件的重用性,而且还使得软件更加容易维护。派生则是继承的直接产物,它通过继承已有的一个或多个类来产生一个新的类,通过派生可以创建一种类族。


继承

基本概念

在定义一个类A时,若它使用了一个已定义类B的部分或全部成员,则称类A继承了类B,并称类B为基类或父类,称类A为派生类或子类。一个派生类又可以作为另一个类的基类,一个基类可以派生出若干个派生类,这样就构成类树。

在C++中有两种继承:单一继承和多重继承。当一个派生类仅由一个基类派生时,称为单一继承;而当一个类由两个或更多个基类派生时,称为多重继承。

C++中派生类从父类继承特性时,可在派生类中扩展它们,或者对其做些限制,也可以改变或删除某一特性,还可对某些特性不作任何修改。所有这些变化可归结为两种基本的面向对象技术:

  • 特性约束,即对父类的特性加以限制或删除;
  • 特性扩展,即增加父类的特性。

单一继承

从一个基类派生一个类的一般格式为:

class 派生类类名: <Access> 基类类名{
    private:
        ...
    public:
        ...
    protected:
        ...
};

其中:Access可有可无,用于规定基类中的成员在派生类中的访问权限,它可以是关键字private、public、protected三者之一。当Access省略的时候,对于类,系统默认约定为private;而对于结构体而言,系统默认约定为public。花括号中的部分是在派生类中新增加的成员数据或成员函数,这部分也可为空。

当Access为public时,称派生类为公有派生;当Access为private时,称派生类为私有派生;当Access为protected时,称派生类为保护派生。派生时指定不同的访问权限,直接影响到基类中的成员在派生类中的访问权限。

公有派生

公有派生时,基类中所有成员在公有派生类中保持各个成员的访问权限。具体地说:

  • 基类中public成员,在派生类中保持public,在派生类中或派生类外部可以直接使用;
  • 基类中private成员,属于基类私有,在派生类中或派生类外部都不能直接使用;
  • 基类中protected成员,可在公有派生类中直接使用,但在派生类外不可直接访问。

例如:

#include <iostream>
using namespace std;

class A {
	private:
		int x;
	protected:
		int y;
	public:
		int z;
		A(int a, int b, int c) { x = a; y = b; z = c; }
		void Setx(int a) { x = a; }
		void Sety(int a) { y = a; }
		int Getx() { return x; }
		int Gety() { return y; }
		void ShowA() {
			cout << x << '\t' << y << '\t' << z << endl;
		}
};

class B : public A {
	private:
		int Length, Width;
	public:
		B(int a, int b, int c, int d, int e) :A(a, b, c) {                //A
			Length = d; Width = e;
		}
		void Show() {
			cout << Length << '\t' << Width << endl;
			cout << Getx() << '\t' << y << '\t' << z << endl;         //B
		}
};

int main()
{
	B b1(1, 2, 3, 4, 5);
	b1.ShowA();
	b1.Show();
	cout << b1.Getx() << '\t' << b1.Gety() << '\t' << b1.z << endl;            //C

	system("pause");
	return 0;
}

解释:A行是在派生类的构造函数中调用基类的构造函数,写法与对象成员比较类似。只不过对象成员是类中有其他类的对象作为成员,而此处为继承;对象成员使用的是对象名,继承为类名。具体的讲解在下面会介绍到。

主要看一下B行和C行。B行是在公有派生类中查看基类的方法,除了private不能直接查看之外,public和protected都能;C行是在公有派生类外查看基类的方法,仅有public的能够直接查看之外,private和protected都不能。

私有派生

对于私有派生类而言,其:

  • 基类中public成员和protected成员在派生类中均变成私有的,在派生类中仍可使用这些成员,而派生类之外均不能;
  • 基类中private成员,在派生类中和派生类外都不可直接使用。

例子:

#include <iostream>
using namespace std;

class A {
	private:
		int x;
	protected:
		int y;
	public:
		int z;
		A(int a, int b, int c) { x = a; y = b; z = c; }
		void Setx(int a) { x = a; }
		void Sety(int a) { y = a; }
		int Getx() { return x; }
		int Gety() { return y; }
		void ShowA() {
			cout << x << '\t' << y << '\t' << z << endl;
		}
};

class B : private A {
	private:
		int Length, Width;
	public:
		B(int a, int b, int c, int d, int e) :A(a, b, c) {                //A
			Length = d; Width = e;
		}
		void Show() {
			cout << Length << '\t' << Width << endl;
			cout << Getx() << '\t' << y << '\t' << z << endl;         //B
		}
};

int main()
{
	B b1(1, 2, 3, 4, 5);
	b1.ShowA();                                                                //D
	b1.Show();
	cout << b1.Getx() << '\t' << b1.Gety() << '\t' << b1.z << endl;            //C

	system("pause");
	return 0;
}

首先:这段程序运行出错!

B行没有问题,基类中的private无法访问,而public和protected都能直接访问。而C行和D行在派生类的外部,都不能直接调用基类的任何方法或成员,出错。

综上所述,在派生类中,继承基类的访问权限可以概括为:

公有或私有派生
派生方式 基类访问权限 派生类中访问权限 派生类外访问权限
public public public 可访问
public protected protected 不可访问
public private 不可访问 不可访问
private

public

private 不可访问
private protected private 不可访问
private private 不可访问 不可访问
抽象类与保护的成员函数

若定义了一个类,该类只能用做基类来派生出新的类,而不能用作定义对象,该类称为抽象类。当对某些特殊的对象要进行很好地封装时,需要定义抽象类。

当把一个类的构造函数和析构函数的访问权限定义为保护的时,这种类为抽象类。

原因:当某个类的构造函数或析构函数的访问权限定义为保护时,在类的外面(如:主函数等)由于无法调用该类的构造函数和析构函数,因此无法产生该类的对象或者撤销对象。而当使用该类作为基类产生派生类时,在派生类中是可以调用其基类的构造函数和析构函数的,因为基类中的保护成员在派生类中一样可以使用。

但:如果将一个类的构造函数和析构函数的访问权限定义为私有的时候,通常这种类是没有任何实际意义的,既不能产生对象,也不能用来产生派生类。

多重继承

用多个基类来派生一个类时,其一般格式为:

class 派生类类名: <Access> 基类类名1, <Access> 基类类名2,..., <Access> 基类类名n {
    private:
        ...
    public:
        ...
    protected:
        ...
};

其中:Access是以限定该基类中的成员在派生类中的访问权限,其规则与单一继承的用法类似。从上述格式可以看出,很容易将单一继承推广到多重继承。


初始化基类成员

在基类中定义了基类的构造函数,并且在派生类中也定义了派生类的构造函数,那么在产生派生类的对象时,一方面系统要调用派生类的构造函数来初始化在派生类中新增的成员数据,另一方面系统也要调用基类的构造函数来初始化派生类中的基类成员。

这种基类的构造函数是由派生类的构造函数来确定的。为了初始化基类成员,派生类的构造函数的一般格式为:

派生类类名(派生类参数列表): 基类类名1(基类1参数列表),  基类类名2(基类2参数列表),..., 基类类名n(基类n参数列表) {
    ...
}

需要注意的是:“派生类参数列表”是带有类型说明的形参表,而“基类参数列表”是不带有类型说明的实参表。其中,“基类参数列表”可以是表达式,可以是“派生类参数列表”下的形参,也可以是各种常量。

如果派生类中包含对象成员,则在派生类的构造函数的初始化成员列表不仅要列举要调用的基类的构造函数,而且要列举调用的对象成员的构造函数。

这里的顺序问题:

  • 如果某派生类有好几个基类,那么各个基类构造函数的调用顺序:在类继承中说明的顺序,与它们在构造函数的初始化成员列表的先后顺序无关;
  • 派生类构造函数、基类构造函数的调用顺序:当说明派生类对象时,系统首先调用个基类的构造函数,对基类成员进行初始化,然后执行派生类的构造函数。若某一个基类仍是派生类,则这种调用基类的构造函数的过程递进下去。当撤销派生类的对象时,析构函数的调用顺序正好与构造函数的顺序相反;
  • 对象成员的构造函数、基类构造函数的调用顺序:先调用基类的构造函数,再调用对象成员的构造函数,最后执行派生类的构造函数。有多个对象成员的条件下,调用这些对象成员的构造函数的顺序取决于它们在派生类中说明的顺序。

重点:在派生类的构造函数的初始化成员列表中,对对象成员的初始化必须使用对象名;而对基类成员的初始化,使用的是对应基类的构造函数名。比如:

Der(int a, int b): Base1(a), Base2(20), b1(200), b2(a+b){
    ...
}

来一条练习题:

#include <iostream>
using namespace std;

class Base1 {
	private:
		int x;
	public:
		Base1(int a) {
			x = a;
			cout << x <<" 调用基类1的构造函数!" << endl;
		}
		~Base1() {
			cout << "调用基类1的析构函数!" << endl;
		}
};

class Base2 {
private:
	int y;
public:
	Base2(int a) {
		y = a;
		cout << y <<" 调用基类2的构造函数!" << endl;
	}
	~Base2() {
		cout << "调用基类2的析构函数!" << endl;
	}
};

class Der : public Base2, public Base1 {                                             //A
	private:
		int z;
		Base1 b1;                                                            //B
		Base2 b2;                                                            //C
	public:
		Der(int a, int b) :Base1(a), Base2(20), b2(200), b1(a + b) {         //D
			z = b;
			cout << "调用派生类的构造函数!" << endl;
		}
		~Der() {
			cout << "调用派生类的析构函数!" << endl;
		}
};

int main()
{
	Der d(100, 200);

	system("pause");
	return 0;
}

这段程序的运行结果为:

20 调用基类2的构造函数!
100 调用基类1的构造函数!
300 调用基类1的构造函数!
200 调用基类2的构造函数!
调用派生类的构造函数!
调用派生类的析构函数!
调用基类2的析构函数!
调用基类1的析构函数!
调用基类1的析构函数!
调用基类2的析构函数!
请按任意键继续. . .

解析:派生类构造函数的顺序,首先基类构造函数,再对象成员构造函数。基类构造函数顺序看A行,对象成员构造函数顺序看B、C行,而不是看初始化成员列表D行。同时注意一下,在D行,基类构造函数用类名,对象成员构造函数用对象名。


冲突、支配规则和赋值兼容性

冲突

若一个公有的派生类是由两个或多个基类派生,当基类中成员的访问权限为public,且不同基类中的成员具有相同的名字时,出现了重名的情况。这是在派生类使用到基类中的同名成员时,出现了不唯一性,这种情况称为冲突。

例如:

class A {
	public:
		int x;
		void Show() {
			cout << x << endl;
		}
};

class B {
	public:
		int x;
		void Show() {
			cout << x << endl;
		}
};

class C :public A, public B {
	public:
		void Setx(int a) {
			x = a;                    //A出错
		}
};

此时,在A行编译器无法确定要访问的时基类A中的x,还是基类B中的x,即出现编译出错。

解决这种冲突的办法有三种:

  • 使得各个基类中的成员名各不相同,但并不是最优解决方法;
  • 在各个基类中,把成员数据的访问权限说明为私有的,并在相应的基类中提供公有的成员函数来对这些成员数据进行操作,但这只适合于成员数据,不适合成员函数;
  • 使用作用域运算符“::”来限定所访问的是属于哪一个基类的。

作用域运算符的一般格式为:

类名:: 成员名

其中:类名可以是任一基类或派生类的类名,成员名只能是成员数据名或成员函数名。

当把派生类作为基类,又派生出新的派生类时,这种限定作用域的运算符不能嵌套使用,也就是说,如下形式的使用方式是不允许的:

类名1:: 类名2:: ...:: 成员名

例如:

class A {
	public:
		int x;
		void Show() {
			cout << x << endl;
		}
};

class B {
	public:
		int x;
		void Show() {
			cout << x << endl;
		}
};

class C :public A, public B {
	public:
		void Setx(int a) {
			A::x = a;                    //作用域运算符
		}
};

支配规则

在C++中,允许派生类中新增加的成员名与其基类的成员名相同,这种同名并不产生冲突。当没有使用作用域运算符时,则派生类中定义的成员名优先于基类中的成员名,这种优先关系称为支配规则。

有点类似于“局部优先”的意思。

例如:

#include <iostream>
using namespace std;

class A {
	public:
		int x;
		void Showx() {
			cout << x << endl;
		}
};

class B {
	public:
		int x;
		void Showx() {
			cout << x << endl;
		}
		int y;
		void Showy() {
			cout << y << endl;
		}
};

class C :public A, public B {
	public:
		int y;
		void Showy() {
			cout << y << endl;
		}
};

int main()
{
	C c1;
	c1.A::x = 100;                        //冲突,基类A的x
	c1.B::x = 100;                        //冲突,基类B的x
	c1.y = 200;                           //支配,派生类中的y
	c1.B::y = 100;                        //支配,基类B的y

	system("pause");
	return 0;
}

基类和对象成员

任一基类在派生类中只能继承一次,否则就会造成成员名的冲突。比如:

class A {
    public:
        float x;
};

class B :public A, public A{                //发生冲突
    ...
};

此时在派生类B中包含两个继承来的成员x,会在使用的时候产生冲突。为了避免这样的冲突,C++规定同一基类只能继承一次。

若在类B中,确实要有两个类A的成员,则可用类A的两个对象作为类B的成员。例如:

class B {
    A a1, a2;
};

把一个类作为派生类的基类或把一个类的对象作为一个类的成员,从程序的执行效果上看是相同的,但在使用上是有区别的:在派生类中可直接使用基类的成员(访问权限允许的话),但要使用对象成员的成员时,必须在对象名后面加上成员运算符“.”和成员名。

赋值兼容规则

在同类型的对象之间可以相互赋值,那么派生类的对象与其基类的对象之间能否相互赋值呢?这里就要讨论赋值兼容规则。

简答的说:对于公有派生类来说,可以将派生类的对象赋给其基类的对象,反之是不允许的。

例如:

#include <iostream>
using namespace std;

class A {
	public:
		int x;
};

class B {
	public:
		int y;
};

class C :public A, public B {
	public:
		int y;
};

int main()
{
	C c1, c2, c3;
	A a1, *pa1;
	B b1, *pb1;

	system("pause");
	return 0;
}

赋值兼容与限制可归结为以下四点:

  • 派生类的对象可以赋值给基类的对象,系统是将派生类对象中从对应基类中继承来的成员赋给基类对象。例如:
a1 = c1;                //将c1中从a1继承下来的对应成员(x)赋值给a1的对应成员

  • 不能将基类的对象赋值给派生类对象。例如:
c2 = a1;                //错误

  • 可以将一个派生类对象的地址赋给基类的指针变量。例如:
pa1 = &c2;
pb1 = &c3;

  • 派生类对象可以初始化基类的引用。例如:
B &rb = c1;

注意:在后两者的情况下,使用基类的指针或引用时,只能访问从相应基类中继承来的成员,而不允许访问其他基类的成员或在派生类中增加的成员。


虚基类

在C++中,假定已定义了一个公共的基类A,类B由类A公有派生,类C也由类A公有派生,而类D是由类B和类C共同公有派生。显然在类D中包含类A的两个拷贝(实例)。这种同一个公共的基类在派生类中产生多个拷贝不仅多占用了存储空间,而且可能会造成多个拷贝中数据不一致。

例如:

#include <iostream>
using namespace std;

class A {
	public:
		int x;
		A(int a = 0){
			x = a;
		}
};

class B : public A {
	public:
		int y;
		B(int a = 0, int b=0):A(b) {
			y = a;
		}
		void PB() {
			cout << x << ' ' << y << endl;
		}
};

class C : public A {
	public:
		int z;
		C(int a = 0, int b = 0) :A(b) {
			z = a;
		}
		void PC() {
			cout << x << ' ' << z << endl;
		}
};

class D :public B, public C {
	public:
		int m;
		D(int a, int b, int d, int e, int f) :B(a, b), C(d, e) {
			m = f;
		}
		void Print() {
			PB();
			PC();
			cout << m << endl;
		}
};

int main()
{
	D d1(100, 200, 300, 400, 500);
	d1.Print();

	system("pause");
	return 0;
}

这段程序的运行结果为:

200 100
400 300
500
请按任意键继续. . .

从结果可以看出,在类D中包含公共基类A的两个不同的拷贝(实例)。

但如果将类D改成如下:

class D :public B, public C {
	public:
		int m;
		D(int a, int b, int d, int e, int f) :B(a, b), C(d, e) {
			m = f;
		}
		void Print() {
			cout << x << ' ' << y << endl;                //出错
			cout << x << ' ' << z << endl;                //出错
			cout << m << endl;
		}
};

此时编译器无法确定成员x时从B类继承过来的,还是C类继承过来的,产生了冲突。可使用作用域运算符“::”来限定成员是属于类B或类C。也就是将程序改为:

class D :public B, public C {
	public:
		int m;
		D(int a, int b, int d, int e, int f) :B(a, b), C(d, e) {
			m = f;
		}
		void Print() {
			cout << B::x << ' ' << y << endl;
			cout << C::x << ' ' << z << endl;
			cout << m << endl;
		}
};

需要注意的是,这里使用的是B::x和C::x,而不是B::A::x和C::A::x。也就是和上文冲突的区别:

冲突是派生类继承的两个基类中含有相同名称的成员数据,而此处是派生类继承的两个基类同时又继承同一个基类。

上面都是派生类中包含同一基类的两个拷贝,这样极有可能造成使用上的冲突。那么,在多重派生的过程中,若欲使公共的基类在派生类中只有一个拷贝,怎么来实现呢?

可以将这种基类说明为虚基类。在派生类的定义中,只要在其基类的类名前加上关键字virtual,就可以将基类说明为虚基类。其一般格式为:

class 派生类类名:virtual <Access> 基类类名{
    ...
}

class 派生类类名:<Access> virtual 基类类名{
    ...
}

其中:关键字virtual可放在访问权限之前,也可以放在访问权限之后,而且该关键字只对紧随其后的基类名起作用。

例如:

#include <iostream>
using namespace std;

class A {
	public:
		int x;
		A(int a = 0){                                        //A
			x = a;
		}
};

class B : virtual public A {                                        //B
	public:
		int y;
		B(int a = 0, int b=0):A(b) {
			y = a;
		}
		void PB() {
			cout << x << ' ' << y << endl;
		}
};

class C : public virtual A {                                        //C
	public:
		int z;
		C(int a = 0, int b = 0) :A(b) {
			z = a;
		}
		void PC() {
			cout << x << ' ' << z << endl;
		}
};

class D :public B, public C {
	public:
		int m;
		D(int a, int b, int d, int e, int f) :B(a, b), C(d, e) {        //D
			m = f;
		}
		void Print() {
			PB();
			PC();
			cout << m << endl;
		}
};

int main()
{
	D d1(100, 200, 300, 400, 500);                                            //E
	d1.Print();
	d1.x = 600;                                                               //F
	d1.Print();

	system("pause");
	return 0;
}

这段程序的运行结果为:

0 100
0 300
500
600 100
600 300
500
请按任意键继续. . .

首先,从这段程序的输出可以看出:在派生类D中的对象d1中仅仅只有基类A的一个拷贝(原因:当F行改变了基类中成员x的值之后,接下来基类B和C的输出结果同时都改变了)。这是因为在B行和C行都定义为了虚基类。

其次,还可以看出一个好玩的现象:x的初值为0。

当没有定义成虚基类的时候,在类D中包含公共基类A的两个不同的拷贝(实例),所以在E行初始化的时候,两份拷贝一份为200,一份为400。一旦变成虚基类仅有一个拷贝的时候,保留哪一个?还是怎么处理这个问题呢?

解释:调用虚基类的构造函数的方法与调用一般基类的构造函数的方法是不同的。由虚基类经过一次或多次派生出来的派生类,在其每一个派生类的构造函数的成员初始化列表中,都必须给出对虚基类的构造函数的调用;如果未列出,则调用虚基类的缺省的构造函数。在这种情况下,在虚基类的定义中必须要有缺省的构造函数。

可能还会有点理解不了,下面就结合实例分析一下:

在程序的D行,类D的构造函数尽管调用了其基类B和基类C的构造函数,但由于虚基类A在D中只有一个拷贝,所以编译器也无法确定应该由B类的构造函数还是C类的构造函数来调用类A的构造函数。在这种情况下,编译器约定,在执行类B和类C的构造函数时都不调用虚基类A的构造函数,而是在类D的构造函数中直接调用虚基类A的构造函数。但由于类D的构造函数中并没有调用,所以就调用虚基类A的缺省构造函数(也就是A行)。所以x的初值为0。

若将A类改成:

class A {
	public:
		int x;
		A(int a){
			x = a;
		}
};

重新编译时,会出现错误。原因是:D行未给出虚基类A的构造函数,基类A有没有缺省的构造函数。

若将D类改成:

class D :public B, public C {
	public:
		int m;
		D(int a, int b, int d, int e, int f) :B(a, b), C(d, e), A(1000) {
			m = f;
		}
		void Print() {
			PB();
			PC();
			cout << m << endl;
		}
};

那么,此时就不会调用基类A的缺省构造函数,而是直接调用基类A的构造函数来进行1000的赋值。

最后再次强调:用虚基类进行多重派生时,若虚基类没有缺省的构造函数,则在派生的每一个派生类的构造函数的初始化成员列表中,必须有对虚基类构造函数的调用!