C++的学习之旅——类继承

目录

一、一个简单的基类

1、派生一个类

2、构造函数:访问权限的考虑 

3、使用派生类

4、派生类和基类之间的特殊关系

二、继承:is-a关系

三、多态公有继承

1、开发Brass类和BrassPlus类

(1)类声明

(2)类实现

四、静态联编和动态联编

1、指针和引用类型的兼容性

2、虚成员函数和动态联编

(1)静态联编和动态联编区别

(2)虚函数的工作原理

3、虚函数的注意事项

五、访问控制:protected

六、抽象基类

1、什么是抽象基类

2、应用场景

3、ABC理念


有时候我们需要对已有的类进行修改,但是修改伴随着一定的风险,特别是对于已经封装好了的类库。C++提供了比修改代码更好的方法来扩展和修改类,即类继承。它能够从已有的类中派生出新的类,而派生类继承了原有类(称为基类)的特征,包括方法。可以使用继承完成以下工作:

①在原有基础上添加功能。例如对于数组类,可以添加数学运算。

②可以给类添加数据

③可以修改类方法的行为

一、一个简单的基类

从一个类派生出另一个类时,原始类称为基类,继承类称为派生类。为说明继承,需要一个基类。以下例子为Webtown俱乐部用于跟踪乒乓球会会员的类

#ifndef TABTENN0_H_
#define TABTENN0_H_
#include <string>

using std::string;

class TableTennisPlayer
{
	private:
		string firstname;
		string lastname;
		bool hasTable;
	public:
		TableTennisPlayer(const string & fn="none",const string & ln="none",bool ht=false);
		bool HasTable()const{return hasTable;}
		void Name()const;
		void ResetTable(bool v){hasTable=v;}
}
#endif
#include "tabtenn0.h"
#include <iostream>

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

void TableTennisPlayer::Name()const
{
	std::cout<<lastname<<", "<<firstname;
 } 

 TableTennisPlayer用于记录球员姓名以及是否有球桌。此外,构造函数还使用了初始化列表的语法。

1、派生一个类

Webtown俱乐部的成员需要记录某场比赛的比分,则可在TableTennisPlayer类派生出一个类。首先将RatedPlayer类声明为从TableTennisPlayer类派生而来:

class RatedPlayer:public TableTennisPlayer
{
    ......
}

 冒号指出RatedPlayer类的基类是TableTennisPlayer类。上述特殊的声明头表明 TableTennisPlayer 是一个公有基类,这被称为公有派生。派生类对象包含基类对象。使用公有派生,基类的公有成员将成为派生类的公有成员;基类的私有部分也将成为派生类的一部分,但只能通过基类的公有和保护方法访问(稍后将介绍保护成员)

这样,Ratedplayer 对象将具有以下特征:
①派生类对象存储了基类的数据成员(派生类继承了基类的实现)

②派生类对象可以使用基类的方法(派生类继承了基类的接口)

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

另外还需要在继承特性中添加:

①派生类自己的折构函数

②派生类可以根据需要添加额外的数据成员和成员函数

例如上述例子,还需要在RatedPlayer类中添加数据成员存储比分,以及检索比分和重置比分的方法。

class RatedPlayer:public TableTennisPlayer
{
    private:
    	unsigned int rating;
    public:
    	RatedPlayer(unsigned int r=0,const string &fn="none",const string & ln="none",bool ht=false);
    	RatedPlayer(unsigned int r,const TableTennisPlayer & tp);
    	unsigned int Rating()const{return rating;}
    	void ResetRating(unsigned int t){rating=r;}
}

其中包含两个构造函数,第一个可以传递TableTennisPlayer的三个数据以及新增的比分数据,第二个传递比分数据以及一个TableTennisPlayer对象。

2、构造函数:访问权限的考虑 

派生类不能访问基类的私有成员,而必须通过基类方法进行访问。例如RatedPlayer的构造函数,需要使用基类的构造函数才可以初始化基类的私有数据。

