C++学习之虚函数和联编

RatedPlayer继承示例很简单。派生类对象使用基类的方法,并未做任何修改。然而,可能遇到希望同一个方法在基类和派生类中的行为是不同的。即希望方法的行为应取决于调用对象,也是一种多态(Polymorphism)——具有多种形态。有两种重要机制可以使用多态公有继承:

• 在派生类中重新定义基类方法。
• 使用虚方法。

多态公有继承

看一个普通银行账户类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include<string>
using std::string;

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() {}
};

然后我们对其进行继承,写出新的BrassPlus类,这个类的用户新增了向银行透支取款的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 ViewAcct()const;
virtual void Withdraw(double amt);
void ResetMax(double m) { maxLoan = m; }
void ResetRate(double r) { rate = r; }
void ResetOwes() { owesBank = 0; }
};

虚函数

两个类虽然都声明了ViewAcctWithdraw方法,但很明显两个类对象的方法行为是不同的。所以在声明时使用了关键字virtual,这些方法被称为虚方法。(定义的时候不需要写virtual)使用virtual,程序将根据引用或指针所指向的对象类型选择方法。如果ViewAcct不是虚的,程序是根据引用或指针本身的类型来选择方法。例如:

1
2
3
4
5
6
Brass zc("zc", 381299, 4000.00);
BrassPlus wb("wb", 382288, 3000.00);
Brass & r1 = zc;
Brass & r2 = wb;
r1.ViewAcct();
r2.ViewAcct();

上述代码中,如果ViewAcct()不是虚方法的话,引用变量的类型是Brass,所以会调用Brass::ViewAcct()。如果ViewAcct()是虚的,上述代码段的执行情况是:r2引用的是一个BrassPlus对象,所以使用BrassPlus::ViewAcct();

当将基类中的某一成员函数声明为虚函数后,派生类中的同名函数(函数名相同、参数列表完全一致、返回值类型相关)自动成为虚函数。然而在派生类中继续使用virtual来指明那些函数是虚函数是一个好方法。

第17行基类声明虚析构函数,虽然该析构函数不执行任何操作。但这样是为了确保释放派生对象时,按正确的顺序调用析构函数。

为什么需要虚析构函数?使用delete释放由new分配的对象的代码说明了为何基类需要包含一个虚析构函数。如果析构函数不是虚的,如果指针指向了一个BrassPlus对象,但还是意味着只会调用Brass的析构函数。如果析构函数是虚的,指针指向的是BrassPlus对象,那么将调用BrassPlus的析构函数,然后自动调用基类的析构函数。因此,使用虚析构函数可以确保正确的析构函数调用序列。

使用基类指针去调用

让我们完成两个类的类设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
#include<string>
#include<iostream>

using namespace std;

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

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);
}

//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 << "不能存入数量为负的钱" << endl;
else
balance += amt;
}

void Brass::Withdraw(double amt)
{
//set up ###.## format
format initialState = setFormat();
precis prec = cout.precision(2);

if (amt < 0)
cout << "不能取出数量为负的钱" << endl;
else if (amt <= balance)
balance -= amt;
else
cout << "你的取款额: " << amt << " 大于存款余额" << endl;
restore(initialState, prec);
}

double Brass::Balance() const
{
return balance;
}
void Brass::ViewAcct() const
{
format initialState = setFormat();
precis prec = cout.precision(2);

cout << "客户: " << fullName << endl;
cout << "卡号: " << acctNum << endl;
cout << "存款: $" << 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;
}

void BrassPlus::ViewAcct()const
{
format initialState = setFormat();
precis prec = cout.precision(2);

Brass::ViewAcct();
cout << "最大贷款额: " << maxLoan << endl;
cout << "需向银行还款额: " << owesBank << endl;
cout.precision(3);
cout << "贷款利率: " << 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 << "透支款: " << advance << endl;
cout << "透支款需要交纳利息: " << advance*rate << endl;
Deposit(advance);
Brass::Withdraw(amt);
}
else
cout << "需要取出的钱超过了透支额度,交易取消" << endl;
restore(initialState, prec);
}

int main()
{
using std::cout;
using std::endl;

Brass zc("zc", 381299, 4000.00);
BrassPlus wb("wb", 382288, 3000.00);
zc.ViewAcct();
cout << endl;
wb.ViewAcct();
cout << endl;

cout << "向wb的账户存入1000元" << endl;
wb.Deposit(1000.00);
cout << "wb的余额: " << wb.Balance() << endl;
cout << endl;

cout << "从zc的账户中取出4200元" << endl;
zc.Withdraw(4200.00);
cout << "zc的余额: " << zc.Balance() << endl;
cout << endl;

cout << "从wb的账户中取出4200元" << endl;
wb.Withdraw(4200.00);
wb.ViewAcct();

return 0;
}

上述代码的执行结果如下:

假设要同时管理BrassBrassPlus账户,如果能用同一个数组来保存,那再好不过了。然而这是不可行的,因为数组要求所有元素类型相同。Brass和BrassPlus是不同的类型。然而可以创建指向Brass的指针数组。这样,每个元素类型都相同。而且用到了C++的继承特性,因此Brass指针既可以指向Brass对象,也可以指向BrassPlus对象。这就是多态性

