【面向对象】多态公有继承、抽象基类

本文探讨了C++中的继承和多态概念,包括派生类如何继承基类的特性和方法,以及如何通过虚函数实现多态。此外,还讨论了抽象基类的概念及其应用场景。

从一个类派生出另一个类时,原始类称为其类,继承类称为派生类。为说明继承,首先需要一个基类。

Webtown俱乐部决定跟踪乒乓球会会员。作为俱乐部的首席程序员,需要设计一个TableTennisPlayer类,TableTennisPlayer类只是记录会员的姓名以及是否有球桌。Webtown俱乐部的一些成员曾经参加过当地的乒乓球锦标赛,需要这样一个类,它能包括成员在比赛中的比分。与其从零开始,不如从TableTennisPlayer类派生出一个类。首先将RatePlayer类声明为从TableTennisPlayer类派生而来。类的声明如下代码所示:

#ifndef TABTENN_H_
#define TABTENN_H_

#include <string>
using namespace std; 

class TableTennisPlayer
{
	private:
		string firstname; 
		string lastname; 
		bool hasTable; 
	public:
		TableTennisPlayer(const string & fn = "none", 
						const string & ln = "none", bool ht = false); 
		void Name() const; 
		bool HasTable() const { return hasTable; }
		void ResetTable(bool v) { hasTable = v; }
}; 

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 r) { rating = r; }
}; 

#endif
上述代码完成了哪些工作呢?RatePlayer对象将具有以下特征:

派生类对象存储了基类的数据成员(派生类继承了基类的实现);

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

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

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

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

如下代码清单实现了这两个类:

#include "tabtenn.h"
#include <iostream>
using namespace std; 

TableTennisPlayer::TableTennisPlayer(const string & fn, const string & ln, bool ht) :
	firstname(fn), lastname(ln), hasTable(ht) {}
	
void TableTennisPlayer::Name() const 
{
	cout << lastname << ", " << firstname; 
}

RatedPlayer::RatedPlayer(unsigned int r, const string & fn, const string & ln, bool ht) : 
TableTennisPlayer(fn, ln, ht)
{
	rating = r; 
}

RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer & tp) :
TableTennisPlayer(tp), rating(r) {}
对如上代码作以下说明:

1. 构造函数:访问权限的考虑

派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。例如,RatedPlayer构造函数不能直接设置继承的成员(firstname、lastname和hasTable),而必须使用基类的公有方法访问私有的基类成员。具体地说,派生类构造函数必须使用基类构造函数

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

首先创建基类对象;

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

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

释放对象的顺序与创建对象的顺序相反,即首先执行派生类的析构函数,然后自动调用基类的析构函数。

2. 派生类和基类之间的特殊关系

派生类与基类之间有一些特殊关系。其中之一是派生类对象可以直接使用基类的方法,条件是方法不是私有的。

另外两个重要的关系是:基类指针可以不在进行显式类型转换的情况下指向派生类对象;基类引用可以在不进行显式类型转换的情况下引用派生类对象。

3. 继承:is-a关系

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

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

公有继承不能建立is-like-a关系,也就是说,它不采用明喻。人们通常说律师就像鲨鱼,但律师并不是鲨鱼。例如,鲨鱼可以在水下生活。所以,不应从Shark类派生出Lawyer类。继承可以在基类的基础上添加属性,但不能删除基类的属性。

公有继承不建立is-implemented-as-a(作为……来实现)关系。例如,可以使用数组来实现栈,但从Array类派生出Stack类是不合适的,因为栈不是数组。正确的方法是,通过让栈包含一个私有Array对象成员来隐藏数组实现。

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

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

=======================================================================================

RatedPlayer继承示例很简单。派生类对象使用基类的方法,而未做任何的修改。然而,可能会遇到这样的情况,即希望同一个方法在派生类和基类中的行为是不同的。换句话来说,方法的行为应取决于调用该方法的对象。这种较复杂的行为称为多态——具有多种形态,即同一个方法的行为随上下文而异。有两种重要的机制可用于实现多态公有继承:

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

使用虚方法。