//第一个构造函数代码
RatedPlayer::RatedPlayer(usigned int r,const string&fn,const string&ln,bool ht):TableTennisPlayer(fn,ln,ht)
{
	rating=r;
}

 其中:TableTennisPlayer(fn,ln,ht)是成员初始化列表。将调用TableTennisPlayer构造函数。若不调用基类构造函数,程序将使用默认的基类构造函数。

//第二个构造函数
RatedPlayer::RatedPlayer(usigned int r,const TableTennisPlayer&tp):TableTennisPlayer(tp),rating(r)
{
}

 由于tp是TableTennisPlayer&,将调用基类的复制构造函数,由于没有定义复制构造函数,编译器将自动生成一个。

派生类构造函数的要点如下:

①首先创建基类对象

②派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数

③派生类构造函数应初始化派生类新增的数据成员

另外,这个例子没有提供显式折构函数,将使用隐式折构函数。释放对象顺序和创建对象顺序相反,先执行派生类的折构函数,再执行基类的折构函数。

3、使用派生类

使用派生类,程序需要能够访问基类声明,因而将两个类声明放在一起会更好些。在RatedPlayer对象中,同样可以使用' . '引出TableTennisPlayer类中的成员函数。

RatedPlayer repalyer(1140,"Mallory","Duck",true);
replayer.Name();

4、派生类和基类之间的特殊关系

基类指针可以在不进行显式转换的情况下指向派生类对象;基类引用可以在不进行显式类型转换的情况下引用派生类对象,不过需要注意的是,基类的指针和引用只能调用基类的方法,不能调用派生类的方法。另外,不能将基类的对象和指针赋给派生类的指针和引用。

RatedPlayer replayer1(1140,"Mallory","Duck",true);
TableTennisPlayer & rt=replayer;
TableTennisPlayer * pt=&replayer;

rt.Name();
pt->Name();

 基类指针和引用可以用于派生类对象,意味着当函数形参为基类引用或者指针时,可以传递基类或者派生类的引用或者指针。还可以将派生类的对象用于基类对象的初始化。

RatedPlayer olaf1(1840,"Olaf","Loaf",true);
TabeltennisPlayer olaf2(olaf1);

//若有构造函数,则将调用构造函数
TableTennisPlayer(const RatedPlayer &);

//若没有构造函数,存在隐式复制构造函数,形参是基类,所以可以传递派生类对象
TableTennisPlayer(const TableTennisPlayer &);

 同样,也可以将派生对象赋给基类对象

RatedPlayer olaf1(1840,"Olaf","Loaf",true);
TabelTennisPlayer olaf2;
olaf2=olaf1;

//将调用隐式重载运算符
TableTennisPlayer & operator=(const TableTennisPlayer &)const