根据基类引用或指针去调用,这才是使用的精髓,如果不定义基类的指针去使用,没有太大的意义。

我们改成如下的main函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
const int CLIENTS = 4;
int main()
{
Brass *p_clients[CLIENTS];
std::string temp;
long tempnum;
double tempbal;
char kind;

for (int i = 0; i < CLIENTS; i++)
{
cout << "输入用户的姓名: ";
getline(cin, temp);
cout << "输入用户的卡号: ";
cin >> tempnum;
cout << "输入存款额: ";
cin >> tempbal;
cout << "输入: 1 :创建普通账户 或者 "
<< "输入: 2 :创建可以透支取款的账户: ";

while (cin >> kind && (kind != '1'&&kind != '2'))
cout << "输入有误,请输入1或2: ";
if (kind == '1')
p_clients[i] = new Brass(temp, tempnum, tempbal);
else
{
double tmax, trate;
cout << "请输入可透支金额上限: $";
cin >> tmax;
cout << "请输入利率(十进制小数): ";
cin >> trate;
p_clients[i] = new BrassPlus(temp, tempnum, tempbal,
tmax, trate);
}
while (cin.get() != '\n')
continue;
}

cout << endl;
for (int i = 0; i < CLIENTS; i++)
{
p_clients[i]->ViewAcct();
cout << endl;
}

for (int i = 0; i < CLIENTS; i++)
{
delete p_clients[i];
}

cout << "\nDone\n";
return 0;
}

第8行声明的基类指针数组Brass *p_clients[CLIENTS];是精髓。程序的执行结果如下图:

联编

定义

程序调用函数时,将执行哪个代码块呢?编译器负责这个问题。

在C语言中,这非常简单,因为每个函数名都对应一个不同的函数。在C++中,由于有重载,这项任务更复杂。编译器必须查看参数才能确定到底使用那个函数。

将源代码中的函数调用解释为执行特定的代码块称为函数名联编(binding)。在编译过程中进行联编被称为静态联编(static binding),又称为早期联编。

然而,虚函数让这项工作更困难。在上面的第main函数里,使用哪一个函数不是在编译时就能确定的,所以编译器必须能够让程序运行时选择正确的虚方法的代码块,这就叫动态联编(dynamic binding),又称晚期联编。

指针和引用类型的兼容性

动态联编与通过指针和引用的调用方法有关。

我们知道通常是不允许将一种类型的地址赋给另一种类型的指针,不允许一种类型的引用指向另一种类型。例如:

1
2
3
double x = 2.5;
int *pi = &x;//not allowed
long & r = x;//not allowed

在类中,指向基类的引用或指针可以引用派生类对象,不必进行显示类型转换。将派生类引用或指针转换为基类引用或指针被称为向上强制转换(upcasting)。

该规则是is-a关系的一部分。一切BrassPlus对象都是Brass对象,因为它继承了所有可以对Brass对象执行的操作,这些操作都可以对BrassPlus对象执行,而不必担心出现任何的问题。向上强制转换是可以传递的。也就是说如果又从BrassPlus类派生出BrassPlusPlus类,那么Brass指针可以引用Brass对象、BrassPlus对象、BrassPlusPlus对象。

相反的过程,基类引用或指针转换为派生类引用或指针,成为向下强制转换(downcasting),如果不显示转换,则是不允许的。原因是is-a的关系通常是不可逆的。例如从学生类Student派生出大学本科学生类Undergraduate,你在需要用到学生类的一个对象时,你当然可以使用一个大学生。而你需要一个大学生时,随随便便找一个学生是不符合要求的。

隐式向上强制转换,需要动态联编。C++使用虚函数来满足这种要求。

虚函数和动态联编

编译器对非虚方法使用静态联编(根据指针or引用的类型调用方法)。如果将方法声明为虚的,则将根据对象的类型调用方法。编译器对虚方法使用动态联编。

大多情况下,动态联编很好,你可能就问了:

• 为什么要有两种类型的联编?
• 既然动态联编好,为什么不把它设置成默认的?
• 动态联编是如何工作的?

效率:不要为不使用的特性付出代价。如果派生类(BrassPlus)不重新定义基类方法,就不需要动态联编。在这种情况下,使用静态联编更合理,效率也更高。

概念模型:例如Brass::Balance()就不该重新定义。有两方面好处:第一提高效率,第二,指出不要重新定义该函数。仅将那些预期被重新定义的函数声明为虚的。与现实世界的很多方面一样,类设计并不是一个线行过程。

虚函数注意事项

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

• 析构函数应当是虚函数,即使析构函数不作任何操作。除非不做基类。
如果Employee类是基类,Singer是派生类,如果是默认的静态联编,如果delete一个Singer对象,调用的也是~Employee(),这会释放掉Singer对象中Employee部分指向的内存,但不会释放新的成员指向的内存。如果析构函数是虚的,则会先调用~Singer(),然后调用~Employee()

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

• 如果重新定义继承的方法,应确保与原来的原型完全相同。但如果返回的类型是基类引用或指针,可以修改为指向派生类的引用或指针。

• 如果基类函数声明被重载了,则应在派生类中重新定义所有基类版本。


参考链接: