虚函数
可用于实现多态公有继承的机制:
- 在派生类中重新定义基类的方法。
- 使用虚方法
类Brass和类BrassPlus如下所示:
class Brass { private: char fullName[MAX]; long acctNum; double balance; public: virtual void WithDraw(double amt); virtual void ViewAcct()const; virtual ~Brass(){} }; class BrassPlus: public Brass { private: double rate; public: virtual void ViewAcct()const; virtual void WithDraw(double amt); };
如果没有使用关键字virtual,程序将根据引用类型或指针类型选择方法;如果使用了virtual,程序将根据引用或指针指向的对象的类型来选择方法。如果ViewAcct()不是虚拟的,则程序的行为如下:
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();//调用Brass::ViewAcct() b2_ref.ViewAcct();//调用Brass::ViewAcct()
如果ViewAcct是虚拟的,则行为如下:
1 Brass dom("Dominic Banker",11224,4183.45); 2 BrassPlus dot("Dorothy Banker",12118,2592.00); 3 Brass & b1_ref = dom; 4 Brass & b2_ref = dot; 5 b1_ref.ViewAcct();//调用Brass::ViewAcct() 6 b2_ref.ViewAcct();//调用BrassPlus::ViewAcct()
用途:
假设要同时管理Brass和BrassPlus账户,我们无法用同一个数组来保存Brass和BrassPlus对象,但是我们可以创建一个指向Brass的指针数组。这样,每个元素的类型都相同,而且Brass指针既可以纸箱Brass对象,也可以纸箱BrassPlus对象。因此,可以用一个数组来表示多种类型的对象。这就是多态性。
虚函数的工作原理:
编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表(vtbl)。虚函数表中存储了为类对象声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,该vtbl将保存函数原始版本的地址。如果派生类定义了新的虚函数,则虚函数的地址也将被添加到vtbl中。注意,无论类中包含多少个虚函数,都只要再对象中添加1个地址成员,只是表的大小不同而已。
使用虚函数时,在内存和执行速度方面有一定的成本,包括:
- 每个对象都将增大,增大量为存储地址的空间。
- 对每个类,编译器都创建一个虚函数地址表。
- 每个函数调用都需要执行一步额外的操作,即到表中查找地址。
虽然非虚函数的效率比虚函数稍高,但不具备动态联编功能。
虚函数要点:
- 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有的派生类中是虚拟的。
- 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而使用为引用或指针类型定义的方法。这称为动态联编。这种行为使得基类指针或引用可以指向派生类对象。
- 如果定义的类将被用做基类,则应将那些要在派生类中重新定义的类方法声明为虚函数。
注意事项:
- 构造函数不能是虚函数。
- 析构函数应当是虚函数,除非类不用做基类。
- 友元不能是虚函数,因为友元不是类成员,而只有成员才能使虚函数。
- 如果派生类没有重新定义函数,将使用该函数的基类版本。
- 重新定义隐藏方法。
*Tip:通常应该给基类提供一个虚拟析构函数,即使它并不需要析构函数。
析构函数应当是虚函数,除非类不用做基类。例如假设给BrassPlus添加一个char*成员,该成员指向由new分配的内存。当BrassPlus对象过期时,必须调用~BrassPlus析构函数来释放内存。
请看下面的代码:
Brass* p = new BrassPlus; delete p;
delete语句将调用~Brass()析构函数。这将释放由BrassPlus对象中的Brass部分指向的内存,但不会释放新的类成员指向的内存。但如果析构函数是虚拟的,则上述代码将先调用~BrassPlus析构函数释放由BrassPlus组件指向的内存,然后调用~Brass()析构函数来释放由Brass组件指向的内存。这意味着,即使基类不需要显示析构函数提供服务,也不应该依赖于默认构造函数,而应提供虚拟析构函数,即使它不执行任何操作:
virtual ~Brass(){}
给类定义一个虚拟析构函数并非错误,即使这个类不用做基类;这只是一个效率方面的问题。
*Tip:重新定义隐藏方法
假设创建了如下所示的代码:
class Dwelling { public: virtual void showperks(int a)const; ... }; class Hovel: public Dwelling { public: virtual void showperks()const; ... };
这可能会出现类似于下面这样的警告:
Warning: Hovel::showperks(void) hides Dwelling::showperks(int)
也可能不会出现警告。但不管结果如何,代码将具有如下含义:
Hove trump; trump.showperks();//valid trump.showperks(5);//invalid
重新定义不会生成函数的两个重载版本,而是隐藏了接受一个int参数的基类版本。也就是说重新定义方法并不是重载。派生类重新定义函数,将不是使用相同的函数特征覆盖基类声明,而是隐藏同名的基类函数,不管函数的参数特征如何。
这引出了两条经验规则:
第一,如果重新定义继承的方法,应确保与原来的原型完全相同。但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针。这种特征被称为返回类型协变。如:
class Dwelling { public: virtual Dweling& 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; ... };
如果只重新定义一个版本,则另外两个版本将被隐藏,派生类对象将无法使用它们。如果不需要修改,则新定义可调用基类版本。