二、继承:is-a关系

        派生类和基类之间的特殊关系基于 C++继承的底层模型。实际上,C++有 3 种继承方式:公有继承、保护继承和私有继承。公有继承是最常用的方式,它建立一种 is-a 关系,即派生类对象也是一个基类对象,可以对基类对象执行的任何操作,也可以对派生类对象执行。例如,假设有一个Fruit类,可以保存水果的重量和热量。因为香蕉是一种特殊的水果,所以可以从 Fruit 类派生出 Banana 类。新类将继承原始类的所有数据成员,因此,Banana 对象将包含表示香蕉重量和热量的成员。新的 Banana 类还添加了专门用于香蕉的成员,这些成员通常不用于水果,例如 Banana Institute PeelIndex(香蕉机构果皮索引)。因为派生类可以添加特性,所以,这种关系称为 is-a-kind-of(是一种)关系可能更准确,通常使用术语 is-a。

        为阐明 is-a 关系,来看一些与该模型不符的例子。公有继承不建立 has-a 关系。例如,餐可能包括水果,但通常午餐并不是水果。所以,不能通过从 Fruit 类派生出 Lunch 类来在午餐中添加水果。在午餐中加入水果的正确方法是将其作为一种 has-a 关系:午餐有水果。最容易的建模方式是,将Fruit对象作为Lunch类的数据成员。

        公有继承不能建立 is-like-a 关系,也就是说,它不采用明喻。人们通常说律师就像鲨鱼,但律师并是鲨鱼。例如,鲨鱼可以在水下生活。所以,不应从 Shark 类派生出 Lawyer 类。继承可以在基类的基础添加属性,但不能删除基类的属性。在有些情况下,可以设计一个包含共有特征的类,然后以is-a或has-a关系,在这个类的基础上定义相关的类。
        公有继承不建立 is-implemented-as-a (作为...来实现)关系。例如,可以使用数组来实现栈,但从Array 类派生出Stack 类是不合适的,因为栈不是数组。例如,数组索引不是栈的属性。另外,可以以其"方式实现栈,如链表。正确的方法是,通过让栈包含一个私有 Array 对象成员来隐藏数组实现。

        公有继承不建立 uses-a 关系。例如,计算机可以使用激光打印机,但从 Computer 类派生出 Printer(或反过来)是没有意义的。然而,可以使用友元函数或类来处理 Printer 对象和 Computer 对象之间的通信

        在C++中,完全可以使用公有继承来建立 has-a、is-implemented-as-a 或 uses-a 关系:然而,这样常会导致编程方面的问题。因此,还是坚持使用 is-a 关系吧。

三、多态公有继承

当同一个方法在派生类和基类中的行为不同,即方法的行为取决于调用的对象。这种行为称为多态,有两种机制可以实现多态公有继承:

①在派生类中重新定义基类的方法

②使用虚方法

以一个例子,银行开发两个类,一个用于表示基本支票账户——brassAccount,另一个用于表示BrassPlus支票账户,它添加了透支保护特性,即如果用户签出一张超出其存款余额的支票——但是超出的数额不是很大,银行将支付这张支票,对超出的部分收取额外的费用,并追加罚款。

两个类包含如下信息:

BrassAccout:

①客户姓名;

②账号;

③当前结余;

可执行操作:

①创建账户;

②存款;

③取款;

④显示账户信息。

BrassPlus:

①BrassAccout的所有信息

②透支上限;

③透支贷款利息

④当前的透支总额

两个类的不同之处:对于取款操作,必须考虑透支保护;显示操作必须显示BrassAccout账户的其他信息。

1、开发Brass类和BrassPlus类

(1)类声明

#ifndef BRASS_H_
#define BRASS_H_
#include <string>

class Brass
{
	std::string fullName;
	long acctNum;
	double balance;
	public:
		Brass(const std::string & s="Nullbody",long an=-1,double bal=0.0);
		void Deposit(double amt);
		virtual void Withdraw(double amt);
		double Balance()const; 
		virtual void ViewAcct()const;
		virtual ~Brass(){}
};

class BrassPlus:public Brass
{
	private:
		double maxLoan;
		double rate;
		double owesBank;
	public:
		BrassPlus(const std::string & s="Nullbody",long an=-1,double bal=0.0,double ml=500,double r=0.11125);
		BrassPlus(const Brass & ba,double m1=500,double r=0.11125);
		virtual void ViewAcct()const;
		virtual void Withdraw(double amt);
		void ResetMax(double m){maxLoan =m;}
		void ResetRate(double r){rate=r;}
		void ResetOwes(){owesBank=0;}
};
#endif

①BrassPlus类在Brass类基础上新增了3个私有数据,3个公有成员函数;

②Brass类和BrassPlus类都声明了ViewAcct()和Withdraw()方法,但BrassPlus和Brass对象的这些方法不同,表明两个函数将有两个版本,限定名为Brass::ViewAcct(),BrassPlus::ViewAcct(),程序将使用对象类型来确定使用哪个版本。

③Brass类在声明ViewAcct()和Withdraw()方法使用了关键字virtual。这些方法被称为虚方法,如果对象是通过引用或指针而不是对象调用,它将确定使用哪种方法。如果没有使用关键字virtual,程序将根据引用类型或者指针类型选择方法;若使用了virtual,程序将根据引用或者指针指向的对象类型来选择方法。