现在来看一个例子。由于Webtown俱乐部的工作经历,您成为Pontoon银行的首席程序员。银行要求您完成的第一项工作是开发两个类。一个类用于表示基本支票账户——Brass Account,另一个类用于表示代表Brass Plus支票账户,它添加了透支保护特性。也就是说,如果用户签出一张超出其存款余额的支票——但是超出的数额并不是很大,银行将支付这张支票,对超出的部分收取额外的费用,并追加罚款。可以根据要保存的数据以及允许执行的操作来确定这两种账户的特征。

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

客户姓名;

账号;

当前结余。

下面是可以执行的操作:

创建账户;

存款;

取款;

显示账户信息。

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

透支上限;

透支贷款利率;

当前的透支总额。

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

对于取款操作,必须考虑透支保护;

显示操作必须显示Brass Plus账户的其他信息。

假设将第一个类命名为Brass,第二个类命名为BrassPlus。应从Brass公有派生出BrassPlus吗?要回答这个问题,必须先回答另一个问题:BrassPlus类是否满足is-a条件?当然满足。对于Brass对象是正确的事情,对于BrassPlus对象也是正确的。它们都将保存客户姓名、账号以及结余。使用这两个类都可以存款、取款和显示账户信息。请注意,is-a关系通常是不可逆的。

有关这两个类的信息声明,如下代码清单所示:

// brass.h -- bank account classes
#ifndef BRASS_H_
#define BRASS_H_

#include <string>
using namespace std; 

// Brass Account Class
class Brass
{
	private:
		string fullName; 
		long acctNum; 
		double balance; 
	public:
		Brass(const 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() {}
}; 

// Brass Plus Account Class
class BrassPlus : public Brass
{
	private:
		double maxLoan; 
		double rate; 
		double owesBank; 
	public:
		BrassPlus(const string & s = "NullBody", long an = -1, 
				  double bal = 0.0, double ml = 500, double r = 0.11125);
		BrassPlus(const Brass & ba, double ml = 500, double r = 0.11125); 
		virtual void ViedAcct() const; 
		virtual void Withdraw(double amt); 
		void ResetMax(double m) { maxLoan = m; }
		void ResetRate(double r) { rate = r; }
		void ResetOwes() { owesBank = 0; } 
}; 

#endif
对于如上程序清单,需要说明以下几点:

1. BrassPlus类在Brass类的基础上添加了3个私有数据成员和3个公有数据成员。

2. Brass类和BrassPlus类都声明了ViewAcct() 和Withdraw()方法,但BrassPlus对象和Brass对象的这些方法的行为是不同的。

3. Brass类在声明ViewAcct()和Withdraw()方法时使用了新的关键字virtual。这些方法称为虚方法。

这比前两点都要复杂,如果方法是通过指针或引用而不是对象调用的,它将确定使用哪一种方法。如果没有使用关键字virtual,程序将根据引用类型或指针类型选择方法;如果使用了virtual,程序将根据引用或指针指向的对象的类型来选择方法。因此,经常在基类中将派生类会重新定义的方法声明为虚方法。方法在基类中被声明为虚后,它在派生类中将自动成为虚方法。然而,在派生类声明中使用关键字virtual来指出哪些函数是虚函数也不失为一种好办法。

4. Brass类还声明了一个虚析构函数,虽然该析构函数不执行任何操作。

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

注意:如果要在派生类中重新定义基类的方法,通常应将基类方法声明为虚的。这样,程序将根据对象类型而不是引用或指针的类型来选择方法版本。为基类声明一个虚析构函数也是一种惯例。

类的实现如下代码清单所示:

// brass.cpp -- bank account class methods

#include <iostream>
#include "brass.h"
using namespace std; 

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

// Brass methods
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)
{
	// set up ###.## format
	format initialState = setFormat(); 
	precis prec = cout.precision(2); 
	
	if (amt < 0)
	    cout << "Withdrawal amount must be positive; withdrawal cancelled.\n"; 
	else if (amt <= balance)
		balance -= amt; 
	else 
		cout << "Withdrawal amount of $" << amt 
			 << " exceeds your balance.\n" << "Withdrawal cancelled.\n"; 
	
	restore(initialState, prec); 
}

double Brass::Balance() const
{
	return balance; 
}

