第 10 章 类继承
文章目录
对于一个类,C++ 提供了比修改代码更好的方法来扩展和修改类。这种方法叫做类继承,它能够从已有的类派生出新的类,而派生类继承了原有类(称为基类)的特征和方法。下面是可以通过继承完成的一些工作:
- 可以在已有类的基础上添加功能。
- 可以给类添加数据。
- 可以修改类方法的行为。
当然,可以通过复制原始类代码,并对其进行修改完成上述工作,但继承机制只需要提供新特性,甚至不需要访问源代码就可以派生出类。
10.1 一个简单的基类
从一个类派生出另一个类时,原始类称为基类,继承类称为派生类。以下面一个简单的类作为基类:
tabtenn0.h
#include <string>
using namespace std;
class TableTennisPlayer
{
private:
string firstname, 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; }
};
10.1.1 派生一个类
假设需要给每个 Player
加上得分这一数据,可以从该类派生出一个新类 RatedPlayer
:
class RatedPlayer : public TableTennisPlayer
{
...
};
冒号指出 RatedPlayer
类的基类是 TableTennisPlayer
。public
表明 TableTennisPlayer
是一个公有基类,这被称为公有派生。使用公有派生,基类的公有成员将成为派生类的公有成员;基类的私有部分也将成为派生类的一部分,但只能通过基类的公有和保护方法访问。
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 r) { rating = r; }
};
10.1.2 构造函数:访问权限的考虑
派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。例如,RatedPlayer
构造函数不能直接设置继承的成员(firstname
、lastname
和 hasTable
),而必须使用基类的公有方法来访问私有的基类成员。具体地说,派生类构造函数必须使用基类构造函数。
创建派生类对象时,程序首先创建基类对象。从概念上说,这意味着基类对象应当在程序进入派生类构造函数之前被创建。C++ 使用成员初始化列表语法来完成这种工作,例如:
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 string& fn, const string& ln, bool ht)
{
rating = r;
}
//等价于
RatedPlayer::RatedPlayer(unsigned int r, const string& fn, const string& ln, bool ht) : TableTennisPlayer()
{
rating = r;
}
除非要使用默认构造函数,否则应该显式调用正确的基类构造函数。
下面看第二个构造函数的代码:
RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer& tp): TableTennisPlayer(tp)
{
rating = r;
}
这将调用基类的复制构造函数,如果没有定义,编译器将自动生成一个。在这种情况下,执行成员复制的隐式复制构造函数是合适的,因为这个类没有使用动态内存分配。
如果愿意,也可以对派生类成员使用成员初始化列表语法,例如:
RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer& tp): TableTennisPlayer(tp), rating(r)
{
}
有关派生类构造函数的要点如下:
- 首先创建基类对象
- 派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数
- 派生类构造函数应初始化派生类新增的数据成员
派生类对象过期时,程序将首先调用派生类的析构函数,再调用基类的析构函数。
注意:创建派生类对象时,程序首先调用基类构造函数,然后再调用派生类构造函数。基类构造函数负责初始化继承的数据成员;派生类构造函数主要用于初始化新增的数据成员。派生类的构造函数总是调用一个基类构造函数。可以使用初始化列表语法指明要使用的基类构造函数,否则将使用默认的基类构造函数。
10.1.3 派生类和基类之间的特殊关系
派生类与基类之间有一些特殊关系:
- 派生类对象可以使用基类的方法,条件是方法不是私有的
- 基类指针可以在不进行显示类型转换的情况下指向派生类对象
- 基类引用可以在不进行显示类型转换的情况下引用派生类对象
例如:
RatedPlayer rplayer1;
TableTennisPlayer * pt = &rplayer1;
TableTennisPlayer & rt = rplayer1;
然而,基类指针或引用只能用于调用基类方法,因此不能使用 rt
或 pt
调用派生类的 ResetRating()
方法。
通常,C++ 要求引用和指针类型与赋给的类型匹配,但这一规则对继承来说是例外。然而,这种例外只是单向的,不可以将基类对象和地址赋给派生类引用和指针。
这种规则有以下几种应用场景:
-
基类引用定义的函数或指针参数可用于基类对象或派生类对象:
void Show(const TableTennisPlayer& rt) { cout<<"Name: "; rt.Name(); ... } int main() { RatedPlayer rplayer; TableTennisPlayer player; Show(player); Show(rplayer); }
-
引用兼容性属性允许使用派生类对象初始化基类对象:
RatedPlayer olaf1(1840, "Olaf", "Loaf", true); TableTennisPlayer olaf2(olaf1);
要初始化
olaf2
,匹配的构造函数原型应如下:TableTennisPlayer(const RatedPlayer&);
类定义中没有这样的构造函数,但存在隐式复制构造函数:
TableTennisPlayer(const TableTennisPlayer&);
形参是基类引用,因此它可以引用派生类。
同样,也可以将派生对象赋给基类对象,在这种情况下,程序将使用隐式重载赋值运算符:
TableTennisPlayer & operator=(const TableTennisPlayer &) const;
10.2 继承:is-a
关系
派生类和基类之间的特殊关系是基于 C++ 继承的底层模型的。实际上,C++ 有
3
3
3 种继承方式:公有继承、保护继承和私有继承。公有继承是最常用的方式,它建立一种 is-a
关系,即派生类对象也是一个基类对象,可以对基类对象执行的任何操作,也可以对派生类执行。例如基类为 Fruit
,派生出 Banana
类。因为派生类可以添加特性,所以将这种关系称为 is-a-kind-of
关系可能更加准确,但是通常使用术语 is-a
。
为阐明 is-a
关系,来看一些与该模型不符的例子。公有继承不建立 has-a
关系。例如,午餐可能包括水果,但午餐并不是水果。
公有继承不建立 is-like-a
关系,也就是说,它不采用明喻。人们通常说律师就像鲨鱼,但律师并不是鲨鱼,所以不应从 Shark
类种派生出 Laywer
类。
公有继承不建立 is-implemented-as-a
关系。例如,可以使用数组来实现栈,但从 Array
类派生出 Stack
类是不合适的,因为栈不是数组。
公有继承不建立 use-a
关系,例如计算机可以使用打印机,但从 computer
类派生出 printer
类是没有意义的。
10.3 多态公有继承
有时可能会遇到这样的情况:希望同一个方法在派生类和基类中的行为是不同的。换句话说,方法的行为应取决于调用该方法的对象。这种较复杂的行为称为多态——具有多种形态。
有两种重要的机制可以用于实现多态公有继承:
- 在派生类中重新定义基类的方法
- 使用虚方法
10.3.1 开发 Brass
类和 BrassPlus
类
#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() {}
};
class BrassPlus : public Brass
{
private:
double maxLoan, rate, 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; }
};
对于该基类和派生类,需要说明以下几点:
-
第一点,派生类在基类的基础上新增了三个数据成员和三个方法
-
第二点,两个类都声明了
ViewAcct()
和Withdraw()
方法,但这些方法的行为是不同的。基类版本的限定名为Brass::ViewAcct()
,派生类版本为BrassPlus::ViewAcct()
。程序将使用对象类型来确定使用哪个版本:Brass dom; BrassPlus dot; dom.ViewAcct(); //将调用Brass::ViewAcct() dot.ViewAcct(); //将调用BrassPlus::ViewAcct()
-
第三点,有些方法前面使用了关键字
virtual
。如果方法是通过引用或指针而不是对象调用的,是否使用virtual
将确定使用哪一种方法。如果没有使用virtual
,程序将根据引用类型或指针类型选择方法;如果使用了virtual
,程序将根据引用或指针指向的对象的类型来选择方法。例如,如果
ViewAcct()
不是虚的,则程序的行为如下:Brass dom; BrassPlus dot; Brass & b1_ref = dom; Brass & b2_ref = dot; b1_ref.ViewAcct(); //调用Brass::ViewAcct() b2_ref.ViewAcct(); //调用Brass::ViewAcct()
如果
ViewAcct()
是虚的,则程序的行为如下:Brass dom; BrassPlus dot; Brass & b1_ref = dom; Brass & b2_ref = dot; b1_ref.ViewAcct(); //调用Brass::ViewAcct() b2_ref.ViewAcct(); //调用BrassPlus::ViewAcct()
虚函数的这种行为非常方便。因此,经常在基类中将派生类会重新定义的方法声明为虚方法。方法在基类中声明为虚的后,它在派生类中将自动成为虚方法。然而,在派生类声明中使用关键字
virtual
来指出哪些函数是虚函数也不失为一个好办法。 -
第四点,基类声明了一个虚析构函数。
为何需要虚析构函数?在使用
new
为对象分配内存时,当使用delete
释放内存,如果析构函数不是虚的,则将只调用对应指针类型的析构函数。例如:Brass * p1 = new Brass; Brass * p2 = new BrassPlus; delete p1; delete p2;
如果析构函数不是虚的,则
delete p2
只会调用~Brass()
;如果析构函数是虚的,则delete p2
将调用~BrassPlus()
,然后自动调用基类的析构函数。因此,使用虚析构函数可以确保正确的析构序列被调用。
下面是完整的类实现:
#include "Brass.h"
#include <iostream>
#include <string>
using namespace std;
typedef ios_base::fmtflags format;
typedef streamsize precis;
format setFormat()
{
return cout.setf(ios_base::fixed, ios_base::floatfield);
}
void restore(format f, precis p)
{
cout.setf(f, ios_base::floatfield);
cout.precision(p);
}
Brass::Brass(const std::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 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
{
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 std::string& s, long an, double bal, double ml, double r) : Brass(s, an, bal)
{
maxLoan = ml;
rate = r;
owesBank = 0.0;
}
BrassPlus::BrassPlus(const Brass& ba, double ml, double r) :Brass(ba)
{
maxLoan = ml;
rate = r;
owesBank = 0.0;
}
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);
}
首先需要记住,派生类并不能直接访问基类的私有数据,而必须使用基类的公有方法才能访问这些数据。访问的方式取决于方法,对于构造函数,可以使用初始化成员列表。而非构造函数不能使用成员初始化列表语法,但派生类方法可以调用公有的基类方法。例如:
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::ViewAcct() const
{
ViewAcct();
...
}
如果代码没有使用作用域解析运算符,编译器将认为 ViewAcct();
是 BrassPlus::ViewAcct();
,将导致无限递归。
10.4 静态联编和动态联编
程序调用函数时,将使用哪个可执行代码块呢?编译器负责回答这个问题。将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编。在 C 语言中,这非常简单,因为每个函数名对应一个不同的函数。在 C++ 中,由于函数重载的缘故,这项工作更加复杂。然而,C/C++ 编译器可以在编译过程中完成这种联编。在编译过程中进行联编被称为静态联编,然而,虚函数使得这项工作更加困难。使用哪一个函数是不能在编译时确定的,因为编译器不知道用户将选择哪一种类型的对象。所以,编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编。
10.4.1 指针和引用类型的兼容性
通常,C++ 不允许将一种类型的地址赋给另一种类型的指针,也不允许一种类型的引用指向另一种类型。然而,指向基类的引用或指针可以引用派生类对象,而不需要进行显式类型转换,这被称为向上强制转换。向上强制转换是可以传递的,也就是说,如果从 BrassPlus
派生出 BrassPlusPlus
类,则 Brass
指针或引用可以引用 Brass
对象、BrassPlus
对象或 BrassPlusPlus
对象。
相反的过程——将基类指针或引用转换为派生类指针或引用——称为向下强制转换。如果不使用显示类型转换,则向下强制转换是不允许的,原因是 is-a
关系通常是不可逆的。
10.4.2 虚成员函数和动态联编
请看以下代码:
BrassPlus ophelia;
Brass * bp;
bp = &ophelia;
bp->ViewAcct();
正如前面介绍的,如果在基类中没有将 ViewAcct()
声明为虚的,则 bp->ViewAcct()
将根据指针类型调用 Brass::ViewAcct()
。指针类型在编译时已知,因此编译器在编译时,可以将 ViewAcct()
关联到 Brass::ViewAcct()
。总之,编译器对非虚方法使用静态联编。
然而,如果在基类中将 ViewAcct()
声明为虚的,则 bp->ViewAcct();
将根据对象类型(BrassPlus
)调用 BrassPlus::ViewAcct()
。总之,编译器对虚方法使用动态联编。
在大多数情况下,动态联编很好,但意味着额外的开销。也就是说,使用虚函数会增加程序的开销。所以,如果要在派生中重新定义基类的方法,则将它设置为虚方法;否则,应设置为非虚方法。
10.4.3 有关虚函数注意事项
我们已经讨论了虚函数的一些要点:
- 在基类方法的声明中使用关键字
virtual
可使该方法在基类以及所有的派生类(包括从派生类派生出来的类)中是虚的。 - 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为动态联编。
- 如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的。
对于虚方法,还有一些知识需要了解:
-
构造函数
构造函数不能是虚函数。创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后派生类的构造函数将使用基类的一个构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以将类构造函数声明为虚的没什么意义。
-
析构函数
析构函数应当是虚函数,除非类不用作基类。
例如,假设
Employee
是基类,Singer
是派生类。对于以下代码:Employee * pe = new Singer; ... delete Singer;
如果析构函数不是虚的,则
delete
将调用~Employee()
,这将释放Singer
对象中Employee
对象部分的内存,但不会释放新的类成员指向的内存。但如果析构函数是虚的,则上述代码将先调用~Singer()
析构函数释放由Singer
组件指向的内存,再调用~Employee()
来释放Employee()
组件指向的内存。这意味着即使基类不需要显式析构函数提供服务,也不应依赖于默认的析构函数,而应提供虚析构函数,即使它不执行任何操作。
-
友元
友元不能是虚函数,因为友元不是类成员,而只有类成员才能是虚函数。
-
没有重新定义
如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本。
-
重新定义将隐藏方法
假设创建了如下代码:
class Dwelling { public: virtual void showperks(int a) const; ... } class Hovel : public Dwelling { public: virtual void showperks() const; }
这将导致问题,新定义的
showperks()
不接受任何参数。重新定义不会生成函数的两个重载版本,而是隐藏了接受一个int
参数的基类版本。总之,重新定义继承的方法并不是重载,而是会隐藏所有同名的基类方法。 这引出了两条经验规则:
-
第一,如果重新定义继承的方法,应确保与原来的原型完全相同。但如果返回类型是基类的引用或指针,则可以修改为指向派生类的引用或指针。这种特性被称为返回类型协变,因为允许返回类型随类类型的变化而变化,例如:
class Dwelling { public: virtual Dwelling & build(int n); ... } class Hovel : public Dwelling { public: virtual Hovel & build(int n); }
注意,这种例外只适用于返回值,而不适用于参数。
-
第二,如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。
class Dwelling { public: virtual void showperks(int a) const; virtual void showperks(double x) const; virtual void showperks() const; ... } class Hovel : public Dwelling { public: virtual void showperks(int a) const; virtual void showperks(double x) const; virtual void showperks() const; }
如果只重新定义了一个版本,则另外两个版本将被隐藏,派生类对象将无法使用它们。注意,如果不需要修改,则新定义可只调用基类版本:
void Hovel::showperks() const {Dwelling::showperks();}
-
10.5 访问控制:protected
关键字 protected
与 private
类似,在类外只能用公有类成员来访问 protected
部分中的类成员。private
和 protected
之间的区别只有在派生类中才会表现出来。派生类可以直接访问基类的保护成员,但不能直接访问基类的私有成员。因此,对于外部世界来说,保护成员的行为与私有成员相似;但对于派生类来说,保护成员的行为与公有成员相似。
使用保护数据成员可以简化代码的编写工作,但存在设计缺陷,例如:
class Brass
{
protected:
double balance;
...
}
void BrassPlus::Reset(double amt)
{
balance = amt;
}
Brass
类被设计成只能通过 Deposit()
和 Withdraw()
修改 balance
。但对于 BrassPlus
对象,Reset()
方法将忽略前两个函数的保护措施,而更加危险。
警告:最好对类数据成员采用私有访问控制,不要使用保护访问控制;同时通过基类方法使派生类能够访问基类数据。
然而,对于成员函数来说,保护访问控制很有用,它让派生类能够访问公众不能使用的内部函数。
10.6 抽象基类
有时候,从一个类派生出另一个类并不是那么方便,例如从椭圆类派生出圆类(圆是一种特殊的椭圆,满足 is-a
关系)时,椭圆有许多圆没有的特性,这种派生显得十分笨拙。对于这个问题,要么可以直接定义新的圆类,而不使用继承;要么使用一种新的方法:从椭圆和圆中抽象出它们的共性,将这些共性放到一个抽象基类 ABC
中,然后从该 ABC
派生出椭圆和圆。这样,便可以使用基类指针数组同时管理这两个类。示例如下:
class BaseEllipse
{
private:
double x, y;
public:
BaseEllipse(double x0=0, double y0=0): x(x0),y(y0) {}
virtual ~BaseEllipse() {}
void Move(double nx, double ny) { x = nx, y = ny; }
virtual double Area() const = 0;
};
因为面积对于椭圆和圆来说所求的方法不同,所以无法在抽象基类中给出具体实现。C++ 通过使用纯虚函数提供未实现的函数,纯虚函数声明的结尾处为 =0
。
当类声明中包含纯虚函数时,则不能创建该类对象。这里的理念是,包含纯虚函数的类只能用作基类。
C++ 允许纯虚函数有定义,例如,也许所有的基类方法都和 Move()
一样,可以在基类中进行定义,但仍需要将这个类声明为抽象的。在这情况下,可以将函数原型声明为虚的:
void Move(double nx, double ny) = 0;
这使得基类成为抽象的,但仍可以在实现文件中提供方法的定义:
void BaseEllipse::Move(double nx, double ny) {x = nx, y = ny;}
总之,在函数原型中使用 =0
指出类是抽象基类,在类中可以不定义该函数。
下面是抽象基类的一个示例:
#include <iostream>
#include <string>
class AcctABC
{
private:
std::string fullName;
long acctNum;
double balance;
protected:
struct Formatting
{
std::ios_base::fmtflags flag;
std::streamsize pr;
};
const std::string& FullName() const { return fullName; }
long AcctNum() const { return acctNum; }
Formatting SetFormat() const;
void Restore(Formatting& f) const;
public:
AcctABC(const std::string& s = "Nullbody", long an = -1, double bal = 0.0);
void Deposit(double amt);
virtual void Withdraw(double amt) = 0;
double Balance() const { return balance; }
virtual void ViewAcct() const = 0;
virtual ~AcctABC() {}
};
class Brass :public AcctABC
{
public:
Brass(const std::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() {}
};
class BrassPlus :public AcctABC
{
private:
double maxLoan, rate, owesBank;
public:
BrassPlus(const std::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.1);
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; }
};
#include "AcctABC.h"
#include <iostream>
using namespace std;
AcctABC::AcctABC(const std::string& s, long an, double bal)
{
fullName = s;
acctNum = an;
balance = bal;
}
void AcctABC::Deposit(double amt)
{
if (amt < 0)
cout << "Negative deposit not allowed; deposit is cancelled.\n";
else
balance += amt;
}
void AcctABC::Withdraw(double amt)
{
balance -= amt;
}
AcctABC::Formatting AcctABC::SetFormat() const
{
Formatting f;
f.flag = cout.setf(ios_base::fixed, ios_base::floatfield);
f.pr = cout.precision(2);
return f;
}
void AcctABC::Restore(Formatting& f) const
{
cout.setf(f.flag, ios_base::floatfield);
cout.precision(f.pr);
}
void Brass::Withdraw(double amt)
{
if (amt < 0)
cout << "Withdrawal amount must be positive; withdrawal canceled.\n";
else if (amt <= Balance())
AcctABC::Withdraw(amt);
else
cout << "Withdrawal amount of $" << amt << " exceeds your balance.\n" << "Withdrawal canceled.\n";
}
void Brass::ViewAcct() const
{
Formatting f = SetFormat();
cout << "Brass Client: " << FullName() << endl;
cout << "Account Number: " << AcctNum() << endl;
cout << "Balance: $" << Balance() << endl;
Restore(f);
}
BrassPlus::BrassPlus(const std::string& s, long an, double bal, double ml, double r) :AcctABC(s, an, bal)
{
maxLoan = ml;
rate = r;
owesBank = 0.0;
}
BrassPlus::BrassPlus(const Brass& ba, double ml, double r) :AcctABC(ba)
{
maxLoan = ml;
rate = r;
owesBank = 0.0;
}
void BrassPlus::Withdraw(double amt)
{
Formatting f = SetFormat();
double bal = Balance();
if (amt <= bal)
AcctABC::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);
AcctABC::Withdraw(amt);
}
else
cout << "Credit limit exceeded. Transaction cancelled.\n";
Restore(f);
}
void BrassPlus::ViewAcct() const
{
Formatting f = SetFormat();
cout << "BrassPlus Client: " << FullName() << endl;
cout << "Account Number: " << AcctNum() << endl;
cout << "Balance: $" << Balance() << endl;
cout << "Maximum loan: $" << maxLoan << endl;
cout << "Owed to bank: $" << owesBank << endl;
cout.precision(3);
cout << "Loan Rate: " << 100 * rate << "%\n";
Restore(f);
}
10.7 继承和动态内存分配
继承是怎样与动态内存分配进行互动的呢?需要考虑以下两种情况:
10.7.1 第一种情况:派生类不使用 new
假设基类使用了动态内存分配:
class baseDMA
{
private:
char * label;
int rating;
public:
baseDMA(const char * l= "null", int r = 0);
baseDMA(const baseDMA& rs);
virtual ~baseDMA();
baseDMA & operator=(const baseDMA& rs);
}
声明中包含了构造函数使用 new
时需要的特殊方法:析构函数、复制构造函数和重载赋值运算符。
现在,从该类派生出 lacksDMA
:
class lacksDMA :public baseDMA
{
private:
char color[40];
public:
...
}
对于该派生类,是否需要为其定义显式析构函数、复制构造函数和重载赋值运算符呢?不需要。
因为派生类新增的数据成员没有使用 new
分配内存,默认函数进行的浅复制已经能够达到目的。
10.7.2 第二种情况:派生类使用 new
假设派生类使用了 new
:
class hasDMA :public baseDMA
{
private:
char * style;
public:
...
}
在这种情况下,必须为派生类定义显式析构函数、复制构造函数和重载赋值运算符。下面依次考虑这些方法。
-
析构函数
派生类析构函数自动调用基类的析构函数,故其自身的职责是对派生类构造函数执行的工作来进行清理。
因此,
hasDMA
析构函数必须释放style
指向的内存,并依赖于baseDMA
的析构函数来释放指针label
指向的内存:baseDMA::~baseDMA() { delete [] label; } hasDMA::~baseDMA() { delete [] style; }
-
复制构造函数
首先看
baseDMA
的复制构造函数:baseDMA::baseDMA(const baseDMA & rs) { label = new char[strlen(rs.label)+1]; strcpy(label, rs.label); rating = rs.rating; }
hasDMA
复制构造函数只能访问hasDMA
的数据,因此它必须调用baseDMA
复制构造函数来处理共享的baseDMA
数据:hasDMA::hasDMA(const hasDMA & hs) :baseDMA(hs) { style = new char[strlen(hs.style) + 1]; strcpy(style, hs.style); }
-
赋值运算符
由于
hasDMA
也使用动态内存分配,所以它也需要一个显式赋值运算符。派生类的显式赋值运算符必须负责所有继承的baseDMA
基类对象的赋值,可以通过显式调用基类赋值运算符来完成这项工作,如下所示:hasDMA & hasDMA::operator=(const hasDMA & hs) { if(this == &hs) return *this; base::operator=(hs); //复制基类部分 delete [] style; style = new char[strlen(hs.style) + 1]; strcpy(style, hs.style); return *this; }
总之,当基类和派生类都采用动态内存分配时,派生类的析构函数、复制构造函数和赋值运算符都必须使用相应的基类方法来处理基类元素。这种要求是通过三种不同的方式来满足的。对于析构函数,这是自动完成的;对于构造函数,这是通过在初始化成员列表中调用基类的复制构造函数来完成的,如果不这样做,将自动调用基类的默认构造函数;对于赋值运算符,这是通过使用作用域解析运算符显式地调用基类的赋值运算符来完成的。
10.7.3 使用动态内存分配和友元的继承示例
#include <iostream>
class baseDMA
{
private:
char* label;
int rating;
public:
baseDMA(const char* l = "null", int r = 0);
baseDMA(const baseDMA& rs);
virtual ~baseDMA();
baseDMA& operator=(const baseDMA& rs);
friend std::ostream& operator<<(std::ostream& os, const baseDMA& rs);
};
class lacksDMA :public baseDMA
{
private:
enum { COL_LEN = 40 };
char color[COL_LEN];
public:
lacksDMA(const char* c = "blank", const char* l = "null", int r = 0);
lacksDMA(const char* c, const baseDMA& rs);
friend std::ostream& operator<<(std::ostream& os, const lacksDMA& rs);
};
class hasDMA :public baseDMA
{
private:
char* style;
public:
hasDMA(const char* s = "none", const char* l = "null", int r = 0);
hasDMA(const char* s, const baseDMA& rs);
hasDMA(const hasDMA& hs);
~hasDMA();
hasDMA& operator=(const hasDMA& rs);
friend std::ostream& operator<<(std::ostream& os, const hasDMA& rs);
};
#include "dma.h"
#include <iostream>
using namespace std;
baseDMA::baseDMA(const char* l, int r)
{
label = new char[strlen(l) + 1];
strcpy(label, l);
rating = r;
}
baseDMA::baseDMA(const baseDMA& rs)
{
label = new char[strlen(rs.label) + 1];
strcpy(label, rs.label);
rating = rs.rating;
}
baseDMA::~baseDMA()
{
delete[] label;
}
baseDMA& baseDMA::operator=(const baseDMA& rs)
{
if (this == &rs)
return *this;
delete[] label;
label = new char[strlen(rs.label) + 1];
strcpy(label, rs.label);
rating = rs.rating;
return *this;
}
std::ostream& operator<<(std::ostream& os, const baseDMA& rs)
{
os << "Label: " << rs.label << endl;
os << "Rating: " << rs.rating << endl;
return os;
}
lacksDMA::lacksDMA(const char* c, const char* l, int r) :baseDMA(l,r)
{
strcpy(color, c);
}
lacksDMA::lacksDMA(const char* c, const baseDMA& rs) :baseDMA(rs)
{
strcpy(color, c);
}
std::ostream& operator<<(std::ostream& os, const lacksDMA& rs)
{
os << (const baseDMA&)rs;
os << "Color: " << rs.color << endl;
return os;
}
hasDMA::hasDMA(const char* s, const char* l, int r) :baseDMA(l,r)
{
style = new char[strlen(s) + 1];
strcpy(style, s);
}
hasDMA::hasDMA(const char* s, const baseDMA& rs) :baseDMA(rs)
{
style = new char[strlen(s) + 1];
strcpy(style, s);
}
hasDMA::hasDMA(const hasDMA& hs) :baseDMA(hs)
{
style = new char[strlen(hs.style) + 1];
strcpy(style, hs.style);
}
hasDMA::~hasDMA()
{
delete[] style;
}
hasDMA& hasDMA::operator=(const hasDMA& rs)
{
if (this == &rs)
return *this;
baseDMA::operator=(rs);
delete[] style;
style = new char[strlen(rs.style) + 1];
strcpy(style, rs.style);
return *this;
}
std::ostream& operator<<(std::ostream& os, const hasDMA& rs)
{
os << (const baseDMA&)rs;
os << "Style: " << rs.style << endl;
return os;
}
上面的程序中,需要注意的新特性是派生类如何使用基类的友元。例如:friend std::ostream& operator<<(std::ostream& os, const hasDMA& rs)
。
作为 hasDMA
的友元,该函数能够访问 style
数据成员。然而,还有一个问题:该函数不是 baseDMA
的友元,那它如何访问成员 label
和 rating
呢?答案是使用 baseDMA
类的友元函数 operator<<()
。下一个问题是:因为友元不是成员函数,所以不能使用作用域解析运算符来指出要使用哪个函数。这个问题的解决方法是使用强制类型转换,以匹配原型时能够选择正确的函数:
std::ostream& operator<<(std::ostream& os, const hasDMA& rs)
{
os << (const baseDMA&)rs;
os << "Style: " << rs.style << endl;
return os;
}
10.8 类设计回顾
10.8.1 编译器生成的成员函数
-
默认构造函数
默认构造函数要么没有参数,要么所有的参数都有默认值。如果没有定义任何构造函数,编译器将自动定义默认构造函数。
如果派生类构造函数的成员初始化列表中没有显式调用基类构造函数,则编译器将使用基类的默认构造函数来构造派生类对象的基类部分。在这种情况下,如果基类没有默认构造函数,将导致编译错误。
如果定义了某种构造函数,编译器将不会定义默认构造函数。在这种情况下,如果需要默认构造函数,则必须自己提供。
-
复制构造函数
复制构造函数接受其所属类的对象作为参数。
在以下情况,将使用复制构造函数:
- 将新对象初始化为一个同类对象
- 按值将对象传递给函数
- 函数按值返回对象
- 编译器生成临时对象
在某些情况下,编译器生成的复制构造函数是不合适的。例如,使用
new
初始化的成员指针通常要求执行深复制。在这些情况下,需要自己定义复制构造函数。 -
赋值运算符
默认的赋值运算符用于处理同类对象直接的赋值。不要将赋值与初始化混淆了:如果语句创建新的对象,则使用初始化;如果语句修改已有对象的值,则是赋值。
如果需要显式定义复制构造函数,则基于相同的原因,也需要显式定义赋值运算符。
10.8.2 其它的类方法
-
转换
使用一个参数就可以调用的构造函数定义了从参数类型到类的转换。例如:
Star(const char *); Star a; a = "abc";
在带一个参数的构造函数原型中使用
explicit
将禁止进行隐式转换,但仍允许显式转换:class Star { ... public: explicit Star(const char *); ... } Star north; north = "polaris"; //error!不允许隐式转换 north = Star("polaris");//允许显式转换
要将类对象转换为其他类型,应定义转换函数。转换函数可以是没有参数的类成员函数,也可以是返回类型被声明为目标类型的类成员函数。例如:
Star::operator double() {...} Star:operator const char*() {...}
应理智地使用这样的函数,因为它们可能会增加代码的二义性。例如:
Vector ius(6.0, 0.0); Vector lux = ius + 20.2;
编译器可以将
ius
转换成double
并使用double
加法;或将20.2
转换成vector
并使用vector
加法。但除了指出二义性外,它什么也不做。 -
按值传递对象与传递引用
通常,编写使用对象作为参数的函数时,应按引用而不是按值来传递对象。这样做的原因之一是为了提高效率。按值传递对象涉及到生成临时拷贝,调用复制构造函数,然后调用析构函数。复制大型对象比传递引用花费的时间多得多。如果函数不修改对象,应将参数声明为
const
引用。 按引用传递对象的另一个原因是,在继承使用虚函数时,被定义为接收基类引用参数的函数可以接受派生类。
-
返回对象和返回引用
在能返回引用的情况下,应尽可能返回引用,因为这样做效率更高。直接返回对象与按值传递对象类似:它们都生成临时副本。
然而,并不是总能返回引用。通用的规则是,如果函数返回在函数在创建的临时对象,则不要返回引用;如果函数返回的是通过引用或指针传递给它的对象,则应按引用返回对象。
10.8.3 公有继承的考虑因素
-
is-a
关系 要遵循
is-a
关系。如果派生类不是一种特殊的基类,则不要使用公有派生。 -
什么不能被继承
构造函数是不能被继承的,也就是说,创建派生类对象时,必须调用派生类的构造函数。然而,派生类构造函数通常使用成员初始化列表语法来调用基类构造函数,以创建派生对象的基类部分。如果派生类构造函数没有使用成员初始化列表语法显式调用基类构造函数,将使用基类的默认构造函数。
析构函数也是不能继承的。然而,在释放对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数。通常,对于基类,其析构函数应该设置为虚的。
赋值运算符是不能被继承的。
-
赋值运算符
对于赋值运算符,如果对象属于派生类,编译器将使用基类赋值运算符来处理派生对象中基类部分的赋值。如果显式地为基类提供了赋值运算符,将使用该运算符。与此相似,如果成员是另一个类的对象,则对于该成员,将使用其所属类的赋值运算符。
如果类构造函数使用了
new
来初始化指针,则需要提供一个显式赋值运算符。 派生类对象可以赋值给基类对象。但基类对象是否可以赋值给派生类对象呢?答案是也许。如果派生类包含了这样的构造函数或赋值运算符,则可以这样做。例如:
BrassPlus(const Brass& ba,...); BrassPlus & BrassPlus::operator=(const Brass&) {...}
-
私有成员和保护成员
对派生类而言,保护成员类似公有成员;但对外部而言,保护成员与私有成员类似。派生类可以直接访问基类的保护成员,但只能通过基类的方法来访问私有成员。因此,将基类成员设置为私有的可以提高安全性。一般而言,使用私有数据成员比使用保护数据成员更好,但保护方法很有用。
-
虚方法
设计基类时,必须确定是否将类方法声明为虚的。如果希望派生类能够重新定义方法,则应在基类中将方法定义为虚的;如果不希望重新定义方法,则不必将其声明为虚的。
-
析构函数
正如前面介绍的,基类的析构函数应当是虚的。这样,当通过基类指针或引用来删除派生对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数,而不仅仅是调用基类的析构函数。
-
友元函数
由于友元函数并非类成员,因此不能继承。然而,有时可能希望派生类的友元函数能够使用基类的友元函数。为此,可以通过强制类型转换,将派生类引用或指针转换为基类引用或指针,然后再调用基类的友元函数。
例如:
std::ostream& operator<<(std::ostream& os, const hasDMA& rs) { os << (const baseDMA&)rs; os << "Style: " << rs.style << endl; return os; }
-
有关使用基类方法的说明
- 派生类对象自动使用继承而来的基类方法,如果派生类没有重新定义该方法
- 派生类的构造函数自动调用基类的默认构造函数,如果没有在成员初始化列表中指定其他构造函数
- 派生类方法可以使用作用域解析运算符来调用公有的和受保护的基类方法
- 派生类的友元函数可以通过强制类型转换,再使用基类的友元函数