④Brass类还声明了虚构折构函数,使得释放派生对象时,按照正确的顺序调用折构函数。

在对象释放的时候,若折构函数不是虚的,将只调用对应于指针类型的折构函数。而当使用基类指针指向派生类时,若基类折构函数不是虚的,将只调用基类的折构函数。只有基类折构函数是虚的,才会调用相应对象类型的(即派生类的)折构函数。

(2)类实现

#include <iostream>
#include "brass.h"
using std::cout;
using std::endl;
using std::string;

typedef std::ios_base::fmtflags format;
typedef std::streamsize precis;
format setFormat();
void restore(format f,precis p);

//构造函数
Brass::Brass(const string & s,long an,double bal)
{
	fullName=s;
	acctNum=an;
	balance=bal;
}
//存钱
void Brass::Deposit(double amt)
{
	if(amt<0)
		cout<<"Negative deposit not allowed;"
			<<"deposit is cancelled.\n";
	else 
		balance+=amt; 
}
//取款
void Brass::Withdraw(double amt)
{
	format initialState=setFormat();
	precis prec=cout.precision(2);
	
	if(amt<0)
		cout<<"Withdrawal amout must be positive;"
			<<"Withdrawal canceled.\n";
	else if(amt<=balance)
		balance-=amt;
	else
		cout<<"Withdrawal amout of $"<<amt
			<<" exceeds your balance.\n"
			<<"Withdrawal canceled.\n";
	restore(initialState,prec);
}
//获得现存金额
double Brass::Balance()const
{
	return balance;
}
//打印Brass数据
void Brass::ViewAcct()const
{
	format initialState=setFormat();
	precis prec=cout.precision(2);
	cout<<"Client: "<<fullName<<endl;
	cout<<"Account Number:"<<acctNum<<endl;
	cout<<"Balance: $"<<balance<<endl;
	restore(initialState,prec);
}
//构造函数
BrassPlus::BrassPlus(const string & s,long an,double bal,double m1,double r):Brass(s,an,bal)
{
	maxLoan=m1;
	owesBank=0.0;
	rate=r;
}
//构造函数
BrassPlus::BrassPlus(const Brass & ba,double m1,double r):Brass(ba)
{
	maxLoan=m1;
	owesBank=0.0;
	rate=r;
}
//打印BrassPlus数据
void BrassPlus::ViewAcct()const
{
	format initialState=setFormat();
	precis prec=cout.precision(2);
	
	Brass::ViewAcct();
	cout<<"Maximum loan:$"<<maxLoan<<endl;
	cout<<"Owed to bank:$"<<owesBank<<endl;
	cout.precision(3);
	cout<<"Loan Rate:"<<100*rate<<"%.n";
	restore(initialState,prec);
}
//取款
void BrassPlus::Withdraw(double amt)
{
	format initialState=setFormat();
	precis prec=cout.precision(2);
	
	double bal=Balance();
	if(amt<=bal)
		Brass::Withdraw(amt);
	else if(amt<=bal+maxLoan-owesBank)
	{
		double advance=amt-bal;
		owesBank+=advance*(1.0+rate);
		cout<<"Bank advance:$"<<advance<<endl;
		cout<<"Finance charge:$"<<advance*rate<<endl;
		Deposit(advance);
		Brass::Withdraw(amt);
	}
	else
		cout<<"Credit limit exceeded.Transaction cancelled.\n";
	restore(initialState,prec);
 } 
 
format setFormat()
{
	return cout.setf(std::ios_base::fixed,std::ios_base::floatfield);
}

void restore(format f,precis p)
{
	cout.setf(f,std::ios_base::floatfield);
	cout.precision(p);
}

类说明:

①派生类构造函数在初始化基类私有成员时,采用的是成员初始化列表语法,非构造函数不能使用成员初始化列表语法,但派生类可以调用公有的基类方法,不过需要使用作用域解析运算符,声明为基类,例如BrassPlus::ViewAcct()函数,中调用了基类的ViewAcct则需要使用如下语句才可调用:

Brass::ViewAcct();

②setFormat和restore函数一个用于设置浮点值的输出模式,一个用于重置输出格式

四、静态联编和动态联编

将源代码中的函数调用用解释为执行特定函数代码块被称为函数联编。在编译过程进行联编的称为静态联编,又称早期联编;编译器在程序运行时选择正确的代码块被称为动态联编,又称晚期联编

1、指针和引用类型的兼容性

        可以使用指向基类的引用和指针来引用派生类对象而不进行显式类型转换

        将派生类引用或指针转换为基类引用或指针被称为向上强制转换 (upcasting)。该规则是is-a 关系的一部分。BrassPlus 对象都是 Brass对象,因为它继承了 Brass对所有的数据成员和成员函数。所以可以对 Brass 对象执行的任何操作,都适用于 BrassPlus 对象。因此为处理 Brass引用而设计的函数可以对 BrassPlus 对象执行同样的操作,而不必担心会导致任何问题。将指向对象的指针作为函数参数时,也是如此。向上强制转换是可传递的,也就是说,如果从 BrassPlus 派生BrassPlusPlus 类,则Brass 指针或引用可以引用 Brass 对象、BrassPlus 对象或 BrassPlusPlus 对象。

        相反的过程一一将基类指针或引用转换为派生类指针或引用一一称为向下强制转换(downcasting).如果不使用显式类型转换,则向下强制转换是不允许的。原因是 is-a 关系通常是不可逆的。派生类可以新增数据成员,因此使用这些数据成员的类成员函数不能应用于基类。例如,假设从Employee 类派生出Singe类,并添加了表示歌手音域的数据成员和用于报告音域的值的成员函数 range()则将 range()方法应用于Employee 对象是没有意义的。但如果允许隐式向下强制转换则可能无意间将指向Singer 的指针设置为个Employee 对象的地址,并使用该指针来调用range()方法。

        对于使用基类引用或者指针作为参数的函数调用,将进行向上转换:

void fr(Brass & b);
void fp(Brass * pb);
void fv(Brass b);

int main
{
	Brass b=("Billy",123432,10000.0);
	BrassPlus bp("Betty Beep",232313,12345.0);
	fr(b);//使用 Brasss::ViewAcct() 
	fr(bp);//使用 BrasssPlus::ViewAcct() 
	fp(b);//使用 Brasss::ViewAcct() 
	fp(bp);//使用 BrasssPlus::ViewAcct() 
	fv(b);//使用 Brasss::ViewAcct() 
	fv(bp);//使用 Brasss::ViewAcct() 
}

 按值传递只将BrassPlus对象的Brass部分传递给函数fv()。但引用和指针发生的隐式向上转换导致函数fr()fp()为Brass对象和BrassPlus对象使用Brasss::ViewAcct() 和BrasssPlus::ViewAcct() 。隐式向上强制转换使得基类指针可以指向基类对象或派生对象,因而需要动态联编。虚函数成员可以满足这个要求

2、虚成员函数和动态联编

(1)静态联编和动态联编区别

动态联编可以让我们重新定义类方法,但是效率不高,而静态联编则效率更高且不需要重新定义方法。

(2)虚函数的工作原理

编译器处理虚函数的方法是:

给每个对象添加一个隐藏成员,该成员保存了一个指向函数地址数组的指针,该数组被称为虚函数表(vtbl)。其中存储了为类对象声明的虚函数地址。例如,基类对象存在一个指向虚函数地址数组的隐藏成员指针,而派生类也存在一个指向与基类不同的独立地址表的指针。当派生类未定义虚函数时,vtbl将保存基类函数的地址,当派生类定义了新的虚函数时,vtbl将该函数的地址添加到vtbl中。注意无论虚函数有多少,在对象中都只有一个地址成员,只是指向的虚函数表大小不同。

使用虚函数时,对内存和执行速度都有一定的成本:

①每个对象都将增大,增大量为存储地址的空间

②对于每个类,编译器都将创建一个虚函数地址表(数组)

③对于函数调用,都需要执行到表中查找地址的操作

这也是动态联编效率不高的原因

3、虚函数的注意事项

构造函数不能是虚函数,派生类不继承基类的构造函数,需要编写派生类的构造函数,并调用基类的构造函数,因而虚函对构造函数没什么意义

折构函数应为虚函数,除非类不作为基类(即使不作为基类也建议使用虚函数)。

友元不能是虚函数,友元不是类成员函数。不过可以在友元函数中使用虚函数

若派生类没有重新定义函数,将使用该函数的基类版本

⑤重新定义将隐藏方法:若在基类和派生类定义的虚函数方法参数列表不同, 将隐藏基类的所有同名方法,这引出了两条规则:第一,如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类的引用或指针,则可以修改为指向派生类的引用或指针,这种特性被称为返回类型协变。第二,如果基类声明被重载了,则应在派生类中重新定义所有的同名基类版本(若不需要修改,则可以直接调用)。若只重新定义一个版本,则基类中的其他同名版本将隐藏,派生类无法使用它们。

五、访问控制:protected

前面使用private和public来控制类成员的访问,此外还有一个关键字protected,其功能与private相似,在类外只能由公有类成员来访问protected中的类成员。它们两个的唯一区别是在派生类中,基类的private类成员即使在派生类中也无法直接访问私有成员,只能通过调用基类公有成员函数进行访问,而基类的protected类成员,在派生类的公有成员中可以直接访问保护成员。即对于外界来说,保护成员和私有成员相似,在派生类中,保护成员和公有成员相似。

一般来说,类数据成员采用私有访问控制,再通过基类方法进行访问会更好,而成员函数,保护访问控制很有用,可以使派生类能够访问公众不能访问的内部函数。

六、抽象基类

1、什么是抽象基类

先介绍一下纯虚函数:

在基类的函数声明的结尾处添加 =0,以表示此函数为纯虚函数在类中可以不定义该函数。

举例声明方式如下:

class BaseEllipse
{
    public:
        virtual double Area()const=0;
        ...
}

抽象基类(abstract  base class,简称ABC)主要用于定义派生类的通用接口。它只定义接口,不涉及实现,另外抽象基类必须包含至少一个纯虚函数。包含纯虚函数的抽象基类,不能用来创建对象,只能用作基类。而由抽象基类派生的类被称为具体类,可以在派生类中重新声明定义纯虚函数(不用在派生类声明后面加=0),并创建派生类的对象。

2、应用场景

当两个类A、B类具有共同特征,但是相互之间又有部分特征不互通时,使用继承不妥,直接定义成两个类又比较繁琐。这时可以使用抽象基类的方式进行设计。将两个类的共同特征部分抽象为ABC,再从ABC中派生出这两个类。这样可以使用基类指针同时管理两个类。

3、ABC理念

①设计ABC之前,首先应开发一个模型——指出编程问题所需的类以及它们之间的相互关系。

②一种学院派思想认为,如果要设计类继承层次,则只能将那些不会被用作基类的类设计为具体的类。这种方法的设计更清晰,复杂程度更低。

③可以将ABC(抽象基类)看作是一种必须实施的接口。ABC要求具体派生类覆盖其纯虚函数–迫使派生类遵循ABC(抽象基类)设置的接口规则。这种模型在基于组件的编程模式中很常见,在这种情况下,使用ABC(抽象基类)使得组件设计人员能够制定接口约定,这样确保了从ABC(抽象基类)派生的所有组件都至少支持ABC(抽象基类)指定的功能。


C++的学习笔记持续更新中~

要是文章有帮助的话

就点赞收藏关注一下啦!

感谢大家的观看

欢迎大家提出问题并指正~

猜你喜欢

转载自blog.csdn.net/qq_47134270/article/details/128725233
今日推荐