void Brass::ViewAcct() const 
{
	// set up ###.## format
	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 methods
BrassPlus::BrassPlus(const string & s, long an, double bal, 
					 double ml, double r) : Brass(s, an, bal)
{
	maxLoan = ml; 
	owesBank = 0.0; 
	rate = r; 
}

BrassPlus::BrassPlus(const Brass & ba, double ml, double r) : Brass(ba)
{
	maxLoan = ml; 
	owesBank = 0.0; 
	rate = r; 
}

// redefine how ViewAcct() works
void BrassPlus::ViedAcct() const
{
	// set up ###.## format
	format initialState = setFormat(); 
	precis prec = cout.precision(2); 
	
	Brass::ViewAcct(); 		// display base portion
	cout << "Maximum loan: $" << maxLoan << endl; 
	cout << "Owed to bank: $" << owesBank << endl; 
	cout.precision(3); 		// ###.### format
	cout << "Loan Rate: " << 100 * rate << "%" << endl; 
	
	restore(initialState, prec); 
}

// redefine how Withdraw() works
void BrassPlus::Withdraw(double amt)
{
	// set up ###.## format
	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()
{
	// set up ###.## format
	return cout.setf(ios_base::fixed, ios_base::floatfield); 
}

void restore(format f, precis p)
{
	cout.setf(f, ios_base::floatfield); 
	cout.precision(p); 
}
对以上程序我们作如下说明:

1. 非构造函数不能使用成员初始化列表语法,但派生类方法可以调用公有的基类方法。

2. 在派生类方法中,标准技术是使用作用域运算符来调用基类方法。

=======================================================================================

为什么需要虚析构函数?虚函数是怎么工作的呢?带着这些问题,我们研究一下虚函数的工作原理。

1. 静态联编与动态联编

程序调用函数时,将使用哪个可执行代码块呢?编译器负责回答这个问题。将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding)。在C语言中,这非常简单,因为每个函数名都对应一个不同的函数。在C++中,由于函数重载的缘故,这项任务更复杂。编译器必须查看函数参数以及函数名才能确定使用哪个函数。然而C/C++编译器可以在编译过程完成这种联编。在编译过程中进行联编被称为静态联编(static binding),又称为早期联编(ealy binding)。然而,虚函数使这项工作变得更困难。如上例子可以看出,有时候使用哪一个函数是不能在编译时确定的,因为编译器不知道用户将选择哪种类型的对象。所以编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编(dynamic binding),又称为晚期联编(late binding)。

2. 指针和引用的兼容性

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

相反的过程——将基类的指针或引用转换为派生类的指针或引用——称为向下强制转换(downcasting)。如果不使用显式类型转换,则向下强制转换是不允许的。原因是is-a关系通常是不可逆的。派生类可以新增数据成员,因此使用这些数据成员的类成员函数不能应用于基类。

3. 为什么有两种类型的联编以及为什么默认为静态联编

如果动态联编让您能够重新定义类方法,而静态联编在这方面很差,为何不摒弃静态联编呢?原因有两个——效率和概念模型。

首先来看效率。为使程序能够在运行阶段进行决策,必须采取一些方法来跟踪基类指针或引用指向的对象类型 ,这增加了额外的处理开销。例如,如果类不会作基类,则不需要动态联编。同样,如果派生类不重新定义基类的任何方法,也不需要使用动态联编。在这些情况下,使用静态联编更为合理,效率也更高。由于静态联编的效率更高,因此被设置为C++默认选择。

接下来看概念模型。在设计类时,可能包含一些不在派生类重新定义的成员函数。不将该函数设置为虚函数,有两个方面的好处:首先效率高;其次,指出不要重新定义该函数。这表明,仅将那些预期将被重新定义的方法声明为虚的。

4. 虚函数工作原理 

通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表(virtual function table, vtbl)。虚函数表中存储了为类对象进行声明的虚函数的地址。

调用虚函数时,程序将查看存储在对象中的vtbl地址,然而转向相应的虚函数地址表。

5. 有关虚函数注意事项

构造函数不能为虚函数。创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数将使用基类的一个构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以将类构造函数声明为虚的没什么意义。

析构函数应当为虚函数,除非不用作基类。通常应该给基类提供一个虚析构函数,即使它并不需要析构函数。

友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数。

=======================================================================================

至此,介绍了简单继承和较复杂的多态继承。接下来更为复杂的是抽象基类(abstract base class, ABC)。我们来看一些可使用ABC的编程情况。

有时候,使用is-a规则并不是看上去的那么简单。例如,假设您正在开发一个图形程序,该程序会显示圆和椭圆等。圆是椭圆的一种特殊情况——长轴和短轴相等的椭圆。因此,所有的圆都是椭圆,可以从Ellipse类派生出Circle类。但涉及到细节时,将发现很多问题。

首先考虑Ellipse类包含的内容。数据成员可以包括椭圆中心的坐标、半长轴(长轴的一半)、半短轴(短轴的一半)以及方向角(水平坐标轴与长轴之间的角度)。另外,还可以包括一些移动椭圆、返回椭圆面积、旋转椭圆以及缩放长半轴和短半轴的方法。

现在假设从Ellipse类派生出一个Circle类。虽然圆是一种椭圆,但是这种派生是笨拙的。例如,圆只需要一个值(半径)就可以描述大小和形状、并不需要有长半轴a和短半轴b。Circle构造函数可以通过将同一个值赋给成员a和b来照顾这种情况,但将导致信息冗余。angle参数和Rotate()方法对圆来说没有实际意义;而Scale()方法(顾名思义)会将两个轴作不同的缩放,将圆变成椭圆。可以使用一些技巧来修正这些问题,例如在Circle类中私有部分包含重新定义的Rotate()方法,使Rotate()不能以公有方式用于圆。但总的来说,不使用继承,直接定义Circle类更简单。

但这种解决方法的效率也不高,Circle和Ellipse类有很多共同点,将它们分别定义则忽略了这一事实。

还有一种解决方法,即从Ellipse类和Circle类中抽象出它们的共性,将这些特性放到一个ABC中。然后从该ABC派生出Circle类和Ellipse类。这样,便可以使用基类指针数组同时管理Circle和Ellipse对象,即可以使用多态方法。在这个例子中,这两个类的共同点是中心坐标、Move()方法和Area()方法(对于这两个类来说,是不同的)。确实,甚至不能在ABC中实现Area()方法,因为它没有包含必要的数据成员。C++通过使用纯虚函数(pure virtual function)提供未实现的函数。纯虚函数声明的结尾外为=0

当类声明中包含纯虚函数时,则不能创建该类的对象。这里的理念是,包含纯虚函数的类只用作基类。要成为真正的ABC,必须至少包含一个纯虚函数。原型中=0使虚函数成为纯虚函数。在原型中使用=0指出类的一个抽象基类,在类中可以不定义该函数。

最后,我们用一个完整的ABC示例结束此文。

这里将这一概念用于Brass和BrassPlus账户,首先定义一个名为AcctABC的ABC。这个类包含了Brass和BrassPlus类共有的所有方法和数据成员,而那些在BrassPlus类和Brass类中的行为不同的方法应被声明为虚函数。至少应有一个虚函数是纯虚函数,这样才能使AcctABC成为抽象类。

程序清单如下所示(类方法的定义略去):

// acctabc.h -- bank account classes

#ifndef ACCTABC_H_
#define ACCTABC_H_

#include <string>

// Abstract Base Class
class AcctABC
{
	private:
		string fullName; 
		long acctNum;
		double balance; 
	protected:
		struct Formatting
		{
			ios_base::fmtflags flag; 
			streamsize pr; 
		}; 
		const string & FullName() const { return fullName; }
		long AcctNum() const { return acctNum; }
		Formatting setFormat() const; 
		void Restore(Formatting & f) const;  
	public:
		AcctABC(const string & s = "Nullbody", long an = -1, double bal = 0.0); 
		void Deposit(double amt); 
		virtual void Withdraw(double amt) = 0; 		// pure virtual function
		double Balance() const { return balance; }
		virtual void ViewAcct() const = 0; 			// pure virtual function
		virtual ~AcctABC() {}
}; 

// Brass Account Class
class Brass : public AcctABC
{
	public:
		Brass(const string & s = "Nullbody", long an = -1, 
			  double bal = 0.0) : AcctABC(s, an, bal) {}
		virtual void Withdraw(double amt); 
		virtual void ViewAcct() const; 
		virtual ~Brass() {}
}; 

// Brass Plus Account Class
class BrassPlus : public AcctABC
{
	private:
		double maxLoan; 
		double rate; 
		double owesBank; 
	public:
		BrassPlus(const string & s = "Nullbody", long an = -1, 
				  double bal = 0.0, double ml = 500, double r = 0.10); 
		BrassPlus(const Brass & ba, double ml = 500, double r = 0.10); 
		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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值