分类: C/C++
2008-12-08 12:30:59
96、类继承
派生一个类
比如一个基类 TableTennisClass类,要声明为一个子类RatePlayer,则其语句为
TableTennisClass class;
class RatedPlayer:public TableTennisPlayer
{
...
}
冒号指出RatePlayer类的基类是TableTennisplayer。上述声明头表明TableTennisPlayer是一个公有基类,这杯称为公有派生。派生类对象包含基类对象。使用公有派生,基类的公有成员将称为派生类的公有成员;基类的私有部分也将称为派生类的一部分,但只能通过基类的公有和保护方法访问。
上述代码完成了那些工作:
1)派生类对象存储了基类的数据成员(派生类继承基类的实现)
2)派生类对象可以使用基类的方法(派生类的基类的接口)
构造函数:访问权限的考虑
派生类不能直接访问基类的公有成员,而必须通过基类的方法进行访问。具体说,派生类构造函数必须使用基类构造函数。创建派生类对象时,首先创建基类对象。从概念上讲,这意味着街垒对象应当在程序进入派生类构造函数之前被创建。c++使用初始化列表句法来完成这种工作。
RatePlayer::RaterPlayer(unsigned int r,const char *fn,const char *ln,bool ht):TableTennisPlayer(fn,ln,ht)
{
sating=r;
}
其中TableTennisPlayer是成员初始化列表,它是可执行的代码,它调用TableTennisPlayer构造函数。
除非要使用默认构造函数,否则显式调用正确的基类构造函数。
有关派生类构造函数的要点如下:
1、基类对象首先被创建。
2、派生类构造函数应通过成员初始化列表将基类信息传递给传递给基类构造函数。
3、派生类构造函数应初始化派生类新增的数据成员。
成员初始化列表只能用于构造函数。
RatedPlayer 是派生类,TableTennisPlayer是基类
派生类和基类之间的关系
1)派生类对象可使用基类的方法,条件是方法不是私有的
RatedPlayer rplayer1(1140,"Mallory","Duck",true);
rplayer1.Name();
2)基类指针可以在不显式类型转换情况下指向派生类对象:基类引用可以在不进行显式类型转换的情况下引用派生对象。不过基类指针或引用只能调用基类方法。
RatedPlayer rplayer1(1140,"Mallory","Duck",true);
TableTennisPlayer &rt=rplayer;
TableTennisPlayer *pt=&rplayer;
rt.Name();
pt->name();
3)通常c++要求引用和指针类型与赋给的类型匹配,但这一规则对继承是例外。不过这种例外是单向的,不可以将基类对象和地址派生给类引用和指针。
TableTennisPlayer player("Besty","Bloop",true);
RatePlayer &rr=player; //not allowed
RatePlayer *pr=player; //Not allowed
此规则的道理在于:比如,如果允许基类引用隐式地引用派生类对象,则可以使用基类引用为派生类对象调用基类的方法。因为派生类继承了基类的方法,所以不会出问题。如果可以将基类对象赋给派生类引用,将发生什么情况呢?派生类引用能够为基对象调用派生类方法,这样将初相问题。
如果基类引用和指针可以指向派生类对象。其中之一是基类引用定义的函数或指针参数可用于基类对象或派生类对象。
void Show(const TableTennisPlayer &rt)
{
cout << "Name: ";
rt.Name();
cout << "\nTable: ";
if(rt.HasTable());
cout << "yes\n";
else
cout << "no\n";
}
形参rt是一个基类引用,它可以指向基类对象或派生类对象,所以可以在show()中使用TableTennisPlayer参数或RatedPlayer参数,比如:
TableTennisPlayer player1("Tara","Boomdea",false);
RatedPlayer rplayer1(1140,"Mallory","Duck",true);
show(player1);
show(rplayer1);
引用兼容属性也能够将基类对象初始化为派生类对象,尽管不那么直接
RatedTennisPlayer olaf1(const RatedPlayer &);
TableTennisPlayer olaf2(olaf1);
要初始化olaf2,匹配的构造函数的原型如下:
TableTennisPlayer(const RatedPlayer &);
可以将派生对象赋给基类对象
RatePlayer olaf1(1840,"Olaf","Loaf",true);
TableTennisPlayer winner;
winner=olaf1;
这种情况下,程序使用隐式重载赋值操作符
TableTennisPlayer &operator(const TableTennisPlayer &)const;
基类引用指向的也是派生类对象,因此olaf1的基类部分被复制给winner。
继承——is-a关系
派生类和基类之间的特殊关系是基于c++继承的底层模型的。c++有3中继承方法:公有继承,保护继承和私有继承。
公有继承建立一种is-a关系,不建立has-a关系,即派生对象也是基类对象,可以对基类对象执行任何操作,也可以对派生类对象执行。
公有继承也不能建立is-like-a关系,也就是说,它不采用明喻。继承可以在基类的基础上添加属性,但不能删除基类的属性。
公有继承不建立is-implemented-a(作为...来实现)关系。
多态公有继承
派生类对象使用基类的方法,而未做任何修改。不过可能遇到这种情况,即希望同一个方法在派生类和基类中的行为是不同的,换句话说,方法的行为应取决于调用该方法的对象。这种方法的行为应取决于调用该方法的对象。这种较复杂的行为称为多态---具有多种形态,就是指同一个方法的行为将随上下文而异。有两种机制可用与实现多态公有继承:
1)在派生类重新定义基类的方法。
2)使用虚方法。
如下面例子:
1 //brass.h --- bank acount classes
2
3 #ifndef BRASS_H_
4 #define BRASS_H_
5
6 class Brass
7 {
8 private:
9 enum {MAX=35};
10 char fullname[MAX];
11 long accNum;
12 double balance;
13 public:
14 Brass(const char *s="NullBody",long an=-1,
15 double bal=0.0);
16 void Deposit(double amt);
17 virtual void Withdraw(double amt);
18 double Balance()const;
19 virtual void ViewAcct()const;
20 virtual ~Brass(){}
21 };
22
23 class BrassPlus:public Brass
24 {
25 private:
26 double maxLoan;
27 double rate;
28 double owesBank;
29 public:
30 BrassPlus(const char *s="Nullbody",long an=-1,
31 double bal=0.0,double m1=500,
32 double r=0.10);
33 BrassPlus(const Brass &ba,double m1=500,double r=0.1);
34 virtual void ViwAcct()const;
35 virtual void Withdraw(double amt);
36 void ResetMax(double m){maxLoan=m;}
37 void ResetRate(double r){rate=r;}
38 void ResetOwes(){owesBank=0;}
39 };
40
41 #endif
对于这个声明有如下几点:
1)Brass类和BrassPlus类都声明了View()和Withdraw()方法,但BrassPlus对象和Brass对象的这些方法的行为是不同的。
2)Brass类在声明ViewAcct()和Withdraw()时使用了关键字virtual。这些方法称为虚方法
3)Brass类还声明了虚拟的析构函数,虽然该析构函数不执行任何操作。
第一点介绍了声明如何指出方法在派生类的行为的不同。两个ViewAcct()原型表明将有2个对立的方法定义。程序将根据使用对象类型来确定使用哪个版本
第二点使用virtual比前两点要复杂。如果方法是通过引用或指针而不是对象调用的,它将确定使用哪一种方法。如果没有使用关键字virtual,程序将根据引用类型或指针类型选择方法;如果使用了virtual,程序将根据引用或指针指向的对象类型来选择方法。因此经常在基类中将派生类会重新定义的方法声明为虚方法。方法在基类中被声明为虚拟的后,它在派生类中将自动称为虚方法。
第三点基类声明了一个虚拟析构函数,这样做是为了确保释放派生对象时,按正确的顺序调用析构函数。
注意:如果要在派生类中重新定义基类的方法,通常应将基类的方法声明为虚拟的。这样,程序将根据对象类型而不是引用或指针类型来选择方法版本。
为何需要虚拟析构函数
如果析构函数不是虚拟的,则将只调用对应指针类型的修够函数。
静态联编和动态联编
程序调用函数,将使用哪个可执行代码块呢?编译器负责回答。将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding)。在c++中,由于函数重载的缘故,编译器必须查看函数数参数以及函数数名才能确定使用哪个函数,然而C/C++编译器可以在编译过程中完成这种联编。在编译过程中进行联编被称为静态联编(static binding)。编译器必须生成能够在程序运行时选择正确的虚构方法代码,这称为动态联编。
指针和引用类型兼容性
在c++中,动态联编与指针和引用调用的方法相关,从某种程度上,这是由继承控制的。公有继承建立is-a关系的一种方法是如何处理指向对象的指针和引用。通常,c++不允许将一种欧冠类型的地址赋给另一种类型的指针,也不允许一种类型的引用指向另一种类型。
不过,指向基类的引用或指针可以引用派生类的对象,而不必进行显式类型转换。该规则是is-a关系的一部分。此转换称为向上强制类型转换。
但是相反的过程——将基类指针或引用转换为派生类指针或引用——称为向下强制转换。如果不适用显式类型转换,则向下强制转换是不允许的。原因是is-a关系通常是不可逆的。派生类可以新增数据成员,因此使用这些数据成员的类成员函数不能应用于基类。
隐式向上强制转换使基类指针或引用可以指向基类的对象或派生类对象,因此需要动态联编。c++使用需成员函数来满足这种需求。
虚拟成员函数和动态联编
如下例子:
BrassPlus ophelia;
Brass *bp;
bp=&ophelia; //brass pointer to BrassPlus object
bp->ViewAcct() //which version?
如果在基类中没有将ViewAcct()声明为虚拟的,则bp->ViewAcct()将根据指针类型(Brass *),而调用Brass::ViewAcct()。指针类型在进行编译时是已知的,因此编译器在进行编译时,可以将ViewAcct()关联到BrassAcct()。简言之,编译器对非虚方法使用静态联编。
如果在基类中将ViewAcct()声明为虚拟的,则bp->ViewAcct()根据对象类型(BrassPlus)调用BrassPlus::ViewAcct()。在这个例子中,对象类型为BrassPlus,但通常只有在运行程序时才能确定对象的类型。所以编译器生成的代码将在程序执行时,根据对象类型将ViewAcct()关联到Brass::ViewAcct()或BrassPlus::ViewAcct()。简言之,编译器对虚方法使用动态联编。
使用动态联编让程序能够选择特定类型设计的方法。但是:
1)为什么有两种类型的联编?
2)既然动态联编如此之好,为什么不将它设置成默认的?
3)动态联编是如何工作的?
首先看效率,为使程序能够在运行阶段进行决策,必须采取一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销。例如,如果类不会用作基类,则不需要动态联编。同样,如果派生类不重新定义基类的方法,也不需要动态联编。这些情况下,使用静态联编更合理,效率更高。由于静态联编效率更高,因此被设置成c++的默认选择。c++的指导原则之一是:不要为不适用的特性的付出代价。
虚函数的工作原理:
通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏的成员,隐藏的成员保存了一个指向函数地址数组的指针。这种数组称为虚函数表(virtual function table,vtbl)。虚函数表中存储了为类对象进行声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向对立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,该vbtl将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到vbtl中。注意,无论类中包含的虚函数是1个还是10个,都是需要在对象中添加1个地址成员,只是表的大小不同而已。
调用虚函数时,程序将查看存储在对象中vtbl地址,然后转向相应的函数地址表。如果使用类声明中定义的第一个虚函数,则程序将使用数组中的第一个函数地址,并执行具有该地址的函数。如果使用类声明的第三个虚函数,程序将使用地址为数组中第三个元素的函数。
简而言之,使用虚函数时,在内存和执行速度方面有一定的成本,包括:
1)每个对象都将增大,增大量为存储地址的空间。
2)对每个类,编译器都将创建一个虚函数地址表(数组)
3)每个函数调用都需要执行一步额外的操作,即到表中查找地址。
虽然非虚函数的效率比虚函数稍高,但不具备动态联编功能。
下面是有关虚函数的注意事项:
1)在基类方法的声明中使用关键字virtual可使方法在基类以及所有的派生类中是虚拟的。
2)如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。——动态联编
3)如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚拟的。
构造函数不能是虚函数,创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数使用基类的一个构造函数,这种顺序不同于继承机制。因此派生类不继承基类的构造函数,所以将类构造声明为虚拟的没有意义。
析构函数应当是虚函数,除非类不用做基类。
友元不能是虚函数,因为友元不是类成员,而只有成员才能使虚函数。如果由于这个原因引起了设计问题,可以通过让友元函数使用虚拟成员函数来解决。
没有重新定义-如果派生类没有重新定义,将使用该函数的基类版本。
重新定义隐藏方法--重新定义继承的方法并不是重载。如果在派生类中重新定义函数,将不是使用相同的函数特征标覆盖基类声明,而是隐藏同名的基类方法,不管参数特征标如何。
这有两条经验规则:1)如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针,这种特性被称为返回类型协变(convariance of return type),因为允许返回类型随类类型变化而变化。
2)如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。如果只重新定义一个版本,则另外两个版本将被隐藏,派生类对象将无法使用它们。如果不需要修改,则新定义可只调用基类版本。