RatedPlayer继承示例很简单。派生类对象使用基类的方法,并未做任何修改。然而,可能遇到希望同一个方法在基类和派生类中的行为是不同的。即希望方法的行为应取决于调用对象,也是一种多态(Polymorphism)——具有多种形态。有两种重要机制可以使用多态公有继承:
• 在派生类中重新定义基类方法。
• 使用虚方法。
多态公有继承
看一个普通银行账户类:
1 |
|
然后我们对其进行继承,写出新的BrassPlus类,这个类的用户新增了向银行透支取款的功能。
1 | class BrassPlus : public Brass |
虚函数
两个类虽然都声明了ViewAcct
和Withdraw
方法,但很明显两个类对象的方法行为是不同的。所以在声明时使用了关键字virtual
,这些方法被称为虚方法。(定义的时候不需要写virtual)使用virtual
,程序将根据引用或指针所指向的对象类型选择方法。如果ViewAcct不是虚的,程序是根据引用或指针本身的类型来选择方法。例如:1
2
3
4
5
6Brass 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 |
|
上述代码的执行结果如下:
假设要同时管理Brass
和BrassPlus
账户,如果能用同一个数组来保存,那再好不过了。然而这是不可行的,因为数组要求所有元素类型相同。Brass和BrassPlus是不同的类型。然而可以创建指向Brass
的指针数组。这样,每个元素类型都相同。而且用到了C++的继承特性,因此Brass指针既可以指向Brass对象,也可以指向BrassPlus对象。这就是多态性。
根据基类引用或指针去调用,这才是使用的精髓,如果不定义基类的指针去使用,没有太大的意义。
我们改成如下的main函数:
1 | const int CLIENTS = 4; |
第8行声明的基类指针数组Brass *p_clients[CLIENTS];
是精髓。程序的执行结果如下图:
联编
定义
程序调用函数时,将执行哪个代码块呢?编译器负责这个问题。
在C语言中,这非常简单,因为每个函数名都对应一个不同的函数。在C++中,由于有重载,这项任务更复杂。编译器必须查看参数才能确定到底使用那个函数。
将源代码中的函数调用解释为执行特定的代码块称为函数名联编(binding)。在编译过程中进行联编被称为静态联编(static binding),又称为早期联编。
然而,虚函数让这项工作更困难。在上面的第main函数里,使用哪一个函数不是在编译时就能确定的,所以编译器必须能够让程序运行时选择正确的虚方法的代码块,这就叫动态联编(dynamic binding),又称晚期联编。
指针和引用类型的兼容性
动态联编与通过指针和引用的调用方法有关。
我们知道通常是不允许将一种类型的地址赋给另一种类型的指针,不允许一种类型的引用指向另一种类型。例如:1
2
3double 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()
。
• 友元不能是虚函数。
友元不是类成员函数,只有类成员函数才能是虚函数。
• 如果重新定义继承的方法,应确保与原来的原型完全相同。但如果返回的类型是基类引用或指针,可以修改为指向派生类的引用或指针。
• 如果基类函数声明被重载了,则应在派生类中重新定义所有基类版本。
参考链接: