目录
5.1 继承和派生的概念
代码重用是提高软件开发效率的重要手段,因此C++对代码重用有很强的支持, “继承” 就是支持代码重用的机制之一。
5.1.1 基本概念
在C++中,当定义一个新的类B时,如果发现类B拥有某个已写好的类A的全部特点,此外还有类A没有的特点,那么就不必从头重写类B,而是可以把类A作为一个 “基类” (也称 “父类” ),把类B写为基类A的一个 “派生类”(也称 “子类” )。这样,就可以说从类A “派生” 出了类B,也可以说类B “继承” 了类A。
派生类是通过对基类进行扩充和修改得到的。基类的所有成员自动成为派生类的成员。所谓扩充,指的是在派生类中可以添加新的成员变量和成员函数;所谓修改,指的是在派生类中可以重写从基类继承得到的成员。
派生类的成员函数不能访问基类的私有成员。
在C++中,从一个类派生出另一个类的写法如下:
class 派生类名:继承方式说明符 基类名
{
...
};
继承方式说明符可以是public(公有继承)、private(私有继承)或protected(保护继承)。一般都使用public。protected或private方式很少用到。
派生类对象占用的存储空间大小,等于基类对象占用的存储空间大小加上派生类对象自身成员变量占用的存储空间大小。派生类对象中包含基类对象,而且基类对象的存储位置位于派生类对象新增的成员变量之前。
5.1.2 程序实例
下面来看一个有两个类的简单学生管理程序。
#include <iostream>
#include <string>
using namespace std;
class CStudent{
private:
string name;
string id;
char gender;
int age;
public:
void PrintInfo();
void SetInfo(const string & name_, const string & id_, int age_, char gender_);
string GetName() {return name;}
};
class CUndergraduateStudent:public CStudent{
private:
string department;
public:
void QulifiedForBaoyan(){
CStudent::PrintInfo();
cout << "Department:" << department << endl;
}
void SetInfo(const string & name_, const string & id_, int age_,
char gender_, const string & department_) {
CStudent::SetInfo(name_, id_, age_, gender_);
department = department_;
}
};
void CStudent::PrintInfo()
{
cout << "Name:" << name << endl;
cout << "ID:" << id << endl;
cout << "Age:" << age << endl;
cout << "Gender:" << gender << endl;
}
void CStudent::SetInfo(const string & name_, const string & id_, int age_,
char gender_, const string & department_)
{
name = name_;
id = id_;
age = age_;
gender = gender_;
}
int main()
{
CStudent s1;
CUndergraduateStudent s2;
s2.SetInfo("Harry Potter", "118829212", 19, 'M', "Computer Science");
cout << s2.GetName() << " ";
s2.QulifiedForBaoyan();
s2.PrintInfo();
cout << "sizeof(string) =" << sizeof(string) << endl;
cout << "sizeof(CStudent) =" << sizeof(CStudent) << endl;
cout << "sizeof(CUndergraduateStudent) =" <<
sizeof(CUndergraduateStudent) << endl;
return 0;
}
程序的输出结果是:
Harry Potter qulified for baoyan
Name:Harry Potter
ID:118829212
Age:19
Gender:M
Department:Computer Science
sizeof(string)=4
sizeof(CStudent)=16
sizeof(CUndergraduateStudent)=20
上述程序中,CStudent 类概括了所有学生的共同特点。CUndergraduateStudent 类继承了CStudent 类,所有CStudent 类的成员也都是 CUndergraduateStudent 类的成员,因此第53行调用 GetName 成员函数是没有问题的。此外,CUndergraduateStudent 类还添加了新成员变量department,,以及新的成员函数 QulifiedForBaoyan。
CUndergraduateStudent 类还重写了从基类继承的两个成员函数 SetInfo 和 PrintInfo 。在基类和派生类有同名成员(可以是成员变量,也可以是成员函数)的情况下,在派生类的成员函数中访问同名成员,或通过派生类对象访问同名成员,除非特另别指明,访问的就是派生类的成员,这种情况叫 “覆盖” ,即派生类的成员覆盖基类的同名成员。因此,第52行调用的是CUndergraduateStudent 类的SetInfo成员函数,第55行调用的是 CUndergraduateStudent 类的 PrintInfo 成员函数。如果要访问基类的同名成员,则需要在成员名前面加 “基类名::” 。例如,假设s2是CUndergraduateStudent 类的对象, p是 CUndergraduateStudent 类的指针,则以下写法就调用了基类的成员函数:
s2.CStudent::PrintInfo();
p->CStudent::PrintInfo();
第25行和第30行都调用了基类的同名成员函数。派生类的 PrintInfo 成员函数先调用基类的PrintInfo 成员函数输出基类部分的成员信息,然后再输出新增的 department 成员信息。基类和派生类有同名成员函数,完成类似的功能。在派生类的同名成员函数中,先调用基类的同名成员函数完成基类部分的功能,然后再执行自己的代码完成派生类的功能,这种做法非常常见。在Windows 面向对象的 MFC 编程、Android 系统应用程序开发等编程环境中,许多程序员编写的关键的类都必须是由编译器提供的类派生而来的,在其中往往都必须编写和基类同名的一些成员函数。而在派生类的这些成员函数中,一般都需要先调用基类的同名成员函数来完成必要的功能。
派生类和基类有同名成员函数很常见,但一般不会在派生类中定义和基类同名的成员变量,这样做会很让人困惑。
第56行及其后的几行输出是为了说明派生类对象占用的存储空间大小等于基类对象占用的存储空间大小加上派生类新增成员变量占用的存储空间大小。第56行输出 “sizeof (string)=4" ,是用 Dev C ++编译后程序的输出结果。如果用 Visual Studio 2010编译, 输出结果会是“sizeof(string)=32”。这是由于不同的编译器所提供的类库对于 string 类有不同的实现方法,因此sizeof(string)在不同编译器上的值是不同的。按理说,对象占用的存储空间大小等于各成员变量占用的存储空间大小之和,那么 sizeof(CStudent)的值应该为13,但输出结果却是“sizeof(CStudent)=16",这是为什么呢?由于计算机内部在 CPU 和内存之间传输数据都是以4字节(对于32位计算机)或8字节(对于64位计算机)为单位进行的,出于传输效率的考虑,应该尽量使对象的成员变量的地址是4或8的整数倍,这叫作对齐。对于 CStudent 类,编译器为每个CStudent 对象中的 char 类型成员变量 gender 补齐3个字节,使得 age 成员变量能够对齐,这样 CStudent 对象就变成了16字节。在一些编译器中,关于对象的成员变量如何对齐是有选项可以设定的。例如在 Visual Studio 中, 这个选项就是:
Projects -> Properties -> Configuration Properties -> C/C ++ -> Code genera- tion -> Struct Member Alignment,
这称作结构成员对齐,默认值是8。
5.2 正确处理类的复合关系和继承关系
在C++中,类和类之间有两种基本的关系:复合关系和继承关系。
复合关系也称为 “has a” 关系或 “有” 的关系,表现为封闭类,即一个类以另一个类的对象作为成员变量。例如,5.1 节 CStudent 类的例子,每个CStudent 对象都 “有” 一个 string 类的成员对象 name,代表姓名。
继承关系也称为 “is a” 关系或 “是” 的关系,即派生类对象也是一个基类对象。例如,在 5.1 节的程序中,CUndergraduateStudent 类(代表本科生)继承了 CStudent 类(代表学生)。因为本科生也是学生,所以说每一个 CUndergraduateStudent 类的对象也是一个 CStudent 类的对象。
在设计两个相关的类时,要注意,并非两个类有共同点,就可以让它们成为继承关系。让类B继承类A,必须满足 “B所代表的事物也是A所代表的事物" 这个命题是成立的。例如,编写了一个平面上的点类 CPoint:
class CPoint
{
double x, y; //点的坐标
};
又要编写一个圆类 CCircle。CCircle 有圆心,圆心也是平面上的一点,因而 CCircle 类和CPoint 似乎有相同的成员变量。如果因此就让 CCircle 类从 CPoint 类派生而来,即采用如下写法:
class CCircle:public CPoint{
double radius; 1/半径
};
那就是不正确的。因为 “圆也是点” 这个命题是不成立的。这个错误不但初学者常犯,甚至不止一本销量很大的著名教科书中都以此作为继承的例子。正确的做法是使用 “has a” 关系,即在 CCircle 类中引入 CPoint成员变量,代表圆心:
class CCircle{
CPoint center; //圆心
double radius; //半径
}
这样,从逻辑上来说,每一个 “圆” 对象中都包含(有)一个 “点” 对象,这个 “点” 对象就是圆心,非常合理。
如果编写了一个 CMan 类代表男人,后来又发现需要一个 CWoman 类来代表女人,仅仅因为 CWoman 类和 CMan 类有共同之处,就让 CWoman 类从 CMan 类派生而来,同样也是不合理的。因为 “一个女人也是一个男人” 这个命题不成立。但是让 CWoman 类包含 CMan 类成员对象,当然就更不合适。此时正确的做法应该是概括男人和女人共同特点,编写一个 CHuman 类,代表 “人”,然后 CMan 和 CWoman 都是从 CHuman 派生出来的。
有时,复合关系也不一定都是通过封闭类来实现,尤其是A中有B、B中又有A的例子。
假设要编写一个小区养狗管理程序,该程序会需要编写一个 “主人” 类,还需要编写一个 “狗” 类。狗是有主人的,主人也有狗。假定狗只有一个主人,但一个主人可以有最多10条狗。该如何处理 “主人” 类和 “狗” 类的关系?下面是一种初学者喜欢的写法:
class CDog;
class CMaster //主人
{
CDog dogs[10];
int dogNum; //狗的数量6.;
};
class CDog
{
CMaster m;
};
这种写法编译是不能通过的。因为尽管将 CDog 类提前声明了,但编译到第 4 行时,编译器还是不知道 CDog 类的对象是什么样的,所以无法编译定义了 dog 对象的语句。而且这种人中有狗、狗中有人的做法导致了循环定义。避免循环定义的方法是在一个类中用另一个类的指针,而不是对象作为成员变量。例如下面的写法:
class CDog;
class CMaster
{
CDog* dogs[10];
int dogNum; //狗的数量
};
class CDog
{
CMaster m;
}
上面这种写法,在第 4 行定义了一个 CDog 类的指针数组作为 CMaster 类的成员对象。指针就是地址,大小固定为 4 个字节,所以编译器编译到此时不需要知道 CDog 类是什么样子。这种写法的思想是:当一个 CMaster 对象养了一条狗时,就用 new 运算符动态分配一个 CDog 类的对象,然后在 dogs 数组中找一个元素,让它指向动态分配的 CDog 对象。
这种写法还是不够好,问题出在 CDog 对象中包含了 CMaster 对象。在多条狗主人相同的情况下,多个 CDog 对象中的 CMaster 对象都代表同一个主人,这造成了没有必要的冗余,一个主人用一个 CMaster 对象表示足矣,没有理由对应于多个CMaster对象。而且,在一对多这种情况下,当主人的个人信息发生了变化,那么就需要将与其对应的、位于多个 CDog 对象中的 CMaster 成员变量都找出来修改,这毫无必要,而且非常麻烦。
正确的写法应该是为 “狗” 类设一个 “主人” 类的指针成员变量,为 “主人” 类设一个 “狗” 类的对象数组。例如:
class CMaster;
class CDog
{
CMaster* pm;
};
class CMaster
{
CDog dogs[10];
int dogNum;
};
这样,主人相同的多个 CDog 对象,其 pm 指针都指向同一个 CMaster 对象即可。实际上,每个主人未必都养 10 条狗,因此出于节省空间的目的,在 CMaster 类中设置 CDog 指针数组,而不是对象数组,也是一种好的写法:
class CMaster
{
CDog* dogs[10];
int dogNum;
};
有的教科书上,将类 A 的成员变量是类 B 的指针这种情况称为 “类A知道类B”,两个类之间是 “知道” 关系。
5.3 protected 访问范围说明符
前面学过了类的成员可以是私有成员或公有成员。实际上,类的成员还可以用 “protected” 访问范围说明符修饰,从而成为 “保护成员”。保护成员的可访问范围比私有成员大,比公有成员小。能访问私有成员的地方,都能访问保护成员。保护成员扩大的访问范围表现在:基类的保护成员可以在派生类的成员函数中被访问。引人保护成员理由是基类的成员本来就是派生类的成员,因此对于那些出于隐藏的目的,不宜设为公有,但又确实需要在派生类的成员函数中经常访问的基类成员,将它们设置为保护成员,既能起到隐藏的目的,又避免了派生类成员函数中要访问它们时都只能间接访问所带来的麻烦。不过需要注意的是,在派生类的成员函数中,只能访问成员函数所作用的那个对象(即 this 指针指向的对象)的基类保护成员,不能访问其他同类对象的基类保护成员。请看下面的例子:
class CBase {
private: int nPrivate; //私有成员
public: int nPublic; //公有成员
protected: int nProtected; //保护成员
};
class CDerived:public CBase{
void AccessBase()
{
nPublic = 1; //oK
nPrivate = 1; //错,不能访问基类私有成员
nProtected=1; //OK,访问从基类继承的protected成员
CBase f;
f.nProtected= 1; //错,f不是函数所作用的对象
}
};
int main()
{
CBase b;
CDerived d;
int n = b.nProtected; //错,不在派生类成员函数内,不能访问基类保护成员
n=d. nPrivate; //错,此处不能访问d的私有成员
int m = d. nPublic; //oK
return 0;
}
第 11 行编译出错,因为在派生类的成员函数中不能访问基类的私有成员。第 12 行没有问题,在派生类的成员函数中可以访问基类的保护成员。
第 14 行编译出错,因为 f 不是 this 指针所指向的对象,即不是 AccessBase 函数所作用的对象,所以不能访问其保护成员。
第 21 行和第 22 行都会编译出错,因为在类的成员函数外部,不能访问对象的私有成员和保护成员。
在基类中,更常见的做法是将需要隐藏的成员说明为保护成员而非私有成员。
5.4 派生类的构造函数和析构函数
派生类对象中包含基类对象,因此派生类对象在创建时,除了要调用自身的构造函数进行初始化,还要调用基类的构造函数初始化其包含的基类对象。因此,程序中任何能够生成派生类对象的语句,都要交代清楚其包含的基类对象是如何初始化的。如果对此不做交代,则编译器认为基类对象是用无参的构造函数初始化,如果基类没有无参构造函数,则会导致编译错误。
在执行一个派生类的构造函数之前,总是先执行基类的构造函数。
和封闭类交代成员对象如何初始化类似,派生类交代基类对象如何初始化,也需要在派生类构造函数后面添加初始化列表。在初始化列表中,要指明调用基类构造函数的形式。具体写法如下:
构造函数名(形参表):基类名(基类构造函数实参表) { }
派生类对象消亡时,先执行派生类的析构函数,再执行基类的析构函数。请看下面的程序:
#include <iostream>
#include <string>
using namespace std;
class CBug
{
int legNum, color;
public:
CBug (int ln, int cl):legNum(ln), color(cl)
{
cout << "CBug Constructor" << endl;
};
~CBug()
{
cout << "CBug Destructor" << endl;
}
void PrintInfo()
{
cout << legNum << "," << color << endl;
}
};
class CFlyingBug:public CBug
{
int wingNum;
public:
//CFlyingBug(){}若不注释掉则会编译出错
CFlyingBug(int ln, int cl,int wn):CBug(ln, cl), wingNum(wn)
{
cout << "CFlyingBug Constructor" << endl;
}
~CFlyingBug()
{
cout << "CFlyingBug Destructor" << endl;
}
};
int main()
{
CFlyingBug fb(2,3,4);
fb.PrintInfo();
return 0;
}
程序的输出结果:
CBug Constructor
CFlyingBug Constructor
2,3
CElyingBug Destructor
CBug Destructor
第 25 行如果没有注释掉就会出错。因为这个构造函数没有交代,在派生类对象用本构造函数初始化的情况下,其基类对象该如何初始化,那么就意味着基类对象应该用无参构造函数初始化。可是 CBug 类并没有无参构造函数,所以编译会出错。
第26行中的 “CBug(In,cl)” 指明了派生类对象用本构造函数初始化的情况下,其基类对象的初始化方式。
思考题:派生类对象生成时,要先执行基类构造函数;派生类对象消亡时,先执行自身析构函数,再执行基类析构函数,为什么?
答:在派生类中使用基类成员时,若此时基类对象未被初始化,则会出错;析构函数类似。
和封闭类的情况类似,如果一个派生类对象是用默认复制构造函数初始化的,那么它内部包含的基类对象也是用基类的复制构造函数初始化。
5.5 多层次的派生
在C++语言中,派生可以是多层次的。例如,学生类派生出中学生类,中学生类又派生出初中生类和高中生类。总之,类A派生类B,类B可再派生类C,类C又能派生类D……这种情况下,称类A是类B的直接基类,类B是类C的直接基类,类A是类C的间接基类。当然类A也是类D的间接基类。在定义派生类时,只写直接基类,不写间接基类。派生类沿着类的层次自动向上继承它所有的间接基类。
派生类的成员包括派生类自己定义的成员,直接基类中定义的成员,以及所有间接基类的全部成员。
当派生类对象生成时,会从最顶层的基类开始逐层往下执行所有基类的构造函数,最后再执行自身的构造函数,派生类对象消亡时,会先执行自身的析构函数,然后从底向上依次执行各个基类的析构函数。例如下面的程序:
#include <iostream>
using namespace std;
class A
{
public:
int n;
A(int i):n(i){cout<< "A" << n << "constructed"<< endl;}
~A(){cout<< "A" << n << "destructed" << endl; }
};
class B:public A
{
public:
B(int i):A(i){cout<< "B constructed"<< endl;}
~B(){cout<< "B destructed"<< endl;}
};
class C:public B
{
public:
C():B(2){cout<< "B constructed" << endl;}
~C(){cout<< "B destructed"<<endl;}
};
int main()
{
C Obj;
return 0;
}
程序的输出结果:
A 2 constructed
B constructed
B constructed
B destructed
B destructed
A 2 destructed
5.6 包含成员对象的派生类
在派生类也是封闭类的情况下,构造函数的初始化列表不但要指明基类对象的初始化方式,还要指明成员对象的初始化方式。派生类对象生成时,会引发一系列构造函数调用,顺序是先从上至下执行所有基类的构造函数,再按照成员对象的定义顺序执行各个成员对象的构造函数,最后执行自身的构造函数。而派生类对象消亡时,先执行自身的析构函数,然后按与构造的次序相反的顺序依次执行所有成员对象的析构函数,最后再从底向上依次执行各个基类的析构函数。
5.7 公有派生的赋值兼容规则
在公有派生的情况下,有以下3条赋值兼容规则。
(1) 派生类对象可以赋值给基类对象。
(2) 派生类对象可以用来初始化基类引用。
(3) 派生类对象的地址可以赋值给基类指针,也即派生类的指针可以赋值给基类的指针。
上面的3条,反过来是不可以的。例如,不能把基类对象赋值给派生类对象。下面的程序是能够成功编译的,充分说明了上述3条规则。
class A
{
};
class B:public A //公有派生
{
};
int main()
{
A a;
B b;
a = b; //派生类对象赋值给基类对象
A& r = b; //派生类对象初始化基类引用
A* pa = &b; //派生类对象地址赋值给基类指针
B* pb = &b;
pa=pb; //派生类指针赋值给基类指针
return 0;
}
将派生类对象赋值给基类对象(如上面程序中的 “a = b;” 这条语句),在赋值号 “=” 没有被重载的情况下,所做的操作就是将派生类对象中的基类对象,逐个字节复制到 “=” 左边的基类对象中去。
在公有派生的情况下,可以认为,派生类对象也是基类对象,所以任何本该出现基类对象的地方,如果出现的是派生类对象,那也是没有问题的。但如果派生方式不是 public,而是 private 或 protected,那么上面这个结论就不成立了。
5.8 基类与派生类指针的互相转换
在公有派生的情况下,派生类的指针可以直接赋值给基类指针。但即便基类指针指向的是一个派生类对象,也不能通过基类指针访问基类没有的而派生类中有的成员。
基类的指针不能赋值给派生类的指针。但是通过强制类型转换,也可以将基类指针强制转换成派生类指针后再赋值给派生类指针。只是在这种情况下,程序员需要保证被转换的基类指针本来就指向一个派生类的对象,这样才是安全的,否则,很容易导致出错。
#include <iostream>
using namespace std;
class CBase
{
protected:
int n;
public:
CBase(int i):n(i) {}
void Print() { cout << "CBase:n=" << n << endl;}
};
class CDerived:public CBase
{
public:
int v;
CDerived( int i):CBase(i),v(2 *i){}
void Func() {};
void Print( )
{
cout << "CDerived:n=" << n << endl;
cout << "CDerived:v=" << v << endl ;
}
};
int main()
{
CDerived objDerived(3);
CBase objBase(5);
CBase *pBase= & objDerived; //使得基类指针指向派生类对象
//*pBase->Func(); //错,CAase类没有Func()成员函数
// pBase->v = 5; //错,CBase类没有v成员变量
pBase->Print();
cout << "1)------------" << endl;
//CDerived *pDerived = & objBase; //错,不能将基类指针赋值给派生类指针
CDerived *pDerived = (CDerived*)(&objBase);
pDerived-> Print(); //慎用,可能出现不可预期的错误
cout << "2)------------" << endl;
objDerived.Print();
cout << "3)------------" << endl;
pDerived->v = 128; //往别人的空间中写入数据,会有问题
objDerived.Print();
return 0;
}
程序的输出结果:
CBase:n=3
1)------------
CDerived:n=5
CDerived:v=3
2)------------
CDerived:n=3
CDerived:v=6
3)------------
CDerived:n=128
CDerived:v=6
第 27 行使得基类指针 pBase 指向派生类对象 objDerived.,这是合法的。虽然执行完此行语句后,pBase 指向的是派生类对象,但是第 28 行如果不注释掉,编译是会出错的,因为 CBase 类并没有 Func 成员函数。同理,第 29 行若不注释掉,编译也会出错。在第 30 行,尽管基类和派生类都有 Print 成员函数,而且 pBase 指向的是派生类对象,本行依然执行的是基类的 Print 成员函数,产生第一行输出。编译器看到的是哪个类的指针,那么就会认为通过它访问的,就应该是哪个类的成员,编译器不会分析基类指针到底指向的是基类对象还是派生类对象。
第 33 行,通过强制类型转换,使得派生类的指针 pDerived 指向了基类对象 objBase。第 34 行调用的 Print 就是 CDerived 类的 Print,这是有风险的语句。在 CDerived 对象中,成员变量 v 紧挨成员变量 n 存放,如图 5.3 所示。
那么执行 pDerived->Print()时,虽然 pDerived 指向的是一个基类对象,但这不影响成员变量 v 的地址计算方式,因此第 20 行 “cout<<"CDerived:v="<<v<<endl,” 所访问的 v 就位于如图 5.4 所示的阴影位置。
该位置并不属于 objBase 对象,可能属于其他变量,也可能是存放指令的。第 20 行输出 “CDerived:v=3” 是因为将阴影位置的 4 个字节看做一个 int 类型变量,其值是 3。但这个值是不确定、不可预测的。此处存放什么,不同编译器的处理办法不同,如果该位置是操作系统规定不可访问的区域,那么程序就可能由于出错而中止。
同理,第 38 行的 “pDerived->v = 128;” 也是不安全的,它往图 5.4 的阴影部分写入 128,这就可能修改了别的变量的值。通过对比第 36 行和第 39 行的输出发现,objDerived. n 的值莫名奇妙地变成了128,这实际上就是由于 “pDerived- >v=128” 造成的。因为碰巧在栈上 objDerived 对象是紧挨着 objBase 下方存放的,如图 5.5 所示。
因此,“pDerived一 >v=128;” 实际上改写了 objDerived. n。
本程序用 Dev C++4.9.9.2 编译后,输出结果如上所述。不同编译器在栈上放置局部变量的方式有所不同,用 Visual Studio 2010 编译本程序,输出结果就未必如此。
基类引用也可以强制转换为派生类引用。将基类指针强制转换为派生类指针或将基类引用强制转换为派生类引用,都有安全隐患。C++提供了 dynamic_cast 强制类型转换运算符,在特定情况下,可以判断这两种转换是否安全(即转换后的指针或引用是否真的指向或引用派生类对象)。为讲述方便,本书将 dynamic_cast 运算符放在第 11 章 “C++高级主题” 中讲解,但是强烈建议读者掌握这部分内容。
5.9 私有派生和保护派生
除了公有派生,C++还支持私有派生和保护派生。具体写法是在派生类定义的 “派生方式说明符” 位置写 “private” 或 “protected”。例如:
class B: private A { };
class C: protected A{ };
不同派生方式会导致基类的成员在派生类中的可访问范围属性不同,如表 5.1 所示。
基类成员 | 派生方式 | ||
公有派生 | 私有派生 | 保护派生 | |
私有成员 | 不可访问 | 不可访问 | 不可访问 |
保护成员 | 保护 | 私有 | 保护 |
公有成员 | 公有 | 私有 | 保护 |
举一个例子说明该表的解读方法:以第四行第三列进行说明,基类的公有成员,经过私有派生后,在派生类中变为私有成员。其他情况以此类推。
类的 “不可访问” 成员是指类的成员函数内部也不能访问的成员。例如,基类的私有成员,在派生类成员函数内就不能访问,但它依然是派生类的成员,因此就说基类的私有成员在派生类中成为不可访问成员。
假设基类 A 私有派生出 B,B 又派生出 C。依据表 5.1 可知,经过私有派生后,A 的公有成员 x 在 B 中成为私有成员,于是在 C 的成员函数中,就不能访问 x 了,为在派生类的成员函数中不能访问基类的私有成员。
一般情况下都使用公有派生。
5.10 派生类和赋值运算符
前面提到过,派生类的默认复制构造函数会调用基类的复制构造函数,以对派生类对象中的基类对象进行初始化。
如果基类重载了赋值运算符 “=” 而派生类没有,那么在派生类对象之间赋值或用派生类对象对基类对象进行赋值时,其中基类部分的赋值操作,是调用被基类重载的 “=” 来完成的。请看下面的程序:
#include <iostream>
using namespace std;
class CBase
{
public:
CBase(){ }
CBase(CBase &c) {cout << "CBase::copy constructor called" << endl;}
CBase & operator = (const CBase & b)
{
cout << "CBase::opeartor=called" << endl;
return *this;
}
};
class CDerived:public CBase{};
int main()
{
CDerived d1, d2;
CDerived d3(d1); //d3初始化过程中会调用 CBase类复制构造函数
d2 = d1; //会调用 CBase类重载的"="
return 0;
}
程序的输出结果:
CBase::copy constructor called
CBase::opeartor=called
5.11 多重继承
5.11.1 多继承的概念及其引发的二义性
在C++中,一个类可以从多个直接基类派生而来,继承多个直接基类的全部成员。这种机制称作“多重继承”,简称“多继承”。多继承的写法如下:
class 派生类名:派生方式说明符基类1,派生方式说明符基类2,…
{
};
有时很自然地会需要多继承。例如,编写一个公司员工管理程序,其中会有销售员类,销售员类有销售额的属性;还会有经理类,经理类有管辖人数的属性。有一类人称为销售经理,其手下销售员的销售总额就是他的销售额;销售经理既具有销售员的特点,又有经理的特点,因此要实现销售经理类,很自然的想法就是它同时继承销售人员类和经理类。C++ 的多继承机制对此提供了支持。
虽然多继承机制是现实的需要,但是它有 “二义性” 这个严重的缺点。这使得有些面向对象的程序设计语言放弃了对多继承的完全支持,如 Java。用 C++ 编程时,使用多继承也要慎重。以员工管理程序为例,设置一个员工类,概括所有员工的共同特点,然后销售员类和经理类都从
员工类派生而来,销售经理类又从销售员类和经理类共同派生,各类关系如图 5.6 所示。
根据派生类对象中包含基类对象的原则,一个销售经理类对象中会包含两个员工类的对象,一个从销售人员类继承得到,另一个从经理类继承得到。员工类中会有姓名、年龄、工号等所有员工都有的成员变量,于是在销售经理类对象中,就会有两份姓名、两份年龄、两份工号等。到底应该使用哪一份呢?这就是二义性。实际上随便使用哪一份都是可以的,只要在整个程序中都一致就行,可是要让一起协作的多个程序员都搞清楚并记住用的到底是哪--份,是件麻烦的事情。例如下面的员工管理程序:
#include <iostream>
#include <string>
using namespace std;
class CEmployee
{
string name;
int age;
char gender;
public:
CEmployee(){cout << "CEmployee constructor" << endl;}
void PrintBasicInfo()
{
cout << "Name:" << name << endl;
cout << "Age:" << age << endl;
cout << "Gender:" << gender << endl;
};
void SetBasicInfo(const string & name_,int age_, int gender_)
{
name = name_; age = age_; gender = gender_;
}
};
class CSalesman:public CEmployee
{
protected:
int salesVolume; //销售额
};
class CManager:public CEmployee
{
protected:
int totalSubordinates; //手下人数
};
class CSalesManager:public CSalesman, public CManager
{
public:
void setInfo(int sv, int sn)
{
salesVolume = sv;
totalSubordinates = sn;
}
void PrintInfo()
{
CSalesman::PrintBasicInfo(); //必须指明在哪个基类对象上
//执行 PrintBasicInfo
cout << "Sales Volume:" << salesVolume << endl ;
cout << "Total Subordinates:" << totalSubordinates << endl;
}
};
int main()
{
CSalesManager sm;
sm.CSalesman::SetBasicInfo("Tom",24,'M');//必须指明在哪个基类对象上52.
//执行SetBasicInfo
sm.setInfo(100000,20);
sm.PrintInfo();
return 0;
}
程序的输出结果:
CEmployee constructor
CEmployee constructor
Name:Tom
Age:24
Gender:M
Sales Volume:100000
Total Subordinates:20
CSalesManager 对象 sm 中有两份 CEmployee 对象,一份从 CSalesman 继承得到,一份从 CManager 继承得到。sm 生成的时候,两份 CEmployee 对象都需要用 CEmployee 类的构造函数初始化,因此输出两行 “CEmployee constructor”。CSalesManager 对象的内存布局如图 5.7 所示。
第 42 行调用 CEmployee 类的 PrintBasicInfo 成员函数。如果仅仅写 “PrintBasicInfo();",编译会出错,因为没有交待这个 PrintBasicInfo 到底是作用在从 CSalesman 继承的 CEmployee 对象上,还是作用在从 CManager 继承的 CEmployee 对象上。而加上 “CSalesman::” 就指明了这一点。同理,第 51 行也需要指明调用的 SetBasicInfo 到底是作用在哪个 CEmployee 对象上。由于程序中 PrintBasicInfo 所作用的对象和 SetBasicInfo 所作用的对象是一致的,都是从 CSalesman 继承得到那个 CEmployee 对象,所以输出的结果和设置的数据相同。可以想象,如果在复杂的程序中要维持这种一致性,无疑是个负担。
5.11.2 用 “虚继承” 解决二义性
有什么办法在多继承结构中避免二义性呢?C++ 语言提供了 “虚继承” 的概念来支持这一点。所谓 “虚继承”,就是在定义派生类的时候,在继承方式说明符之前加上 “virtual" 关键字。如果一个基类A在派生出其他类 B、C、D……的时候,都使用了 “virtual” 关键字,那么即使有某个类 X 由 B、C、D 等类共同派生而来,类X的对象中也不会出现两个 A 对象,如图 5.8 所示。
例如,将上节的程序稍做修改,将第 22 行修改为:
class CSalesman:virtual public CEmployee
将第27行修改为:
class CManager:virtual public CEmployee
那么在 CSalesManager 对象中就只会有一个 CEmployec 对象,不存在二义性问题了。于是第 42 行可以改成 “PrintBasicInfo();”,不需要指明 PrintBasicInfo 是作用在哪个 CEmployee 对象上的;同理,第 51 行也可以改成 “sm.SetBasicInfo("Tom",24,'M');”。修改后的程序输出结果如下:
CEmployee constructor
Name: Tom
Age: 24
Gender:M
Sales Volume: 100000
Total Subordinates:20
可以看到, CEmployee 类的构造函数只被调用了一次,可见 CSalesManager 类的对象 sm 中,只有一个 CEmployee 对象。
在图 5.8 中,如果从 A 派生出 B、C 的时候使用了 “virtual” 关键字,而派生出 D 的时候没有使用 “virtual” 关键字,那么在 X 对象中,就会存在两份 A 对象,二义性问题依然存在。
使用虚继承,并不能完全解决多继承的二义性问题。因为在程序扩充时,未必每个程序员从现有的类派生出新类的时候都会知道要使用 “virtual” 关键字。如果编译器自动将所有的继承都处理成虚继承,那么在程序运行时会带来很多额外的空间和时间开销,更不可行。
Java语言在多继承问题上的处理办法是引人 “接口” 的概念。接口和类的差别主要在于接口中不能有非静态成员变量。在Java中,类不可以由多个基类派生而来,但可以由一个基类和多个接口共同派生而来。这个办法在保留多继承优点和避免其麻烦方面做了一个比较合适折中。