第15讲——类继承

我们称C++为面向对象的编程,前面我们讲了OOP的多态、封装,这一讲我们将学习继承。

 

何谓继承?

能够从已有的类派生出新的类,而派生类继承了原有类(称为基类)的特征,包括方法,然后在此基础上添加派生类的新特性

 

继承在派生类上可以完成的工作:

  • 可以在已有类的基础上添加新功能;
  • 可以给类添加数据
  • 可以修改类方法的行为

 

为何要继承?

首先,OOP的主要目的之一是提供可重用的代码。当开发的项目十分庞大时,重用经过测试的代码将比重新编写代码要好得多,包括节省时间、降低程序出错的可能性

 

继承的高级性(优越性):

传统的C函数库通过预定义、预编译的函数(如strlen()和rand()这些可以在程序中使用的函数)提供了可重用性。很多厂商都提供了专用的C库,这些专用库提供标准C库没有的函数,但是这些函数库有局限性,除非厂商提供了库函数的源代码,否则我们将无法根据自己特定的需求对函数进行扩展或修改,而必须根据库的情况修改自己的程序。即使厂商提供了源代码,在修改时也有一定的风险。

而C++类提供了更高层次的重用性。目前,很多厂商提供了类库,类库由类声明和实现构成。因为类组合了数据表示和类方法,因此提供了比函数库更加完整的程序包。例如,单个类就可以提供用于管理对话框的全部资源。通常,类库是以源代码的方式提供的,这意味着可以对其进行修改,以满足需求。然而,C++提供了比修改代码更好的方法来扩展和修改类——类继承。他能够从已有的类派生出新的类,而派生类继承了原有类(称为基类)的特征,包括方法。正如继承一笔财产要比自己白手起家容易一样,通过继承派生出的类通常比设计新类要容易得多。

此外,相比于需要通过复制原始类代码并对其进行修改给原有类添加功能、添加数据、修改方法行为实现期望的类,继承机制只需提供新特性,甚至不需要访问源代码就可以派生出类,因此不需要访问源代码就可以派生出类。因此,如果购买的类库只提供了类方法的头文件和编译后代码,仍可以使用库中的类派生出新的类。而且可以在不公开实现的情况下将自己的类分发给其他人,同时允许他们在类中添加新特性。

 

上面是关于类继承的引文。

========================兴趣是最好的老师=============================

关于类继承的具体内容,希望大家看书自己理解。我将把key-point贴出来。

【关键词】

基类

派生类

派生类需要自己的构造函数

派生类的权限

3种继承方式:公有继承、保护继承和私有继承

继承:is-a关系

多态公有继承

静态联编和动态联编

虚(成员)函数

访问控制:protected

抽象基类

继承和动态内存分配

 

keypoint1——成员初始化列表

下面是一个TableTennisPlayer类,其用于记录会员的姓名以及是否有球桌:

 1 // tabtenn0.h -- a table-tennis base class
 2 #ifndef TABTENN0_H_
 3 #define TABTENN0_H_
 4 #include <string>
 5 using std::string;
 6 // simple base class
 7 class TableTennisPlayer
 8 {
 9 private:
10     string firstname;
11     string lastname;
12     bool hasTable;
13 public:
14     TableTennisPlayer (const string & fn = "none",
15                        const string & ln = "none", bool ht = false);
16     void Name() const;
17     bool HasTable() const { return hasTable; };
18     void ResetTable(bool v) { hasTable = v; };
19 };
20 #endif
tabletenn0.h
 1 //tabtenn0.cpp -- simple base-class methods
 2 #include "tabtenn0.h"
 3 #include <iostream>
 4 
 5 TableTennisPlayer::TableTennisPlayer (const string & fn, 
 6     const string & ln, bool ht) : firstname(fn),
 7         lastname(ln), hasTable(ht) {}
 8     
 9 void TableTennisPlayer::Name() const
10 {
11     std::cout << lastname << ", " << firstname;
12 }
tabtenn0.cpp

构造函数使用了上一讲中介绍的成员初始化列表语法,但也可以这样:

TableTennisPlayer::TableTennisPlayer (const string & fn, const string & ln, bool ht)
{
	firstname = fn;
	lastname = ln;
	hasTable = ht;
}

