文章目录
1. 静态联编和动态联编
程序调用函数时,将使用哪个可执行代码块呢?编译器负责回答这个问题。
将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编( binding)。
在C语言中,这非常简单,因为每个函数名都对应一个不同的函数。在C++中,由于函数重载的缘故,这项任务更复杂。
编译器必须査看函数参数以及函数名才能确定使用哪个函数。然而,C/C++编译器可以在编译过程完成这种联编。在编译过程中进行联编被称为静态联编( static binding),又称为早期联编( early binding)。然而,虚函数使这项工作变得更困难。
正如在程序清单13.10所示的那样,使用哪一个函数是不能在编译时确定的,因为编译器不知道用户将选择哪种类型的对象。所以,编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编( dynamic binding),又称为晚期联编( late binding)。
知道虚方法的行为后,下面深入地探讨这一过程,首先介绍C++如何处理指针和引用类型的兼容性。
2. 指针和引用类型的兼容性
在C++中,动态联编与通过指针和引用调用方法相关,从某种程度上说,这是由继承控制的。公有继承建立is-a关系的一种方法是如何处理指向对象的指针和引用。
通常,C++不允许将一种类型的地址赋给另一种类型的指针,也不允许一种类型的引用指向另一种类型:
double x = 2.5;
int * pi = &x; //invalid assignment, mismatched pointer types
long & r1 = x; // invalid assignment, mismatched reference type
然而,正如您看到的,指向基类的引用或指针可以引用派生类对象,而不必进行显式类型转换。例如,下面的初始化是允许的
BrassPlus dilly("Annie Dill", 493222, 2000)
Brass *pb = &dilly; // ok
Brass &rb = dilly; // ok
将派生类引用或指针转换为基类引用或指针被称为向上强制转换( upcasting),这使公有继承不需要进行显式类型转换。
该规则是is-a关系的一部分。 BrassPlus对象都是 Brass对象,因为它继承了 Brass对象所有的数据成员和成员函数。
所以,可以对 Brass对象执行的任何操作,都适用于 BrassPlus对象。因此,为处理 Brass 引用而设计的函数可以对 BrassPlus对象执行同样的操作,而不必担心会导致任何问题。将指向对象的指针作为函数参数时,也是如此。
向上强制转换是可传递的,也就是说,如果从BrasPlus派生出BrassPlusPlus类,则 Brass指针或引用可以引用 Brass对象、 BrassPlus对象或 BrassPlusPlus对象。
相反的过程将基类指针或引用转换为派生类指针或引用一称为向下强制转换( downcasting)
如果不使用显式类型转换,则向下强制转换是不允许的。原因是is-a关系通常是不可逆的。
派生类可以新增数据成员,因此使用这些数据成员的类成员函数不能应用于基类。例如,假设从 Employee类派生出 Singer类,并添加了表示歌手音域的数据成员和用于报告音域的值的成员函数 range(),则将 range()方法应用于Employee对象是没有意义的。但如果允许隐式向下强制转换,则可能无意间将指向 Singer的指针设置为一个 Employee对象的地址,并使用该指针来调用 range()方法(参见图13.4)。
对于使用基类引用或指针作为参数的函数调用,将进行向上转换。请看下面的代码段,这里假定每个函数都调用虚方法 ViewAcct()
void fr(Brass & rb); //uses rb.ViewAcct()
void fp(Brass *pb); // uses pb->ViewAcct()
void fv(Brass b); //uses b.ViewAcct()
int main()
{
Brass b("Billy Bee",123432,10000.0);
BrassPlus bp("Betty Beep", 232313, 12345.0);
fr(b); //uses Brass::ViewAcct(
fr(bp); // uses BrassPlus::ViewAcct()
fp(b); //uses Brass::ViewAcct()
fp(bp); // uses BrassPlus::ViewAcct()
fv(b); //uses Brass::ViewAcct()
fv(bp); //uses Brass::ViewAcct()
}
按值传递导致只将 BrassPlus对象的 Brass 部分传递给函数fv()。但随引用和指针发生的隐式向上转换导致函数fr()和fp()分别为 Brass对象和 BrassPlus对象使用Brass::ViewAcct()和 BrassPlus::ViewAcct()。
隐式向上强制转换使基类指针或引用可以指向基类对象或派生类对象,因此需要动态联编。C++使用虚成员函数来满足这种需求。
3. 虚成员函数和动态联编
来回顾一下使用引用或指针调用方法的过程。请看下面的代码:
BrassPlus ophelia; //derived-class object
Brass *bp; // base-class pointer
bp = &ophelia; // Brass pointer to BrassPlus object
bp->ViewAcct(); // which version?
正如前面介绍的,如果在基类中没有将 ViewAcct()声明为虚的,则bp-> ViewAcct()
将根据指针类型(Brass*)调用 Brass::ViewAcct(()。
指针类型在编译时已知,因此编译器在编译时,可以将 ViewAcct()关联到Brass::ViewAcct()。
总之,编译器对非虚方法使用静态联编。
然而,如果在基类中将 ViewAcct()声明为虚的,则bp-> ViewAcct()
根据对象类型( BrassPlus)调用BrassPlus::ViewAcct()。
在这个例子中,对象类型为 BrassPlus,但通常(如程序清单13.10所示)只有在运行程序时才能确定对象的类型。所以编译器生成的代码将在程序执行时,根据对象类型将 ViewAcct()关联
到Brass::Viewacct()或 BrassPlus::ViewAcct()。
总之,编译器对虚方法使用动态联编
在大多数情况下,动态联编很好,因为它让程序能够选择为特定类型设计的方法。因此,您可能会问:
- 为什么有两种类型的联编?
- 既然动态联编如此之好,为什么不将它设置成默认的?
- 动态联编是如何工作的?
下面来看看这些问题的答案。
4.为什么有两种类型的联编以及为什么默认为静态联编?
如果动态联编让您能够重新定义类方法,而静态联编在这方面很差,为何不摒弃静态联编呢?
原因有两个——效率和概念模型。
首先来看效率。为使程序能够在运行阶段进行决策,必须采取一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销(稍后将介绍一种动态联编方法)。
例如,如果类不会用作基类,则不需要动态联编。同样,如果派生类(如 RatedPlayer)不重新定义基类的任何方法,也不需要使用动态联编。
在这些情况下,使用静态联编更合理,效率也更高。由于静态联编的效率更高,因此被设置为C++的默认选择。 Strousstrup说,C++的指导原则之一是,不要为不使用的特性付出代价(内存或者处理时间)。仅当程序设计确实需要虚函数时,才使用它们。
接下来看概念模型。在设计类时,可能包含一些不在派生类重新定义的成员函数。例如,Bras::Balance()函数返回账户结余,不应该重新定义。不将该函数设置为虚函数,有两方面的好处: 首先效率更高;其次,指出不要重新定义该函数。
这表明,仅将那些预期将被重新定义的方法声明为虚的。
提示:如果要在派生类中重新定义基类的方法,则将它设置为虚方法;否则,设置为非虚方法。
当然,设计类时,方法属于哪种情况有时并不那么明显。与现实世界中的很多方面一样,类设计并不是一个线性过程。
5.虚函数的工作原理
C++规定了虚函数的行为,但将实现方法留给了编译器作者。不需要知道实现方法就可以使用虚函数,但了解虚函数的工作原理有助于更好地理解概念,因此,这里对其进行介绍。
通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。
这种数组称为虚函数表( virtual function table,vtbl)。
虚函数表中存储了为类对象进行声明的虚函数的地址。
例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。
如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址,如果派生类没有重新定义虚函数,该 vtbl将保存函数原始版本的地址。
如果派生类定义了新的虚函数,则该函数的地址也将被添加到vtbl中(参见图13.5)。注意,无论类中包含的虚函数是1个还是10个,都只需要在对象中添加1个地址成员,只是表的大小不同而已。
调用虚函数时,程序将查看存储在对象中的vbl地址,然后转向相应的函数地址表。
如果使用类声明中定义的第一个虚函数,则程序将使用数组中的第一个函数地址,并执行具有该地址的函数。
如果使用类声明中的第三个虚函数,程序将使用地址为数组中第三个元素的函数
总之,使用虚函数时,在内存和执行速度方面有一定的成本,包括:
- 每个对象都将增大,增大量为存储地址的空间;
- 对于每个类,编译器都创建一个虚函数地址表(数组)
- 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址。
虽然非虚函数的效率比虚函数稍高,但不具备动态联编功能。
6.有关虚函数注意事项
我们已经讨论了虚函数的一些要点。
在基类方法的声明中使用关键字 virtual可使该方法在基类以及所有的派生类(包括从派生类派生出来的类)中是虚的。
如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为动态联编或晚期联编。这种行为非常重要,因为这样基类指针或引用可以指向派生类对象。
如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的。
对于虚方法,还需要了解其他一些知识,其中有的已经介绍过。下面来看看这些内容。
1.构造函数
构造函数不能是虚函数。创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数将使用基类的一个构造函数,这种顺序不同于继承机制。
因此,派生类不继承基类的构造函数,所以将类构造函数声明为虚的没什么意义。
2.析构函数
析构函数应当是虚函数,除非类不用做基类。
例如,假设 Employee是基类, Singer是派生类,并添加一个char*成员,该成员指向由new分配的内存。当 Singer对象过期时,必须调用~Singer()析构函数来释放内存。
请看下面的代码:
Employee *pe = new Singer; // legal because Employee is base for Singer
delete pe; //~Employee() or -Singer()?
如果使用默认的静态联编,deltete语句将调用~ Employee()析构函数。这将释放由 Singer对象中的Employee 部分指向的内存,但不会释放新的类成员指向的内存。但如果析构函数是虚的,则上述代码将先调用 ~Singer()
析构函数释放由 Singer组件指向的内存,然后,调用~Employee()析构函数来释放由 Employee组件指向的内存。
这意味着,即使基类不需要显式析构函数提供服务,也不应依赖于默认构造函数,而应提供虚析构函数,即使它不执行任何操作:
virtual ~BaseClass() {}
顺便说一句,给类定义一个虚析构函数并非错误,即使这个类不用做基类;这只是一个效率方面的问题。
提示:通常应给基类提供一个虚析构函数,即使它并不需要析构函数。
3.友元
友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数。
如果由于这个原因引起了设计问题,可以通过让友元函数使用虚成员函数来解决。
4.没有重新定义
如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类版本是隐藏的(稍后将介绍)
5.重新定义将隐藏方法
假设创建了如下所示的代码:
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)
也可能不会出现警告。但不管结果怎样,代码将具有如下含义:
Hovel trump;
trump.showperks(); // valid
trump.showperks(5) // invalid
新定义将 showperks()定义为一个不接受任何参数的函数。
重新定义不会生成函数的两个重载版本,而是隐藏了接受一个int参数的基类版本。
总之,重新定义继承的方法并不是重载。如果在派生类中重新定义函数,将不是使用相同的函数特征标覆盖基类声明,而是隐藏同名的基类方法,不管参数特征标如何。
这引出了两条经验规则:
第一,如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针(这种例外是新出现的)。
这种特性被称为返回类型协变( covariance of return type),因为允许返回类型随类类型的变化而变化:
class Dwelling
{
public:
// a base method
virtual Dwelling build(int n);
}
class Hovel: public Dwelling
{
public:
// a derived method with a covariant return type
virtual Hovel build(int n); //same function signature
}
注意,这种例外只适用于返回值,而不适用于参数
第二,如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。
class Dwelling
{
public:
// three overloaded showperks()
virtual void showperks(int a) const;
virtual void showperks(double x)const;
virtual void showperks() const;
}
class Hovel: public Dwelling
{
public:
// three redefined showperks(
virtual void showperks(int a)const;
virtual void showperks(double x)const;
virtual void showperks()const;
}
如果只重新定义一个版本,则另外两个版本将被隐藏,派生类对象将无法使用它们。注意,如果不需要修改,则新定义可只调用基类版本:
void Hovel::showperks() const { Dwelling::showperks();}