继承:is-a 关系
派生类和基类之间的特殊关系是基于 C++ 继承的底层模型的。实际上,C++有3种继承方式:公有继承、保护继承和私有继承。公有继承是最常用的方式,它建立一种is-a关系,即派生类对象也是一个基类对象,可以对基类对象执行的任何操作也可以对派生类对象执行。例如,假设有一个 Fruit 类,可以保存水果的重量和热量。因为香蕉是一种特殊的水果,所以可以从 Fruit 类派生出 Banana 类。新类将继承原始类的所有数据成员,因此,Banana对象将包含表示香蕉重量和热量的成员。新的Banana类还添加了专门用于香蕉的成员,这些成员通常不用于水果,例如 Banana Institute Peel Index(香蕉机构果皮索引)。因为派生类可以添加特性,所以,将这种关系成为 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 对象成员来隐藏数组实现。
公有继承不建立 use-a 关系。例如,计算机可以使用激光打印机,但从 Computer 来派生出 Printer 类(或反过来)是没有意义的。然而,可以使用友元函数或类来处理 Printer 对象和 Computer 对象之间的通信。
在 C++ 中,完全可以使用公有继承来建立 has-a、is-implemented-as-a 或 use-a 关系;然而,这样做通常会导致编程方面的问题。因此,还是坚持使用 is-a 关系吧。
多态公有继承
RatedPlayer 继承示例很简单。派生类对象使用基类的方法,而未作任何修改。然而,可能会遇到这样的情况,即希望同一个方法在派生类和基类中的行为是不同的。换句话来说,方法的行为应取决于调用该方法的对象。这种较复杂的行为称为多态——具有多种形态,即同一个方法的行为随上下文而异,有两种重要的机制可用于实现多态公有继承;
- 在派生类中重新定义基类的方法。
- 使用虚方法
现在来看另一个例子。由于 Webtown 俱乐部的工作经历,您成了 Ponton 银行的首席程序员。银行要求您完成的第一项工作是开发两个类。一个类用于表示基本支票账户——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 对象不具备 BrassPlus 对象的所有功能。
开发 Brass 类和 BrassPlus 类
Brass Account 类的信息很简单,但是银行没有告诉您有关透支系统的细节。当您向友好的Pontoon 银行代表询问时,他提供了如下信息:
- Brass Plus 账户限制了客户的透支款额。默认为 500 元,但有些客户的限额可能不同;
- 银行可以修改客户的透支限额;
- Brass Plus 账户对贷款收入利息。默认为 11.125%,但有些客户的利率可能不同;
- 银行可以修改客户的利率;
- 账户记录客户所欠银行的金额(透支数额加利息)。用户不能通过常规存款或从其它账户转账的方式偿付,而必须以现金的方式交给特定的银行工作人员。如果有必要,工作人员可以找到该客户。欠款偿还后,欠款金额将归零。
最后一种特性是银行出于做生意的考虑而采用的,这种方法有它有利的一面——使编程更简单。
上述列表表明,新的类需要构造函数,而且构造函数应提供账户信息,设置透支上限(默认为 500 元)和利率(默认为 11.125%)。另外,还应有重新设置透支限额、利率和当前欠款的方法。要添加到 Brass 类中的就是这些,这将在 BrassPlus 类声明中声明。
// brass.h -- bank account classes
#ifndef BRASS_H_
#define BRASS_H_
#include<string>
class Brass{
private:
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() {}
};
// Brass Plus Account Class
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 ml = 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() 和 Withdraw() 时使用了新关键字 virtual。这些方法被称为虚方法( virtual method);
-
Brass 类还声明了一个虚析构函数,虽然该析构函数不执行任何操作。
第一点没有什么新鲜的。RatedPlayer 类在 TableTennisPlayer 类的基础上添加新数据成员和2个新方法的方式与此类似。
第二点介绍了声明如何指出方法在派生类的行为的不同。两个 ViewAcct() 原型表明将有2个独立的方法定义。基类版本的限定名为 Brass::ViewAcct(),派生类版本的限定名为 BrassPlus::ViewAcct()。程序将使用对象类型来确定使用哪个版本:Brass dom("Dominic Banker", 11224, 4183.45); BrassPlus dot("Dorothy Banker", 12118, 2592.00); dom.viewAcct(); // use Brass::ViewAcct() dot.viewAcct(); // use BrassPlus::ViewAcct()
同样,Withdraw() 也有 2 个版本,一个供 Brass 对象调用,另一个供 BrassPlus 对象使用。对于在两个类中行为相同的方法( 如Deposit() 和 Balance() ),则只在基类中声明。
第三点(使用 virtual )比前两点要复杂。如果方法是通过引用或指针而不是对象调用的,它将确定使用哪一种方法。如果没有使用关键字 virtual,程序将根据引用类型或指针类型选择方法;如果使用了 virtual,程序将根据引用或指针指向的对象的类型来选择方法。如果 ViewAcct() 不是虚的,则程序的行为如下:
// behaviour with non-virtual ViewAcct() // method chosen according to reference type Brass dom("Dominic Banker", 11224, 4183.45); BrassPlus dot("Dorothy Banker", 12118, 2592.00); Brase & b1_ref = dom; Brase & b2_ref = dot; b1_ref.ViewAcct(); // use Brass::ViewAcct() b2_ref.ViewAcct(); // use Brass::ViewAcct()
引用变量的类型为 Brass, 所以选择了 Brass::ViewAccount()。使用 Brass 指针代替引用时,行为将与此类似。
如果 ViewAcct() 是虚的,则行为如下:// behavior with virtual ViewAcct() // method chosen according to object type Brass dom("Dominic Banker", 11224, 4183.45); BrassPlus dot("Dorothy Banker", 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 指针代替引用时,行为将类似。
稍后您将看到,虚函数的这种行为非常方便。因此,经常在基类中将派生类会重新定义的方法声明为虚方法。方法在基类中被声明为虚的后,它在派生类中将自动成为虚方法。然而,在派生类声明中使用关键字 virtual 来指出哪些函数是虚函数也不失为一个好办法。
第四点是,基类声明了一个虚析构函数。这样做是为了确保释放派生对象时,按正确的顺序调用析构函数。本章后面将详细介绍这个问题。
注意:如果要在派生类中重新定义基类的方法,通常应将基类方法声明为虚的。这样,程序将根据对象类型而不是引用或指针的类型来选择方法版本。为基类声明一个虚析构函数也是一种惯例。- 类实现
接下来需要实现类,其中的部分工作已由头文件中的内联函数定义完成了。下面列出了其它方法的定义。注意,关键字 virtual 只用于类声明的方法原型中,而没有用于下面程序的方法定义中。
// brass.cpp -- bank account class methods #include <ios> #include <iostream> #include "13.7_brass.h" using std::cout; using std::endl; using std::string; // formatting stuff typedef std::ios_base::fmtflags format; typedef std::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 canceled.\n"; } else if(amt <= balance) balance -= amt; else{ cout << "Withdrawal amount of $" << amt << " exceeds your balance.\n" << "Withdrawal canceled.\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); // restore original format } // 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; } // redefine how ViewAcct() works void BrassPlus::ViewAcct() 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 << "%\n"; 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(std::ios_base::fixed, std::ios_base::floatfield); } void restore(format f, precis p){ cout.setf(f, std::ios_base::floatfield); cout.precision(p); }
介绍上述程序的具体细节(比如一些方法的格式化处理)之前,先来看一下与继承直接相关的方面。记住,派生类不能直接访问基类的私有数据,而必须使用基类的公有方法才能访问这些数据。访问的方式取决于方法。构造函数使用一种技术,而其它成员函数使用另一种技术。
派生类构造函数在初始化基类私有数据时,采用的是成员初始化列表语法。RatedPlayer 类构造函数和 BrassPlus 构造函数都使用这种技术: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) // use implicit copy constructor { maxLoan = ml; owesBank = 0.0; rate = r; }
这几个构造函数都使用成员初始化列表语法,将基类信息传递给基类构造函数,然后使用构造函数体初始化 BrassPlus 类新增的数据项。
非构造函数不能使用成员初始化列表语法,但派生类方法可以调用公有的基类方法。例如,BrassPlus 版本的 ViewAcct() 核心内容如下(忽略了格式方面):// redefine how ViewAcct() works void BrassPlus::ViewAcct() const{ ... Brass::ViewAcct(); // display base portion cout << "Maximum loan: $ " << maxLoan << endl; cout << "Owed to bank: $ " << owesBank << endl; cout << "Loan Rate: " << 100 * rate << "%\n"; }
换句话说,BrassPlus::ViewAcct() 显示新增的 BrassPlus 数据成员,并调用基类方法 Brass::ViewAcct() 来显示基类数据成员。在派生类方法中,标准技术是使用作用域解析运算符来调用基类方法。
代码必须使用作用域解析运算符。假如这样编写代码:// redefine erroneously how ViewAcct() works void BrassPlus::ViewAccct() const{ ... ViewAcct(); // oops! recursive call ... }
编译器将认为 ViewAcct() 是 BrassPlus::ViewAcct(),这将创建一个不会终止的递归函数——这可不好。
接下来看 BrassPlus::Withdraw() 方法。如果客户提取的金额超过了结余,该方法将安排贷款。它可以使用 Brass::Withdraw() 来访问 balance 成员,但如果取款金额超过了结余,Brass::Withdraw() 将发出一个错误消息。这种实现使用 Deposit() 方法进行放贷,然后在得到了足够的结余后调用 Brass::Withdraw,从而避免了错误消息:// redefine how Withdraw() work void BrassPlus::Withdraw(double amt){ ... 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"; } ... }
该方法使用基类的Balance()函数来确定结余。因为派生类没有重新定义该方法,代码不必对 Balance() 使用作用域解析运算符。
方法 ViewAcct() 和 Withdraw() 使用格式化方法 setf() 和 precision() 将浮点值的输出模式设置为定点,即包含两位小数。设置模式后,输出的模式将保持不变,因此该方法将格式模式重置为调用前的状态。未避免代码重复,该程序将设置格式的代码放在辅助函数中: - 类实现
// formatting stuff
typedef std::ios_base::fmtflags format;
typedef std::streamsize precis;
format setFormat();
void restore(format f, precis p);
```
函数 setFormat() 设置定点表示法并返回以前的标记设置:
```
format setFormat(){
return cout.setf(std::ios_base::fixed, std::ios_base::floatfield);
}
```
而函数 restore() 重置格式和精度:
```
void restore(format f, precis p){
cout.setf(f, std::ios_base::floatfield);
cout.precision(p);
}
```
2. 使用 Brass 和 BrassPlus 类
```
// usebrass1.cpp -- testing bank account classes
// compile with brass.cpp
#include<iostream>
#include"13.7_brass.h"
int main(){
using std::cout;
using std::endl;
Brass Piggy("Porcelot Pigg", 381299, 4000.00 );
BrassPlus Hoggy("Horatio Hogg", 382288, 3000.00);
Piggy.ViewAcct();
cout << endl;
cout << "Depositing $1000 into the Hogg Account:\n";
Hoggy.Deposit(1000.00);
cout << "New balance: $" << Hoggy.Balance() << endl;
cout << "Withdrawing $4200 from the Pigg Account:\n";
Piggy.Withdraw(4200.00);
cout << "Pigg account balance: $" << Piggy.Balance() << endl;
cout << "Withdrawing $4200 from the Hogg Account:\n";
Hoggy.Withdraw(4200.00);
Hoggy.ViewAcct();
return 0;
}
- 演示虚方法的行为
在上述程序中,方法是通过对象(而不是指针或引用)调用的,没有使用虚方法特性。下面来看一个使用了虚方法的例子。假设要同时管理 Brass 和 BrassPlus 账户,如果能使用同一个数组来保存 Brass 和 BrassPlus 对象,将很有帮助,但这是不可能的。数组中所有元素的类型必须相同,而 Brass 和 BrassPlus 是不同的类型。然而,可以创建指向 Brass 的指针数组。这样,每个元素的类型都相同,但由于使用的是公有继承模型,因此 Brass 指针既可以指向 Brass 对象,也可以指向 BrassPlus 对象。因此,可以使用一个数组来表示多种类型的对象。这就是多态性,下面的程序是一个简单的例子:
// usebrass1.cpp -- testing bank account classes
// compile with brass.cpp
#include<iostream>
#include"13.7_brass.h"
int main(){
using std::cout;
using std::endl;
Brass Piggy("Porcelot Pigg", 381299, 4000.00 );
BrassPlus Hoggy("Horatio Hogg", 382288, 3000.00);
Piggy.ViewAcct();
cout << endl;
cout << "Depositing $1000 into the Hogg Account:\n";
Hoggy.Deposit(1000.00);
cout << "New balance: $" << Hoggy.Balance() << endl;
cout << "Withdrawing $4200 from the Pigg Account:\n";
Piggy.Withdraw(4200.00);
cout << "Pigg account balance: $" << Piggy.Balance() << endl;
cout << "Withdrawing $4200 from the Hogg Account:\n";
Hoggy.Withdraw(4200.00);
Hoggy.ViewAcct();
return 0;
}
如果数组成员指向的是 Brass 对象,则调用 Brass::ViewAcct();如果指向的是 BrassPlus 对象,则调用 BrassPlus::ViewAcct()。
- 为何需要虚析构函数
在上面的程序中,使用 delete 释放由 new 分配的对象的代码说明了为何基类应包含一个虚析构函数。虽然有时好像并不需要析构函数。如果析构函数不是虚的,则将只调用对应于指针类型的析构函数。对应于上面的程序,这意味着只有 Brass 的析构函数被调用,即使指针指向的是一个 BrassPlus 对象,将调用 BrassPlus 的析构函数,然后自动调用基类的析构函数。因此,使用虚析构函数可以确保正确的析构函数序列被调用。对于上面的程序,这种正确的行为并不是很重要,因为析构函数没有执行任何操作。然而,如果 BrassPlus 包含一个执行某些操作的析构函数,则 Brass 必须有一个虚析构函数,即使该析构函数不执行任何操作。