这将首先为firstname调用string的默认构造函数,再调用string的赋值运算符将firstname设置为fn,但初始化列表语法可减少一个步骤,它直接使用string的复制构造函数将firstname初始化为fn。

 

keypoint2——派生类相关

(1)我们现在需要一个类,它不仅包含了上个类的成员,还要能包括成员在比赛中的得分。

与其从零开始,不如从TableTennisClass类中派生出一个类RatedPlayer。

(2)那么派生类RatedPlayer对象具有什么特征呢:

  • 派生类对象存储了基类的数据成员(继承了基类的实现);
  • 派生类对象可以使用积累的方法(继承了基类的接口)。

因此,RatedPlayer对象可以存储运动员的姓名及其是否有球桌。另外,RatedPlayer对象还可以使用TableTennisPlayer类的Name()、hasTable()和ResetTable()方法。

(3)需要在继承特性中添加什么呢?

  • 派生类需要自己的构造函数;
  • 派生类可以根据需要添加额外的数据成员和成员函数。

在这个例子中,我们将添加一个数据成员来存储比分,还添加检索比分的方法和重置比分的方法。

(4)派生类的构造函数:

必须给新成员(如果有的话)和继承的成员提供数据。

由于派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。例如,RatedPlayer构造函数不能直接设置继承的成员(firstname/lastname/hasTable),而必须使用基类的公有方法来访问私有的基类成员。笔记:派生类构造函数必须使用基类构造函数。

(5)创建派生类对象

派生类构造函数创建派生类对象时:程序先创建基类对象,然后初始化派生类新增的数据成员。

具体地说:基类对象应该在程序进入派生类构造函数之前被创建→C++使用成员初始化列表完成这种工作:

//version 1
RatedPlayer::RatedPlayer(unsigned int r, const string & fn, const string & ln, bool ht) : TableTennisPlayer(fn,ln,ht)
{
	rating = r;
}
//version2
RatedPlayer::RatedPlayer(unsigned int r, const string & fn, const string & ln, bool ht) : TableTennisPlayer(fn,ln,ht), rating(r) //成员名
{
}

其中,: TableTennisPlayer(fn,ln,ht) 是成员初始化列表,它是可执行的代码,调用TableTennisPlayer 构造函数。

假如程序包含如下声明:

RatedPlayer rplayer1(1140, "Mallory", "Duck", true);

则RealPlayer构造函数将把实参“Mallory”、“Duck”和true赋给形参fn、In和ht,然后将这些参数作为实参传递给TableTennisPlayer构造函数,后者将创建一个嵌套TableTennisPlayer对象,并将数据“Mallory”、“Duck”和true存储在该对象中。然后程序进入RealPlayer构造函数体,完成RealPlayer对象的创建,并将参数r 的值(即1140)赋给rating成员。

如果省略成员初始化列表,程序将使用默认的基类构造函数,反正必须先创建完基类对象。

笔记:创建派生类对象时,程序首先调用基类构造函数,然后再调用派生类构造函数。基类构造函数负责初始化继承的数据成员,派生类构造函数主要用于初始化新增的数据成员。派生类的构造函数总是调用一个基类构造函数,可以使用初始化列表语法指明要使用的基类构造函数,否则将使用默认的基类构造函数。

(6)派生类与基类的关系plus:

  • 派生类可以使用基类的方法,条件是方法不是私有的;
  • 基类指针可以在不进行显示类型转换的情况下指向派生类对象;
  • 基类引用可以在不进行显示类型转换的情况下引用派生类对象。

然而,基类指针或引用只能用于调用基类方法,而不能调用派生类方法。

 

keypoint3——is-a关系

公有继承建立一种is-a关系,即派生类也是一个基类对象,可以对基类对象执行的任何操作,也可以对派生类对象执行。

例如,假设有一个Fruit类,可以保存水果的重量和热量。因为香蕉是一种特殊的水果,所以可以从Fruit类中派生出Banana类。新类将继承原始类的所有数据成员,因此,Banana对象将包含表示香蕉重量和热量的成员。新的Banana类还添加了专门用于香蕉的成员Banana Institute Peel Index(香蕉机构果皮索引)。因为派生类可以添加特性,所以将这种关系称为is-a-kind-of(是一种)关系可能更加准确,但是通常使用术语is-a。

