最近学习DirectX,接触到一个FPS游戏项目,创建引擎时用到了虚函数,将渲染基类定义为抽象类,“虚函数”,多么熟悉的字眼,但是细细一想,确实还是有些不那么明白。周五的时候,跟同事讨论,发现其实他也不是特别的清楚,周末的时候又研究了一下。现将自己的认识写下来,一是理清思路,二是方便他人。
虚函数(virtual function),就是在作为基类的类声明中,在前面加了"virtual"声明的成员函数,包括析构函数,但不包括构造函数,友元函数(友元函数可在内部调用虚函数).它的主要作用就是为了实现动态binding, 习惯称为(动态联编)。
说到动态联编,就不得不讲讲几个概念:函数名联编,静态联编,动态联编以及指针引用的类型兼容性。
函数名联编:将源代码中的函数调用解释为执行特定的函数代码的行为。
在C++中,因为有函数重载,所以如int foo(int a, int b); ---> 解释成 foo_int_int, (这里略懂一点,具体如何,希望有人能补充)
静态联编:在编译时,编译器必须查看函数参数以及函数名才能确定使用哪个函数,这样的联编就叫静态联编。编译时就解决了程序中的操作调用与执行该操作代码间的关系。
动态联编:类似于虚函数这样,在编译期间无法确定使用哪一个函数,故编译器必须生成能够在运行时选择正确的虚函数的代码,这被称为动态联编(晚期联编)。
通常,C++不允许将一种类型的地址赋给另一类型的指针,也不允许赋值给另一类型的引用。
不过,指向基类的引用或指针可以引用派生类对象,而不必进行显式的类型转换,这被称为向上强制转换(upcasting).
相反,将基类的引用或指针转换为派生类指针或引用,这被称为向下强制转换(downcasting)。需要显式强制转换。
下面给出一个没有使用虚函数的例子:
class Base
{
public:
virtual void print() { cout << "Base print()\n"; }
};
class Drived : public Base
{
public:
void print() { cout << "Drived print()\n"; }
void test() { cout << "Drived test()\n"; }
};
int main()
{
Base b;
Drived d;
Base b1;
Base *pb = &b; // 基类指针指向基类对象
Base *pd = &d; // 基类指针指向派生类对象 ,向上隐式转换
Drived *pD = (Drived *)&b1; // 派生类指针指向基类对象,向下强制显式转换
b.print(); // 此处调用的是基类print()
d.print(); // 此处调用的是派生类print()
pb->print(); // 此处调用的是基类print()
pd->print(); // 此处调用的是基类print()
pd->test(); // 此处报错,基类没有test()
pD->test(); // 此处调用派生类test(),有隐患
return 0;
}
从上面的例子我们可以看到:
1.同样是基类的指针,指向的对象不同,输出的结果却是一样的,因为这里是静态联编,所以编译器将根据指针类型,函数调用确定为基类函数;
2.同样我们也可以看到,向上隐式转换是安全的,向下强制却不是。因为向下强制转换后得到的指针,只具有基类的全部功能,但是派生类具有的额外的功能,很有可能是未定义的。本例中是没有问题的。
下面再看一个动态联编的例子,就在刚刚的例子上做一个小小的改动,我们的虚函数就是为了实现动态联编而存在的。
class Base
{
public:
virtual void print() { cout << "Base print()\n"; }
};
class Drived : public Base
{
public:
virtual void print() { cout << "Drived print()\n"; }
void test() { cout << "Drived test()\n"; }
};
int main()
{
Base b;
Drived d;
Base b1;
Drived *pD = (Drived *) &b1;
Base *pb = &b; // 基类指针指向基类对象
Base *pd = &d; // 基类指针指向派生类对象
b.print(); // 此处调用的是基类print()
d.print(); // 此处调用的是派生类print()
pb->print(); // 此处调用的是基类print()
pd->print(); // 此处调用的是派生类print()
pD->test(); // 此处调用派生类test()
pd->test(); // 此处报错,基类没有test()
return 0;
}
跟上一处代码相比,只是将基类跟派生类的print()声明前加上了"virtual"关键字,此时我们看到,pd->print()可以正常调用了。这是为什么呢?
首先我们得了解一下虚函数的工作原理:
编译器处理虚函数的方法是:为每一个对象添加一个隐藏成员,隐藏成员保存了指向虚函数地址数组的指针。该数组被称为虚函数表(vtbl).里面保存了为类对象进行声明的虚函数的地址。该表的第一项一般是type_info对象,用来支持runtime type identification,RTTI。接下来便是各虚函数地址。
请看下面的代码:
class Point
{
public:
Point(float value);
virtual ~Point(); // 虚析构函数,为了确保正确的析构函数序列被调用。先派生类,后基类
float x() const;
static int PointCount();
protected:
virtual ostream& print(ostream &os) const; // 虚操作符重载函数
float x;
static int _point_count;
};
该类的vtbl及vptr指向情况:
由上可知,如果基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,该vtbl将保存函数原始版本的地址。如果派生类新定义了其他虚函数,则该函数地址将加入到vtbl中。
函数调用时,程序将查看存储在对象中的vtbl的值,然后转向相应的函数地址表。如果使用类声明中定义的第一个虚函数,则程序将使用vtbl中的第一个虚函数地址,并执行具有该地址的函数。
总之,使用虚函数需要注意以下几点:
1.必须是基类的成员函数,使用virtual声明
2.它将增加额外的开销,若非必要,不必使用。
3.对于每个类,它都将创建一个虚函数地址表。即每个对象都将增大。
4.每一次函数调用都需要执行额外的操作,即到vtbl中查找地址。
5.实现多态的做法是将基类指针或引用指向派生类对象,然后调用相应的虚函数。
6.构造函数,友元函数不能为虚函数。构造函数不能继承,友元函数非类成员。
7.基类的析构函数强烈建议使用虚函数,保证按正确的序列析构,释放内存。
8.如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类指针或引用,则可以修改为派生类指针或引用(返回类型协变)。
9.如果基类虚函数声明被重载,则应在派生类中重新定义所有基类版本。防止派生类的函数被隐藏。