虚函数的介绍与使用:
对于C++语言来说,程序在执行类成员函数时(跳转到相应的函数地址),有两种方式,一种是静态联编(static binding),意思就是程序在编译时就知道该函数的地址;另一种是动态联编(dynamic binding),意思是就算代码在编译后,程序还是不知道相应的函数地址,得到程序执行到这个地方,程序才能得到正确的函数地址。那么为什么需要动态联编呢?考虑这样的情况:比方说有一种类叫做宠物,狗、猫、兔子、金鱼、乌龟等都是它的子类,那么当我们定义宠物类的时候,应该定义一个吃饭的功能,因为宠物都应该吃饭。这样的话,我们把程序当成小明,小明要领养什么宠物,只有他才知道(程序执行时才能确定),宠物或许是狗,或许是兔子。可是我们不能给狗喂萝卜,兔子也不吃骨头。所以我们在定义宠物类的时候,就应该把吃饭这个类成员函数,声明称一个虚函数。
先用代码来做一个简单的例子:
#include <iostream>
using namespace std;
class Pet//这个是我们的基类:宠物类
{
public:
char * name;
int weight;
int age;
Pet() {};
//下面这个就是我们的虚函数
virtual void eat()
{
cout << "错误:我们还不能确定宠物的品种,无法喂食!" << endl;
};
~Pet() {};
};
class Dog : public Pet
{
public:
Dog(char * s, int w, int a)
{
name = s; weight = w; age = a;
}
virtual void eat() //重新定义这个虚函数
{
cout << "到点了,请准备好狗粮," << name << "该吃饭了。" << endl;
}
~Dog() {};
};
class Rabbit : public Pet
{
public:
Rabbit(char * s, int w, int a)
{
name = s; weight = w; age = a;
}
void eat()
{
cout << "到点了,请准备好萝卜," << name << "该吃饭了。" << endl;
}
~Rabbit() {};
};
int main()
{
int a, b, c;
char s[20];
cout << "请输入1或2来创建一个生物(1是狗2是兔);然后分别输"
<< "入该生物的姓名、重量和年龄,用回车键分割" << endl;
cin >> a;cin >> s;cin >> b;cin >> c;
Pet * p;
if (1 == a)
{
p = new Dog(s, b, c);
cout << "您创建的生物是狗狗,它的名字叫做" << s
<< ",它今年" << c << "岁了,重大" << b << "公斤!"
<<"它现在被您收养了。"
<< endl;
}
else
{
p = new Rabbit(s, b, c);
cout << "您创建的生物是兔子,它的名字叫做" << s
<< ",它今年" << c << "岁了,重大" << b << "公斤!"
<< "它现在被您收养了。"
<< endl;
}
cout << "接下来是您与宠物的日常:" << endl;
for (int i = 0; i < 10; i++)
{
if (i < 9) cout << "您与宠物愉快的玩耍" << endl;
else p->eat();//到了这里就会调用相应的虚函数,如果子类是狗类,那么调用狗类的吃饭函数,否则是兔类吃饭函数
}
return 0;
}
虚函数的使用实境:
虚函数一般用在这种情况下:在菱形或更为复杂的继承体系下,由于同层子类不能互相引用或指针指向,所以一般都是使用基类的指针或引用指向派生类对象,而无需做任何强制类型转换。就像我们的上个梨子一样:由于我们不知道“小明”要养的是什么动物,所以无法确定应该调用怎样的“喂食”函数;然而我们可以创建一个基类的“宠物”指针,然后用它来指向实际中的实例对象,这样“喂食”时,就会调用对应的函数了。
虚函数的工作原理:
虚函数的原理并不复杂,通常都是这样的:编译器在编译每一种类之后,会搜集出该类的所有函数地址,然后在某块内存(或许是代码区,或许是数据区)中,开辟出一个数组,数组中的每个元素,保存的都是该类的一个函数地址,而这个数组的头结点,就是该类的所谓虚函数表指针。然后在程序实例化该类的一个实际对象时,会偷偷的给这个对象增加一个数据成员,而这个数据成员,是一个指针,保存的就是前面说的数组地址的头结点。还是以我们上面的程序为例:编译器首先为基类宠物类创建一个地址表(虚函数地址数组),然后增添一个隐藏成员“虚函数表指针”,该成员指向这个地址表;然后对派生类比如“狗”类而言,将做同样的事:创建一个新的地址表,里面保存的是“狗类”的所有虚函数地址,然后用一个数据成员指向这个“狗”类的函数表地址;这样当程序使用这个类对象的虚函数时,会根据该虚函数在地址表中的偏移量来调用对应的函数。(但如果我们的派生类没有定义那个喂食函数,那么派生类数组地址中,对应的保存的是基类中的那个喂食函数,读者朋友如果有心,可以自己试一下)
虚函数的使用注意事项:
1、构造函数不能声明为虚函数。这是因为实例一个派生类对象时,先调用该类的构造函数,执行完毕后,再调用基类的某个构造函数。所以派生类不应该重新定义基类的构造函数。
2、友元函数不能声明为虚函数,这是友元函数不是类成员,而虚函数必须是类成员。如果这种规则会导致您编程时的困惑,可以试着在友元函数中调用虚函数来解决这个问题。
3、对于一个在继承体系下是基类或中间类的类而言,应该将它的析构函数定位为虚函数。考虑这样一种情况:A是基类,B是继承类,其中B比A多出了一个在堆中new出的字符串内容,假如有以下代码【A * p = b;】,其中那个b是B类的一个实例对象。当我们delete这个p指向的对象时,如果A不是虚析构函数,那么这将会导致内存泄漏,因为在B中的析构函数才会释放字符串占用的内存,所以说应该将A这种的类的析构函数定位为虚函数。【另外还有一种新手常见的错误,考虑这样一种继承体系,B继承自A,B内有虚函数,A内没有,新手犯得错误就是,先实例化一个B类对象,然后再做出一个A类指针指向这个B实例对象,最后delete这个指针时,程序崩溃了。。。其实原因很简单,因为B中有一个虚函数表指针,而A没有,所以当把B的对象“变成”A类型时,不应该包含这个虚函数表指针!如果我们的编译器是把虚函数表指针放在开头部分的话,那么当A指针指向B对象时,会漫过去这个虚表指针,最后当delete这个指针时,不崩溃才怪,因为malloc的返回值和free的传入值不一样嘛。所以如果子类中有虚函数时,我们应该在父类中也弄虚函数,如果确实没啥虚函数好弄的,我们可以把析构函数做成虚函数啊,总结归纳就是:把父类的析构函数声明为虚函数,是一种良好的编程习惯!】
4、就像我们前面提过的一样:如果一个基类或中间类定义了某个虚函数,而在下一链中的派生类没有重新定义虚函数,那么对于这个派生类而言,它的实例对象中使用这个虚函数时,将会使用上一链的那个最新版本的虚函数。
5、所有虚函数的参数表应该是相同的,假如不这样,如下所示:
class A
{
...
virtual void fun(int i){...};
}
class B : public A
{
...
virtual void fun(){...};
}
那么下面的代码就会是:
A * p = new B();
p->fun(); //合法的代码
p->fun(9);//非法的代码
这是因为在B中,fun被定义为一个不接受任何参数的函数,而且隐藏了A中那个接受int参数的fun版本,而不是将fun生成具有两个函数特征标的重载版本。
但是,如果返回值是基类的引用或指针,那么我们可以在重新定义下一链的派生类函数时,将返回值改为该派生类的引用或指针,这称为“返回类型协变”,英文叫做“covariance of return type”,她允许返回类型随类类型的变化而变化。
6、如果某个基类或中间类的虚函数有很多重载版本,那么在下一链中的类虚函数定义中,应该要重新定义所有的重载版本。如果只定义一个或很少的虚函数,那么其它那些没有被定义的虚函数都会被隐藏掉,该派生类对象无法使用这些被隐藏掉的版本。