为阐明is-a关系,来看一些与该模型不符的例子:

has-a关系:午餐可能包括水果,但通常午餐并不是水果。所以不能从Fruit类派生出Lunch类来在午餐中添加水果,而应该描述为午餐有水果(将Fruit对象作为Lunch类的数据成员)。

类似的还有is-like-a、uses-a关系,不再赘述。

 

keypoint4——多态公有继承

前面派生类对象使用基类的方法,而未做任何修改。然而,我们有时会希望同一个方法在派生类和基类中的行为是不同的。

我们来看一个例子。现在银行要求我完成一项工作——开发两个类。一个类用于表示基本支票账户——Brass Account,另一个类用于表示代表Brass Plus支票账户,它添加了透支保护特性。也就是说,如果用户签出一张超出其存款余额的支票——但是超出的数额在银行透支上限之内,银行将支付这张支票,对超出的部分收取额外的费用,并追加罚款。可以根据要保存的数据以及允许执行的操作来确定这两种账户的特征。

下面是用于Brass Account支票账户的信息:

客户姓名、账号、当前结余;

下面是可以执行的操作:

创建账户、存款、取款、显示账户信息。

银行希望Brass Plus支票账户包含Brass Account的所有信息及如下信息:

透支上限、透支贷款利率、当前的透支总额;

不需要新增操作,但有两种操作的实现不同:

对于取款操作,必须考虑透支保护,,显示操作必须显示Brass Plus账户的其他信息。

现在,通过咨询银行客服,我们知道新的类需要构造函数,而且构造函数应提供账户信息,设置透支上限(默认为500元)和利率(默认为11.125%)。另外,还应有重新设置透支限额、利率和当前欠款的方法。

下面给出两个类的声明:

 1 // brass.h  -- bank account classes
 2 #ifndef BRASS_H_
 3 #define BRASS_H_
 4 #include <string>
 5 // Brass Account Class
 6 class Brass
 7 {
 8 private:
 9     std::string fullName;
10     long acctNum;
11     double balance;
12 public:
13     Brass(const std::string & s = "Nullbody", long an = -1,
14                 double bal = 0.0);
15     void Deposit(double amt);
16     virtual void Withdraw(double amt);
17     double Balance() const;
18     virtual void ViewAcct() const;
19     virtual ~Brass() {}
20 };
21 
22 //Brass Plus Account Class
23 class BrassPlus : public Brass
24 {
25 private:
26     double maxLoan;
27     double rate;
28     double owesBank;
29 public:
30     BrassPlus(const std::string & s = "Nullbody", long an = -1,
31             double bal = 0.0, double ml = 500,
32             double r = 0.11125);
33     BrassPlus(const Brass & ba, double ml = 500, 
34                                 double r = 0.11125);
35     virtual void ViewAcct()const;
36     virtual void Withdraw(double amt);
37     void ResetMax(double m) { maxLoan = m; }
38     void ResetRate(double r) { rate = r; };
39     void ResetOwes() { owesBank = 0; }
40 };
41 
42 #endif
brass.h
  1. BrassPlus类在Brass类的基础上添加了3个私有数据成员和3个公有成员函数;
  2. Brass类和BrassPlus类都声明了ViewAcct()和Withdraw()方法,但两个类的对象的这些方法的行为是不同的;
  3. Brass类在声明ViewAcct()和Withdraw()时使用了新关键字virtual,这些方法被称为虚方法;
  4. Brass类还声明了一个虚析构函数。

第2点介绍了声明如何指出方法在派生类的行为的不同。两个ViewAcct()原型表明将有2个独立的方法定义。基类版本的限定名为Brass::ViewAcct(),派生类版本的限定名为BrassPlus::ViewAcct(),程序将根据对象类型来确定使用哪个版本:

Brass dom("Bob", 11224, 4183.45);
BrassPlus dot("Dog", 12118, 2592.00);
dom.ViewAcct();		//使用Brass::ViewAcct() 
dot.ViewAcct();		//使用BrassPlus::ViewAcct() 

同样,Withdraw()也有两个版本,一个供Brass对象使用,另一个供BrassPlus对象使用。

对于在两个类中行为相同的方法(如Deposit()),则只在基类中声明。

第3点是虚方法的使用。如果方法是通过引用或指针而不是对象调用的,“virtual”将确定使用哪一种方法。

  • 如果没有使用virtual,程序将根据引用类型或指针类型选择方法;
  • 如果使用了virtual,程序将根据引用或指针指向的对象的类型来选择方法。

如果ViewAcct()不是虚的,则程序的行为如下:

Brass dom("Bob", 11224, 4183.45);
BrassPlus dot("Dog", 12118, 2592.00);
Brass & b1_ref = dom;
Brass & b2_ref = dot;
b1_ref.ViewAcct();		//use Brass::ViewAcct()
b2_ref.ViewAcct();		//use Brass::ViewAcct()

引用变量的类型为Brass,所以选择了Brass::ViewAccount()。使用Brass指针代替引用时,行为将与此类似。

如果ViewAcct()是虚的,则程序的行为如下:

Brass dom("Bob", 11224, 4183.45);
BrassPlus dot("Dog", 12118, 2592.00);
Brass & b1_ref = dom;
Brass & b2_ref = dot;
b1_ref.ViewAcct();		//use Brass::ViewAcct()
b2_ref.ViewAcct();		//use BrassPlus::ViewAcct()

这里两个引用的类型都是Brass,但b2_ref引用的是一个BrassPlus对象,所以使用的是BrassPlus::ViewAcct()。使用Brass指针代替引用时,行为将与此类似。

小结:虚函数的这种行为非常方便。所以我们经常在基类中将派生类会重新定义的方法声明为虚方法。方法在基类中被声明为虚的后,它在派生类中将自动成为虚方法(在派生类中使用关键字来指出哪些函数是虚函数当然更好啦)。

笔记:如果要在类中重新定义基类的方法,通常应将基类方法声明为虚的。这样,程序将根据对象类型(而不是引用或指针的类型)来选择方法版本。

第4点是基类声明了一个虚析构函数。这样做的目的是确保释放派生类对象时,按正确的顺序调用析构函数。

如果不使用虚析构函数,则程序将只调用对应于指针类型的析构函数。这意味着只有基类的析构函数被调用,即使指针指向的是一个派生类对象。如果虚构函数是虚的,将调用相应对象类型的析构函数。因此,如果指针指向的是BrassPlus对象,将调用BrassPlus的析构函数,然后自动调用基类的析构函数。因此,使用虚析构函数可以确保正确的析构函数序列被调用。

 

keypoint5——静态联编和动态联编

函数名联编:将源代码中的函数调用解释为执行特定的函数代码块。

在C++中,由于函数重载的缘故,这项任务更复杂。编译器必须查看函数参数以及函数名才能确定使用哪个函数。

静态联编:在编译过程中进行联编。

然而,虚函数的存在,使得程序使用哪一个函数是不能在编译时确定的,因为编译器不知道用户将选择哪种类型的对象。

动态联编:编译器生成能够在程序运行时选择正确的虚方法的代码。

【指针与引用类型的兼容性】

在C++中,动态联编与通过指针和引用调用方法有关。

通产,C++不允许将一种类型的地址赋给另一种类型的指针,也不允许一种类型的引用指向另一种类型。

然而,指向基类的引用或指针可以引用派生类对象,而不必进行显式类型转换。

将派生类引用或指针转换为基类引用或指针被称为向上强制转换,这使公有继承不需要进行显式类型转换。

将基类指针或引用转换为派生类指针或引用被称为向下强制转换,如果不使用显式类型转换,则向下强制类型转换是不允许的。

笔记:隐式向上强制转换使基类指针或引用可以指向基类对象或派生类对象,因此需要动态联编。C++使用虚成员函数来满足这种要求。

【虚成员函数和动态联编】

编译器对非虚方法使用静态联编;对虚方法使用动态联编。

 

keypoint6——访问控制protected

private与protected之间的区别只有在基类派生的类中才会表现出来:

  • 对于外部世界来说,保护成员的行为与私有成员类似;
  • 对于派生类来说,保护成员的行为与公有成员类似。

 

keypoint7——继承和动态内存分配

(1)派生类不使用new

(2)派生类使用new

 

 

 

 

 

 

 

posted @ 2017-08-20 22:19  GGBeng  阅读(288)  评论(0编辑  收藏  举报