深入了解C++虚函数

2018-12-05  本文已影响0人  Sky_Mao
一、认识虚函数

虚函数(Virtual Function):在基类中声明为 virtual 并在一个或多个派生类中被重新定义的成员函数。
作用:
C++ “虚函数”的存在是为了实现面向对象中的“多态”,即父类类别的指针(或者引用)指向其子类的实例,然后通过父类的指针(或者引用)调用实际子类的成员函数。通过动态赋值,实现调用不同的子类的成员函数(动态绑定)。正是因为这种机制,把析构函数声明为“虚函数”可以防止内存泄露。

简单的示例:

class bass
{
public:
    bass() {};
    virtual void Func() { std::cout << "bass func" << std::endl; };
};

class derived : public bass
{
public:
    derived() {};
    virtual void Func() { std::cout << "derived func" << std::endl; };
};

int main()
{
    bass * pB = new derived();
    pB->Func();
    return 0;
}

输出结果:


二、虚函数表

虚函数的调用是通过虚函数表(vitrual tables)和指向这张虚函数表的指针(virtual table pointers)来确定调用的是哪一个对象的函数,此二者通常被简写为vtbls和vptrs。
程序中每一个class凡声明(或继承)虚函数者,都有自己的一个vtbls,而其中的条目就是该class的各个虚函数实现体的指针。

凡是声明有虚函数的class,其对象都含有一个隐藏的数据成员,用来指向该class的vtbl。这个隐藏的数据成员就是vptr,effective C++中的描述是:这个vptr被编译器加入对象的内某个唯有编译器才知道的位置,网上搜的资料说这个数据成员会被放在对象内存布局的第一个位置。具体的可以试验一下!
先假设放在第一个位置。
例如这样一个类:

class c1
{
public:
    c1() {};
    virtual void f1() {};
    virtual void f2() {};
    virtual void f3() {};
};

c1的vtbl看起来应该是这样的:

&c1


下面试验一下,vptr是否在对象内存布局的第一个位置。
在f1函数打印一条信息:

virtual void f1() { std::cout << "test func pos"; };

在main函数内添加这些代码:

int main()
{
    c1 * pc = new c1();
    typedef void (*Func)(void);
    Func  pFun = (Func)*((int*)*(int*)(pc) + 0);
    pFun();
    return 0;
}
 (Func)*((int*)*(int*)(pc) + 0); 

这行代码可能理解起来比较吃力,我有的时候看起来也比较费劲,这一堆都是啥 玩意儿啊?
我们可以拆开来理解
首先把c1指针类对象强转为int*

int * nPc = (int*)(pc);

然后把类对象的首地址的所指物给强转成int*,首地址存放的应该是虚表指针 vtbls

int * vtabls = (int*)*(nPc);

最后把虚表指针第1个虚函数(从0开始的),转成Func

Func pFun = (Func)*(vtabls + 0);

这样的话是不是好理解多了。
我使用的环境是VS2017

输出结果:



从输出结果上来看,在vs里指向虚函数表的指针,是存放在对象内存布局的第一个位置,其他编译器由于没有测试不确定是否存放在第一个位置

下边看一下发生继承关系以后,虚函数表的状态
假如有一个类(单继承无覆盖的情况):

class c2 : public c1
{
public:
    c2() {};
    virtual void f4() { std::cout << "c2::f4()" << std::endl; };
    virtual void f5() {};
};

那么c2的虚函数表看起来应该是这样的:

&c2


在写一段代码来测试一下:

c1 * pc = new c2();
typedef void (*Func)(void);
Func pFun = (Func)*((int*)*(int*)(pc) + 0);
Func pFun2 = (Func)*((int*)*(int*)(pc)+3);
pFun();
pFun2();

输出结果:



从输出结果上可以看出:
1、虚函数按照其声明顺序放于表中。
2、父类的虚函数在子类的虚函数前面。
如果c2继承c1后重写基类的方法c1:f1(),那么根据之前的测试,他的虚函数表应该是这样的:

&c2


多重继承情况下,子类的虚函数表:
继承关系如下:



虚函数表应该是这样的:

&c4


有兴趣的同学可以写一段测试代码进行验证一下,我这里就不写了。

三、虚函数的成本

从上面的分析可以看出:
1、你必须为每一个拥有虚函数的class耗费一个vtbl空间,其大小视虚函数的个数(包括继承而来的)而定。
2、你必须在每一个拥有虚函数的对象内付出“一个额外指针”的代价,包括继承而来的。
3、虚函数不应该inlined,因为inline意味“在编译期,将调用端的调用动作被调用函数的函数本体取代”,而virtual则意味着“等待,直到运行时期才知道哪个函数被调用”,当编译器对某个调用动作,却无法知道哪个函数该被调用时,你就可以了解它们没有能力将该函数调用加以“inlining”了,事实上等于放弃了inlining。(如果虚函数通过对象调用,倒是可以inlined,但是大部分虚函数调用动作是通过对象的指针或references完成的,此类行为无法被inlined。由于此等调用行为是常态,所以虚函数事实上等于无法被inlined)-----摘自more effective C++。

四、安全性

如果父类的虚函数是private或是protected的,但这些非public的虚函数同样会存在于虚函数表中,所以,我们同样可以使用访问虚函数表的方式来访问这些non-public的虚函数,这是很容易做到的。
比如:

class bass
{
public:
    bass() {};
private:
    virtual void fc() { std::cout << "bass::fc()" << std::endl; };
};
int main()
{
    typedef void(*Func)(void);
    bass *pBass = new bass();
    Func pF = (Func)*((int*)*(int*)(pBass));
    pF();
    return 0;
}

输出结果:


五、将构造函数与非成员函数虚化(来自more effective C++ 条款25)

第一次面对虚构造函数的时候,似乎不觉得有什么道理可言,并且还有些荒谬,但它们很有用。
比如我有一个函数需要根据获得的输入,来构造不同类型的对象的时候。
假设有一个链表,存储图形或者文字信息:

class common
{
public:
};
class text : public common
{
public:
};
class graphic : public common
{
public:
};
std::list<common*> oCommonInfo;
template<class T>
common* readCommonInfo(T inPut)
{
    //根据输入的信息来构造text还是graphic
}

oCommonInfo.push_back(readCommonInfo(inPut));
思考一下,readCommonInfo做了一些什么事,它产生一个新对象,或许是text,也或许是graphic,
视输入的数据而定,由于它产生了新对象,所以行为仿若构造函数,但它能够产生不同类型的对象,
所以我们称它为一个virtual construction。所谓的virtual construction是某种函数,视其获得的输入,可产生不同类型的对象。

还有一种比较特殊的virtual construction,比如virtual copy construction,常见的是类的clone
比如:

class a
{
public:
    a() {};
    virtual a* clone() const = 0;
};
class b : public a
{
public:
    b() {};
    virtual b* clone() const {};
};
class c : public a
{
public:
    c() {};
    virtual c* clone() const {};
};

虚函数在重写的时候,返回类型、函数名称、参数个数、参数类型必须相同,但是当基类虚函数返回基类指针,派生类虚函数返回派生类指针,是允许的。
a *pa = new b或者c;
list.push_back(a.clone());

就像construction无法被真正虚化一样,非成员函数也是一样。
不过可以将非成员函数的行为虚化,
可以写一个虚函数做实际工作,在写一个什么也不做的非虚函数,只负责调用虚函数。
当然为了避免此巧妙安排蒙受函数调用带来的成本,可以将非虚函数inline化。

上一篇下一篇

猜你喜欢

热点阅读