现在的位置: 首页 > 综合 > 正文

关于虚函数

2013年09月13日 ⁄ 综合 ⁄ 共 3074字 ⁄ 字号 评论关闭

         最近学习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.如果基类虚函数声明被重载,则应在派生类中重新定义所有基类版本。防止派生类的函数被隐藏。

抱歉!评论已关闭.